diff --git a/src/App.css b/src/App.css index b2766d62..29c0e3ff 100644 --- a/src/App.css +++ b/src/App.css @@ -65,6 +65,62 @@ font-weight: 700; line-height: 1.25; } +/* Headings inside a comment or a chat message are emphasis within prose, not + page titles: the browser's 2em h1 dwarfed everything around it. */ +.md h1 { + font-size: 1.3em; +} +.md h2 { + font-size: 1.18em; +} +.md h3 { + font-size: 1.06em; +} +.md h4, +.md h5, +.md h6 { + font-size: 1em; +} +.md > :first-child { + margin-top: 0; +} +.md hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 0.85em 0; +} +.md del { + opacity: 0.7; +} +/* GFM task lists: the checkbox stands in for the bullet. */ +.md .contains-task-list { + list-style: none; + padding-left: 0.2em; +} +.md .task-list-item input { + margin: 0 0.4em 0 0; + vertical-align: -0.1em; +} +.md-table-wrap { + max-width: 100%; + overflow-x: auto; + scrollbar-width: thin; + margin: 0.6em 0; +} +.md table { + border-collapse: collapse; + font-size: 0.95em; +} +.md th, +.md td { + border: 1px solid var(--color-border); + padding: 0.3em 0.6em; + text-align: left; +} +.md th { + font-weight: 700; + background: var(--color-bg); +} .md img { max-width: 100%; @@ -83,6 +139,19 @@ .md--inline li { margin: 0; } +.md--inline h1, +.md--inline h2, +.md--inline h3, +.md--inline h4, +.md--inline h5, +.md--inline h6 { + margin: 0; + font-size: inherit; +} +.md--inline hr, +.md--inline .md-table-wrap { + display: none; +} .md--inline ul, .md--inline ol { padding-left: 1.2em; @@ -3155,6 +3224,11 @@ body.has-player .chat-fab { display: flex; flex-direction: column; } + .dump-edit-panel--details .text-editor-field { + flex: 1 1 auto; + display: flex; + flex-direction: column; + } .dump-edit-panel--details .mention-textarea-wrap textarea { flex: 1 1 auto; } @@ -4522,6 +4596,15 @@ body.has-player .chat-fab { line-height: 1.45; } +/* A message that's only an emoji or three, shown large as chats do. */ +.chat-message-text--jumbo { + font-size: 2.1em; + line-height: 1.2; +} +.chat-message-text--jumbo.md { + line-height: 1.2; +} + .chat-message-edited { font-size: var(--text-2xs); color: var(--color-text-muted); @@ -4617,16 +4700,21 @@ body.has-player .chat-fab { scrollbar-width: thin; } -/* The counter (only shown near the limit) sits just above the input's right - edge, in the gap over the composer: inside the textarea, the text ran under - it, and in a row below it would knock the Send button out of alignment. */ -.chat-composer .text-editor-count { +/* The counter (only shown near the limit) and the upload notice sit just + above the input, in the gap over the composer: inside the textarea, the text + ran under them, and in a row below they would knock the Send button out of + alignment. */ +.chat-composer .text-editor-footer { position: absolute; + left: 0.25rem; right: 0.25rem; bottom: calc(100% + 1px); margin: 0; line-height: 1; } +.chat-composer .text-editor-footer > * { + margin: 0; +} .chat-send-btn { flex-shrink: 0; @@ -6440,6 +6528,78 @@ body.has-player .chat-fab { box-sizing: border-box; } +/* The textarea and the emoji button in its corner. */ +.text-editor-field { + position: relative; +} +/* Room for the button, so text never runs under it. Qualified to outrank the + per-editor and per-theme padding shorthands. */ +.mention-textarea-wrap .text-editor-field > textarea { + padding-right: 2.25rem; +} +/* Top corner, not bottom: the bottom one is a resizable textarea's grip. */ +.text-editor-emoji-btn { + position: absolute; + right: 0.3rem; + top: 0.3rem; + width: 1.75rem; + height: 1.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text-muted); + opacity: 0.6; + cursor: pointer; + transition: opacity var(--dur-fast), color var(--dur-fast), + background var(--dur-fast); +} +.text-editor-emoji-btn svg { + width: 1.15rem; + height: 1.15rem; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; +} +.text-editor-emoji-btn svg circle[r=".6"] { + fill: currentColor; +} +.text-editor-emoji-btn:hover:not(:disabled), +.text-editor-emoji-btn:focus-visible, +.text-editor-emoji-btn--open { + opacity: 1; + color: var(--color-accent); +} +.text-editor-emoji-btn:disabled { + opacity: 0.25; + cursor: default; +} +/* Clear of the scrollbar a long chat message grows. */ +.chat-input + .text-editor-emoji-btn { + right: 0.6rem; +} + +.text-editor-footer { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-top: 2px; +} +.text-editor-footer .text-editor-count { + margin: 0 0 0 auto; +} +.text-editor-notice { + font-size: var(--text-2xs); + color: var(--color-text-muted); +} +.text-editor-notice--error { + color: var(--color-danger, #c0392b); +} + .mention-textarea-wrap--dragover textarea { outline: 2px dashed var(--color-accent, #6c8ebf); outline-offset: -2px; @@ -6536,10 +6696,29 @@ body.has-player .chat-fab { font-size: var(--text-md); outline: none; } +/* The row has its own ✕; the browser's clear button would be a second one. */ +.emoji-picker-float input::-webkit-search-cancel-button { + display: none; +} .emoji-picker-float input::placeholder { color: var(--color-text-muted, #888); } +.emoji-picker-float [frimousse-skin-tone-selector] { + flex-shrink: 0; + padding: 0 6px; + height: 100%; + background: none; + border: none; + font-size: var(--text-md); + line-height: 1; + cursor: pointer; + border-radius: var(--radius-sm); +} +.emoji-picker-float [frimousse-skin-tone-selector]:hover { + background: var(--color-surface); +} + .emoji-picker-close-btn { flex-shrink: 0; padding: 0 10px; diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx index ce5dd5ee..6a750a2c 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/ChatModal.tsx @@ -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)} > Save @@ -419,7 +430,11 @@ function ChatMessageItem( onCancel={onEndEdit} /> ) - : {message.body}} + : ( + + {message.body} + + )} {grouped && !editing && edited} @@ -485,7 +500,9 @@ function PendingItem( {user.username} )} - {pending.body} + + {pending.body} +
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)} > Send diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index 2762550a..1347a52e 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -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), diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 14e38f8c..2456b824 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -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} /> diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 8f033c8e..f99b5f59 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -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["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 {linkChildren}; - } + const path = internalPath(href); + if (path) return {linkChildren}; return ( {linkChildren} ); }, + img: ({ src, alt }) => ( + {alt + ), + // A wide GFM table scrolls inside its own box instead of the page. + table: ({ children: tableChildren }) => ( +
+ {tableChildren}
+
+ ), }; // Inline renderings are line-clamped teasers inside cards, where an embedded diff --git a/src/components/MentionDropdown.tsx b/src/components/MentionDropdown.tsx index 1fc54d42..334b44c6 100644 --- a/src/components/MentionDropdown.tsx +++ b/src/components/MentionDropdown.tsx @@ -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(null); + + // Arrowing past the visible rows keeps the selection in view. + useLayoutEffect(() => { + listRef.current + ?.querySelector("[aria-selected='true']") + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex, results]); + if (results.length === 0) return null; return ( -
    +
      {results.map((user, i) => (
    • name="isPublic" /> diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index 7c4fdbd1..779e725d 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -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) => 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( @@ -54,24 +105,36 @@ export const TextEditor = forwardRef( className, autoResize = false, onSubmit, + onCancel, onKeyDown, maxLength, - countFrom = 0, + countFrom, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, }, ref, ) { const { i18n } = useLingui(); const textareaRef = useRef(null); + const floatRef = useRef(null); + const emojiButtonRef = useRef(null); const emojiViewportRef = useRef(null); const emojiSearchRef = useRef(null); - const valueRef = useRef(value); + const onChangeRef = useRef(onChange); const [uploading, setUploading] = useState(false); + const [notice, setNotice] = useState(null); const [dragOver, setDragOver] = useState(false); + const pendingInsertRef = useRef(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( }, }), []); + /** + * 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 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( 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( 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) => { - const imageItem = Array.from(e.clipboardData.items).find( - (item) => item.kind === "file" && item.type.startsWith("image/"), + (e: React.ClipboardEvent) => { + // 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) => { - e.preventDefault(); + (e: React.DragEvent) => { 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) => { - 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( + "[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, + ): 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 (
      -