v3: the editors stop fighting the person typing — a review of every edit box turned up an Enter that sent a chat message mid-IME, an emoji picker that grabbed focus on ":D" and never went away, markdown that folded every line break, and limits that were counted but not kept. TextEditor: an Enter confirming an IME candidate no longer also sends, saves or picks. The : trigger wants two letters (Unicode ones too), not one, so emoticons, "note:" and "12:30" type through, and it keeps focus in the textarea — the picker's search follows what's typed, Enter or Tab takes the best match (a name equal to the query, then one starting with it, rather than frimousse's first in emoji order, which made ":rocket" an astronaut), the arrows step into the grid and typing there carries on in the textarea, and Escape closes just the picker, not the chat around it. It closes on a press outside, which it never did, and neither autocomplete fires inside code or a ``` fence. A ☺ button in each editor's corner opens the picker for mouse users, with a skin-tone selector that's remembered, and its search takes focus only once the picker is placed (a hidden field can't). Emoji, mentions and pasted images go in through execCommand, so Ctrl+Z takes back the insertion instead of wrecking the undo stack. Uploading an image made the textarea disabled, which dropped focus for good; it's read-only now, says "Uploading image…" under the box rather than only in an empty one's placeholder, reports a failed upload instead of swallowing it, and holds Enter until it's done. Any dropped file is caught — a non-image used to be opened by the browser in place of the page, draft and all — and a paste carrying text (a spreadsheet's cells come with a picture of themselves) pastes the text. The mention list follows the caret (a stale lookup can't close a newer one, lookups abort), wraps, scrolls its selection into view, closes on blur, and is a real listbox with aria-activedescendant. The counter shows from 80% of the limit by default rather than a standing "0 / 5000". Limits: chat Send and edit Save refused nothing over 2000 and let the server bounce it; they're disabled past it now. RichTextField's bare maxLength rule failed without a message, so an over-long "Why?" made Save do nothing and say nothing; it says so now. Inline editors answer Escape through a new onCancel (comment reply and edit, profile description, playlist description and title), the playlist editor takes Ctrl+Enter and can't be saved without a title, and saving a comment unchanged no longer stamps it edited. Markdown: a single newline is a line break, so a message written over three lines stops reading as one; links into the app route in place instead of opening a tab; headings are sized for prose instead of 2em page titles, and dropped from card teasers; GFM tables scroll in their own box and task lists lose the bullet; images load lazily. A chat message of only an emoji or three is shown large. Themes: brutalist and geocities give the new buttons no chrome, and geocities' ghost-button list, split in two by the placeholder-thumbnail rules in 1cb904d, is whole again, so its modal ✕, emoji cells and the picker's ✕ are flat as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 10:24:30 +00:00
parent 5fd1d364da
commit 85d0703077
22 changed files with 1321 additions and 520 deletions

View File

@@ -22,6 +22,7 @@ import { useAuth } from "../hooks/useAuth.ts";
import { can } from "../utils/permissions.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { markdownToPlainText, mentionsUser } from "../utils/chatText.ts";
import { isEmojiOnly } from "../utils/editorText.ts";
import { i18n } from "../i18n.ts";
import { API_URL } from "../config/api.ts";
import type {
@@ -77,6 +78,19 @@ function compareMessages(a: ChatMessage, b: ChatMessage): number {
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
}
// What the server holds a message to: its length once trimmed.
function sendable(draft: string): boolean {
const body = draft.trim();
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
}
// A message of nothing but a few emoji is shown large, as chats do.
function messageTextClass(body: string): string {
return isEmojiOnly(body)
? "chat-message-text chat-message-text--jumbo"
: "chat-message-text";
}
function snippet(body: string): string {
return markdownToPlainText(body, t`image`);
}
@@ -184,9 +198,8 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
}, []);
const save = () => {
const body = draft.trim();
if (!body) return;
onSave(body);
if (!sendable(draft)) return;
onSave(draft.trim());
};
return (
@@ -201,13 +214,11 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
countFrom={COUNT_FROM}
className="comment-reply-textarea chat-input"
onSubmit={save}
onCancel={onCancel}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
@@ -216,7 +227,7 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
type="button"
className="btn-primary"
onClick={save}
disabled={!draft.trim()}
disabled={!sendable(draft)}
>
<Trans>Save</Trans>
</button>
@@ -419,7 +430,11 @@ function ChatMessageItem(
onCancel={onEndEdit}
/>
)
: <Markdown className="chat-message-text">{message.body}</Markdown>}
: (
<Markdown className={messageTextClass(message.body)}>
{message.body}
</Markdown>
)}
{grouped && !editing && edited}
</div>
@@ -485,7 +500,9 @@ function PendingItem(
<span className="chat-message-author">{user.username}</span>
</div>
)}
<Markdown className="chat-message-text">{pending.body}</Markdown>
<Markdown className={messageTextClass(pending.body)}>
{pending.body}
</Markdown>
<div
className="chat-pending-status"
role={failed ? "alert" : undefined}
@@ -956,9 +973,8 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
}, []);
const handleSend = useCallback(() => {
const body = draft.trim();
if (!body) return;
sendChatMessage(body, replyTarget ?? undefined);
if (!sendable(draft)) return;
sendChatMessage(draft.trim(), replyTarget ?? undefined);
setDraft("");
setReplyTarget(null);
stopTyping();
@@ -1275,7 +1291,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
type="button"
className="btn-primary chat-send-btn"
onClick={handleSend}
disabled={!draft.trim()}
disabled={!sendable(draft)}
>
<Trans>Send</Trans>
</button>

View File

@@ -93,6 +93,14 @@ function CommentForm({
value={field.value}
onChange={field.onChange}
onSubmit={() => void submit()}
// Escape backs out of a reply or an edit; the top-level box has
// nothing to back out of.
onCancel={onCancel && !cancelWhenEmpty
? () => {
form.reset({ body: "" });
onCancel();
}
: undefined}
placeholder={placeholder}
autoResize
rows={1}
@@ -228,6 +236,11 @@ function CommentNode({
}
async function handleEditSave(body: string) {
// Saving it unchanged would only stamp it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest),

View File

@@ -912,7 +912,6 @@ export function DumpCreateModal(
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
disabled={submitting}
editorRef={commentEditorRef}
/>

View File

@@ -57,22 +57,74 @@ function remarkMentions() {
return (tree: MdNode) => walk(tree);
}
const REMARK_PLUGINS = [remarkGfm, remarkMentions];
/**
* A single newline is a line break, as it is in chat everywhere: markdown's
* own rule folds it into a space, so a message typed on three lines with
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
* nodes.)
*/
function remarkBreaks() {
const walk = (node: MdNode) => {
if (!node.children) return;
node.children = node.children.flatMap((child) => {
if (child.type !== "text" || !child.value?.includes("\n")) {
walk(child);
return [child];
}
return child.value.split("\n").flatMap((part, i) =>
i === 0
? [{ type: "text", value: part }]
: [{ type: "break" }, { type: "text", value: part }]
);
});
};
return (tree: MdNode) => walk(tree);
}
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
/** The path of a link into this app — relative, or absolute to this origin —
* or null for anywhere else. */
function internalPath(href: string | undefined): string | null {
if (!href) return null;
if (href.startsWith("/") && !href.startsWith("//")) {
// Files and attachments are the API's, not routes: a new tab, as before.
return href.startsWith("/api/") ? null : href;
}
try {
const url = new URL(href);
if (url.origin !== globalThis.location.origin) return null;
if (url.pathname.startsWith("/api/")) return null;
return url.pathname + url.search + url.hash;
} catch {
return null;
}
}
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
// Static components objects — defined once at module scope to avoid recreation on every render
const MARKDOWN_COMPONENTS: Components = {
// Mentions and links to other pages here stay in the app; the rest open
// in a new tab.
a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>;
}
const path = internalPath(href);
if (path) return <Link to={path}>{linkChildren}</Link>;
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{linkChildren}
</a>
);
},
img: ({ src, alt }) => (
<img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
),
// A wide GFM table scrolls inside its own box instead of the page.
table: ({ children: tableChildren }) => (
<div className="md-table-wrap">
<table>{tableChildren}</table>
</div>
),
};
// Inline renderings are line-clamped teasers inside cards, where an embedded

View File

@@ -1,8 +1,10 @@
import type { CSSProperties } from "react";
import { type CSSProperties, useLayoutEffect, useRef } from "react";
import { Avatar } from "./Avatar.tsx";
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
interface MentionDropdownProps {
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
id: string;
results: UserResult[];
selectedIndex: number;
onSelect: (username: string) => void;
@@ -11,14 +13,33 @@ interface MentionDropdownProps {
}
export function MentionDropdown(
{ results, selectedIndex, onSelect, style }: MentionDropdownProps,
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
) {
const listRef = useRef<HTMLUListElement>(null);
// Arrowing past the visible rows keeps the selection in view.
useLayoutEffect(() => {
listRef.current
?.querySelector<HTMLElement>("[aria-selected='true']")
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex, results]);
if (results.length === 0) return null;
return (
<ul className="mention-dropdown" style={style}>
<ul
ref={listRef}
id={id}
role="listbox"
className="mention-dropdown"
style={style}
>
{results.map((user, i) => (
<li
key={user.id}
// TextEditor points aria-activedescendant at `${id}-${index}`.
id={`${id}-${i}`}
role="option"
aria-selected={i === selectedIndex}
className={`mention-dropdown-item${
i === selectedIndex ? " mention-dropdown-item--selected" : ""
}`}

View File

@@ -82,7 +82,6 @@ export function PlaylistCreateForm(
placeholder={t`Description (optional)`}
rows={3}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
rules={{ maxLength: VALIDATION.PLAYLIST_DESCRIPTION_MAX }}
/>
<VisibilityToggle<Values> name="isPublic" />
<FormError title={t`Failed to create playlist`} />

View File

@@ -2,13 +2,20 @@ import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
import {
EmojiPicker,
type Locale as EmojiLocale,
type SkinTone,
useSkinTone,
} from "frimousse";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
import { charCountClass } from "../utils/charCount.ts";
@@ -34,12 +41,56 @@ interface TextEditorProps {
id?: string;
className?: string;
autoResize?: boolean;
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
onSubmit?: () => void;
/** Escape, once no popup of the editor's own is left to close it: for the
* inline editors (an edit, a reply), where it means "never mind". */
onCancel?: () => void;
/** Runs after the editor's own keys (autocompletes, Ctrl+Enter) and never
* while an IME is composing, so an Enter that confirms a candidate isn't
* taken for "send". */
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
maxLength?: number;
/** Hide the character counter until the text is at least this long, for
* places where a standing "0 / 2000" is just noise. */
/** Show the character counter only from this length. Defaults to 80% of
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
* up well before the warning colour does. */
countFrom?: number;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
}
const SKIN_TONE_KEY = "emoji-skin-tone";
function readSkinTone(): SkinTone {
try {
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
} catch {
return "none";
}
}
/** Remembers the skin tone picked in the selector for the next picker. */
function SkinTonePersist() {
const [skinTone] = useSkinTone();
useEffect(() => {
try {
localStorage.setItem(SKIN_TONE_KEY, skinTone);
} catch {
// Private mode: the tone lasts as long as the picker.
}
}, [skinTone]);
return null;
}
function hasFiles(dt: DataTransfer): boolean {
return Array.from(dt.types).includes("Files");
}
interface PendingInsert {
start: number;
end: number;
text: string;
focus: boolean;
}
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
@@ -54,24 +105,36 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
className,
autoResize = false,
onSubmit,
onCancel,
onKeyDown,
maxLength,
countFrom = 0,
countFrom,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
},
ref,
) {
const { i18n } = useLingui();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const floatRef = useRef<HTMLDivElement>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const emojiViewportRef = useRef<HTMLDivElement>(null);
const emojiSearchRef = useRef<HTMLInputElement>(null);
const valueRef = useRef(value);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const pendingInsertRef = useRef<PendingInsert | null>(null);
// Whether the highlighted emoji was chosen with the arrows, rather than
// being frimousse's automatic first result for the query.
const arrowedRef = useRef(false);
const { authFetch } = useAuth();
const mentionListId = useId();
const noticeId = useId();
useEffect(() => {
valueRef.current = value;
}, [value]);
useLayoutEffect(() => {
onChangeRef.current = onChange;
});
useImperativeHandle(ref, () => ({
focus: (options) => {
@@ -85,28 +148,86 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
},
}), []);
/**
* Puts `text` over `[start, end)` of the textarea, then a space when
* `word` is set and none follows already, and leaves the caret after it.
* Goes through execCommand where it can, so the insertion joins the
* browser's undo stack — Ctrl+Z takes back the emoji or mention itself,
* not everything typed before it — and fires the input event onChange
* listens for.
*/
const replace = useCallback(
(
start: number,
end: number,
text: string,
word: boolean,
focus = true,
) => {
const el = textareaRef.current;
if (!el) return;
const current = el.value;
const spaced = word && /^\s/.test(current.slice(end));
const insert = word && !spaced ? `${text} ` : text;
const caret = start + insert.length + (spaced ? 1 : 0);
if (focus) {
el.focus({ preventScroll: true });
el.setSelectionRange(start, end);
if (document.execCommand("insertText", false, insert)) {
el.setSelectionRange(caret, caret);
return;
}
}
onChangeRef.current(
current.slice(0, start) + insert + current.slice(end),
);
if (!focus) return;
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(caret, caret);
});
},
[],
);
const {
mentionOpen,
mentionResults,
mentionSelectedIndex,
handleMentionChange,
syncMention,
closeMention,
handleMentionKeyDown,
handleMentionSelect,
} = useMentionAutocomplete(value, onChange, textareaRef);
} = useMentionAutocomplete(replace);
const {
emojiOpen,
emojiMode,
emojiQuery,
detectEmojiTrigger,
handleEmojiSelect,
syncEmojiTrigger,
openEmojiBrowser,
setEmojiQuery,
selectEmoji,
closeEmoji,
} = useEmojiTrigger(value, onChange, textareaRef);
} = useEmojiTrigger(replace);
const emojiOpen = emojiMode !== "closed";
// A press anywhere outside the picker puts it away — except, for an
// inline `:query`, a press in the textarea: that's a caret move, which
// keeps or closes it depending on where the caret lands.
useEffect(() => {
if (emojiOpen) {
emojiViewportRef.current?.focus({ preventScroll: true });
}
}, [emojiOpen]);
if (emojiMode === "closed") return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (
floatRef.current?.contains(target) ||
emojiButtonRef.current?.contains(target) ||
(emojiMode === "inline" && textareaRef.current?.contains(target))
) return;
closeEmoji();
};
document.addEventListener("pointerdown", onPointerDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
}, [emojiMode, closeEmoji]);
// Both popups are portaled to <body> and anchored to the textarea: the
// editor sits inside cards, modal bodies and scrollers that clip anything
@@ -118,6 +239,20 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
maxContentHeight: 220,
});
// Browsing starts in the picker's search; an inline `:query` keeps typing
// in the textarea and only lends the picker its query. Only once the
// picker is placed: until then it's hidden, and a hidden field can't take
// focus.
const emojiPlaced = emojiStyle != null;
useEffect(() => {
arrowedRef.current = false;
}, [emojiQuery, emojiMode]);
useEffect(() => {
if (emojiMode === "browse" && emojiPlaced) {
emojiSearchRef.current?.focus({ preventScroll: true });
}
}, [emojiMode, emojiPlaced]);
const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, {
width: "anchor", // it used to be stretched edge to edge by left/right: 0
chromeHeight: 10, // list padding + borders
@@ -135,128 +270,302 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
el.style.height = `${el.scrollHeight}px`;
}, [value, autoResize]);
const insertAtCursor = useCallback((text: string) => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart ?? valueRef.current.length;
const end = el.selectionEnd ?? valueRef.current.length;
const newValue = valueRef.current.slice(0, start) +
text +
valueRef.current.slice(end);
onChange(newValue);
requestAnimationFrame(() => {
if (!textareaRef.current) return;
const pos = start + text.length;
textareaRef.current.selectionStart = pos;
textareaRef.current.selectionEnd = pos;
});
}, [onChange]);
// The textarea is read-only while images upload (so the spot they're
// going to can't move); once it isn't, the images go in.
useLayoutEffect(() => {
if (uploading) return;
const pending = pendingInsertRef.current;
if (!pending) return;
pendingInsertRef.current = null;
replace(pending.start, pending.end, pending.text, false, pending.focus);
}, [uploading, replace]);
const uploadImage = useCallback(async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const uploadImages = useCallback(async (files: File[]) => {
const el = textareaRef.current;
if (!el || files.length === 0) return;
const focus = document.activeElement === el;
const start = el.selectionStart;
const end = el.selectionEnd;
setNotice(null);
setUploading(true);
try {
const res = await authFetch(`${API_URL}/api/attachments`, {
method: "POST",
body: formData,
});
const json = await res.json();
if (json.success) {
insertAtCursor(`![](${API_URL}/api/attachments/${json.data.id})`);
const images: string[] = [];
for (const file of files) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(`${API_URL}/api/attachments`, {
method: "POST",
body: formData,
});
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
images.push(`![](${API_URL}/api/attachments/${json.data.id})`);
} catch {
setNotice(
files.length > 1
? t`Some images couldn't be uploaded.`
: t`The image couldn't be uploaded.`,
);
}
} catch {
// silently ignore — user can retry
} finally {
setUploading(false);
textareaRef.current?.focus();
}
}, [authFetch, insertAtCursor]);
if (images.length > 0) {
pendingInsertRef.current = {
start,
end,
text: images.join("\n"),
focus,
};
}
setUploading(false);
}, [authFetch]);
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const imageItem = Array.from(e.clipboardData.items).find(
(item) => item.kind === "file" && item.type.startsWith("image/"),
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Copying from a spreadsheet or a document puts a picture of the
// selection on the clipboard next to its text: the text is what was
// meant.
if (e.clipboardData.types.includes("text/plain")) return;
const images = Array.from(e.clipboardData.files).filter((f) =>
f.type.startsWith("image/")
);
if (!imageItem) return;
if (images.length === 0) return;
e.preventDefault();
const file = imageItem.getAsFile();
if (file) await uploadImage(file);
void uploadImages(images);
},
[uploadImage],
[uploadImages],
);
const handleDrop = useCallback(
async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
(e: React.DragEvent<HTMLTextAreaElement>) => {
setDragOver(false);
const files = Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/")
);
for (const file of files) {
await uploadImage(file);
if (!hasFiles(e.dataTransfer)) return; // dragged text: the default
e.preventDefault();
if (disabled || uploading) return;
const files = Array.from(e.dataTransfer.files);
const images = files.filter((f) => f.type.startsWith("image/"));
if (images.length < files.length) {
setNotice(t`Only images can be dropped here.`);
}
void uploadImages(images);
},
[uploadImage],
[uploadImages, disabled, uploading],
);
// Any file is caught, not just images: a file dropped on a textarea that
// doesn't take it is opened by the browser in place of the page, and a
// half-written message goes with it.
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLTextAreaElement>) => {
const hasImage = Array.from(e.dataTransfer.items).some(
(item) => item.kind === "file" && item.type.startsWith("image/"),
);
if (!hasImage) return;
if (!hasFiles(e.dataTransfer)) return;
e.preventDefault();
setDragOver(true);
const accepts = !disabled && !uploading;
e.dataTransfer.dropEffect = accepts ? "copy" : "none";
setDragOver(accepts);
},
[],
[disabled, uploading],
);
const handleDragLeave = useCallback(() => setDragOver(false), []);
// The best match for the query among the results on screen. frimousse
// lists matches in emoji order, so ":rocket" would lead with 🧑‍🚀; a name
// that is the query, then one that starts with it, comes first. (It also
// renders a hidden row of unlabelled stand-ins to measure rows with;
// those aren't picks.)
const firstEmoji = () => {
const buttons = Array.from(
floatRef.current?.querySelectorAll<HTMLButtonElement>(
"[frimousse-list] [frimousse-emoji]:not([aria-label=''])",
) ?? [],
);
const query = emojiQuery.trim().toLowerCase();
const name = (b: HTMLButtonElement) =>
(b.getAttribute("aria-label") ?? "").toLowerCase();
return buttons.find((b) => name(b) === query) ??
buttons.find((b) => name(b).startsWith(query)) ??
buttons[0];
};
// Moves the keyboard into the picker's grid: frimousse only listens for
// arrows while focus is inside it, and the arrow that got us there is
// replayed so the first press already lands on an emoji.
const enterPicker = (key: string) => {
const viewport = emojiViewportRef.current;
if (!viewport) return;
viewport.focus({ preventScroll: true });
setTimeout(() => {
viewport.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true }),
);
}, 0);
};
/** Keys for an inline `:query` picker, pressed in the textarea. */
const handleInlineEmojiKey = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
): boolean => {
const plain = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
if ((e.key === "Enter" || e.key === "Tab") && plain) {
const first = firstEmoji();
if (!first) {
// Nothing to pick (no match, or the data is still loading): the
// key does what it would have.
closeEmoji();
return false;
}
e.preventDefault();
first.click();
return true;
}
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && plain) {
if (!firstEmoji()) return false;
e.preventDefault();
arrowedRef.current = true;
enterPicker(e.key);
return true;
}
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeEmoji();
return true;
}
return false;
};
const openBrowser = () => {
const el = textareaRef.current;
if (!el) return;
if (emojiMode === "browse") {
closeEmoji();
el.focus();
return;
}
closeMention();
openEmojiBrowser(el.selectionStart, el.selectionEnd);
};
const showCount = maxLength != null &&
value.length >= (countFrom ?? Math.floor(maxLength * 0.8));
const describedBy = [ariaDescribedBy, notice ? noticeId : null]
.filter(Boolean).join(" ") || undefined;
return (
<div
className={`mention-textarea-wrap${
dragOver ? " mention-textarea-wrap--dragover" : ""
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
handleMentionChange(e);
detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit?.();
return;
}
handleMentionKeyDown(e);
if (!e.defaultPrevented) onKeyDown?.(e);
}}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
placeholder={uploading ? "Uploading image…" : placeholder}
disabled={disabled || uploading}
rows={rows}
id={id}
className={className}
/>
{maxLength != null && value.length >= countFrom && (
<span
className={`text-editor-count${
charCountClass(value.length, maxLength)
<div className="text-editor-field">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
const next = e.target.value;
const cursor = e.target.selectionStart ?? next.length;
onChange(next);
if (notice) setNotice(null);
syncMention(next, cursor, true);
syncEmojiTrigger(next, cursor, true);
}}
onSelect={(e) => {
const el = e.currentTarget;
syncMention(el.value, el.selectionStart, false);
syncEmojiTrigger(el.value, el.selectionStart, false);
}}
onBlur={() => closeMention()}
onKeyDown={(e) => {
// Enter while an IME is composing confirms the candidate; it
// mustn't also pick, send or save.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
// Nothing is sent or saved with an image still on its way.
if (uploading && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
return;
}
if (emojiMode === "inline" && handleInlineEmojiKey(e)) return;
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit?.();
return;
}
if (handleMentionKeyDown(e)) return;
if (e.key === "Escape" && onCancel) {
e.preventDefault();
onCancel();
return;
}
onKeyDown?.(e);
}}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
placeholder={placeholder}
disabled={disabled}
readOnly={uploading}
aria-busy={uploading || undefined}
aria-invalid={ariaInvalid ||
(maxLength != null && value.length > maxLength) || undefined}
aria-describedby={describedBy}
aria-autocomplete="list"
aria-controls={mentionOpen ? mentionListId : undefined}
aria-activedescendant={mentionOpen
? `${mentionListId}-${mentionSelectedIndex}`
: undefined}
rows={rows}
id={id}
className={className}
/>
<button
ref={emojiButtonRef}
type="button"
className={`text-editor-emoji-btn${
emojiMode === "browse" ? " text-editor-emoji-btn--open" : ""
}`}
// Keeps the textarea's caret (and focus) where it was.
onMouseDown={(e) => e.preventDefault()}
onClick={openBrowser}
disabled={disabled || uploading}
aria-label={t`Insert emoji`}
aria-expanded={emojiMode === "browse"}
title={t`Insert emoji`}
>
{value.length} / {maxLength}
</span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5c.9 1.2 2.1 1.8 3.5 1.8s2.6-.6 3.5-1.8" />
<circle cx="9" cy="10" r=".6" />
<circle cx="15" cy="10" r=".6" />
</svg>
</button>
</div>
{(uploading || notice || showCount) && (
<div className="text-editor-footer">
{(uploading || notice) && (
<span
id={noticeId}
className={`text-editor-notice${
notice ? " text-editor-notice--error" : ""
}`}
role={notice ? "alert" : "status"}
>
{uploading ? <Trans>Uploading image…</Trans> : notice}
</span>
)}
{showCount && (
<span
className={`text-editor-count${
charCountClass(value.length, maxLength)
}`}
>
{value.length} / {maxLength}
</span>
)}
</div>
)}
{mentionOpen && createPortal(
<MentionDropdown
id={mentionListId}
results={mentionResults}
selectedIndex={mentionSelectedIndex}
onSelect={handleMentionSelect}
@@ -266,6 +575,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
)}
{emojiOpen && createPortal(
<div
ref={floatRef}
className="emoji-picker-float"
style={emojiStyle ?? { visibility: "hidden" }}
onKeyDownCapture={(e) => {
@@ -273,47 +583,74 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
e.preventDefault();
e.stopPropagation();
closeEmoji();
textareaRef.current?.focus();
return;
}
}}
onKeyDown={(e) => {
// Redirect printable characters to the search input when it
// doesn't already have focus (e.g. while navigating with arrows)
const search = emojiSearchRef.current;
if (e.key.startsWith("Arrow")) arrowedRef.current = true;
// Enter takes the emoji arrowed to, or else the best match —
// not the first result frimousse highlights on its own.
if (e.key === "Enter" && !arrowedRef.current) {
e.preventDefault();
e.stopPropagation();
firstEmoji()?.click();
return;
}
const printable = e.key.length === 1 && !e.ctrlKey &&
!e.metaKey && !e.altKey;
if (
e.key.length === 1 &&
!e.ctrlKey && !e.metaKey && !e.altKey &&
document.activeElement !== emojiSearchRef.current
) {
const search = emojiSearchRef.current;
if (search) {
e.preventDefault();
search.focus({ preventScroll: true });
// Inject the character via the native setter so React's
// synthetic onChange fires and frimousse updates its search state
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(search, search.value + e.key);
search.dispatchEvent(new Event("input", { bubbles: true }));
}
(!printable && e.key !== "Backspace") ||
document.activeElement === search
) return;
e.preventDefault();
if (emojiMode === "inline") {
// Typing while arrowing through the grid goes on with the
// `:query` in the textarea, which the search follows.
const el = textareaRef.current;
if (!el) return;
el.focus({ preventScroll: true });
document.execCommand(
printable ? "insertText" : "delete",
false,
printable ? e.key : undefined,
);
} else if (search) {
search.focus({ preventScroll: true });
setEmojiQuery(
printable ? search.value + e.key : search.value.slice(0, -1),
);
}
}}
>
<EmojiPicker.Root
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
onEmojiSelect={(e) => selectEmoji(e.emoji)}
locale={i18n.locale as EmojiLocale}
skinTone={readSkinTone()}
>
<SkinTonePersist />
<div className="emoji-picker-search-row">
<EmojiPicker.Search
ref={emojiSearchRef}
defaultValue={emojiQuery}
placeholder="Search emoji…"
value={emojiQuery}
onChange={(e) => setEmojiQuery(e.target.value)}
placeholder={t`Search emoji…`}
aria-label={t`Search emoji`}
// Inline, the query is typed in the textarea; the field
// only shows it.
readOnly={emojiMode === "inline"}
tabIndex={emojiMode === "inline" ? -1 : undefined}
/>
<EmojiPicker.SkinToneSelector
aria-label={t`Change skin tone`}
/>
<button
type="button"
className="emoji-picker-close-btn"
onClick={closeEmoji}
aria-label="Close emoji picker"
onClick={() => {
closeEmoji();
textareaRef.current?.focus();
}}
aria-label={t`Close emoji picker`}
>
✕
</button>

View File

@@ -1,4 +1,5 @@
import { useId } from "react";
import { t } from "@lingui/core/macro";
import {
Controller,
type FieldValues,
@@ -14,6 +15,7 @@ interface RichTextFieldProps<T extends FieldValues> {
label?: string;
placeholder?: string;
rows?: number;
/** Counted in the editor and enforced on submit, with a message saying so. */
maxLength?: number;
autoResize?: boolean;
disabled?: boolean;
@@ -42,8 +44,18 @@ export function RichTextField<T extends FieldValues>({
onSubmit,
}: RichTextFieldProps<T>) {
const id = useId();
const hintId = useId();
const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name];
// A bare numeric rule fails without a message, which left a submit that did
// nothing and said nothing.
const allRules = maxLength == null ? rules : {
...rules,
maxLength: {
value: maxLength,
message: t`At most ${maxLength} characters.`,
},
};
return (
<div className="form-field">
@@ -55,7 +67,7 @@ export function RichTextField<T extends FieldValues>({
<Controller
name={name}
control={control}
rules={rules}
rules={allRules}
render={({ field }) => (
<TextEditor
ref={editorRef}
@@ -68,11 +80,13 @@ export function RichTextField<T extends FieldValues>({
autoResize={autoResize}
disabled={disabled}
onSubmit={onSubmit}
aria-invalid={error ? true : undefined}
aria-describedby={error?.message ? hintId : undefined}
/>
)}
/>
{error?.message && (
<span className="form-hint form-hint--error">
<span id={hintId} className="form-hint form-hint--error">
{String(error.message)}
</span>
)}