mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-31 05:24:13 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			463 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			463 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <!--
 | |
| SPDX-FileCopyrightText: syuilo and misskey-project
 | |
| SPDX-License-Identifier: AGPL-3.0-only
 | |
| -->
 | |
| 
 | |
| <template>
 | |
| <div ref="rootEl" :class="$style.root" class="_popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
 | |
| 	<ol v-if="type === 'user'" ref="suggests" :class="$style.list">
 | |
| 		<li v-for="user in users" tabindex="-1" :class="$style.item" @click="complete(type, user)" @keydown="onKeydown">
 | |
| 			<img :class="$style.avatar" :src="user.avatarUrl"/>
 | |
| 			<span :class="$style.userName">
 | |
| 				<MkUserName :key="user.id" :user="user"/>
 | |
| 			</span>
 | |
| 			<span>@{{ acct(user) }}</span>
 | |
| 		</li>
 | |
| 		<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
 | |
| 	</ol>
 | |
| 	<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
 | |
| 		<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
 | |
| 			<span class="name">{{ hashtag }}</span>
 | |
| 		</li>
 | |
| 	</ol>
 | |
| 	<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
 | |
| 		<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
 | |
| 			<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
 | |
| 			<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
 | |
| 			<!-- eslint-disable-next-line vue/no-v-html -->
 | |
| 			<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
 | |
| 			<span v-else v-text="emoji.name"></span>
 | |
| 			<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
 | |
| 		</li>
 | |
| 	</ol>
 | |
| 	<ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list">
 | |
| 		<li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
 | |
| 			<span>{{ tag }}</span>
 | |
| 		</li>
 | |
| 	</ol>
 | |
| 	<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
 | |
| 		<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
 | |
| 			<span>{{ param }}</span>
 | |
| 		</li>
 | |
| 	</ol>
 | |
| </div>
 | |
| </template>
 | |
| 
 | |
| <script lang="ts">
 | |
| import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
 | |
| import sanitizeHtml from 'sanitize-html';
 | |
| import { emojilist, getEmojiName } from '@@/js/emojilist.js';
 | |
| import contains from '@/scripts/contains.js';
 | |
| import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@@/js/emoji-base.js';
 | |
| import { acct } from '@/filters/user.js';
 | |
| import * as os from '@/os.js';
 | |
| import { misskeyApi } from '@/scripts/misskey-api.js';
 | |
| import { defaultStore } from '@/store.js';
 | |
| import { i18n } from '@/i18n.js';
 | |
| import { miLocalStorage } from '@/local-storage.js';
 | |
| import { customEmojis } from '@/custom-emojis.js';
 | |
| import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
 | |
| import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
 | |
| 
 | |
| const lib = emojilist.filter(x => x.category !== 'flags');
 | |
| 
 | |
