mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-09 19:43:09 +02:00
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:
parent
5cee481083
commit
b33fe53047
12 changed files with 80 additions and 19 deletions
|
@ -37,6 +37,8 @@
|
||||||
- Enhance: データセーバーの適用範囲を個別で設定できるように
|
- Enhance: データセーバーの適用範囲を個別で設定できるように
|
||||||
- 従来のデータセーバーの設定はリセットされます
|
- 従来のデータセーバーの設定はリセットされます
|
||||||
- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように
|
- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように
|
||||||
|
- Enhance: ユーザー名、プロフィール、お知らせ、ページの編集画面でMFMや絵文字のオートコンプリートが使用できるように
|
||||||
|
- Enhance: プロフィール、お知らせの編集画面でMFMのプレビューを表示できるように
|
||||||
- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように
|
- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように
|
||||||
- ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。
|
- ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。
|
||||||
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
|
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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[]>([]);
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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('[', ''));
|
||||||
|
|
Loading…
Reference in a new issue