File
Blob: src/client/components/editor/extensions/emoji.ts
| 1 | import type { Editor, Range } from "@tiptap/core"; |
| 2 | import Emoji, { emojiToShortcode, type EmojiItem } from "@tiptap/extension-emoji"; |
| 3 | import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"; |
| 4 | import { mountEmojiSuggestion, type EmojiSuggestionOverlayHandle } from "../controllers/emoji/suggestion-overlay"; |
| 5 | |
| 6 | const MAX_EMOJI_SUGGESTIONS = 8; |
| 7 | |
| 8 | function getEmojiSearchTerms(item: EmojiItem): string[] { |
| 9 | return [item.name, ...item.shortcodes, ...item.tags].map((term) => term.toLowerCase()); |
| 10 | } |
| 11 | |
| 12 | export 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 | |
| 38 | export 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 | |
| 79 | function 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 | |
| 88 | export 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 | } |