| const emojiDb = computed(() => {
 | |
| 	//#region Unicode Emoji
 | |
| 	const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
 | |
| 
 | |
| 	const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({
 | |
| 		emoji: x.char,
 | |
| 		name: x.name,
 | |
| 		url: char2path(x.char),
 | |
| 	}));
 | |
| 
 | |
| 	for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
 | |
| 		for (const [emoji, keywords] of Object.entries(index)) {
 | |
| 			for (const k of keywords) {
 | |
| 				unicodeEmojiDB.push({
 | |
| 					emoji: emoji,
 | |
| 					name: k,
 | |
| 					aliasOf: getEmojiName(emoji),
 | |
| 					url: char2path(emoji),
 | |
| 				});
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
 | |
| 	//#endregion
 | |
| 
 | |
| 	//#region Custom Emoji
 | |
| 	const customEmojiDB: EmojiDef[] = [];
 | |
| 
 | |
| 	for (const x of customEmojis.value) {
 | |
| 		customEmojiDB.push({
 | |
| 			name: x.name,
 | |
| 			emoji: `:${x.name}:`,
 | |
| 			isCustomEmoji: true,
 | |
| 		});
 | |
| 
 | |
| 		if (x.aliases) {
 | |
| 			for (const alias of x.aliases) {
 | |
| 				customEmojiDB.push({
 | |
| 					name: alias,
 | |
| 					aliasOf: x.name,
 | |
| 					emoji: `:${x.name}:`,
 | |
| 					isCustomEmoji: true,
 | |
| 				});
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	customEmojiDB.sort((a, b) => a.name.length - b.name.length);
 | |
| 	//#endregion
 | |
| 
 | |
| 	return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
 | |
| });
 | |
| 
 | |
| export default {
 | |
| 	emojiDb,
 | |
| 	emojilist,
 | |
| };
 | |
| </script>
 | |
| 
 | |
| <script lang="ts" setup>
 | |
| const props = defineProps<{
 | |
| 	type: string;
 | |
| 	q: any;
 | |
| 	textarea: HTMLTextAreaElement;
 | |
| 	close: () => void;
 | |
| 	x: number;
 | |
| 	y: number;
 | |
| }>();
 | |
| 
 | |
| const emit = defineEmits<{
 | |
| 	(event: 'done', value: { type: string; value: any }): void;
 | |
| 	(event: 'closed'): void;
 | |
| }>();
 | |
| 
 | |
| const suggests = ref<Element>();
 | |
| const rootEl = shallowRef<HTMLDivElement>();
 | |
| 
 | |
| const fetching = ref(true);
 | |
| const users = ref<any[]>([]);
 | |
| const hashtags = ref<any[]>([]);
 | |
| const emojis = ref<(EmojiDef)[]>([]);
 | |
| const items = ref<Element[] | HTMLCollection>([]);
 | |
| const mfmTags = ref<string[]>([]);
 | |
| const mfmParams = ref<string[]>([]);
 | |
| const select = ref(-1);
 | |
| const zIndex = os.claimZIndex('high');
 | |
| 
 | |
| function complete(type: string, value: any) {
 | |
| 	emit('done', { type, value });
 | |
| 	emit('closed');
 | |
| 	if (type === 'emoji') {
 | |
| 		let recents = defaultStore.state.recentlyUsedEmojis;
 | |
| 		recents = recents.filter((emoji: any) => emoji !== value);
 | |
| 		recents.unshift(value);
 | |
| 		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
 | |
| 	}
 | |
| }
 | |
| 
 | |
| function setPosition() {
 | |
| 	if (!rootEl.value) return;
 | |
| 	if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
 | |
| 		rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
 | |
| 	} else {
 | |
| 		rootEl.value.style.left = `${props.x}px`;
 | |
| 	}
 | |
| 	if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
 | |
| 		rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
 | |
| 		rootEl.value.style.marginTop = '0';
 | |
| 	} else {
 | |
| 		rootEl.value.style.top = props.y + 'px';
 | |
| 		rootEl.value.style.marginTop = 'calc(1em + 8px)';
 | |
| 	}
 | |
| }
 | |
| 
 | |
| function exec() {
 | |
| 	select.value = -1;
 | |
| 	if (suggests.value) {
 | |
| 		for (const el of Array.from(items.value)) {
 | |
| 			el.removeAttribute('data-selected');
 | |
| 		}
 | |
| 	}
 | |
| 	if (props.type === 'user') {
 | |
| 		if (!props.q) {
 | |
| 			users.value = [];
 | |
| 			fetching.value = false;
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		const cacheKey = `autocomplete:user:${props.q}`;
 | |
| 		const cache = sessionStorage.getItem(cacheKey);
 | |
| 
 | |
| 		if (cache) {
 | |
| 			users.value = JSON.parse(cache);
 | |
| 			fetching.value = false;
 | |
| 		} else {
 | |
| 			misskeyApi('users/search-by-username-and-host', {
 | |
| 				username: props.q,
 | |
| 				limit: 10,
 | |
| 				detail: false,
 | |
| 			}).then(searchedUsers => {
 | |
| 				users.value = searchedUsers as any[];
 | |
| 				fetching.value = false;
 | |
| 				// キャッシュ
 | |
| 				sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
 | |
| 			});
 | |
| 		}
 | |
| 	} else if (props.type === 'hashtag') {
 | |
| 		if (!props.q || props.q === '') {
 | |
| 			hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]');
 | |
| 			fetching.value = false;
 | |
| 		} else {
 | |
| 			const cacheKey = `autocomplete:hashtag:${props.q}`;
 | |
| 			const cache = sessionStorage.getItem(cacheKey);
 | |
| 			if (cache) {
 | |
| 				const hashtags = JSON.parse(cache);
 | |
| 				hashtags.value = hashtags;
 | |
| 				fetching.value = false;
 | |
| 			} else {
 | |
| 				misskeyApi('hashtags/search', {
 | |
| 					query: props.q,
 | |
| 					limit: 30,
 | |
| 				}).then(searchedHashtags => {
 | |
| 					hashtags.value = searchedHashtags as any[];
 | |
| 					fetching.value = false;
 | |
| 					// キャッシュ
 | |
| 					sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
 | |
| 				});
 | |
| 			}
 | |
| 		}
 | |
| 	} else if (props.type === 'emoji') {
 | |
| 		if (!props.q || props.q === '') {
 | |
| 			// 最近使った絵文字をサジェスト
 | |
| 			emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		emojis.value = searchEmoji(props.q.normalize('NFC').toLowerCase(), emojiDb.value);
 | |
| 	} else if (props.type === 'mfmTag') {
 | |
| 		if (!props.q || props.q === '') {
 | |
| 			mfmTags.value = MFM_TAGS;
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
 | |
| 	} else if (props.type === 'mfmParam') {
 | |
| 		if (props.q.params.at(-1) === '') {
 | |
| 			mfmParams.value = MFM_PARAMS[props.q.tag] ?? [];
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		mfmParams.value = MFM_PARAMS[props.q.tag].filter(param => param.startsWith(props.q.params.at(-1) ?? ''));
 | |
| 	}
 | |
| }
 | |
| 
 | |
| function onMousedown(event: Event) {
 | |
| 	if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
 | |
| }
 | |
| 
 | |
| function onKeydown(event: KeyboardEvent) {
 | |
| 	const cancel = () => {
 | |
| 		event.preventDefault();
 | |
| 		event.stopPropagation();
 | |
| 	};
 | |
| 
 | |
| 	switch (event.key) {
 | |
| 		case 'Enter':
 | |
| 			if (select.value !== -1) {
 | |
| 				cancel();
 | |
| 				(items.value[select.value] as any).click();
 | |
| 			} else {
 | |
| 				props.close();
 | |
| 			}
 | |
| 			break;
 | |
| 
 | |
| 		case 'Escape':
 | |
| 			cancel();
 | |
| 			props.close();
 | |
| 			break;
 | |
| 
 | |
| 		case 'ArrowUp':
 | |
| 			if (select.value !== -1) {
 | |
| 				cancel();
 | |
| 				selectPrev();
 | |
| 			} else {
 | |
| 				props.close();
 | |
| 			}
 | |
| 			break;
 | |
| 
 | |
| 		case 'ArrowDown':
 | |
| 			cancel();
 | |
| 			selectNext();
 | |
| 			break;
 | |
| 
 | |
| 		case 'Tab':
 | |
| 			if (event.shiftKey) {
 | |
| 				if (select.value !== -1) {
 | |
| 					cancel();
 | |
| 					selectPrev();
 | |
| 				} else {
 | |
| 					props.close();
 | |
| 				}
 | |
| 			} else {
 | |
| 				cancel();
 | |
| 				selectNext();
 | |
| 			}
 | |
| 			break;
 | |
| 
 | |
| 		default:
 | |
| 			event.stopPropagation();
 | |
| 			props.textarea.focus();
 | |
| 	}
 | |
| }
 | |
| 
 | |
| function selectNext() {
 | |
| 	if (++select.value >= items.value.length) select.value = 0;
 | |
| 	if (items.value.length === 0) select.value = -1;
 | |
| 	applySelect();
 | |
| }
 | |
| 
 | |
| function selectPrev() {
 | |
| 	if (--select.value < 0) select.value = items.value.length - 1;
 | |
| 	applySelect();
 | |
| }
 | |
| 
 | |
| function applySelect() {
 | |
| 	for (const el of Array.from(items.value)) {
 | |
| 		el.removeAttribute('data-selected');
 | |
| 	}
 | |
| 
 | |
| 	if (select.value !== -1) {
 | |
| 		items.value[select.value].setAttribute('data-selected', 'true');
 | |
| 		(items.value[select.value] as any).focus();
 | |
| 	}
 | |
| }
 | |
| 
 | |
| function chooseUser() {
 | |
| 	props.close();
 | |
| 	os.selectUser({ includeSelf: true }).then(user => {
 | |
| 		complete('user', user);
 | |
| 		props.textarea.focus();
 | |
| 	});
 | |
| }
 | |
| 
 | |
| onUpdated(() => {
 | |
| 	setPosition();
 | |
| 	items.value = suggests.value?.children ?? [];
 | |
| });
 | |
| 
 | |
| onMounted(() => {
 | |
| 	setPosition();
 | |
| 
 | |
| 	props.textarea.addEventListener('keydown', onKeydown);
 | |
| 
 | |
| 	document.body.addEventListener('mousedown', onMousedown);
 | |
| 
 | |
| 	nextTick(() => {
 | |
| 		exec();
 | |
| 
 | |
| 		watch(() => props.q, () => {
 | |
| 			nextTick(() => {
 | |
| 				exec();
 | |
| 			});
 | |
| 		});
 | |
| 	});
 | |
| });
 | |
| 
 | |
| onBeforeUnmount(() => {
 | |
| 	props.textarea.removeEventListener('keydown', onKeydown);
 | |
| 
 | |
| 	document.body.removeEventListener('mousedown', onMousedown);
 | |
| });
 | |
| </script>
 | |
| 
 | |
| <style lang="scss" module>
 | |
| .root {
 | |
| 	position: fixed;
 | |
| 	max-width: 100%;
 | |
| 	margin-top: calc(1em + 8px);
 | |
| 	overflow: clip;
 | |
| 	transition: top 0.1s ease, left 0.1s ease;
 | |
| }
 | |
| 
 | |
| .list {
 | |
| 	display: block;
 | |
| 	margin: 0;
 | |
| 	padding: 4px 0;
 | |
| 	max-height: 190px;
 | |
| 	max-width: 500px;
 | |
| 	overflow: auto;
 | |
| 	list-style: none;
 | |
| }
 | |
| 
 | |
| .item {
 | |
| 	display: flex;
 | |
| 	align-items: center;
 | |
| 	padding: 4px 12px;
 | |
| 	white-space: nowrap;
 | |
| 	overflow: clip;
 | |
| 	font-size: 0.9em;
 | |
| 	cursor: default;
 | |
| 	user-select: none;
 | |
| 	overflow: hidden;
 | |
| 	text-overflow: ellipsis;
 | |
| 
 | |
| 	&:hover {
 | |
| 		background: var(--MI_THEME-X3);
 | |
| 	}
 | |
| 
 | |
| 	&[data-selected='true'] {
 | |
| 		background: var(--MI_THEME-accent);
 | |
| 		color: #fff !important;
 | |
| 	}
 | |
| 
 | |
| 	&:active {
 | |
| 		background: var(--MI_THEME-accentDarken);
 | |
| 		color: #fff !important;
 | |
| 	}
 | |
| }
 | |
| 
 | |
| .avatar {
 | |
| 	min-width: 28px;
 | |
| 	min-height: 28px;
 | |
| 	max-width: 28px;
 | |
| 	max-height: 28px;
 | |
| 	margin: 0 8px 0 0;
 | |
| 	border-radius: var(--MI-radius-full);
 | |
| }
 | |
| 
 | |
| .userName {
 | |
| 	margin: 0 8px 0 0;
 | |
| }
 | |
| 
 | |
| .emoji {
 | |
| 	flex-shrink: 0 !important;
 | |
| 	display: flex !important;
 | |
| 	margin: 0 4px 0 0 !important;
 | |
| 	height: 24px !important;
 | |
| 	width: 24px !important;
 | |
| 	justify-content: center !important;
 | |
| 	align-items: center !important;
 | |
| 	font-size: 20px !important;
 | |
| 	pointer-events: none !important;
 | |
| }
 | |
| 
 | |
| .emojiImg {
 | |
| 	height: 24px;
 | |
| 	width: 24px;
 | |
| 	object-fit: scale-down;
 | |
| }
 | |
| 
 | |
| .emojiName {
 | |
| 	flex-shrink: 1;
 | |
| }
 | |
| 
 | |
| .emojiAlias {
 | |
| 	flex-shrink: 9999999;
 | |
| 	margin: 0 0 0 8px;
 | |
| }
 | |
| </style>
 |