diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a6ce35ef..972c876518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ - Enhance: データセーバーの適用範囲を個別で設定できるように - 従来のデータセーバーの設定はリセットされます - Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように +- Enhance: ユーザー名、プロフィール、お知らせ、ページの編集画面でMFMや絵文字のオートコンプリートが使用できるように +- Enhance: プロフィール、お知らせの編集画面でMFMのプレビューを表示できるように - Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように - ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 24404728ca..6f882cfab7 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -26,11 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkInput> - <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> + <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkInput> - <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> + <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkTextarea> diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 72babfac76..ae797eb7d2 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -43,11 +43,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; +import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; @@ -59,6 +60,7 @@ const props = defineProps<{ placeholder?: string; autofocus?: boolean; autocomplete?: string; + mfmAutocomplete?: boolean | SuggestionType[], autocapitalize?: string; spellcheck?: boolean; step?: any; @@ -93,6 +95,7 @@ const height = props.small ? 33 : props.large ? 39 : 36; +let autocomplete: Autocomplete; const focus = () => inputEl.value.focus(); const onInput = (ev: KeyboardEvent) => { @@ -160,6 +163,16 @@ onMounted(() => { focus(); } }); + + if (props.mfmAutocomplete) { + autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + } +}); + +onUnmounted(() => { + if (autocomplete) { + autocomplete.detach(); + } }); defineExpose({ diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 7c1ddcbbed..23fdd5bfe1 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -26,16 +26,21 @@ SPDX-License-Identifier: AGPL-3.0-only ></textarea> </div> <div :class="$style.caption"><slot name="caption"></slot></div> + <button style="font-size: 0.85em;" class="_textButton" type="button" @click="preview = !preview">{{ i18n.ts.preview }}</button> + <div v-show="preview" v-panel :class="$style.mfmPreview"> + <Mfm :text="v"/> + </div> <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; +import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | null; @@ -46,6 +51,8 @@ const props = defineProps<{ placeholder?: string; autofocus?: boolean; autocomplete?: string; + mfmAutocomplete?: boolean | SuggestionType[], + mfmPreview?: boolean; spellcheck?: boolean; debounce?: boolean; manualSave?: boolean; @@ -68,6 +75,8 @@ const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLTextAreaElement>(); +const preview = ref(false); +let autocomplete: Autocomplete; const focus = () => inputEl.value.focus(); const onInput = (ev) => { @@ -113,6 +122,16 @@ onMounted(() => { focus(); } }); + + if (props.mfmAutocomplete) { + autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + } +}); + +onUnmounted(() => { + if (autocomplete) { + autocomplete.detach(); + } }); </script> @@ -194,4 +213,12 @@ onMounted(() => { .save { margin: 8px 0 0 0; } + +.mfmPreview { + padding: 12px; + border-radius: var(--radius); + box-sizing: border-box; + min-height: 130px; + pointer-events: none; +} </style> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index fe599dcead..28293b287c 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -37,7 +37,7 @@ type MfmProps = { isNote?: boolean; emojiUrls?: string[]; rootScale?: number; - nyaize: boolean | 'respect'; + nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; enableEmojiMenu?: boolean; enableEmojiMenuReaction?: boolean; diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 92070dc6c6..e4bbe15955 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="announcement.title"> <template #label>{{ i18n.ts.title }}</template> </MkInput> - <MkTextarea v-model="announcement.text"> + <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true"> <template #label>{{ i18n.ts.text }}</template> </MkTextarea> <MkInput v-model="announcement.imageUrl" type="url"> @@ -75,7 +75,6 @@ import { ref, computed } from 'vue'; import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -83,6 +82,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkFolder from '@/components/MkFolder.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; const announcements = ref<any[]>([]); diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index af382bb137..f16b8709f3 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.name }}</template> </MkInput> - <MkTextarea v-model="description"> + <MkTextarea v-model="description" mfmAutocomplete :mfmPreview="true"> <template #label>{{ i18n.ts.description }}</template> </MkTextarea> @@ -70,7 +70,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, watch, defineAsyncComponent } from 'vue'; -import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; @@ -81,6 +80,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 2ea0312c7e..3c94db82d7 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -101,6 +101,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ type: 'string', required: false, multiline: true, + treatAsMfm: true, label: i18n.ts.description, default: clip.value.description, }, diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 2390617954..85c016187d 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -60,6 +60,7 @@ async function create() { type: 'string', required: false, multiline: true, + treatAsMfm: true, label: i18n.ts.description, }, isPublic: { diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index 4f47a77bdd..643e8ecdad 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -9,16 +9,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template> <section> - <textarea v-model="text" :class="$style.textarea"></textarea> + <textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea> </section> </XContainer> </template> <script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { watch, ref } from 'vue'; +import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue'; import XContainer from '../page-editor.container.vue'; import { i18n } from '@/i18n.js'; +import { Autocomplete } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: any @@ -28,7 +29,10 @@ const emit = defineEmits<{ (ev: 'update:modelValue', value: any): void; }>(); +let autocomplete: Autocomplete; + const text = ref(props.modelValue.text ?? ''); +const inputEl = shallowRef<HTMLTextAreaElement | null>(null); watch(text, () => { emit('update:modelValue', { @@ -36,6 +40,14 @@ watch(text, () => { text: text.value, }); }); + +onMounted(() => { + autocomplete = new Autocomplete(inputEl.value, text); +}); + +onUnmounted(() => { + autocomplete.detach(); +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 5f0d1aee51..1381042c39 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> </div> - <MkInput v-model="profile.name" :max="30" manualSave> + <MkInput v-model="profile.name" :max="30" manualSave :mfmAutocomplete="['emoji']"> <template #label>{{ i18n.ts._profile.name }}</template> </MkInput> - <MkTextarea v-model="profile.description" :max="500" tall manualSave> + <MkTextarea v-model="profile.description" :max="500" tall manualSave mfmAutocomplete :mfmPreview="true" :nyaize="$i?.isCat ? 'respect' : undefined" :author="($i as Misskey.entities.UserLite)"> <template #label>{{ i18n.ts._profile.description }}</template> <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> </MkTextarea> @@ -112,10 +112,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; +import Misskey from 'misskey-js'; import XAvatarDecoration from './profile.avatar-decoration.vue'; import MkButton from '@/components/MkButton.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 FormSplit from '@/components/form/split.vue'; @@ -130,6 +130,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index 0b4ebb4410..2a9a42ace5 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -8,6 +8,8 @@ import getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode/'; import { popup } from '@/os.js'; +export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag'; + export class Autocomplete { private suggestion: { x: Ref<number>; @@ -19,6 +21,7 @@ export class Autocomplete { private currentType: string; private textRef: Ref<string>; private opening: boolean; + private onlyType: SuggestionType[]; private get text(): string { // Use raw .value to get the latest value @@ -35,7 +38,7 @@ export class Autocomplete { /** * 対象のテキストエリアを与えてインスタンスを初期化します。 */ - constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { + constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) { //#region BIND this.onInput = this.onInput.bind(this); this.complete = this.complete.bind(this); @@ -46,6 +49,7 @@ export class Autocomplete { this.textarea = textarea; this.textRef = textRef; this.opening = false; + this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag']; this.attach(); } @@ -95,7 +99,7 @@ export class Autocomplete { let opened = false; - if (isMention) { + if (isMention && this.onlyType.includes('user')) { const username = text.substring(mentionIndex + 1); if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) { this.open('user', username); @@ -106,7 +110,7 @@ export class Autocomplete { } } - if (isHashtag && !opened) { + if (isHashtag && !opened && this.onlyType.includes('hashtag')) { const hashtag = text.substring(hashtagIndex + 1); if (!hashtag.includes(' ')) { this.open('hashtag', hashtag); @@ -114,7 +118,7 @@ export class Autocomplete { } } - if (isEmoji && !opened) { + if (isEmoji && !opened && this.onlyType.includes('emoji')) { const emoji = text.substring(emojiIndex + 1); if (!emoji.includes(' ')) { this.open('emoji', emoji); @@ -122,7 +126,7 @@ export class Autocomplete { } } - if (isMfmTag && !opened) { + if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) { const mfmTag = text.substring(mfmTagIndex + 1); if (!mfmTag.includes(' ')) { this.open('mfmTag', mfmTag.replace('[', ''));