hippofish/packages/frontend/src/components/MkEmojiPicker.vue
dakkar 354cb2a675 handle non-ASCII emoji names
* use the more inclusive regexp for validating emoji names
* always normalize emoji names, aliases, categories

the latter point is necessary to allow matching, for example, `ä`
against `a`+combining diaeresis

this will also need to bump the version of `sfm-js` once we merge
https://activitypub.software/TransFem-org/sfm-js/-/merge_requests/2
2024-03-09 12:51:51 +00:00

749 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<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" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
<div ref="emojisEl" class="emojis" tabindex="-1">
<section class="result">
<div v-if="searchResultCustom.length > 0" class="body">
<button
v-for="emoji in searchResultCustom"
:key="emoji.name"
class="_button item"
:disabled="!canReact(emoji)"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/>
</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 && (pinned && pinned.length > 0)">
<div class="body">
<button
v-for="emoji in pinnedEmojisDef"
:key="getKey(emoji)"
:data-emoji="getKey(emoji)"
class="_button item"
:disabled="!canReact(emoji)"
tabindex="0"
@pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
</div>
</section>
<section>
<header class="_acrylic"><i class="ph-clock ph-bold ph-lg ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
<div class="body">
<button
v-for="emoji in recentlyUsedEmojisDef"
:key="getKey(emoji)"
class="_button item"
:disabled="!canReact(emoji)"
:data-emoji="getKey(emoji)"
@pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
</div>
</section>
</div>
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
<XSection
v-for="child in customEmojiFolderRoot.children"
:key="`custom:${child.value}`"
:initialShown="false"
:emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))"
:disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="chosen"
>
{{ child.value || 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) ?? []" :hasChildSection="false" @chosen="chosen">{{ category }}</XSection>
</div>
</div>
<div class="tabs">
<button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ph-asterisk ph-bold ph-lg ti-fw"></i></button>
<button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ph-smiley ph-bold ph-lg ti-fw"></i></button>
<button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ph-leaf ph-bold ph-lg ti-fw"></i></button>
<button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ph-hash ph-bold ph-lg 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,
CustomEmojiFolderTree,
getUnicodeEmoji,
} from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
import { isTouchUsing } from '@/scripts/touch.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
import { $i } from '@/account.js';
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
const props = withDefaults(defineProps<{
showPinned?: boolean;
pinnedEmojis?: string[];
maxHeight?: number;
asDrawer?: boolean;
asWindow?: boolean;
asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう
targetNote?: Misskey.entities.Note;
}>(), {
showPinned: true,
});
const emit = defineEmits<{
(ev: 'chosen', v: string): void;
}>();
const searchEl = shallowRef<HTMLInputElement>();
const emojisEl = shallowRef<HTMLDivElement>();
const {
emojiPickerScale,
emojiPickerWidth,
emojiPickerHeight,
recentlyUsedEmojis,
} = defaultStore.reactiveState;
const recentlyUsedEmojisDef = computed(() => {
return recentlyUsedEmojis.value.map(getDef).filter(x => x != null);
});
const pinnedEmojisDef = computed(() => {
return pinned.value?.map(getDef).filter(x => x != null);
});
const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => emojiPickerScale.value);
const width = computed(() => emojiPickerWidth.value);
const height = computed(() => emojiPickerHeight.value);
const q = ref<string>('');
const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
const customEmojiFolderRoot: CustomEmojiFolderTree = { value: '', category: '', children: [] };
function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
const parts = input.split('/').map(p => p.trim());
let currentNode: CustomEmojiFolderTree = root;
for (const part of parts) {
let existingNode = currentNode.children.find((node) => node.value === part);
if (!existingNode) {
const newNode: CustomEmojiFolderTree = { value: part, category: input, children: [] };
currentNode.children.push(newNode);
existingNode = newNode;
}
currentNode = existingNode;
}
return currentNode;
}
customEmojiCategories.value.forEach(ec => {
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
}
});
parseAndMergeCategories('', customEmojiFolderRoot);
watch(q, () => {
if (emojisEl.value) emojisEl.value.scrollTop = 0;
if (q.value === '') {
searchResultCustom.value = [];
searchResultUnicode.value = [];
return;
}
const newQ = q.value.replace(/:/g, '').normalize('NFC').toLowerCase();
const searchCustom = () => {
const max = 100;
const emojis = customEmojis.value;
const matches = new Set<Misskey.entities.EmojiSimple>();
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 {
if (customEmojisMap.has(newQ)) {
matches.add(customEmojisMap.get(newQ)!);
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias === newQ)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
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 = 100;
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 index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
for (const emoji of emojis) {
if (keywords.every(keyword => index[emoji.char].some(k => k.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 index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
for (const emoji of emojis) {
if (index[emoji.char].some(k => k.startsWith(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
}
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 index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
for (const emoji of emojis) {
if (index[emoji.char].some(k => k.includes(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
}
}
return matches;
};
searchResultCustom.value = Array.from(searchCustom());
searchResultUnicode.value = Array.from(searchUnicode());
});
function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
}
function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean {
return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category;
}
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.EmojiSimple | UnicodeEmojiDef): string {
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
}
function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef {
if (emoji.includes(':')) {
// カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、
// サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておくundefinedを返すとエラーになるため
const name = emoji.replaceAll(':', '');
return customEmojisMap.get(name) ?? emoji;
} else {
return getUnicodeEmoji(emoji);
}
}
/** @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);
}
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 = customEmojisMap.get(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;
> .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;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
}
}
}
}
}
&.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;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
}
}
}
}
}
> .search {
width: 100%;
padding: 12px;
box-sizing: border-box;
font-size: 1em;
outline: none;
border: none;
background: transparent;
color: var(--fg);
&:not(:focus):not(.filled) {
margin-bottom: env(safe-area-inset-bottom, 0px);
}
&: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;
line-height: 28px;
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: var(--radius-xs);
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);
}
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
> .emoji {
height: 1.25em;
vertical-align: -.25em;
pointer-events: none;
width: 100%;
object-fit: contain;
}
}
}
&.result {
border-bottom: solid 0.5px var(--divider);
&:empty {
display: none;
}
}
}
}
}
</style>