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:
1STEP621 2023-12-14 13:11:23 +09:00 committed by GitHub
parent 5cee481083
commit b33fe53047
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 80 additions and 19 deletions

View file

@ -37,6 +37,8 @@
- Enhance: データセーバーの適用範囲を個別で設定できるように - Enhance: データセーバーの適用範囲を個別で設定できるように
- 従来のデータセーバーの設定はリセットされます - 従来のデータセーバーの設定はリセットされます
- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように - Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように
- Enhance: ユーザー名、プロフィール、お知らせ、ページの編集画面でMFMや絵文字のオートコンプリートが使用できるように
- Enhance: プロフィール、お知らせの編集画面でMFMのプレビューを表示できるように
- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように - Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように
- ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。 - ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正

View file

@ -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 #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> <template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkInput> </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 #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> <template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkInput> </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 #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> <template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkTextarea> </MkTextarea>

View file

@ -43,11 +43,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <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 { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { useInterval } from '@/scripts/use-interval.js'; import { useInterval } from '@/scripts/use-interval.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
const props = defineProps<{ const props = defineProps<{
modelValue: string | number | null; modelValue: string | number | null;
@ -59,6 +60,7 @@ const props = defineProps<{
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
autocomplete?: string; autocomplete?: string;
mfmAutocomplete?: boolean | SuggestionType[],
autocapitalize?: string; autocapitalize?: string;
spellcheck?: boolean; spellcheck?: boolean;
step?: any; step?: any;
@ -93,6 +95,7 @@ const height =
props.small ? 33 : props.small ? 33 :
props.large ? 39 : props.large ? 39 :
36; 36;
let autocomplete: Autocomplete;
const focus = () => inputEl.value.focus(); const focus = () => inputEl.value.focus();
const onInput = (ev: KeyboardEvent) => { const onInput = (ev: KeyboardEvent) => {
@ -160,6 +163,16 @@ onMounted(() => {
focus(); focus();
} }
}); });
if (props.mfmAutocomplete) {
autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete);
}
});
onUnmounted(() => {
if (autocomplete) {
autocomplete.detach();
}
}); });
defineExpose({ defineExpose({

View file

@ -26,16 +26,21 @@ SPDX-License-Identifier: AGPL-3.0-only
></textarea> ></textarea>
</div> </div>
<div :class="$style.caption"><slot name="caption"></slot></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> <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
</template> </template>
<script lang="ts" setup> <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 { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
const props = defineProps<{ const props = defineProps<{
modelValue: string | null; modelValue: string | null;
@ -46,6 +51,8 @@ const props = defineProps<{
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
autocomplete?: string; autocomplete?: string;
mfmAutocomplete?: boolean | SuggestionType[],
mfmPreview?: boolean;
spellcheck?: boolean; spellcheck?: boolean;
debounce?: boolean; debounce?: boolean;
manualSave?: boolean; manualSave?: boolean;
@ -68,6 +75,8 @@ const changed = ref(false);
const invalid = ref(false); const invalid = ref(false);
const filled = computed(() => v.value !== '' && v.value != null); const filled = computed(() => v.value !== '' && v.value != null);
const inputEl = shallowRef<HTMLTextAreaElement>(); const inputEl = shallowRef<HTMLTextAreaElement>();
const preview = ref(false);
let autocomplete: Autocomplete;
const focus = () => inputEl.value.focus(); const focus = () => inputEl.value.focus();
const onInput = (ev) => { const onInput = (ev) => {
@ -113,6 +122,16 @@ onMounted(() => {
focus(); focus();
} }
}); });
if (props.mfmAutocomplete) {
autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete);
}
});
onUnmounted(() => {
if (autocomplete) {
autocomplete.detach();
}
}); });
</script> </script>
@ -194,4 +213,12 @@ onMounted(() => {
.save { .save {
margin: 8px 0 0 0; margin: 8px 0 0 0;
} }
.mfmPreview {
padding: 12px;
border-radius: var(--radius);
box-sizing: border-box;
min-height: 130px;
pointer-events: none;
}
</style> </style>

View file

@ -37,7 +37,7 @@ type MfmProps = {
isNote?: boolean; isNote?: boolean;
emojiUrls?: string[]; emojiUrls?: string[];
rootScale?: number; rootScale?: number;
nyaize: boolean | 'respect'; nyaize?: boolean | 'respect';
parsedNodes?: mfm.MfmNode[] | null; parsedNodes?: mfm.MfmNode[] | null;
enableEmojiMenu?: boolean; enableEmojiMenu?: boolean;
enableEmojiMenuReaction?: boolean; enableEmojiMenuReaction?: boolean;

View file

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="announcement.title"> <MkInput v-model="announcement.title">
<template #label>{{ i18n.ts.title }}</template> <template #label>{{ i18n.ts.title }}</template>
</MkInput> </MkInput>
<MkTextarea v-model="announcement.text"> <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.text }}</template> <template #label>{{ i18n.ts.text }}</template>
</MkTextarea> </MkTextarea>
<MkInput v-model="announcement.imageUrl" type="url"> <MkInput v-model="announcement.imageUrl" type="url">
@ -75,7 +75,6 @@ import { ref, computed } from 'vue';
import XHeader from './_header_.vue'; import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue'; import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
@ -83,6 +82,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const announcements = ref<any[]>([]); const announcements = ref<any[]>([]);

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.name }}</template> <template #label>{{ i18n.ts.name }}</template>
</MkInput> </MkInput>
<MkTextarea v-model="description"> <MkTextarea v-model="description" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.description }}</template> <template #label>{{ i18n.ts.description }}</template>
</MkTextarea> </MkTextarea>
@ -70,7 +70,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch, defineAsyncComponent } from 'vue'; import { computed, ref, watch, defineAsyncComponent } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue'; import MkColorInput from '@/components/MkColorInput.vue';
@ -81,6 +80,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));

