Skip to content
File

Blob: src/client/components/editor/extensions/emoji.ts

typescript106 lines
1import type { Editor, Range } from "@tiptap/core";
2import Emoji, { emojiToShortcode, type EmojiItem } from "@tiptap/extension-emoji";
3import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
4import { mountEmojiSuggestion, type EmojiSuggestionOverlayHandle } from "../controllers/emoji/suggestion-overlay";
5 
6const MAX_EMOJI_SUGGESTIONS = 8;
7 
8function getEmojiSearchTerms(item: EmojiItem): string[] {
9 return [item.name, ...item.shortcodes, ...item.tags].map((term) => term.toLowerCase());
10}
11 
12export function getEmojiSuggestionItems(emojis: EmojiItem[], query: string): EmojiItem[] {
13 const q = query.trim().toLowerCase();
14 
15 if (q === "") {
16 return emojis.filter((item) => !!item.emoji || !!item.fallbackImage).slice(0, MAX_EMOJI_SUGGESTIONS);
17 }
18 
19 const startsWithMatches: EmojiItem[] = [];
20 const includesMatches: EmojiItem[] = [];
21 
22 for (const item of emojis) {
23 if (!item.emoji && !item.fallbackImage) continue;
24 
25 const terms = getEmojiSearchTerms(item);
26 if (terms.some((term) => term.startsWith(q))) {
27 startsWithMatches.push(item);
28 continue;
29 }
30 if (terms.some((term) => term.includes(q))) {
31 includesMatches.push(item);
32 }
33 }
34 
35 return [...startsWithMatches, ...includesMatches].slice(0, MAX_EMOJI_SUGGESTIONS);
36}
37 
38export const EditorEmoji = Emoji.configure({
39 HTMLAttributes: {
40 class: "tiptap-emoji",
41 },
42 suggestion: {
43 items: ({ editor, query }) => getEmojiSuggestionItems(editor.storage.emoji?.emojis ?? [], query),
44 render: () => {
45 let handle: EmojiSuggestionOverlayHandle | null = null;
46 
47 return {
48 onStart: (props: SuggestionProps<EmojiItem>) => {
49 handle = mountEmojiSuggestion(props.editor, {
50 items: props.items,
51 command: (item) => props.command(item),
52 clientRect: props.clientRect ?? null,
53 });
54 },
55 onUpdate: (props: SuggestionProps<EmojiItem>) => {
56 handle?.updateProps({
57 items: props.items,
58 command: (item) => props.command(item),
59 clientRect: props.clientRect ?? null,
60 });
61 },
62 onKeyDown: (props: SuggestionKeyDownProps) => {
63 if (props.event.key === "Escape") {
64 handle?.destroy();
65 handle = null;
66 return true;
67 }
68 return handle?.onKeyDown(props.event) ?? false;
69 },
70 onExit: () => {
71 handle?.destroy();
72 handle = null;
73 },
74 };
75 },
76 },
77});
78 
79function canInsertEmojiNode(editor: Editor, range?: Range): boolean {
80 const emojiNode = editor.schema.nodes.emoji;
81 if (!emojiNode) return false;
82 
83 const pos = range?.from ?? editor.state.selection.from;
84 const $from = editor.state.doc.resolve(pos);
85 return !!$from.parent.type.contentMatch.matchType(emojiNode);
86}
87 
88export function insertEmoji(editor: Editor, emoji: string, range?: Range): boolean {
89 if (!editor.isEditable || !emoji) return false;
90 
91 const shortcode = emojiToShortcode(emoji, editor.storage.emoji?.emojis ?? []);
92 const chain = editor.chain().focus(null, { scrollIntoView: false });
93 if (range) chain.deleteRange(range);
94 
95 if (shortcode && canInsertEmojiNode(editor, range)) {
96 return chain
97 .insertContent([
98 { type: "emoji", attrs: { name: shortcode } },
99 { type: "text", text: " " },
100 ])
101 .run();
102 }
103 
104 return chain.insertContent(`${emoji} `).run();
105}