<template> <div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> <input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter"> <div ref="emojisEl" class="emojis"> <section class="result"> <div v-if="searchResultCustom.length > 0" class="body"> <button v-for="emoji in searchResultCustom" :key="emoji.name" class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > <MkCustomEmoji class="emoji" :name="emoji.name"/> </button> </div> <div v-if="searchResultUnicode.length > 0" class="body"> <button v-for="emoji in searchResultUnicode" :key="emoji.name" class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > <MkEmoji class="emoji" :emoji="emoji.char"/> </button> </div> </section> <div v-if="tab === 'index'" class="group index"> <section v-if="showPinned"> <div class="body"> <button v-for="emoji in pinned" :key="emoji" :data-emoji="emoji" class="_button item" tabindex="0" @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> <section> <header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header> <div class="body"> <button v-for="emoji in recentlyUsedEmojis" :key="emoji" class="_button item" :data-emoji="emoji" @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> </div> <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <XSection v-for="category in customEmojiCategories" :key="`custom:${category}`" :initial-shown="false" :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).map(e => `:${e.name}:`))" @chosen="chosen" > {{ category || i18n.ts.other }} </XSection> </div> <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.emoji }}</header> <XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" @chosen="chosen">{{ category }}</XSection> </div> </div> <div class="tabs"> <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ti ti-asterisk ti-fw"></i></button> <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ti ti-mood-happy ti-fw"></i></button> <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ti ti-leaf ti-fw"></i></button> <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ti ti-hash ti-fw"></i></button> </div> </div> </template> <script lang="ts" setup> import { ref, shallowRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import XSection from '@/components/MkEmojiPicker.section.vue'; import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { deviceKind } from '@/scripts/device-kind'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import { customEmojiCategories, customEmojis } from '@/custom-emojis'; const props = withDefaults(defineProps<{ showPinned?: boolean; asReactionPicker?: boolean; maxHeight?: number; asDrawer?: boolean; asWindow?: boolean; }>(), { showPinned: true, }); const emit = defineEmits<{ (ev: 'chosen', v: string): void; }>(); const searchEl = shallowRef<HTMLInputElement>(); const emojisEl = shallowRef<HTMLDivElement>(); const { reactions: pinned, reactionPickerSize, reactionPickerWidth, reactionPickerHeight, disableShowingAnimatedImages, recentlyUsedEmojis, } = defaultStore.reactiveState; const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1); const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); const q = ref<string>(''); const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]); const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); watch(q, () => { if (emojisEl.value) emojisEl.value.scrollTop = 0; if (q.value === '') { searchResultCustom.value = []; searchResultUnicode.value = []; return; } const newQ = q.value.replace(/:/g, '').toLowerCase(); const searchCustom = () => { const max = 8; const emojis = customEmojis.value; const matches = new Set<Misskey.entities.CustomEmoji>(); const exactMatch = emojis.find(emoji => emoji.name === newQ); if (exactMatch) matches.add(exactMatch); if (newQ.includes(' ')) { // AND検索 const keywords = newQ.split(' '); // 名前にキーワードが含まれている for (const emoji of emojis) { if (keywords.every(keyword => emoji.name.includes(keyword))) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; // 名前またはエイリアスにキーワードが含まれている for (const emoji of emojis) { if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { matches.add(emoji); if (matches.size >= max) break; } } } else { for (const emoji of emojis) { if (emoji.name.startsWith(newQ)) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.aliases.some(alias => alias.startsWith(newQ))) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.name.includes(newQ)) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.aliases.some(alias => alias.includes(newQ))) { matches.add(emoji); if (matches.size >= max) break; } } } return matches; }; const searchUnicode = () => { const max = 8; const emojis = emojilist; const matches = new Set<UnicodeEmojiDef>(); const exactMatch = emojis.find(emoji => emoji.name === newQ); if (exactMatch) matches.add(exactMatch); if (newQ.includes(' ')) { // AND検索 const keywords = newQ.split(' '); // 名前にキーワードが含まれている for (const emoji of emojis) { if (keywords.every(keyword => emoji.name.includes(keyword))) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; // 名前またはエイリアスにキーワードが含まれている for (const emoji of emojis) { if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { matches.add(emoji); if (matches.size >= max) break; } } } else { for (const emoji of emojis) { if (emoji.name.startsWith(newQ)) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.name.includes(newQ)) { matches.add(emoji); if (matches.size >= max) break; } } if (matches.size >= max) return matches; for (const emoji of emojis) { if (emoji.keywords.some(keyword => keyword.includes(newQ))) { matches.add(emoji); if (matches.size >= max) break; } } } return matches; }; searchResultCustom.value = Array.from(searchCustom()); searchResultUnicode.value = Array.from(searchUnicode()); }); function focus() { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { searchEl.value?.focus({ preventScroll: true, }); } } function reset() { if (emojisEl.value) emojisEl.value.scrollTop = 0; q.value = ''; } function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string { return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; } /** @see MkEmojiPicker.section.vue */ function computeButtonTitle(ev: MouseEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; elm.title = getEmojiName(emoji) ?? emoji; } function chosen(emoji: any, ev?: MouseEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); os.popup(MkRippleEffect, { x, y }, {}, 'end'); } const key = getKey(emoji); emit('chosen', key); // 最近使った絵文字更新 if (!pinned.value.includes(key)) { let recents = defaultStore.state.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== key); recents.unshift(key); defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); } } function input(): void { // Using custom input event instead of v-model to respond immediately on // Android, where composition happens on all languages // (v-model does not update during composition) q.value = searchEl.value?.value.trim() ?? ''; } function paste(event: ClipboardEvent): void { const pasted = event.clipboardData?.getData('text') ?? ''; if (done(pasted)) { event.preventDefault(); } } function onEnter(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; done(); } function done(query?: string): boolean | void { if (query == null) query = q.value; if (query == null || typeof query !== 'string') return; const q2 = query.replace(/:/g, ''); const exactMatchCustom = customEmojis.value.find(emoji => emoji.name === q2); if (exactMatchCustom) { chosen(exactMatchCustom); return true; } const exactMatchUnicode = emojilist.find(emoji => emoji.char === q2 || emoji.name === q2); if (exactMatchUnicode) { chosen(exactMatchUnicode); return true; } if (searchResultCustom.value.length > 0) { chosen(searchResultCustom.value[0]); return true; } if (searchResultUnicode.value.length > 0) { chosen(searchResultUnicode.value[0]); return true; } } onMounted(() => { focus(); }); defineExpose({ focus, reset, }); </script> <style lang="scss" scoped> .omfetrab { $pad: 8px; display: flex; flex-direction: column; &.s1 { --eachSize: 40px; } &.s2 { --eachSize: 45px; } &.s3 { --eachSize: 50px; } &.w1 { width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); --columns: 1fr 1fr 1fr 1fr 1fr; } &.w2 { width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); --columns: 1fr 1fr 1fr 1fr 1fr 1fr; } &.w3 { width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; } &.w4 { width: calc((var(--eachSize) * 8) + (#{$pad} * 2)); --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; } &.w5 { width: calc((var(--eachSize) * 9) + (#{$pad} * 2)); --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; } &.h1 { height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); } &.h2 { height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); } &.h3 { height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); } &.h4 { height: calc((var(--eachSize) * 10) + (#{$pad} * 2)); } &.asDrawer { width: 100% !important; padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0; > .emojis { ::v-deep(section) { > header { height: 32px; line-height: 32px; padding: 0 12px; font-size: 15px; } > .body { display: grid; grid-template-columns: var(--columns); font-size: 30px; > .item { aspect-ratio: 1 / 1; width: auto; height: auto; min-width: 0; } } } } } &.asWindow { width: 100% !important; height: 100% !important; > .emojis { ::v-deep(section) { > .body { display: grid; grid-template-columns: var(--columns); font-size: 30px; > .item { aspect-ratio: 1 / 1; width: auto; height: auto; min-width: 0; } } } } } > .search { width: 100%; padding: 12px; box-sizing: border-box; font-size: 1em; outline: none; border: none; background: transparent; color: var(--fg); &:not(.filled) { order: 1; z-index: 2; box-shadow: 0px -1px 0 0px var(--divider); } } > .tabs { display: flex; display: none; > .tab { flex: 1; height: 38px; border-top: solid 0.5px var(--divider); &.active { border-top: solid 1px var(--accent); color: var(--accent); } } } > .emojis { height: 100%; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; &::-webkit-scrollbar { display: none; } > .group { &:not(.index) { padding: 4px 0 8px 0; border-top: solid 0.5px var(--divider); } > header { /*position: sticky; top: 0; left: 0;*/ height: 32px; line-height: 32px; z-index: 2; padding: 0 8px; font-size: 12px; } } ::v-deep(section) { > header { position: sticky; top: 0; left: 0; height: 32px; line-height: 32px; z-index: 1; padding: 0 8px; font-size: 12px; cursor: pointer; &:hover { color: var(--accent); } } > .body { position: relative; padding: $pad; > .item { position: relative; padding: 0; width: var(--eachSize); height: var(--eachSize); contain: strict; border-radius: 4px; font-size: 24px; &:focus-visible { outline: solid 2px var(--focus); z-index: 1; } &:hover { background: rgba(0, 0, 0, 0.05); } &:active { background: var(--accent); box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } > .emoji { height: 1.25em; vertical-align: -.25em; pointer-events: none; } } } &.result { border-bottom: solid 0.5px var(--divider); &:empty { display: none; } } } } } </style>