View file

@ -101,6 +101,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
type: 'string', type: 'string',
required: false, required: false,
multiline: true, multiline: true,
treatAsMfm: true,
label: i18n.ts.description, label: i18n.ts.description,
default: clip.value.description, default: clip.value.description,
}, },

View file

@ -60,6 +60,7 @@ async function create() {
type: 'string', type: 'string',
required: false, required: false,
multiline: true, multiline: true,
treatAsMfm: true,
label: i18n.ts.description, label: i18n.ts.description,
}, },
isPublic: { isPublic: {

View file

@ -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> <template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
<section> <section>
<textarea v-model="text" :class="$style.textarea"></textarea> <textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
</section> </section>
</XContainer> </XContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */ /* 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 XContainer from '../page-editor.container.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
const props = defineProps<{ const props = defineProps<{
modelValue: any modelValue: any
@ -28,7 +29,10 @@ const emit = defineEmits<{
(ev: 'update:modelValue', value: any): void; (ev: 'update:modelValue', value: any): void;
}>(); }>();
let autocomplete: Autocomplete;
const text = ref(props.modelValue.text ?? ''); const text = ref(props.modelValue.text ?? '');
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
watch(text, () => { watch(text, () => {
emit('update:modelValue', { emit('update:modelValue', {
@ -36,6 +40,14 @@ watch(text, () => {
text: text.value, text: text.value,
}); });
}); });
onMounted(() => {
autocomplete = new Autocomplete(inputEl.value, text);
});
onUnmounted(() => {
autocomplete.detach();
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> <MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
</div> </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> <template #label>{{ i18n.ts._profile.name }}</template>
</MkInput> </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 #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea> </MkTextarea>
@ -112,10 +112,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue';
import Misskey from 'misskey-js';
import XAvatarDecoration from './profile.avatar-decoration.vue'; import XAvatarDecoration from './profile.avatar-decoration.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.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 { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));

View file

@ -8,6 +8,8 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/'; import { toASCII } from 'punycode/';
import { popup } from '@/os.js'; import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
export class Autocomplete { export class Autocomplete {
private suggestion: { private suggestion: {
x: Ref<number>; x: Ref<number>;
@ -19,6 +21,7 @@ export class Autocomplete {
private currentType: string; private currentType: string;
private textRef: Ref<string>; private textRef: Ref<string>;
private opening: boolean; private opening: boolean;
private onlyType: SuggestionType[];
private get text(): string { private get text(): string {
// Use raw .value to get the latest value // 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 //#region BIND
this.onInput = this.onInput.bind(this); this.onInput = this.onInput.bind(this);
this.complete = this.complete.bind(this); this.complete = this.complete.bind(this);
@ -46,6 +49,7 @@ export class Autocomplete {
this.textarea = textarea; this.textarea = textarea;
this.textRef = textRef; this.textRef = textRef;
this.opening = false; this.opening = false;
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
this.attach(); this.attach();
} }
@ -95,7 +99,7 @@ export class Autocomplete {
let opened = false; let opened = false;
if (isMention) { if (isMention && this.onlyType.includes('user')) {
const username = text.substring(mentionIndex + 1); const username = text.substring(mentionIndex + 1);
if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) { if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
this.open('user', username); 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); const hashtag = text.substring(hashtagIndex + 1);
if (!hashtag.includes(' ')) { if (!hashtag.includes(' ')) {
this.open('hashtag', hashtag); 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); const emoji = text.substring(emojiIndex + 1);
if (!emoji.includes(' ')) { if (!emoji.includes(' ')) {
this.open('emoji', emoji); 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); const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) { if (!mfmTag.includes(' ')) {
this.open('mfmTag', mfmTag.replace('[', '')); this.open('mfmTag', mfmTag.replace('[', ''));