diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index a7b1914e5..c32304f4a 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -213,6 +213,8 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting, a **Wrap lines** toggle, and a copy control. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. This is developer-oriented text formatting, not a full rich-text editor. +The smiley button in the commit and reply composers opens the same searchable emoji picker as reactions in remotes. Picking an emoji inserts it where the caret was, or over the selected text, and puts the caret right after it. + In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal. Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies nobody: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. @@ -221,7 +223,7 @@ Typing **#123** in a composer looks up that exact issue or pull request in the / A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text. -Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). +Sources: [body renderer](resources/js/components/commit-body.tsx), [emoji picker button](resources/js/components/emoji-picker.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). ### Draft previews diff --git a/resources/js/components/chat/message-reactions.tsx b/resources/js/components/chat/message-reactions.tsx index 71e53be27..3dec867d0 100644 --- a/resources/js/components/chat/message-reactions.tsx +++ b/resources/js/components/chat/message-reactions.tsx @@ -565,6 +565,12 @@ export function EmojiPicker({ }; const onKeyDown = (event: KeyboardEvent) => { + // Enter also confirms an input method's candidate, which is not a pick. Safari reports that Enter with keyCode + // 229 instead of isComposing. + if (event.nativeEvent.isComposing || event.keyCode === 229) { + return; + } + const moves: Record = { ArrowRight: 1, ArrowLeft: -1, diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a8898760d..d1ef9c8a3 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'; @@ -126,7 +128,7 @@ function CommitComposerBody({ drop: () => void; }) { const keys = useKeyLabels(); - const { body, isRestored } = stash; + const { body, isRestored, updateBody } = stash; const { auth } = usePage().props; const { isScheduling, @@ -177,6 +179,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]'); @@ -1540,6 +1549,11 @@ function ReplyComposer({ onPick={attachments.pick} className="size-8" /> + (); + +/** + * The reactions' emoji picker, for composers. The emoji is handed over once the picker has closed, so `onPick` can put + * the focus back in the textarea instead of on this button. + */ +export function EmojiPickerButton({ + disabled, + onPick, + className, +}: { + disabled?: boolean; + onPick: (emoji: string) => void; + className?: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const pickedEmoji = useRef(null); + + return ( + + + + + + + { + const emoji = pickedEmoji.current; + + if (emoji !== null) { + event.preventDefault(); + pickedEmoji.current = null; + onPick(emoji); + } + }} + > + { + pickedEmoji.current = emoji; + setIsOpen(false); + }} + /> + + + ); +} diff --git a/resources/js/hooks/use-emoji-insertion.ts b/resources/js/hooks/use-emoji-insertion.ts new file mode 100644 index 000000000..1f2b18fff --- /dev/null +++ b/resources/js/hooks/use-emoji-insertion.ts @@ -0,0 +1,54 @@ +import type { RefObject } from 'react'; +import { useLayoutEffect, useRef } from 'react'; +import { insertAtSelection } from '@/lib/text-selection'; + +/** + * Inserts a picked emoji where the caret was in the composer's textarea. A textarea keeps its selection while the + * picker has the focus, so this works whether the picker was opened with the pointer or the keyboard. Once React has + * written the new body, the focus goes back to the textarea with the caret right after the emoji. + */ +export function useEmojiInsertion({ + body, + updateBody, + textareaRef, + maxLength, +}: { + body: string; + updateBody: (body: string) => void; + textareaRef: RefObject; + maxLength: number; +}): (emoji: string) => void { + const caret = useRef(null); + + useLayoutEffect(() => { + const element = textareaRef.current; + + if (caret.current === null || element === null) { + return; + } + + element.focus(); + element.setSelectionRange(caret.current, caret.current); + caret.current = null; + }, [body, textareaRef]); + + return (emoji) => { + const element = textareaRef.current; + const inserted = insertAtSelection(body, element, emoji, maxLength); + + if (inserted === null) { + // Focusing a restored draft moves a caret at its start to the end, so the selection is put back after. + if (element !== null) { + const { selectionStart, selectionEnd } = element; + + element.focus(); + element.setSelectionRange(selectionStart, selectionEnd); + } + + return; + } + + caret.current = inserted.caret; + updateBody(inserted.value); + }; +} 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/EmojiInsertion.test.tsx b/tests/Unit/EmojiInsertion.test.tsx new file mode 100644 index 000000000..fd1cd612a --- /dev/null +++ b/tests/Unit/EmojiInsertion.test.tsx @@ -0,0 +1,120 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterAll, beforeEach, test, vi } from 'vite-plus/test'; + +let layoutEffect: () => void = () => {}; + +// The layout effect runs when the test says React has written the new body. +vi.doMock('react', () => ({ + ...React, + useLayoutEffect: (effect: () => void) => { + layoutEffect = effect; + }, + useRef: (initial: unknown) => ({ current: initial }), +})); + +afterAll(() => { + vi.doUnmock('react'); +}); + +const { useEmojiInsertion } = + await import('../../resources/js/hooks/use-emoji-insertion'); + +/** + * A textarea that kept its selection after the picker took the focus, as browsers do. + */ +function blurredTextarea( + selectionStart: number, + selectionEnd = selectionStart, +) { + const focus = vi.fn(); + const setSelectionRange = vi.fn(); + const textarea = { + selectionStart, + selectionEnd, + focus, + setSelectionRange, + } as unknown as HTMLTextAreaElement; + + return { textarea, focus, setSelectionRange }; +} + +const updateBody = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('inserts where the caret was and puts the focus back right after the emoji', () => { + const { textarea, focus, setSelectionRange } = blurredTextarea(8); + const insertEmoji = useEmojiInsertion({ + body: 'shipped it', + updateBody, + textareaRef: { current: textarea }, + maxLength: 280, + }); + + insertEmoji('πŸš€'); + layoutEffect(); + + assert.deepEqual(updateBody.mock.calls, [['shipped πŸš€it']]); + assert.equal(focus.mock.calls.length, 1); + assert.deepEqual(setSelectionRange.mock.calls, [[10, 10]]); +}); + +test('replaces the text that was selected', () => { + const { textarea } = blurredTextarea(8, 10); + + useEmojiInsertion({ + body: 'shipped it', + updateBody, + textareaRef: { current: textarea }, + maxLength: 280, + })('πŸŽ‰'); + + assert.deepEqual(updateBody.mock.calls, [['shipped πŸŽ‰']]); +}); + +test('leaves a full composer as it is and returns to it', () => { + const { textarea, focus, setSelectionRange } = blurredTextarea(3); + + useEmojiInsertion({ + body: 'abc', + updateBody, + textareaRef: { current: textarea }, + maxLength: 3, + })('πŸ”₯'); + layoutEffect(); + + assert.equal(updateBody.mock.calls.length, 0); + assert.equal(focus.mock.calls.length, 1); + assert.deepEqual(setSelectionRange.mock.calls, [[3, 3]]); +}); + +test('keeps the caret at the start of a full restored draft', () => { + const { textarea, focus, setSelectionRange } = blurredTextarea(0); + + useEmojiInsertion({ + body: 'abc', + updateBody, + textareaRef: { current: textarea }, + maxLength: 3, + })('πŸ”₯'); + + assert.equal(updateBody.mock.calls.length, 0); + assert.equal(focus.mock.calls.length, 1); + assert.deepEqual(setSelectionRange.mock.calls, [[0, 0]]); +}); + +test('appends when the textarea is gone', () => { + useEmojiInsertion({ + body: 'shipped', + updateBody, + textareaRef: { current: null }, + maxLength: 280, + })(' πŸ”₯'); + layoutEffect(); + + assert.deepEqual(updateBody.mock.calls, [['shipped πŸ”₯']]); +}); diff --git a/tests/Unit/EmojiPickerKeys.test.tsx b/tests/Unit/EmojiPickerKeys.test.tsx new file mode 100644 index 000000000..c5d33da53 --- /dev/null +++ b/tests/Unit/EmojiPickerKeys.test.tsx @@ -0,0 +1,81 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterAll, test, vi } from 'vite-plus/test'; + +// Renders the picker once, as a plain function, with the groups still loading. +vi.doMock('react', () => ({ + ...React, + useEffect: () => {}, + useId: () => 'picker', + useMemo: (factory: () => unknown) => factory(), + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => [ + typeof initial === 'function' ? (initial as () => unknown)() : initial, + vi.fn(), + ], +})); + +afterAll(() => { + vi.doUnmock('react'); +}); + +const { EmojiPicker } = + await import('../../resources/js/components/chat/message-reactions'); + +type Element = ReactElement>; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) return []; + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function pressEnter(isComposing: boolean, keyCode = 13) { + const onPick = vi.fn(); + const preventDefault = vi.fn(); + const search = elements( + EmojiPicker({ + usage: [{ emoji: 'πŸš€', count: 2 }], + reacted: new Set(), + onPick, + }), + ).find((element) => element.props['data-test'] === 'emoji-search')!; + + (search.props.onKeyDown as (event: unknown) => void)({ + key: 'Enter', + keyCode, + nativeEvent: { isComposing }, + preventDefault, + }); + + return { onPick, preventDefault }; +} + +test('Enter picks the highlighted emoji', () => { + const { onPick, preventDefault } = pressEnter(false); + + assert.deepEqual(onPick.mock.calls, [['πŸš€']]); + assert.equal(preventDefault.mock.calls.length, 1); +}); + +test('Enter that confirms an input method candidate picks nothing', () => { + const { onPick, preventDefault } = pressEnter(true); + + assert.equal(onPick.mock.calls.length, 0); + assert.equal(preventDefault.mock.calls.length, 0); +}); + +test("Safari's Enter that confirms an input method candidate picks nothing", () => { + const { onPick, preventDefault } = pressEnter(false, 229); + + assert.equal(onPick.mock.calls.length, 0); + assert.equal(preventDefault.mock.calls.length, 0); +}); 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); +});