misskey/packages/frontend/src/components/MkEmojiPicker.vue

598 lines
14 KiB
Vue
Raw Normal View History

<template>
2023-01-07 14:44:50 +09:00
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
2023-01-09 17:04:52 +09:00
<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" @keyup.enter="done()">
2023-01-09 16:45:05 +09:00
<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"
2021-11-19 19:36:12 +09:00
@click="chosen(emoji, $event)"
>
2023-01-01 12:28:30 +09:00
<MkEmoji class="emoji" :emoji="`:${emoji.name}:`"/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0" class="body">
<button
v-for="emoji in searchResultUnicode"
2021-11-19 19:36:12 +09:00
:key="emoji.name"
class="_button item"
:title="emoji.name"
tabindex="0"
2021-11-19 19:36:12 +09:00
@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"
2021-11-19 19:36:12 +09:00
:key="emoji"
class="_button item"
tabindex="0"
2021-11-19 19:36:12 +09:00
@click="chosen(emoji, $event)"
>
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>
<section>
2022-12-20 15:24:31 +09:00
<header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
<div class="body">
<button
v-for="emoji in recentlyUsedEmojis"
2021-11-19 19:36:12 +09:00
:key="emoji"
class="_button item"
@click="chosen(emoji, $event)"
>
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>
</div>
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
2023-01-23 02:33:20 +09:00
<XSection
v-for="category in customEmojiCategories"
:key="`custom:${category}`"
:initial-shown="false"
2023-01-23 02:39:11 +09:00
:emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).map(e => `:${e.name}:`))"
2023-01-23 02:33:20 +09:00
@chosen="chosen"
>
{{ category || i18n.ts.other }}
</XSection>
2021-02-27 19:53:20 +09:00
</div>
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
refactor: use Vite to build instead of webpack (#8575) * update stream.ts * https://github.com/misskey-dev/misskey/pull/7769#issuecomment-917542339 * fix lint * clean up? * add app * fix * nanka iroiro * wip * wip * fix lint * fix loginId * fix * refactor * refactor * remove follow action * clean up * Revert "remove follow action" This reverts commit defbb416480905af2150d1c92f10d8e1d1288c0a. * Revert "clean up" This reverts commit f94919cb9cff41e274044fc69c56ad36a33974f2. * remove fetch specification * renoteの条件追加 * apiFetch => cli * bypass fetch? * fix * refactor: use path alias * temp: add submodule * remove submodule * enhane: unison-reloadに指定したパスに移動できるように * null * null * feat: ログインするアカウントのIDをクエリ文字列で指定する機能 * null * await? * rename * rename * Update read.ts * merge * get-note-summary * fix * swパッケージに * add missing packages * fix getNoteSummary * add webpack-cli * :v: * remove plugins * sw-inject分離したがテストしてない * fix notification.vue * remove a blank line * disconnect intersection observer * disconnect2 * fix notification.vue * remove a blank line * disconnect intersection observer * disconnect2 * fix * :v: * clean up config * typesを戻した * Update packages/client/src/components/notification.vue Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * disconnect * oops * Failed to load the script unexpectedly回避 sw.jsとlib.tsを分離してみた * truncate notification * Update packages/client/src/ui/_common_/common.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * clean up * clean up * キャッシュ対策 * Truncate push notification message * クライアントがあったらストリームに接続しているということなので通知しない判定の位置を修正 * components/drive-file-thumbnail.vue * components/drive-select-dialog.vue * components/drive-window.vue * merge * fix * Service Workerのビルドにesbuildを使うようにする * return createEmptyNotification() * fix * i18n.ts * update * :v: * remove ts-loader * fix * fix * enhance: Service Workerを常に登録するように * pollEnded * URLをsw.jsに戻す * clean up * wip * wip * wip * wip * wip * wip * :v: * use import * fix * install rollup * use defineAsyncComponent. * fix emojilist * wip use defineAsyncComponent * popup(import -> popup(defineAsyncComponent(() => import * draggable? * fix init import * clean up * fix router * add comment * :v: * :v: * :v: * remove webpack * update vite * fix boot sequence * Revert "fix boot sequence" This reverts commit e893dbf37aed83bf9f12e427d98c78a7065b4a39. * revert boot import * never make two app div * ; * remove console.log * change clientEntry sequence * fix * Revert "fix" This reverts commit 12741b3d89950a31dbb1bb81477ddb27b0e9951a. * fix * add comment https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 * add log * add comment Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2022-05-01 22:51:07 +09:00
<XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection>
2021-02-27 19:53:20 +09:00
</div>
</div>
<div class="tabs">
2022-12-20 08:35:49 +09:00
<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>
2022-12-20 08:35:49 +09:00
<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, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
2023-01-01 12:28:30 +09:00
import MkRippleEffect from '@/components/MkRippleEffect.vue';
2021-11-12 02:02:25 +09:00
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { deviceKind } from '@/scripts/device-kind';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
2023-01-16 19:36:29 +09:00
import { customEmojiCategories, customEmojis } from '@/custom-emojis';
const props = withDefaults(defineProps<{
showPinned?: boolean;
asReactionPicker?: boolean;
maxHeight?: number;
asDrawer?: boolean;
2023-01-07 14:44:50 +09:00
asWindow?: boolean;
}>(), {
showPinned: true,
});
const emit = defineEmits<{
(ev: 'chosen', v: string): void;
}>();
2023-01-09 17:04:52 +09:00
const searchEl = shallowRef<HTMLInputElement>();
2023-01-09 16:27:09 +09:00
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, () => {
2023-01-09 16:27:09 +09:00
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;
2023-01-16 18:39:58 +09:00
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;
}
2021-02-28 01:09:59 +09:00
}
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) {
2023-01-09 17:04:52 +09:00
searchEl.value?.focus({
preventScroll: true,
});
}
}
function reset() {
2023-01-09 16:27:09 +09:00
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}:`;
}
function chosen(emoji: any, ev?: MouseEvent) {
2022-01-28 11:53:12 +09:00
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);
2023-01-01 12:28:30 +09:00
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)
2023-01-09 17:04:52 +09:00
q.value = searchEl.value?.value.trim() ?? '';
}
function paste(event: ClipboardEvent): void {
const pasted = event.clipboardData?.getData('text') ?? '';
if (done(pasted)) {
event.preventDefault();
}
}
function done(query?: string): boolean | void {
if (query == null) query = q.value;
if (query == null || typeof query !== 'string') return;
const q2 = query.replace(/:/g, '');
2023-01-16 18:39:58 +09:00
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 {
2020-11-14 11:47:30 +09:00
$pad: 8px;
display: flex;
flex-direction: column;
&.s1 {
--eachSize: 40px;
}
&.s2 {
--eachSize: 45px;
}
&.s3 {
--eachSize: 50px;
2020-11-18 12:09:14 +09:00
}
&.w1 {
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr;
2020-11-18 12:09:14 +09:00
}
&.w2 {
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
2020-11-18 12:09:14 +09:00
}
&.w3 {
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
2020-11-18 12:09:14 +09:00
}
&.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;
}
2020-11-18 12:09:14 +09:00
&.h1 {
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
2020-11-18 12:09:14 +09:00
}
&.h2 {
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
2020-11-18 12:09:14 +09:00
}
&.h3 {
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
}
&.h4 {
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
}
&.asDrawer {
width: 100% !important;
> .emojis {
::v-deep(section) {
2021-12-17 16:14:43 +09:00
> header {
height: 32px;
line-height: 32px;
padding: 0 12px;
font-size: 15px;
}
> .body {
display: grid;
grid-template-columns: var(--columns);
font-size: 30px;
> .item {
2023-01-07 14:44:50 +09:00
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;
}
}
}
}
2020-11-14 11:47:30 +09:00
}
> .search {
width: 100%;
padding: 12px;
box-sizing: border-box;
font-size: 1em;
outline: none;
border: none;
background: transparent;
color: var(--fg);
2020-11-14 11:47:30 +09:00
&:not(.filled) {
order: 1;
z-index: 2;
box-shadow: 0px -1px 0 0px var(--divider);
}
}
2021-02-27 19:53:20 +09:00
> .tabs {
display: flex;
display: none;
> .tab {
flex: 1;
height: 38px;
border-top: solid 0.5px var(--divider);
2021-02-27 19:53:20 +09:00
&.active {
border-top: solid 1px var(--accent);
color: var(--accent);
}
}
}
> .emojis {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
2020-11-14 11:47:30 +09:00
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
> .group {
2021-02-27 19:53:20 +09:00
&:not(.index) {
padding: 4px 0 8px 0;
border-top: solid 0.5px var(--divider);
2021-02-27 19:53:20 +09:00
}
> header {
/*position: sticky;
top: 0;
left: 0;*/
height: 32px;
line-height: 32px;
z-index: 2;
padding: 0 8px;
font-size: 12px;
}
}
2021-02-27 19:53:20 +09:00
::v-deep(section) {
> header {
position: sticky;
top: 0;
left: 0;
2021-02-27 19:53:20 +09:00
height: 32px;
line-height: 32px;
z-index: 1;
2021-02-27 19:53:20 +09:00
padding: 0 8px;
font-size: 12px;
2021-02-27 19:53:20 +09:00
cursor: pointer;
&:hover {
color: var(--accent);
}
}
> .body {
2021-02-27 19:53:20 +09:00
position: relative;
2020-11-14 11:47:30 +09:00
padding: $pad;
> .item {
position: relative;
padding: 0;
2020-11-18 12:09:14 +09:00
width: var(--eachSize);
height: var(--eachSize);
contain: strict;
2020-11-14 11:47:30 +09:00
border-radius: 4px;
font-size: 24px;
2021-10-03 02:46:58 +09:00
&:focus-visible {
outline: solid 2px var(--focus);
z-index: 1;
}
2020-11-14 11:47:30 +09:00
&:hover {
background: rgba(0, 0, 0, 0.05);
}
2020-11-14 11:47:30 +09:00
&:active {
background: var(--accent);
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
}
> .emoji {
2020-11-14 11:47:30 +09:00
height: 1.25em;
vertical-align: -.25em;
pointer-events: none;
}
}
}
&.result {
border-bottom: solid 0.5px var(--divider);
2020-11-14 11:47:30 +09:00
&:empty {
display: none;
}
}
}
}
}
</style>