This commit is contained in:
かっこかり 2025-12-26 05:08:25 +00:00 committed by GitHub
commit 8c00f3e673
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 621 additions and 483 deletions

View File

@ -4,6 +4,7 @@
-
### Client
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
### Server

View File

@ -1406,6 +1406,7 @@ youAreAdmin: "あなたは管理者です"
frame: "フレーム"
presets: "プリセット"
zeroPadding: "ゼロ埋め"
nothingToConfigure: "設定項目はありません"
_imageEditing:
_vars:
@ -3418,7 +3419,6 @@ _imageEffector:
title: "エフェクト"
addEffect: "エフェクトを追加"
discardChangesConfirm: "変更を破棄して終了しますか?"
nothingToConfigure: "設定項目はありません"
failedToLoadImage: "画像の読み込みに失敗しました"
_fxs:

View File

@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
<template #preview>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
@ -43,27 +42,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</div>
<div :class="$style.embedCodeGenSettings" class="_gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode" :items="colorModeDef">
<template #label>{{ i18n.ts.theme }}</template>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</template>
<template #controls>
<div class="_spacer _gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode" :items="colorModeDef">
<template #label>{{ i18n.ts.theme }}</template>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
<div class="_gaps_s">
@ -89,18 +90,17 @@ import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
import { prefer } from '@/preferences.js';
const emit = defineEmits<{
(ev: 'ok'): void;
@ -302,29 +302,6 @@ onUnmounted(() => {
height: 100%;
}
.embedCodeGenInputRoot {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.embedCodeGenPreviewRoot {
position: relative;
cursor: not-allowed;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.embedCodeGenPreviewWrapper {
display: flex;
flex-direction: column;
@ -372,11 +349,6 @@ onUnmounted(() => {
color-scheme: light dark;
}
.embedCodeGenSettings {
padding: 24px;
overflow-y: scroll;
}
.embedCodeGenResultRoot {
box-sizing: border-box;
padding: 24px;
@ -417,11 +389,4 @@ onUnmounted(() => {
.embedCodeGenResultButtons {
margin: 0 auto;
}
@container (max-width: 800px) {
.embedCodeGenInputRoot {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -0,0 +1,84 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="v, k in form">
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
</MkSelect>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
<XFile
v-else-if="v.type === 'drive-file'"
:fileId="v.defaultFileId"
:validate="async f => !v.validate || await v.validate(f)"
@update="f => values[k] = f"
/>
</template>
</div>
<MkResult v-else type="empty" :text="i18n.ts.nothingToConfigure"/>
</template>
<script lang="ts" setup>
import XFile from '@/components/MkForm.file.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue';
import MkRadios from '@/components/MkRadios.vue';
import { i18n } from '@/i18n.js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
const props = defineProps<{
form: Form;
}>();
// TODO:
const values = defineModel<Record<string, any>>({ required: true });
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
return def.enum.map((v) => {
if (typeof v === 'string') {
return { value: v, label: v };
} else {
return { value: v.value, label: v.label };
}
});
}
function getRadioKey(e: RadioFormItem['options'][number]) {
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
}
</script>

View File

@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="(v, k) in Object.fromEntries(Object.entries(form))">
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
</MkSelect>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
<XFile
v-else-if="v.type === 'drive-file'"
:fileId="v.defaultFileId"
:validate="async f => !v.validate || await v.validate(f)"
@update="f => values[k] = f"
/>
</template>
</div>
<MkResult v-else type="empty"/>
<MkForm v-model="values" :form="form"/>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { reactive, useTemplateRef } from 'vue';
import MkInput from './MkInput.vue';
import MkTextarea from './MkTextarea.vue';
import MkSwitch from './MkSwitch.vue';
import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue';
import XFile from './MkFormDialog.file.vue';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
import type { Form } from '@/utility/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import MkForm from '@/components/MkForm.vue';
const props = defineProps<{
title: string;
@ -96,15 +46,18 @@ const emit = defineEmits<{
}>();
const dialog = useTemplateRef('dialog');
const values = reactive({});
for (const item in props.form) {
if ('default' in props.form[item]) {
values[item] = props.form[item].default ?? null;
} else {
values[item] = null;
const values = reactive((() => {
const obj: Record<string, any> = {};
for (const item in props.form) {
if ('default' in props.form[item]) {
obj[item] = props.form[item].default ?? null;
} else {
obj[item] = null;
}
}
}
return obj;
})());
function ok() {
emit('done', {
@ -119,18 +72,4 @@ function cancel() {
});
dialog.value?.close();
}
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
return def.enum.map((v) => {
if (typeof v === 'string') {
return { value: v, label: v };
} else {
return { value: v.value, label: v.label };
}
});
}
function getRadioKey(e: RadioFormItem['options'][number]) {
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
}
</script>

View File

@ -16,37 +16,36 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<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, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div class="_acrylic" :class="$style.editControls">
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
</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>
<MkPreviewWithControls>
<template #preview>
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div class="_acrylic" :class="$style.editControls">
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
</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 :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>
</template>
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
</div>
<template #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>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
@ -56,15 +55,12 @@ import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.
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 MkPreviewWithControls from '@/components/MkPreviewWithControls.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';
import { prefer } from '@/preferences.js';
const props = defineProps<{
image: File;
@ -367,33 +363,6 @@ function onImagePointerdown(ev: PointerEvent) {
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
@ -442,16 +411,6 @@ function onImagePointerdown(ev: PointerEvent) {
}
}
.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;
@ -467,15 +426,4 @@ function onImagePointerdown(ev: PointerEvent) {
object-fit: contain;
touch-action: none;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</div>
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
{{ i18n.ts._imageEffector.nothingToConfigure }}
{{ i18n.ts.nothingToConfigure }}
</div>
</div>
</template>

View File

@ -16,140 +16,139 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
<MkPreviewWithControls>
<template #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>
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
</template>
<template #controls>
<div class="_spacer _gaps">
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
</MkRange>
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
</MkInput>
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
</MkInput>
<MkSelect
v-model="params.font" :items="[
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
]"
>
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
</MkSelect>
<MkFolder :defaultOpen="params.labelTop.enabled">
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
<div class="_gaps">
<MkSwitch v-model="params.labelTop.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="params.labelTop.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="params.labelTop.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="params.labelTop.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="params.labelTop.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</div>
</MkFolder>
<MkFolder :defaultOpen="params.labelBottom.enabled">
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
<div class="_gaps">
<MkSwitch v-model="params.labelBottom.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="params.labelBottom.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="params.labelBottom.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="params.labelBottom.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="params.labelBottom.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkInfo>
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
</MkInfo>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
</MkRange>
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
</MkInput>
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
</MkInput>
<MkSelect
v-model="params.font" :items="[
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
]"
>
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
</MkSelect>
<MkFolder :defaultOpen="params.labelTop.enabled">
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
<div class="_gaps">
<MkSwitch v-model="params.labelTop.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="params.labelTop.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="params.labelTop.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="params.labelTop.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="params.labelTop.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkFolder :defaultOpen="params.labelBottom.enabled">
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
<div class="_gaps">
<MkSwitch v-model="params.labelBottom.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="params.labelBottom.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="params.labelBottom.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="params.labelBottom.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="params.labelBottom.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkInfo>
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
</MkInfo>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
@ -161,8 +160,8 @@ import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-r
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from './MkPreviewWithControls.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
@ -173,8 +172,6 @@ import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { prefer } from '@/preferences.js';
const $i = ensureSignin();
@ -412,33 +409,6 @@ function getRgb(hex: string | number): [number, number, number] | null {
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
@ -495,15 +465,4 @@ function getRgb(hex: string | number): [number, number, number] | null {
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>

View File

@ -0,0 +1,93 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<div :class="$style.previewContent">
<slot name="preview"></slot>
</div>
<div v-if="previewLoading" :class="$style.previewLoading">
<MkLoading :class="$style.previewLoadingSpinner"/>
</div>
</div>
<div :class="$style.controls">
<slot name="controls"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
previewLoading?: boolean;
}>(), {
previewLoading: false,
});
defineSlots<{
preview: () => any;
controls: () => any;
}>();
</script>
<style lang="scss" module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.previewContent {
position: relative;
width: 100%;
height: 100%;
overflow: clip;
}
.previewLoading {
position: absolute;
inset: 0;
background-color: color(from var(--MI_THEME-panel) srgb r g b / 0.7);
display: flex;
justify-content: center;
align-items: center;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -16,50 +16,49 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<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, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
<MkPreviewWithControls>
<template #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>
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<div class="_gaps_s">
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<template #label>
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
<div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</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>
</template>
<XLayer
v-model:layer="layers[i]"
></XLayer>
</MkFolder>
<template #controls>
<div class="_spacer _gaps">
<div class="_gaps_s">
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<template #label>
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
<div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</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>
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
</div>
<XLayer
v-model:layer="layers[i]"
></XLayer>
</MkFolder>
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
@ -69,6 +68,7 @@ import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/Water
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
@ -411,33 +411,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
@ -474,16 +447,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
}
}
.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;
@ -494,15 +457,4 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
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>

View File

@ -0,0 +1,172 @@
<!--
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-icons"></i> {{ widgetName }}</template>
<MkPreviewWithControls>
<template #preview>
<div :class="$style.previewWrapper">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.previewResizerRoot" inert>
<div
ref="resizerEl"
:class="$style.previewResizer"
:style="{ transform: widgetStyle }"
>
<component
:is="`widget-${widgetName}`"
:key="currentId"
:widget="{ name: widgetName, id: '__PREVIEW__', data: settings }"
></component>
</div>
</div>
</div>
</template>
<template #controls>
<div class="_spacer">
<MkForm v-model="settings" :form="form"/>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef, ref, computed, watch, onBeforeUnmount, onMounted } from 'vue';
import { deepClone } from '@/utility/clone.js';
import { genId } from '@/utility/id.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from './MkPreviewWithControls.vue';
import MkForm from '@/components/MkForm.vue';
import type { Form } from '@/utility/form.js';
const props = defineProps<{
widgetName: string;
form: Form;
currentSettings: Record<string, any>;
}>();
const emit = defineEmits<{
(ev: 'saved', settings: Record<string, any>): void;
(ev: 'canceled'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
const settings = reactive<Record<string, any>>(deepClone(props.currentSettings));
const currentId = ref(genId());
watch(settings, () => {
currentId.value = genId();
});
function save() {
emit('saved', deepClone(settings));
dialog.value?.close();
}
function cancel() {
emit('canceled');
dialog.value?.close();
}
//#region
const resizerRootEl = useTemplateRef('resizerRootEl');
const resizerEl = useTemplateRef('resizerEl');
const widgetHeight = ref(0);
const widgetScale = ref(1);
const widgetStyle = computed(() => {
return `translate(-50%, -50%) scale(${widgetScale.value})`;
});
const ro1 = new ResizeObserver(() => {
widgetHeight.value = resizerEl.value!.clientHeight;
calcScale();
});
const ro2 = new ResizeObserver(() => {
calcScale();
});
function calcScale() {
if (!resizerRootEl.value) return;
const previewWidth = resizerRootEl.value.clientWidth - 40; // 20px
const previewHeight = resizerRootEl.value.clientHeight - 40; // 20px
const widgetWidth = 280;
const scale = Math.min(previewWidth / widgetWidth, previewHeight / widgetHeight.value, 1); // 1
widgetScale.value = scale;
}
onMounted(() => {
if (resizerEl.value) {
ro1.observe(resizerEl.value);
}
if (resizerRootEl.value) {
ro2.observe(resizerRootEl.value);
}
calcScale();
});
onBeforeUnmount(() => {
ro1.disconnect();
ro2.disconnect();
});
//#endregion
</script>
<style module>
.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%;
}
.previewWrapper {
display: flex;
flex-direction: column;
height: 100%;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewResizerRoot {
position: relative;
flex: 1 0;
}
.previewResizer {
position: absolute;
container-type: inline-size;
top: 50%;
left: 50%;
width: 280px;
}
</style>

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, watch } from 'vue';
import { defineAsyncComponent, reactive, watch } from 'vue';
import type { Reactive } from 'vue';
import { throttle } from 'throttle-debounce';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
@ -62,11 +62,36 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
for (const item of Object.keys(form)) {
form[item].default = widgetProps[item];
}
const { canceled, result } = await os.form(name, form);
if (canceled) return;
for (const key of Object.keys(result)) {
widgetProps[key] = result[key];
const res = await new Promise<{
canceled: false;
result: GetFormResultType<F>;
} | {
canceled: true;
}>((resolve) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWidgetSettingsDialog.vue')), {
widgetName: name,
form: form,
currentSettings: widgetProps,
}, {
saved: (newProps: GetFormResultType<F>) => {
resolve({ canceled: false, result: newProps });
},
canceled: () => {
resolve({ canceled: true });
},
closed: () => {
dispose();
},
});
});
if (res.canceled) {
return;
}
for (const key of Object.keys(res.result)) {
widgetProps[key] = res.result[key];
}
save();

View File

@ -5639,6 +5639,10 @@ export interface Locale extends ILocale {
*
*/
"zeroPadding": string;
/**
*
*/
"nothingToConfigure": string;
"_imageEditing": {
"_vars": {
/**
@ -12763,10 +12767,6 @@ export interface Locale extends ILocale {
*
*/
"discardChangesConfirm": string;
/**
*
*/
"nothingToConfigure": string;
/**
*
*/