From 85d0703077474519eecab7baed0b66f35800d988 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 10:24:30 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20editors=20stop=20fighting=20the=20p?= =?UTF-8?q?erson=20typing=20=E2=80=94=20a=20review=20of=20every=20edit=20b?= =?UTF-8?q?ox=20turned=20up=20an=20Enter=20that=20sent=20a=20chat=20messag?= =?UTF-8?q?e=20mid-IME,=20an=20emoji=20picker=20that=20grabbed=20focus=20o?= =?UTF-8?q?n=20":D"=20and=20never=20went=20away,=20markdown=20that=20folde?= =?UTF-8?q?d=20every=20line=20break,=20and=20limits=20that=20were=20counte?= =?UTF-8?q?d=20but=20not=20kept.=20TextEditor:=20an=20Enter=20confirming?= =?UTF-8?q?=20an=20IME=20candidate=20no=20longer=20also=20sends,=20saves?= =?UTF-8?q?=20or=20picks.=20The=20`:`=20trigger=20wants=20two=20letters=20?= =?UTF-8?q?(Unicode=20ones=20too),=20not=20one,=20so=20emoticons,=20"note:?= =?UTF-8?q?"=20and=20"12:30"=20type=20through,=20and=20it=20keeps=20focus?= =?UTF-8?q?=20in=20the=20textarea=20=E2=80=94=20the=20picker's=20search=20?= =?UTF-8?q?follows=20what's=20typed,=20Enter=20or=20Tab=20takes=20the=20be?= =?UTF-8?q?st=20match=20(a=20name=20equal=20to=20the=20query,=20then=20one?= =?UTF-8?q?=20starting=20with=20it,=20rather=20than=20frimousse's=20first?= =?UTF-8?q?=20in=20emoji=20order,=20which=20made=20":rocket"=20an=20astron?= =?UTF-8?q?aut),=20the=20arrows=20step=20into=20the=20grid=20and=20typing?= =?UTF-8?q?=20there=20carries=20on=20in=20the=20textarea,=20and=20Escape?= =?UTF-8?q?=20closes=20just=20the=20picker,=20not=20the=20chat=20around=20?= =?UTF-8?q?it.=20It=20closes=20on=20a=20press=20outside,=20which=20it=20ne?= =?UTF-8?q?ver=20did,=20and=20neither=20autocomplete=20fires=20inside=20`c?= =?UTF-8?q?ode`=20or=20a=20```=20fence.=20A=20=E2=98=BA=20button=20in=20ea?= =?UTF-8?q?ch=20editor's=20corner=20opens=20the=20picker=20for=20mouse=20u?= =?UTF-8?q?sers,=20with=20a=20skin-tone=20selector=20that's=20remembered,?= =?UTF-8?q?=20and=20its=20search=20takes=20focus=20only=20once=20the=20pic?= =?UTF-8?q?ker=20is=20placed=20(a=20hidden=20field=20can't).=20Emoji,=20me?= =?UTF-8?q?ntions=20and=20pasted=20images=20go=20in=20through=20execComman?= =?UTF-8?q?d,=20so=20Ctrl+Z=20takes=20back=20the=20insertion=20instead=20o?= =?UTF-8?q?f=20wrecking=20the=20undo=20stack.=20Uploading=20an=20image=20m?= =?UTF-8?q?ade=20the=20textarea=20disabled,=20which=20dropped=20focus=20fo?= =?UTF-8?q?r=20good;=20it's=20read-only=20now,=20says=20"Uploading=20image?= =?UTF-8?q?=E2=80=A6"=20under=20the=20box=20rather=20than=20only=20in=20an?= =?UTF-8?q?=20empty=20one's=20placeholder,=20reports=20a=20failed=20upload?= =?UTF-8?q?=20instead=20of=20swallowing=20it,=20and=20holds=20Enter=20unti?= =?UTF-8?q?l=20it's=20done.=20Any=20dropped=20file=20is=20caught=20?= =?UTF-8?q?=E2=80=94=20a=20non-image=20used=20to=20be=20opened=20by=20the?= =?UTF-8?q?=20browser=20in=20place=20of=20the=20page,=20draft=20and=20all?= =?UTF-8?q?=20=E2=80=94=20and=20a=20paste=20carrying=20text=20(a=20spreads?= =?UTF-8?q?heet's=20cells=20come=20with=20a=20picture=20of=20themselves)?= =?UTF-8?q?=20pastes=20the=20text.=20The=20mention=20list=20follows=20the?= =?UTF-8?q?=20caret=20(a=20stale=20lookup=20can't=20close=20a=20newer=20on?= =?UTF-8?q?e,=20lookups=20abort),=20wraps,=20scrolls=20its=20selection=20i?= =?UTF-8?q?nto=20view,=20closes=20on=20blur,=20and=20is=20a=20real=20listb?= =?UTF-8?q?ox=20with=20aria-activedescendant.=20The=20counter=20shows=20fr?= =?UTF-8?q?om=2080%=20of=20the=20limit=20by=20default=20rather=20than=20a?= =?UTF-8?q?=20standing=20"0=20/=205000".=20Limits:=20chat=20Send=20and=20e?= =?UTF-8?q?dit=20Save=20refused=20nothing=20over=202000=20and=20let=20the?= =?UTF-8?q?=20server=20bounce=20it;=20they're=20disabled=20past=20it=20now?= =?UTF-8?q?.=20RichTextField's=20bare=20maxLength=20rule=20failed=20withou?= =?UTF-8?q?t=20a=20message,=20so=20an=20over-long=20"Why=3F"=20made=20Save?= =?UTF-8?q?=20do=20nothing=20and=20say=20nothing;=20it=20says=20so=20now.?= =?UTF-8?q?=20Inline=20editors=20answer=20Escape=20through=20a=20new=20onC?= =?UTF-8?q?ancel=20(comment=20reply=20and=20edit,=20profile=20description,?= =?UTF-8?q?=20playlist=20description=20and=20title),=20the=20playlist=20ed?= =?UTF-8?q?itor=20takes=20Ctrl+Enter=20and=20can't=20be=20saved=20without?= =?UTF-8?q?=20a=20title,=20and=20saving=20a=20comment=20unchanged=20no=20l?= =?UTF-8?q?onger=20stamps=20it=20edited.=20Markdown:=20a=20single=20newlin?= =?UTF-8?q?e=20is=20a=20line=20break,=20so=20a=20message=20written=20over?= =?UTF-8?q?=20three=20lines=20stops=20reading=20as=20one;=20links=20into?= =?UTF-8?q?=20the=20app=20route=20in=20place=20instead=20of=20opening=20a?= =?UTF-8?q?=20tab;=20headings=20are=20sized=20for=20prose=20instead=20of?= =?UTF-8?q?=202em=20page=20titles,=20and=20dropped=20from=20card=20teasers?= =?UTF-8?q?;=20GFM=20tables=20scroll=20in=20their=20own=20box=20and=20task?= =?UTF-8?q?=20lists=20lose=20the=20bullet;=20images=20load=20lazily.=20A?= =?UTF-8?q?=20chat=20message=20of=20only=20an=20emoji=20or=20three=20is=20?= =?UTF-8?q?shown=20large.=20Themes:=20brutalist=20and=20geocities=20give?= =?UTF-8?q?=20the=20new=20buttons=20no=20chrome,=20and=20geocities'=20ghos?= =?UTF-8?q?t-button=20list,=20split=20in=20two=20by=20the=20placeholder-th?= =?UTF-8?q?umbnail=20rules=20in=201cb904d,=20is=20whole=20again,=20so=20it?= =?UTF-8?q?s=20modal=20=E2=9C=95,=20emoji=20cells=20and=20the=20picker's?= =?UTF-8?q?=20=E2=9C=95=20are=20flat=20as=20intended.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/App.css | 187 +++++++- src/components/ChatModal.tsx | 42 +- src/components/CommentThread.tsx | 13 + src/components/DumpCreateModal.tsx | 1 - src/components/Markdown.tsx | 60 ++- src/components/MentionDropdown.tsx | 27 +- src/components/PlaylistCreateForm.tsx | 1 - src/components/TextEditor.tsx | 597 ++++++++++++++++++++------ src/components/form/RichTextField.tsx | 18 +- src/hooks/useEmojiTrigger.ts | 134 +++--- src/hooks/useMentionAutocomplete.ts | 202 +++++---- src/locales/en.js | 2 +- src/locales/en.po | 243 ++++++----- src/locales/fr.js | 2 +- src/locales/fr.po | 243 ++++++----- src/pages/DumpEdit.tsx | 1 - src/pages/PlaylistDetail.tsx | 10 +- src/pages/UserPublicProfile.tsx | 1 + src/themes/brutalist.css | 4 + src/themes/geocities.css | 14 +- src/utils/charCount.ts | 6 + src/utils/editorText.ts | 33 ++ 22 files changed, 1321 insertions(+), 520 deletions(-) create mode 100644 src/utils/editorText.ts 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 (
      -