diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-12-28 20:50:11 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-28 20:50:11 +0900 |
| commit | 14f58255ee6a98837df680f50293e3ef1a26d2dc (patch) | |
| tree | 87cfbc1c6e5fbc5af0b3a675f7e4f3d516289705 | |
| parent | chore: SearchServiceのunit-test追加 (#17035) (diff) | |
| download | misskey-14f58255ee6a98837df680f50293e3ef1a26d2dc.tar.gz misskey-14f58255ee6a98837df680f50293e3ef1a26d2dc.tar.bz2 misskey-14f58255ee6a98837df680f50293e3ef1a26d2dc.zip | |
enhance(frontend): ウィジェットの設定画面を改良 (#17033)
* enhance(frontend): ウィジェットの設定画面を改良
* Update Changelog
* fix lint
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | locales/ja-JP.yml | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkEmbedCodeGenDialog.vue | 85 | ||||
| -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.vue | 84 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkFormDialog.vue | 87 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkImageEffectorDialog.vue | 106 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkImageEffectorFxForm.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkImageFrameEditorDialog.vue | 263 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPreviewWithControls.vue | 93 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkWatermarkEditorDialog.vue | 124 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkWidgetSettingsDialog.vue | 172 | ||||
| -rw-r--r-- | packages/frontend/src/widgets/widget.ts | 35 | ||||
| -rw-r--r-- | packages/i18n/src/autogen/locale.ts | 8 |
14 files changed, 600 insertions, 462 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c478c83005..fc89b3d727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Client - Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に +- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように - Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061 ### Server diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1eea745e0c..01e5101255 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1406,6 +1406,7 @@ youAreAdmin: "あなたは管理者です" frame: "フレーム" presets: "プリセット" zeroPadding: "ゼロ埋め" +nothingToConfigure: "設定項目はありません" _imageEditing: _vars: @@ -3418,7 +3419,6 @@ _imageEffector: title: "エフェクト" addEffect: "エフェクトを追加" discardChangesConfirm: "変更を破棄して終了しますか?" - nothingToConfigure: "設定項目はありません" failedToLoadImage: "画像の読み込みに失敗しました" _fxs: 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> diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts index c5ca7ac26c..6c5ff36b16 100644 --- a/packages/frontend/src/widgets/widget.ts +++ b/packages/frontend/src/widgets/widget.ts @@ -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(); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 96a728da63..d8571483aa 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -5639,6 +5639,10 @@ export interface Locale extends ILocale { * ゼロ埋め */ "zeroPadding": string; + /** + * 設定項目はありません + */ + "nothingToConfigure": string; "_imageEditing": { "_vars": { /** @@ -12764,10 +12768,6 @@ export interface Locale extends ILocale { */ "discardChangesConfirm": string; /** - * 設定項目はありません - */ - "nothingToConfigure": string; - /** * 画像の読み込みに失敗しました */ "failedToLoadImage": string; |