| 1 | import { type KeyboardEvent, type ReactNode, type RefObject, useId, useState } from "react"; |
| 2 | |
| 3 | import { useEmojiContext } from "./context"; |
| 4 | import { loadEmojiData, readTone, rememberRecent } from "./data"; |
| 5 | import { EmojiGlyph } from "./render"; |
| 6 | import { type EmojiData, type EmojiPick, searchEmoji, shortcodeQuery, skinned } from "../../lib/emoji"; |
| 7 | |
| 8 | /** Suggestions shown at once. */ |
| 9 | const SHOWN = 8; |
| 10 | |
| 11 | type Match = { start: number; typed: string; options: EmojiPick[] }; |
| 12 | |
| 13 | /** |
| 14 | * `:shortcode` completion for a text box. Typing `:` and two letters |
| 15 | * offers emoji by name; Up and Down move, Tab or Enter take one, Escape |
| 16 | * dismisses. A standard emoji goes in as the emoji itself; the workspace's |
| 17 | * own as `:name:`, which messages draw as its image. |
| 18 | * |
| 19 | * Use: call `look()` after input and clicks, give `onKeyDown` the box's |
| 20 | * key events first (it says whether it used one), render `menu` above the |
| 21 | * box, and dismiss on blur. |
| 22 | */ |
| 23 | export function useEmojiAutocomplete({ |
| 24 | box, |
| 25 | setText, |
| 26 | }: { |
| 27 | box: RefObject<HTMLTextAreaElement | HTMLInputElement | null>; |
| 28 | setText: (text: string) => void; |
| 29 | }): { menu: ReactNode; open: boolean; look: () => void; onKeyDown: (event: KeyboardEvent<HTMLElement>) => boolean; dismiss: () => void; listId: string; activeId: string | undefined } { |
| 30 | const { customs, byName, usercontent } = useEmojiContext(); |
| 31 | const [match, setMatch] = useState<Match | null>(null); |
| 32 | const [active, setActive] = useState(0); |
| 33 | const list = useId(); |
| 34 | |
| 35 | const look = () => { |
| 36 | const element = box.current; |
| 37 | if (!element || element.selectionStart == null || element.selectionStart !== element.selectionEnd) return setMatch(null); |
| 38 | const query = shortcodeQuery(element.value, element.selectionStart); |
| 39 | if (!query) return setMatch(null); |
| 40 | const show = (data: EmojiData | null) => { |
| 41 | const options = searchEmoji(data, customs, query.typed, SHOWN); |
| 42 | setMatch(options.length ? { ...query, options } : null); |
| 43 | setActive(0); |
| 44 | }; |
| 45 | // The workspace's own at once; the standard ones as soon as they load. |
| 46 | show(null); |
| 47 | void loadEmojiData() |
| 48 | .then((data) => { |
| 49 | const now = box.current; |
| 50 | if (now && now.selectionStart != null && shortcodeQuery(now.value, now.selectionStart)?.typed === query.typed) show(data); |
| 51 | }) |
| 52 | .catch(() => {}); |
| 53 | }; |
| 54 | |
| 55 | const take = (choice: EmojiPick) => { |
| 56 | const element = box.current; |
| 57 | if (!element || !match) return; |
| 58 | const caret = element.selectionStart ?? match.start; |
| 59 | const emoji = choice.kind === "unicode" ? skinned(choice, readTone()) : choice.emoji; |
| 60 | rememberRecent(emoji); |
| 61 | const next = `${element.value.slice(0, match.start)}${emoji} ${element.value.slice(caret)}`; |
| 62 | setText(next); |
| 63 | setMatch(null); |
| 64 | requestAnimationFrame(() => { |
| 65 | const at = match.start + emoji.length + 1; |
| 66 | element.focus(); |
| 67 | element.setSelectionRange(at, at); |
| 68 | }); |
| 69 | }; |
| 70 | |
| 71 | const onKeyDown = (event: KeyboardEvent<HTMLElement>): boolean => { |
| 72 | if (!match) return false; |
| 73 | const count = match.options.length; |
| 74 | if (event.key === "ArrowDown" || event.key === "ArrowUp") { |
| 75 | event.preventDefault(); |
| 76 | setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count); |
| 77 | return true; |
| 78 | } |
| 79 | if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) { |
| 80 | event.preventDefault(); |
| 81 | take(match.options[active] ?? match.options[0]!); |
| 82 | return true; |
| 83 | } |
| 84 | if (event.key === "Escape") { |
| 85 | event.preventDefault(); |
| 86 | setMatch(null); |
| 87 | return true; |
| 88 | } |
| 89 | return false; |
| 90 | }; |
| 91 | |
| 92 | const menu = match ? ( |
| 93 | <ul |
| 94 | id={list} |
| 95 | role="listbox" |
| 96 | aria-label="Emoji" |
| 97 | className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm overflow-hidden rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40" |
| 98 | > |
| 99 | <li role="presentation" className="px-2 pt-1 pb-1.5 text-[0.6875rem] font-semibold tracking-wide text-faint uppercase"> |
| 100 | Emoji matching :{match.typed} |
| 101 | </li> |
| 102 | {match.options.map((choice, index) => ( |
| 103 | <li |
| 104 | key={choice.emoji} |
| 105 | id={`${list}-${index}`} |
| 106 | role="option" |
| 107 | aria-selected={index === active} |
| 108 | onMouseDown={(event) => event.preventDefault()} |
| 109 | onMouseEnter={() => setActive(index)} |
| 110 | onClick={() => take(choice)} |
| 111 | className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`} |
| 112 | > |
| 113 | <span className="flex size-6 items-center justify-center"> |
| 114 | <EmojiGlyph emoji={choice.emoji} byName={byName} usercontent={usercontent} size={choice.kind === "custom" ? 22 : 18} /> |
| 115 | </span> |
| 116 | <span className="min-w-0 grow truncate font-mono text-[0.8125rem] text-fg-soft"> |
| 117 | :{choice.kind === "custom" ? choice.name : (choice.code ?? choice.name)}: |
| 118 | </span> |
| 119 | {choice.kind === "custom" && <span className="text-[0.6875rem] text-faint">This workspace</span>} |
| 120 | </li> |
| 121 | ))} |
| 122 | </ul> |
| 123 | ) : null; |
| 124 | |
| 125 | return { |
| 126 | menu, |
| 127 | open: match != null, |
| 128 | look, |
| 129 | onKeyDown, |
| 130 | dismiss: () => setMatch(null), |
| 131 | listId: list, |
| 132 | activeId: match ? `${list}-${active}` : undefined, |
| 133 | }; |
| 134 | } |