diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 4668c4e9c..275fe6756 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -19,6 +19,7 @@ import { PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; +import { EmojiPickerButton } from '@/components/emoji-picker'; import { GraphRail, RailAvatar } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import type { UseIssueCommandReturn } from '@/components/issue-command'; @@ -38,6 +39,7 @@ import { } from '@/components/schedule-picker'; import { StashLabel } from '@/components/stash-label'; import { Button } from '@/components/ui/button'; +import { useEmojiInsertion } from '@/hooks/use-emoji-insertion'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; import type { UseLinkCardReturn } from '@/hooks/use-link-card'; @@ -125,7 +127,7 @@ function CommitComposerBody({ drop: () => void; }) { const keys = useKeyLabels(); - const { body, isRestored } = stash; + const { body, isRestored, updateBody } = stash; const { auth } = usePage().props; const { isScheduling, @@ -176,6 +178,13 @@ function CommitComposerBody({ return () => observer.disconnect(); }, [isInDialog]); + const insertEmoji = useEmojiInsertion({ + body, + updateBody, + textareaRef, + maxLength: isIssue ? maxBodyLength : maxRawBodyLength, + }); + return (
+ { const commit = textareaRef.current?.closest('[data-commit]'); @@ -1513,6 +1522,11 @@ function ReplyComposer({ onPick={attachments.pick} className="size-8" /> + void; + className?: string; +}) { + const textareaRef = useRef(null); + const [query, setQuery] = useState(''); + const results = query.trim() === '' ? null : searchEmoji(query); + + return ( + + + event.preventDefault()} + onClick={() => { + const active = document.activeElement; + + textareaRef.current = + active instanceof HTMLTextAreaElement + ? active + : null; + setQuery(''); + }} + aria-label="Add emoji" + className={cn( + buttonVariants({ variant: 'ghost', size: 'icon' }), + 'text-muted-foreground', + className, + )} + data-test="emoji-picker-button" + > + + + + + + {({ close }) => { + const pick = (emoji: string) => { + onPick(emoji, textareaRef.current); + }; + + const pickAndClose = (emoji: string) => { + const textarea = textareaRef.current; + + onPick(emoji, textarea); + close(textarea ?? undefined); + }; + + const pickFirst = (event: KeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + + const first = (results ?? popularEmoji)[0]; + + if (first) { + pickAndClose(first.emoji); + } + }; + + return ( + <> +
+ + setQuery(event.target.value) + } + onKeyDown={pickFirst} + placeholder="Search emoji" + aria-label="Search emoji" + autoComplete="off" + className="h-8 w-full rounded-md border bg-background px-2 text-base outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm" + /> +
+ +
+ {results === null ? ( + <> + + + + ) : results.length === 0 ? ( +

+ No emoji found. +

+ ) : ( + + )} +
+ + ); + }} +
+
+ ); +} + +function EmojiSection({ + title, + emoji, + onPick, + className, +}: { + title: string; + emoji: PickableEmoji[]; + onPick: (emoji: string) => void; + className?: string; +}) { + return ( +
+

+ {title} +

+ +
+ ); +} + +function EmojiGrid({ + emoji, + onPick, +}: { + emoji: PickableEmoji[]; + onPick: (emoji: string) => void; +}) { + return ( +
+ {emoji.map(({ name, emoji: character }) => ( + + ))} +
+ ); +} diff --git a/resources/js/hooks/use-emoji-insertion.ts b/resources/js/hooks/use-emoji-insertion.ts new file mode 100644 index 000000000..2367231f5 --- /dev/null +++ b/resources/js/hooks/use-emoji-insertion.ts @@ -0,0 +1,50 @@ +import type { RefObject } from 'react'; +import { useLayoutEffect, useRef } from 'react'; +import { insertAtSelection } from '@/lib/text-selection'; + +/** + * Inserts a picked emoji at the caret of the composer's textarea. The caret is put back once React has written the + * new body, since writing a long value can land after an animation frame and would leave the caret at the end. Focus + * is left alone so the picker can stay open for the next emoji. + */ +export function useEmojiInsertion({ + body, + updateBody, + textareaRef, + maxLength, +}: { + body: string; + updateBody: (body: string) => void; + textareaRef: RefObject; + maxLength: number; +}): (emoji: string, textarea: HTMLTextAreaElement | null) => void { + const caret = useRef(null); + + useLayoutEffect(() => { + const element = textareaRef.current; + + if (caret.current === null || element === null) { + return; + } + + element.setSelectionRange(caret.current, caret.current); + caret.current = null; + }, [body, textareaRef]); + + return (emoji, textarea) => { + const element = textareaRef.current; + const inserted = insertAtSelection( + body, + textarea !== null && textarea === element ? element : null, + emoji, + maxLength, + ); + + if (inserted === null) { + return; + } + + caret.current = inserted.caret; + updateBody(inserted.value); + }; +} diff --git a/resources/js/lib/emoji-picker.ts b/resources/js/lib/emoji-picker.ts new file mode 100644 index 000000000..50f76767b --- /dev/null +++ b/resources/js/lib/emoji-picker.ts @@ -0,0 +1,67 @@ +import emojiShortcodes from '@/lib/emoji-shortcodes.json?raw'; + +export type PickableEmoji = { + name: string; + emoji: string; +}; + +const popularNames = [ + '+1', + 'heart', + 'joy', + 'fire', + 'rocket', + 'tada', + 'eyes', + '100', + 'pray', + 'thinking', + 'sweat_smile', + 'clap', + 'sparkles', + 'white_check_mark', + 'bug', + 'coffee', +]; + +const shortcodeEmoji = JSON.parse(emojiShortcodes) as Record; + +const seenEmoji = new Set(); + +/** + * Every emoji once, under the first of its shortcodes, so aliases like :+1: and :thumbsup: share one button. + */ +const pickableEmoji: PickableEmoji[] = Object.entries(shortcodeEmoji).flatMap( + ([name, emoji]) => { + if (seenEmoji.has(emoji)) { + return []; + } + + seenEmoji.add(emoji); + + return [{ name, emoji }]; + }, +); + +export const popularEmoji: PickableEmoji[] = popularNames.flatMap((name) => + shortcodeEmoji[name] ? [{ name, emoji: shortcodeEmoji[name] }] : [], +); + +/** + * Matches the search against every shortcode, aliases included, so :thumbsup finds 👍 too. + */ +export function searchEmoji(query: string): PickableEmoji[] { + const term = query.trim().replace(/^:|:$/g, '').toLowerCase(); + + if (term === '') { + return pickableEmoji; + } + + const matching = new Set( + Object.entries(shortcodeEmoji) + .filter(([name]) => name.includes(term)) + .map(([, emoji]) => emoji), + ); + + return pickableEmoji.filter(({ emoji }) => matching.has(emoji)); +} diff --git a/resources/js/lib/text-selection.ts b/resources/js/lib/text-selection.ts new file mode 100644 index 000000000..9e00401eb --- /dev/null +++ b/resources/js/lib/text-selection.ts @@ -0,0 +1,25 @@ +export type TextSelection = Pick< + HTMLTextAreaElement, + 'selectionStart' | 'selectionEnd' +>; + +/** + * Inserts the text over the selection, or at the end without one. Null when the result would be longer than + * `maxLength` characters, the same cap the textarea puts on typing. + */ +export function insertAtSelection( + value: string, + selection: TextSelection | null, + text: string, + maxLength: number, +): { value: string; caret: number } | null { + const start = selection?.selectionStart ?? value.length; + const end = selection?.selectionEnd ?? value.length; + const next = value.slice(0, start) + text + value.slice(end); + + if (Array.from(next).length > maxLength) { + return null; + } + + return { value: next, caret: start + text.length }; +} diff --git a/tests/Unit/TextSelection.test.ts b/tests/Unit/TextSelection.test.ts new file mode 100644 index 000000000..1b325a716 --- /dev/null +++ b/tests/Unit/TextSelection.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { insertAtSelection } from '../../resources/js/lib/text-selection.ts'; + +void test('inserts at the caret and moves the caret after the insertion', () => { + assert.deepEqual( + insertAtSelection( + 'shipped it', + { selectionStart: 8, selectionEnd: 8 }, + '🚀 ', + 280, + ), + { value: 'shipped 🚀 it', caret: 11 }, + ); +}); + +void test('replaces the selected text', () => { + assert.deepEqual( + insertAtSelection( + 'shipped it', + { selectionStart: 8, selectionEnd: 10 }, + '🎉', + 280, + ), + { value: 'shipped 🎉', caret: 10 }, + ); +}); + +void test('appends without a selection', () => { + assert.deepEqual(insertAtSelection('shipped', null, ' 🔥', 280), { + value: 'shipped 🔥', + caret: 10, + }); +}); + +void test('refuses an insertion that would not fit, counting characters like the composer', () => { + assert.deepEqual(insertAtSelection('a'.repeat(279), null, '🔥', 280), { + value: `${'a'.repeat(279)}🔥`, + caret: 281, + }); + assert.equal(insertAtSelection('a'.repeat(280), null, '🔥', 280), null); +});