diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d29acd2c7..90923984f 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -260,7 +260,7 @@ 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. A title after the language, as in ```` ```php title="app/Models/User.php" ````, labels the block in place of the language. Text outside code supports emoji shortcodes, clickable URLs, `@username` mentions, and `#hashtags`, which open commit search for signed-in members and stay plain text for guests. 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. +The smiley button in the commit, reply and quote 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. diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index 17173604d..3b9d66f89 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -17,6 +17,7 @@ import { PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; +import { EmojiPickerButton } from '@/components/emoji-picker'; import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; import { MentionTextarea } from '@/components/mention-textarea'; @@ -32,6 +33,7 @@ import { } from '@/components/ui/dialog'; import { UserAvatar } from '@/components/user-avatar'; import { useMarkCommitDeleted } from '@/hooks/use-deleted-resources'; +import { useEmojiInsertion } from '@/hooks/use-emoji-insertion'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; import { useOwnsCommit } from '@/hooks/use-owns-commit'; @@ -152,6 +154,12 @@ function QuoteComposer({ textareaRef, isSubmittable: isQuotable, }); + const insertEmoji = useEmojiInsertion({ + body, + updateBody, + textareaRef, + maxLength: maxRawBodyLength, + }); const submitOnModEnter = (event: KeyboardEvent) => { if (preview.handleShortcut(event)) { @@ -281,10 +289,19 @@ function QuoteComposer({ } aria-describedby={`${id}-count`} className={cn( - 'col-start-1 row-start-1 block field-sizing-content max-h-[40dvh] min-h-10 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent py-1.5 text-[17px] leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none', + 'col-start-1 row-start-1 block field-sizing-content max-h-[40dvh] min-h-10 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent py-1.5 pr-9 text-[17px] leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none', { invisible: preview.isPreviewing }, )} /> + {!preview.isPreviewing && ( +
+ +
+ )} {preview.isPreviewing && ( ({ '../../resources/js/components/composer-preview', )), useComposerPreview: () => ({ - isPreviewing: false, + isPreviewing, handleShortcut: () => false, }), })); @@ -63,6 +64,10 @@ const { StashLabel } = await import('../../resources/js/components/stash-label'); const { LinkCardPicker } = await import('../../resources/js/components/link-card-picker'); +const { EmojiPickerButton } = + await import('../../resources/js/components/emoji-picker'); +const { MentionTextarea } = + await import('../../resources/js/components/mention-textarea'); const { default: InputError } = await import('../../resources/js/components/input-error'); @@ -81,6 +86,70 @@ function elements(node: ReactNode): ReactElement>[] { ]; } +test('quotes insert picked emoji over the selection and hide the picker in preview', () => { + draft.body = 'Shipping today'; + draft.updateBody.mockClear(); + const commit = { + id: 'quoted', + author: { id: 'author', handle: 'author' }, + status: 'approved', + } as Commit; + const button = CommitQuoteButton({ commit })!; + const dialog = React.Children.toArray( + button.props.children, + )[1] as ReactElement<{ + children: ReactElement<{ commit: Commit; onClose: () => void }>; + }>; + const composer = dialog.props.children; + const renderComposer = composer.type as ( + props: typeof composer.props, + ) => ReactElement; + const renderFields = (processing = false) => { + const form = elements(renderComposer(composer.props)).find( + (element) => element.type === Form, + )!; + const children = form.props.children as (props: { + processing: boolean; + errors: Record; + }) => ReactNode; + + return elements(children({ processing, errors: {} })); + }; + const fields = renderFields(); + const picker = fields.find((element) => element.type === EmojiPickerButton); + assert.ok(picker); + assert.equal(picker.props.disabled, false); + + const textarea = fields.find( + (element) => element.type === MentionTextarea, + )!; + const ref = textarea.props + .ref as React.RefObject; + ref.current = { + selectionStart: 9, + selectionEnd: 14, + } as HTMLTextAreaElement; + (picker.props.onPick as (emoji: string) => void)('🚀'); + assert.deepEqual(draft.updateBody.mock.calls, [['Shipping 🚀']]); + assert.equal( + renderFields(true).find((element) => element.type === EmojiPickerButton) + ?.props.disabled, + true, + ); + + isPreviewing = true; + try { + assert.equal( + renderFields().some( + (element) => element.type === EmojiPickerButton, + ), + false, + ); + } finally { + isPreviewing = false; + } +}); + test('quotes submit restored images and clear the complete draft only on success or discard', async () => { draft.body = 'ghp_' + 'Ab3dEf6hIj9kLm2nOp5qRs8tUv1wXy4zABCD'; const commit = {