Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion resources/js/components/commit-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -176,6 +178,13 @@ function CommitComposerBody({
return () => observer.disconnect();
}, [isInDialog]);

const insertEmoji = useEmojiInsertion({
body,
updateBody,
textareaRef,
maxLength: isIssue ? maxBodyLength : maxRawBodyLength,
});

return (
<div
className={cn('relative flex', { 'min-h-0': isInDialog })}
Expand Down Expand Up @@ -414,6 +423,10 @@ function CommitComposerBody({
onPick={attachments.pick}
className="-ml-2"
/>
<EmojiPickerButton
disabled={processing}
onPick={insertEmoji}
/>
<ActionTooltip
label={
hasImages
Expand Down
14 changes: 14 additions & 0 deletions resources/js/components/commit-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
PreviewToggle,
useComposerPreview,
} from '@/components/composer-preview';
import { EmojiPickerButton } from '@/components/emoji-picker';
import {
useGraphDimmed,
useGraphNode,
Expand All @@ -65,6 +66,7 @@ import {
useCommitNotFound,
useIsCommitDeleted,
} from '@/hooks/use-deleted-resources';
import { useEmojiInsertion } from '@/hooks/use-emoji-insertion';
import { useImageAttachments } from '@/hooks/use-image-attachments';
import { focusCommit } from '@/hooks/use-keyboard-shortcuts';
import { useLinkCard } from '@/hooks/use-link-card';
Expand Down Expand Up @@ -1360,6 +1362,13 @@ function ReplyComposer({
isSubmittable: isReplyable,
});

const insertEmoji = useEmojiInsertion({
body,
updateBody,
textareaRef,
maxLength: maxRawBodyLength,
});

const returnFocusToCommit = () => {
const commit =
textareaRef.current?.closest<HTMLElement>('[data-commit]');
Expand Down Expand Up @@ -1513,6 +1522,11 @@ function ReplyComposer({
onPick={attachments.pick}
className="size-8"
/>
<EmojiPickerButton
disabled={processing}
onPick={insertEmoji}
className="-ml-2 size-8"
/>
<PreviewToggle preview={preview} className="-ml-2 size-8" />
<LinkCardPicker
linkCard={linkCard}
Expand Down
182 changes: 182 additions & 0 deletions resources/js/components/emoji-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { Smile } from 'lucide-react';
import type { KeyboardEvent } from 'react';
import { useRef, useState } from 'react';
import { ActionTooltip } from '@/components/action-tooltip';
import { buttonVariants } from '@/components/ui/button';
import type { PickableEmoji } from '@/lib/emoji-picker';
import { popularEmoji, searchEmoji } from '@/lib/emoji-picker';
import { cn } from '@/lib/utils';

/**
* Pressing the button keeps the focus, and so the caret, in the textarea being written, which is handed to `onPick`
* along with the emoji. It is null when the picker was opened from the keyboard. Clicking an emoji leaves the picker
* open to add more; picking the first result with Enter closes it and returns to the textarea.
*/
export function EmojiPickerButton({
disabled,
onPick,
className,
}: {
disabled?: boolean;
onPick: (emoji: string, textarea: HTMLTextAreaElement | null) => void;
className?: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [query, setQuery] = useState('');
const results = query.trim() === '' ? null : searchEmoji(query);

return (
<Popover>
<ActionTooltip label="Add emoji">
<PopoverButton
type="button"
disabled={disabled}
onMouseDown={(event) => 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"
>
<Smile className="size-5" />
</PopoverButton>
</ActionTooltip>

<PopoverPanel
anchor={{ to: 'bottom start', gap: 4, padding: 16 }}
focus
className="z-50 flex w-72 flex-col overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-[0_12px_32px_-12px_rgb(0_0_0/0.35)] [--anchor-max-height:22rem]"
data-test="emoji-picker"
>
{({ 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 (
<>
<div className="border-b p-2">
<input
type="search"
value={query}
onChange={(event) =>
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"
/>
</div>

<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
{results === null ? (
<>
<EmojiSection
title="Popular"
emoji={popularEmoji}
onPick={pick}
/>
<EmojiSection
title="All"
emoji={searchEmoji('')}
onPick={pick}
className="mt-2"
/>
</>
) : results.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No emoji found.
</p>
) : (
<EmojiGrid emoji={results} onPick={pick} />
)}
</div>
</>
);
}}
</PopoverPanel>
</Popover>
);
}

function EmojiSection({
title,
emoji,
onPick,
className,
}: {
title: string;
emoji: PickableEmoji[];
onPick: (emoji: string) => void;
className?: string;
}) {
return (
<section className={className}>
<h3 className="px-1 pb-1 font-mono text-[11px] text-muted-foreground">
{title}
</h3>
<EmojiGrid emoji={emoji} onPick={onPick} />
</section>
);
}

function EmojiGrid({
emoji,
onPick,
}: {
emoji: PickableEmoji[];
onPick: (emoji: string) => void;
}) {
return (
<div className="grid grid-cols-8 gap-0.5">
{emoji.map(({ name, emoji: character }) => (
<button
key={name}
type="button"
onClick={() => onPick(character)}
aria-label={name.replaceAll('_', ' ')}
title={`:${name}:`}
className="grid size-8 place-items-center rounded-md text-xl leading-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
data-test="emoji-picker-option"
>
{character}
</button>
))}
</div>
);
}
50 changes: 50 additions & 0 deletions resources/js/hooks/use-emoji-insertion.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>;
maxLength: number;
}): (emoji: string, textarea: HTMLTextAreaElement | null) => void {
const caret = useRef<number | null>(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);
};
}
67 changes: 67 additions & 0 deletions resources/js/lib/emoji-picker.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

const seenEmoji = new Set<string>();

/**
* 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));
}
Loading