mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-02 17:55:52 +02:00
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
This commit is contained in:
@@ -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>
|
||||
302
packages/frontend/src/components/MkImageEffectorDialog.vue
Normal file
302
packages/frontend/src/components/MkImageEffectorDialog.vue
Normal file
@@ -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>
|
||||
53
packages/frontend/src/components/MkPositionSelector.vue
Normal file
53
packages/frontend/src/components/MkPositionSelector.vue
Normal file
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (!item.waiting && !item.uploading && !item.uploaded) {
|
||||
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 (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>
|
||||
|
||||
@@ -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>
|
||||
455
packages/frontend/src/components/MkWatermarkEditorDialog.vue
Normal file
455
packages/frontend/src/components/MkWatermarkEditorDialog.vue
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user