summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2025-12-28 20:50:11 +0900
committerGitHub <noreply@github.com>2025-12-28 20:50:11 +0900
commit14f58255ee6a98837df680f50293e3ef1a26d2dc (patch)
tree87cfbc1c6e5fbc5af0b3a675f7e4f3d516289705 /packages/frontend/src/components
parentchore: SearchServiceのunit-test追加 (#17035) (diff)
downloadmisskey-14f58255ee6a98837df680f50293e3ef1a26d2dc.tar.gz
misskey-14f58255ee6a98837df680f50293e3ef1a26d2dc.tar.bz2
misskey-14f58255ee6a98837df680f50293e3ef1a26d2dc.zip
enhance(frontend): ウィジェットの設定画面を改良 (#17033)
* enhance(frontend): ウィジェットの設定画面を改良 * Update Changelog * fix lint
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkEmbedCodeGenDialog.vue85
-rw-r--r--packages/frontend/src/components/MkForm.file.vue (renamed from packages/frontend/src/components/MkFormDialog.file.vue)0
-rw-r--r--packages/frontend/src/components/MkForm.vue84
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue87
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.vue106
-rw-r--r--packages/frontend/src/components/MkImageEffectorFxForm.vue2
-rw-r--r--packages/frontend/src/components/MkImageFrameEditorDialog.vue263
-rw-r--r--packages/frontend/src/components/MkPreviewWithControls.vue93
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.vue124
-rw-r--r--packages/frontend/src/components/MkWidgetSettingsDialog.vue172
10 files changed, 564 insertions, 452 deletions
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
index 4f16149caa..9002669378 100644
--- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkForm.file.vue
index 182ff3ccf5..182ff3ccf5 100644
--- a/packages/frontend/src/components/MkFormDialog.file.vue
+++ b/packages/frontend/src/components/MkForm.file.vue
diff --git a/packages/frontend/src/components/MkForm.vue b/packages/frontend/src/components/MkForm.vue
new file mode 100644
index 0000000000..750ffa77df
--- /dev/null
+++ b/packages/frontend/src/components/MkForm.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 142ccb12a3..e598394ec4 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue
index 3d7801f925..01df7d7496 100644
--- a/packages/frontend/src/components/MkImageEffectorDialog.vue
+++ b/packages/frontend/src/components/MkImageEffectorDialog.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkImageEffectorFxForm.vue b/packages/frontend/src/components/MkImageEffectorFxForm.vue
index e581b1f743..51485977a9 100644
--- a/packages/frontend/src/components/MkImageEffectorFxForm.vue
+++ b/packages/frontend/src/components/MkImageEffectorFxForm.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue
index 2a91c85952..0badda3db7 100644
--- a/packages/frontend/src/components/MkImageFrameEditorDialog.vue
+++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue
@@ -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>
- </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">
- <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
- <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
- </MkRange>
+ </template>
- <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>
+ <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.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
- <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
- </MkInput>
+ <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>
- <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>
+ <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>
- <MkFolder :defaultOpen="params.labelTop.enabled">
- <template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
+ <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>
- <div class="_gaps">
- <MkSwitch v-model="params.labelTop.enabled">
- <template #label>{{ i18n.ts.show }}</template>
- </MkSwitch>
+ <MkFolder :defaultOpen="params.labelTop.enabled">
+ <template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
- <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>
+ <div class="_gaps">
+ <MkSwitch v-model="params.labelTop.enabled">
+ <template #label>{{ i18n.ts.show }}</template>
+ </MkSwitch>
- <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>
+ <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>
- <MkSwitch v-model="params.labelTop.centered">
- <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
- </MkSwitch>
+ <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>
- <MkInput v-model="params.labelTop.textBig">
- <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
- </MkInput>
+ <MkSwitch v-model="params.labelTop.centered">
+ <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
+ </MkSwitch>
- <MkTextarea v-model="params.labelTop.textSmall">
- <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
- </MkTextarea>
+ <MkInput v-model="params.labelTop.textBig">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
+ </MkInput>
- <MkSwitch v-model="params.labelTop.withQrCode">
- <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
- </MkSwitch>
- </div>
- </MkFolder>
+ <MkTextarea v-model="params.labelTop.textSmall">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
+ </MkTextarea>
- <MkFolder :defaultOpen="params.labelBottom.enabled">
- <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
+ <MkSwitch v-model="params.labelTop.withQrCode">
+ <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
- <div class="_gaps">
- <MkSwitch v-model="params.labelBottom.enabled">
- <template #label>{{ i18n.ts.show }}</template>
- </MkSwitch>
+ <MkFolder :defaultOpen="params.labelBottom.enabled">
+ <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
- <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>
+ <div class="_gaps">
+ <MkSwitch v-model="params.labelBottom.enabled">
+ <template #label>{{ i18n.ts.show }}</template>
+ </MkSwitch>
- <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>
+ <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>
- <MkSwitch v-model="params.labelBottom.centered">
- <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
- </MkSwitch>
+ <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>
- <MkInput v-model="params.labelBottom.textBig">
- <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
- </MkInput>
+ <MkSwitch v-model="params.labelBottom.centered">
+ <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
+ </MkSwitch>
- <MkTextarea v-model="params.labelBottom.textSmall">
- <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
- </MkTextarea>
+ <MkInput v-model="params.labelBottom.textBig">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
+ </MkInput>
- <MkSwitch v-model="params.labelBottom.withQrCode">
- <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
- </MkSwitch>
- </div>
- </MkFolder>
+ <MkTextarea v-model="params.labelBottom.textSmall">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
+ </MkTextarea>
- <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>
+ <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>
+ </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>
diff --git a/packages/frontend/src/components/MkPreviewWithControls.vue b/packages/frontend/src/components/MkPreviewWithControls.vue
new file mode 100644
index 0000000000..85cfa2d7e9
--- /dev/null
+++ b/packages/frontend/src/components/MkPreviewWithControls.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index 6cd2111598..7fe497e455 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkWidgetSettingsDialog.vue b/packages/frontend/src/components/MkWidgetSettingsDialog.vue
new file mode 100644
index 0000000000..cebbe93986
--- /dev/null
+++ b/packages/frontend/src/components/MkWidgetSettingsDialog.vue
@@ -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>