Enhance(frontend): MFMや絵文字が使える入力ボックスでオートコンプリートを使えるように (#12643)
* rich autocomplete for use in profiles, announcements, and channel descriptions * implementation omissions * add tab, apply to page editor, and fix something * componentization * fix nyaize doesn't working in profile preview * detach autocomplete instance when unmounted * fix: mismatched camelCase * remove unused / unnecessary styles * update CHANGELOG.md * fix lint * remove dump.rdb * props.richAutocomplete -> autocomplete * Update packages/frontend/src/scripts/autocomplete.ts * clarify namings メンションなども「MFM」に含まれるのか自信がなかったのでrichSyntaxなどとぼかしていましたが、含むようなので変更しました * tweak * Update MkFormDialog.vue * rename --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		| @@ -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> | ||||
|   | ||||
| @@ -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({ | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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[]>([]); | ||||
|  | ||||
|   | ||||
| @@ -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)); | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
| 			}, | ||||
|   | ||||
| @@ -60,6 +60,7 @@ async function create() { | ||||
| 			type: 'string', | ||||
| 			required: false, | ||||
| 			multiline: true, | ||||
| 			treatAsMfm: true, | ||||
| 			label: i18n.ts.description, | ||||
| 		}, | ||||
| 		isPublic: { | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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)); | ||||
|  | ||||
|   | ||||
| @@ -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('[', '')); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 1STEP621
					1STEP621