Skip to content
Snippets Groups Projects
autocomplete.ts 7.17 KiB
Newer Older
/*
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 */

import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
syuilo's avatar
syuilo committed
import { popup } from '@/os.js';
syuilo's avatar
syuilo committed

syuilo's avatar
syuilo committed
export class Autocomplete {
	private suggestion: {
		x: Ref<number>;
		y: Ref<number>;
syuilo's avatar
syuilo committed
		q: Ref<string | null>;
		close: () => void;
syuilo's avatar
syuilo committed
	} | null;
	private textarea: HTMLInputElement | HTMLTextAreaElement;
syuilo's avatar
syuilo committed
	private currentType: string;
	private textRef: Ref<string>;
	private opening: boolean;
syuilo's avatar
syuilo committed

	private get text(): string {
		// Use raw .value to get the latest value
		// (Because v-model does not update while composition)
		return this.textarea.value;
syuilo's avatar
syuilo committed
	}

	private set text(text: string) {
		// Use ref value to notify other watchers
		// (Because .value setter never fires input/change events)
		this.textRef.value = text;
syuilo's avatar
syuilo committed
	}
syuilo's avatar
:v:
syuilo committed

	/**
	 * 対象のテキストエリアを与えてインスタンスを初期化します。
	 */
	constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) {
syuilo's avatar
syuilo committed
		//#region BIND
		this.onInput = this.onInput.bind(this);
		this.complete = this.complete.bind(this);
syuilo's avatar
syuilo committed
		this.close = this.close.bind(this);
		//#endregion
syuilo's avatar
:v:
syuilo committed
		this.suggestion = null;
		this.textarea = textarea;
		this.textRef = textRef;
		this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
syuilo's avatar
syuilo committed

		this.attach();
syuilo's avatar
:v:
syuilo committed
	}

	/**
	 * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
	 */
	public attach() {
syuilo's avatar
:v:
syuilo committed
		this.textarea.addEventListener('input', this.onInput);
	}

	/**
	 * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
	 */
	public detach() {
syuilo's avatar
:v:
syuilo committed
		this.textarea.removeEventListener('input', this.onInput);
		this.close();
	}

	/**
	 * テキスト入力時
syuilo's avatar
:v:
syuilo committed
	 */
	private onInput() {
syuilo's avatar
syuilo committed
		const caretPos = this.textarea.selectionStart;
		const text = this.text.substring(0, caretPos).split('\n').pop()!;
syuilo's avatar
:v:
syuilo committed

		const mentionIndex = text.lastIndexOf('@');
syuilo's avatar
syuilo committed
		const hashtagIndex = text.lastIndexOf('#');
syuilo's avatar
syuilo committed
		const emojiIndex = text.lastIndexOf(':');
		const mfmTagIndex = text.lastIndexOf('$');
syuilo's avatar
syuilo committed

syuilo's avatar
syuilo committed
		const max = Math.max(
			mentionIndex,
			hashtagIndex,
			emojiIndex,
			mfmTagIndex);
syuilo's avatar
syuilo committed

syuilo's avatar
syuilo committed
			this.close();
			return;
		}
syuilo's avatar
syuilo committed

		const isMention = mentionIndex !== -1;
		const isHashtag = hashtagIndex !== -1;
		const isMfmTag = mfmTagIndex !== -1;
		const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
syuilo's avatar
syuilo committed

syuilo's avatar
syuilo committed
		let opened = false;

			const username = text.substring(mentionIndex + 1);
			if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
syuilo's avatar
syuilo committed
				this.open('user', username);
				opened = true;
syuilo's avatar
syuilo committed
			} else if (username === '') {
				this.open('user', null);
				opened = true;
syuilo's avatar
syuilo committed
			}
syuilo's avatar
syuilo committed
		}

			const hashtag = text.substring(hashtagIndex + 1);
syuilo's avatar
syuilo committed
			if (!hashtag.includes(' ')) {
syuilo's avatar
syuilo committed
				this.open('hashtag', hashtag);
				opened = true;
			}
		}

			const emoji = text.substring(emojiIndex + 1);
syuilo's avatar
syuilo committed
			if (!emoji.includes(' ')) {
syuilo's avatar
syuilo committed
				this.open('emoji', emoji);
				opened = true;
			}
		}

			const mfmTag = text.substring(mfmTagIndex + 1);
			if (!mfmTag.includes(' ')) {
				this.open('mfmTag', mfmTag.replace('[', ''));
syuilo's avatar
syuilo committed
		if (!opened) {
			this.close();
syuilo's avatar
syuilo committed
		}
syuilo's avatar
:v:
syuilo committed
	}

	/**
	 * サジェストを提示します。
syuilo's avatar
:v:
syuilo committed
	 */
