mirror of
https://github.com/misskey-dev/misskey.git
synced 2025-12-28 06:54:10 +00:00
Merge 20044c4e8d into bc78bb9b8e
This commit is contained in:
commit
8c00f3e673
@ -4,6 +4,7 @@
|
||||
-
|
||||
|
||||
### Client
|
||||
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
|
||||
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
|
||||
|
||||
### Server
|
||||
|
||||
@ -1406,6 +1406,7 @@ youAreAdmin: "あなたは管理者です"
|
||||
frame: "フレーム"
|
||||
presets: "プリセット"
|
||||
zeroPadding: "ゼロ埋め"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
@ -3418,7 +3419,6 @@ _imageEffector:
|
||||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_fxs:
|
||||
|
||||
@ -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>
|
||||
|
||||
84
packages/frontend/src/components/MkForm.vue
Normal file
84
packages/frontend/src/components/MkForm.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
93
packages/frontend/src/components/MkPreviewWithControls.vue
Normal file
93
packages/frontend/src/components/MkPreviewWithControls.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
172
packages/frontend/src/components/MkWidgetSettingsDialog.vue
Normal file
172
packages/frontend/src/components/MkWidgetSettingsDialog.vue
Normal 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>
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user