summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
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
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')
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.Layer.vue78
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.vue302
-rw-r--r--packages/frontend/src/components/MkPositionSelector.vue53
-rw-r--r--packages/frontend/src/components/MkRange.vue68
-rw-r--r--packages/frontend/src/components/MkUploaderDialog.vue292
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue318
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.vue455
7 files changed, 1493 insertions, 73 deletions
diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue
new file mode 100644
index 0000000000..0312017d86
--- /dev/null
+++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue
@@ -0,0 +1,78 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkFolder :defaultOpen="true" :canPage="false">
+ <template #label>{{ fx.name }}</template>
+ <template #footer>
+ <div class="_buttons">
+ <MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
+ <MkButton iconOnly @click="emit('swapUp')"><i class="ti ti-arrow-up"></i></MkButton>
+ <MkButton iconOnly @click="emit('swapDown')"><i class="ti ti-arrow-down"></i></MkButton>
+ </div>
+ </template>
+
+ <div :class="$style.root" class="_gaps">
+ <div v-for="[k, v] in Object.entries(fx.params)" :key="k">
+ <MkSwitch v-if="v.type === 'boolean'" v-model="layer.params[k]">
+ <template #label>{{ k }}</template>
+ </MkSwitch>
+ <MkRange v-else-if="v.type === 'number'" v-model="layer.params[k]" continuousUpdate :min="v.min" :max="v.max" :step="v.step">
+ <template #label>{{ k }}</template>
+ </MkRange>
+ <MkRadios v-else-if="v.type === 'number:enum'" v-model="layer.params[k]">
+ <template #label>{{ k }}</template>
+ <option v-for="item in v.enum" :value="item.value">{{ item.label }}</option>
+ </MkRadios>
+ <div v-else-if="v.type === 'seed'">
+ <MkRange v-model="layer.params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1">
+ <template #label>{{ k }}</template>
+ </MkRange>
+ </div>
+ <MkInput v-else-if="v.type === 'color'" :modelValue="`#${(layer.params[k][0] * 255).toString(16).padStart(2, '0')}${(layer.params[k][1] * 255).toString(16).padStart(2, '0')}${(layer.params[k][2] * 255).toString(16).padStart(2, '0')}`" type="color" @update:modelValue="v => { const c = v.slice(1).match(/.{2}/g)?.map(x => parseInt(x, 16) / 255); if (c) layer.params[k] = c; }">
+ <template #label>{{ k }}</template>
+ </MkInput>
+ </div>
+ </div>
+</MkFolder>
+</template>
+
+<script setup lang="ts">
+import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
+import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
+import { i18n } from '@/i18n.js';
+import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
+import MkFolder from '@/components/MkFolder.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkRadios from '@/components/MkRadios.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkRange from '@/components/MkRange.vue';
+import FormSlot from '@/components/form/slot.vue';
+import MkPositionSelector from '@/components/MkPositionSelector.vue';
+import * as os from '@/os.js';
+import { selectFile } from '@/utility/drive.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+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);
+if (fx == null) {
+ throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
+}
+
+const emit = defineEmits<{
+ (e: 'del'): void;
+ (e: 'swapUp'): void;
+ (e: 'swapDown'): void;
+}>();
+</script>
+
+<style module>
+.root {
+
+}
+</style>
diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue
new file mode 100644
index 0000000000..997dd4d528
--- /dev/null
+++ b/packages/frontend/src/components/MkImageEffectorDialog.vue
@@ -0,0 +1,302 @@
+<!--
+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-sparkles"></i> {{ i18n.ts._imageEffector.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 class="_acrylic" :class="$style.previewControls">
+ <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
+ <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
+ </div>
+ </div>
+ </div>
+ <div :class="$style.controls">
+ <div class="_spacer _gaps">
+ <XLayer
+ v-for="(layer, i) in layers"
+ :key="layer.id"
+ v-model:layer="layers[i]"
+ @del="onLayerDelete(layer)"
+ @swapUp="onLayerSwapUp(layer)"
+ @swapDown="onLayerSwapDown(layer)"
+ ></XLayer>
+
+ <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
+import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
+import { i18n } from '@/i18n.js';
+import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import XLayer from '@/components/MkImageEffectorDialog.Layer.vue';
+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';
+
+const props = defineProps<{
+ image: File;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'ok', image: File): void;
+ (ev: 'cancel'): void;
+ (ev: 'closed'): void;
+}>();
+
+const dialog = useTemplateRef('dialog');
+
+async function cancel() {
+ if (layers.length > 0) {
+ const { canceled } = await os.confirm({
+ text: i18n.ts._imageEffector.discardChangesConfirm,
+ });
+ if (canceled) return;
+ }
+
+ emit('cancel');
+ dialog.value?.close();
+}
+
+const layers = reactive<ImageEffectorLayer[]>([]);
+
+watch(layers, async () => {
+ if (renderer != null) {
+ renderer.setLayers(layers);
+ }
+}, { deep: true });
+
+function addEffect(ev: MouseEvent) {
+ os.popupMenu(FXS.filter(fx => fx.id !== 'watermarkPlacement').map((fx) => ({
+ text: fx.name,
+ action: () => {
+ layers.push({
+ id: genId(),
+ fxId: fx.id,
+ params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
+ });
+ },
+ })), ev.currentTarget ?? ev.target);
+}
+
+function onLayerSwapUp(layer: ImageEffectorLayer) {
+ const index = layers.indexOf(layer);
+ if (index > 0) {
+ layers.splice(index, 1);
+ layers.splice(index - 1, 0, layer);
+ }
+}
+
+function onLayerSwapDown(layer: ImageEffectorLayer) {
+ const index = layers.indexOf(layer);
+ if (index < layers.length - 1) {
+ layers.splice(index, 1);
+ layers.splice(index + 1, 0, layer);
+ }
+}
+
+function onLayerDelete(layer: ImageEffectorLayer) {
+ const index = layers.indexOf(layer);
+ if (index !== -1) {
+ layers.splice(index, 1);
+ }
+}
+
+const canvasEl = useTemplateRef('canvasEl');
+
+let renderer: ImageEffector | null = null;
+let imageBitmap: ImageBitmap | null = null;
+
+onMounted(async () => {
+ if (canvasEl.value == null) return;
+
+ const closeWaiting = os.waiting();
+
+ await nextTick(); // waitingがレンダリングされるまで待つ
+
+ 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 ImageEffector({
+ canvas: canvasEl.value,
+ renderWidth: w,
+ renderHeight: h,
+ image: imageBitmap,
+ fxs: FXS,
+ });
+
+ await renderer.setLayers(layers);
+
+ renderer.render();
+
+ closeWaiting();
+});
+
+onUnmounted(() => {
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+ if (imageBitmap != null) {
+ imageBitmap.close();
+ imageBitmap = null;
+ }
+});
+
+async function save() {
+ if (layers.length === 0 || renderer == null || imageBitmap == null || canvasEl.value == null) {
+ cancel();
+ return;
+ }
+
+ const closeWaiting = os.waiting();
+
+ await nextTick(); // waitingがレンダリングされるまで待つ
+
+ renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
+ renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
+ canvasEl.value.toBlob((blob) => {
+ emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
+ dialog.value?.close();
+ closeWaiting();
+ }, 'image/png');
+}
+
+const enabled = ref(true);
+watch(enabled, () => {
+ if (renderer != null) {
+ if (enabled.value) {
+ renderer.setLayers(layers);
+ } else {
+ renderer.setLayers([]);
+ }
+ renderer.render();
+ }
+});
+</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>
diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue
new file mode 100644
index 0000000000..002950cdf1
--- /dev/null
+++ b/packages/frontend/src/components/MkPositionSelector.vue
@@ -0,0 +1,53 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root]">
+ <div :class="$style.items">
+ <button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button>
+ <button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button>
+ <button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button>
+ <button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button>
+ <button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button>
+ <button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button>
+ <button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button>
+ <button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button>
+ <button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const x = defineModel<string>('x', { default: 'center' });
+const y = defineModel<string>('y', { default: 'center' });
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+}
+
+.items {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ gap: 4px;
+ border-radius: 8px;
+ overflow: clip;
+}
+
+.item {
+ height: 32px;
+ background: var(--MI_THEME-panel);
+ border-radius: 4px;
+
+ &.active {
+ background: var(--MI_THEME-accentedBg);
+ color: var(--MI_THEME-accent);
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index f36e68b687..9a6a207c74 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -12,7 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="prefix"></slot>
<div ref="containerEl" class="container">
<div class="track">
- <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
+ <div class="highlight right" :style="{ width: ((steppedRawValue - minRatio) * 100) + '%', left: (Math.abs(Math.min(0, min)) / (max + Math.abs(Math.min(0, min)))) * 100 + '%' }">
+ <div class="shine right"></div>
+ </div>
+ <div class="highlight left" :style="{ width: ((minRatio - steppedRawValue) * 100) + '%', left: (steppedRawValue) * 100 + '%' }">
+ <div class="shine left"></div>
+ </div>
</div>
<div v-if="steps && showTicks" class="ticks">
<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
@@ -24,7 +29,9 @@ SPDX-License-Identifier: AGPL-3.0-only
@mouseenter.passive="onMouseenter"
@mousedown="onMousedown"
@touchstart="onMousedown"
- ></div>
+ >
+ <div class="thumbInner"></div>
+ </div>
</div>
<slot name="suffix"></slot>
</div>
@@ -63,6 +70,9 @@ const emit = defineEmits<{
const containerEl = useTemplateRef('containerEl');
const thumbEl = useTemplateRef('thumbEl');
+const maxRatio = computed(() => Math.abs(props.max) / (props.max + Math.abs(Math.min(0, props.min))));
+const minRatio = computed(() => Math.abs(Math.min(0, props.min)) / (props.max + Math.abs(Math.min(0, props.min))));
+
const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
const steppedRawValue = computed(() => {
if (props.step) {
@@ -222,15 +232,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
}
}
- $thumbHeight: 20px;
- $thumbWidth: 20px;
+ $thumbHeight: 32px;
+ $thumbWidth: 32px;
+ $thumbInnerHeight: 19px;
+ $thumbInnerWidth: 19px;
> .body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
- padding: 7px 12px;
+ padding: 0px 4px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel);
border-radius: 6px;
@@ -256,10 +268,30 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
> .highlight {
position: absolute;
top: 0;
- left: 0;
height: 100%;
- background: var(--MI_THEME-accent);
- opacity: 0.5;
+ background: color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0.5);
+ overflow: clip;
+
+ > .shine {
+ position: absolute;
+ top: 0;
+ width: 64px;
+ height: 100%;
+ }
+ }
+
+ > .highlight.right {
+ > .shine.right {
+ right: calc(#{$thumbInnerWidth} / 2);
+ background: linear-gradient(-90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0));
+ }
+ }
+
+ > .highlight.left {
+ > .shine.left {
+ left: calc(#{$thumbInnerWidth} / 2);
+ background: linear-gradient(90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0));
+ }
}
}
@@ -290,11 +322,25 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
width: $thumbWidth;
height: $thumbHeight;
cursor: grab;
- background: var(--MI_THEME-accent);
- border-radius: 999px;
&:hover {
- background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
+ > .thumbInner {
+ background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
+ }
+ }
+
+ > .thumbInner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: $thumbInnerWidth;
+ height: $thumbInnerHeight;
+ background: var(--MI_THEME-accent);
+ border-radius: 999px;
+ pointer-events: none;
}
}
}
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>
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
new file mode 100644
index 0000000000..10de04c16a
--- /dev/null
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
@@ -0,0 +1,318 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" class="_gaps">
+ <template v-if="layer.type === 'text'">
+ <MkInput v-model="layer.text">
+ <template #label>{{ i18n.ts._watermarkEditor.text }}</template>
+ </MkInput>
+
+ <FormSlot>
+ <template #label>{{ i18n.ts._watermarkEditor.position }}</template>
+ <MkPositionSelector
+ v-model:x="layer.align.x"
+ v-model:y="layer.align.y"
+ ></MkPositionSelector>
+ </FormSlot>
+
+ <MkRange
+ v-model="layer.scale"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.angle"
+ :min="-1"
+ :max="1"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.opacity"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
+ </MkRange>
+
+ <MkSwitch v-model="layer.repeat">
+ <template #label>{{ i18n.ts._watermarkEditor.repeat }}</template>
+ </MkSwitch>
+ </template>
+
+ <template v-else-if="layer.type === 'image'">
+ <MkButton inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton>
+
+ <FormSlot>
+ <template #label>{{ i18n.ts._watermarkEditor.position }}</template>
+ <MkPositionSelector
+ v-model:x="layer.align.x"
+ v-model:y="layer.align.y"
+ ></MkPositionSelector>
+ </FormSlot>
+
+ <MkRange
+ v-model="layer.scale"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.angle"
+ :min="-1"
+ :max="1"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.opacity"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
+ </MkRange>
+
+ <MkSwitch v-model="layer.repeat">
+ <template #label>{{ i18n.ts._watermarkEditor.repeat }}</template>
+ </MkSwitch>
+
+ <MkSwitch v-model="layer.cover">
+ <template #label>{{ i18n.ts._watermarkEditor.cover }}</template>
+ </MkSwitch>
+ </template>
+
+ <template v-else-if="layer.type === 'stripe'">
+ <MkRange
+ v-model="layer.frequency"
+ :min="1"
+ :max="30"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.stripeFrequency }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.threshold"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.stripeWidth }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.angle"
+ :min="-1"
+ :max="1"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.opacity"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
+ </MkRange>
+ </template>
+
+ <template v-else-if="layer.type === 'polkadot'">
+ <MkRange
+ v-model="layer.angle"
+ :min="-1"
+ :max="1"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.scale"
+ :min="0"
+ :max="10"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.majorRadius"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotRadius }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.majorOpacity"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotOpacity }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.minorDivisions"
+ :min="0"
+ :max="16"
+ :step="1"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotDivisions }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.minorRadius"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotRadius }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.minorOpacity"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotOpacity }}</template>
+ </MkRange>
+ </template>
+
+ <template v-else-if="layer.type === 'checker'">
+ <MkRange
+ v-model="layer.angle"
+ :min="-1"
+ :max="1"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.scale"
+ :min="0"
+ :max="10"
+ :step="0.01"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
+ </MkRange>
+
+ <MkRange
+ v-model="layer.opacity"
+ :min="0"
+ :max="1"
+ :step="0.01"
+ :textConverter="(v) => (v * 100).toFixed(1) + '%'"
+ continuousUpdate
+ >
+ <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
+ </MkRange>
+ </template>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
+import type { WatermarkPreset } from '@/utility/watermark.js';
+import { i18n } from '@/i18n.js';
+import MkSelect from '@/components/MkSelect.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkRange from '@/components/MkRange.vue';
+import FormSlot from '@/components/form/slot.vue';
+import MkPositionSelector from '@/components/MkPositionSelector.vue';
+import * as os from '@/os.js';
+import { selectFile } from '@/utility/drive.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+
+const layer = defineModel<WatermarkPreset['layers'][number]>('layer', { required: true });
+
+const driveFile = ref();
+const driveFileError = ref(false);
+onMounted(async () => {
+ if (layer.value.type === 'image' && layer.value.imageId != null) {
+ await misskeyApi('drive/files/show', {
+ fileId: layer.value.imageId,
+ }).then((res) => {
+ driveFile.value = res;
+ }).catch((err) => {
+ driveFileError.value = true;
+ });
+ }
+});
+
+function chooseFile(ev: MouseEvent) {
+ selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => {
+ if (!file.type.startsWith('image')) {
+ os.alert({
+ type: 'warning',
+ title: i18n.ts._watermarkEditor.driveFileTypeWarn,
+ text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription,
+ });
+ return;
+ }
+
+ layer.value.imageId = file.id;
+ layer.value.imageUrl = file.url;
+ driveFileError.value = false;
+ });
+}
+</script>
+
+<style module>
+.root {
+
+}
+</style>
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>