syuilo's avatar
syuilo committed
	private async open(type: string, q: string | null) {
		if (type !== this.currentType) {
syuilo's avatar
syuilo committed
			this.close();
		}
		if (this.opening) return;
		this.opening = true;
syuilo's avatar
syuilo committed
		this.currentType = type;
syuilo's avatar
:v:
syuilo committed

syuilo's avatar
syuilo committed
		//#region サジェストを表示すべき位置を計算
		const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);

		const rect = this.textarea.getBoundingClientRect();

		const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
		const y = rect.top + caretPosition.top - this.textarea.scrollTop;
		//#endregion

syuilo's avatar
syuilo committed
		if (this.suggestion) {
syuilo's avatar
syuilo committed
			this.suggestion.x.value = x;
			this.suggestion.y.value = y;
			this.suggestion.q.value = q;
syuilo's avatar
syuilo committed
		} else {
syuilo's avatar
syuilo committed
			const _x = ref(x);
			const _y = ref(y);
			const _q = ref(q);

			const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), {
syuilo's avatar
syuilo committed
				textarea: this.textarea,
				close: this.close,
				type: type,
				q: _q,
				x: _x,
				y: _y,
			}, {
				done: (res) => {
					this.complete(res);
syuilo's avatar
syuilo committed
			});
syuilo's avatar
syuilo committed

syuilo's avatar
syuilo committed
			this.suggestion = {
				q: _q,
				x: _x,
				y: _y,
				close: () => dispose(),
			};
syuilo's avatar
syuilo committed
		}
syuilo's avatar
:v:
syuilo committed
	}

	/**
	 * サジェストを閉じます。
syuilo's avatar
:v:
syuilo committed
	 */
	private close() {
syuilo's avatar
syuilo committed
		if (this.suggestion == null) return;
syuilo's avatar
:v:
syuilo committed

syuilo's avatar
syuilo committed
		this.suggestion.close();
syuilo's avatar
:v:
syuilo committed
		this.suggestion = null;

		this.textarea.focus();
	}

	/**
	 * オートコンプリートする
syuilo's avatar
:v:
syuilo committed
	 */
syuilo's avatar
syuilo committed
	private complete({ type, value }) {
syuilo's avatar
:v:
syuilo committed
		this.close();

		const caret = this.textarea.selectionStart;

		if (type === 'user') {
syuilo's avatar
syuilo committed
			const source = this.text;
syuilo's avatar
:v:
syuilo committed

			const before = source.substring(0, caret);
syuilo's avatar
syuilo committed
			const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
			const after = source.substring(caret);
syuilo's avatar
:v:
syuilo committed

			const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
syuilo's avatar
syuilo committed

syuilo's avatar
syuilo committed
			// 挿入
Aya Morisawa's avatar
Aya Morisawa committed
			this.text = `${trimmedBefore}@${acct} ${after}`;
syuilo's avatar
syuilo committed

			// キャレットを戻す
syuilo's avatar
syuilo committed
				this.textarea.focus();
syuilo's avatar
syuilo committed
				const pos = trimmedBefore.length + (acct.length + 2);
syuilo's avatar
syuilo committed
				this.textarea.setSelectionRange(pos, pos);
			});
		} else if (type === 'hashtag') {
syuilo's avatar
syuilo committed
			const source = this.text;

			const before = source.substring(0, caret);
syuilo's avatar
syuilo committed
			const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
			const after = source.substring(caret);
syuilo's avatar
syuilo committed

			// 挿入
Aya Morisawa's avatar
Aya Morisawa committed
			this.text = `${trimmedBefore}#${value} ${after}`;
syuilo's avatar
syuilo committed

			// キャレットを戻す
syuilo's avatar
syuilo committed
				this.textarea.focus();
				const pos = trimmedBefore.length + (value.length + 2);
				this.textarea.setSelectionRange(pos, pos);
			});
		} else if (type === 'emoji') {
syuilo's avatar
syuilo committed
			const source = this.text;
syuilo's avatar
syuilo committed

			const before = source.substring(0, caret);
syuilo's avatar
syuilo committed
			const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
			const after = source.substring(caret);
syuilo's avatar
syuilo committed

			// 挿入
syuilo's avatar
syuilo committed
			this.text = trimmedBefore + value + after;
syuilo's avatar
syuilo committed

			// キャレットを戻す
syuilo's avatar
syuilo committed
				this.textarea.focus();
MeiMei's avatar
MeiMei committed
				const pos = trimmedBefore.length + value.length;
syuilo's avatar
syuilo committed
				this.textarea.setSelectionRange(pos, pos);
			});
		} else if (type === 'mfmTag') {
			const source = this.text;

			const before = source.substring(0, caret);
			const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
			const after = source.substring(caret);

			// 挿入
			this.text = `${trimmedBefore}$[${value} ]${after}`;

			// キャレットを戻す
				this.textarea.focus();
				const pos = trimmedBefore.length + (value.length + 3);
				this.textarea.setSelectionRange(pos, pos);
			});
syuilo's avatar
syuilo committed
		}
syuilo's avatar
:v:
syuilo committed
	}
}