v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
112
src/components/editor/EmojiPickerPopup.tsx
Normal file
112
src/components/editor/EmojiPickerPopup.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
EmojiPicker,
|
||||
type Locale as EmojiLocale,
|
||||
useSkinTone,
|
||||
} from "frimousse";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { readSkinTone, SKIN_TONE_KEY } from "./skinTone.ts";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
interface EmojiPickerPopupProps {
|
||||
floatRef: Ref<HTMLDivElement>;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
style: CSSProperties;
|
||||
/** Inline, the query is typed in the textarea and the search only shows it;
|
||||
* browsing, the search field takes it. */
|
||||
inline: boolean;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/** The floating emoji picker the editor opens from its button or a `:query`. */
|
||||
export function EmojiPickerPopup(
|
||||
{
|
||||
floatRef,
|
||||
searchRef,
|
||||
viewportRef,
|
||||
style,
|
||||
inline,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onClose,
|
||||
onKeyDownCapture,
|
||||
}: EmojiPickerPopupProps,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<div
|
||||
ref={floatRef}
|
||||
className="emoji-picker-float"
|
||||
style={style}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => onSelect(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL}
|
||||
skinTone={readSkinTone()}
|
||||
>
|
||||
<SkinTonePersist />
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder={t`Search emoji…`}
|
||||
aria-label={t`Search emoji`}
|
||||
readOnly={inline}
|
||||
tabIndex={inline ? -1 : undefined}
|
||||
/>
|
||||
<EmojiPicker.SkinToneSelector aria-label={t`Change skin tone`} />
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-picker-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label={t`Close emoji picker`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={viewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/editor/Markdown.tsx
Normal file
158
src/components/editor/Markdown.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// The mention rule the server notifies by (see utils/chatText.ts): an `@` not
|
||||
// preceded by a word character, so an email address isn't a mention.
|
||||
const MENTION_RE = /(?<!\w)@(\w+)/g;
|
||||
|
||||
interface MdNode {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MdNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns bare @username into a link to that user. Works on the parsed tree
|
||||
* rather than the source text, so code keeps its @s verbatim and a URL that
|
||||
* contains one (`https://mastodon.social/@bob`) isn't split in two.
|
||||
*/
|
||||
function remarkMentions() {
|
||||
const walk = (node: MdNode) => {
|
||||
if (!node.children) return;
|
||||
// Link text is already a link, whatever it says.
|
||||
if (node.type === "link" || node.type === "linkReference") return;
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type !== "text" || !child.value?.includes("@")) {
|
||||
walk(child);
|
||||
return [child];
|
||||
}
|
||||
const out: MdNode[] = [];
|
||||
let last = 0;
|
||||
for (const m of child.value.matchAll(MENTION_RE)) {
|
||||
if (m.index > last) {
|
||||
out.push({ type: "text", value: child.value.slice(last, m.index) });
|
||||
}
|
||||
out.push({
|
||||
type: "link",
|
||||
url: `/users/${m[1]}`,
|
||||
children: [{ type: "text", value: m[0] }],
|
||||
});
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last === 0) return [child];
|
||||
if (last < child.value.length) {
|
||||
out.push({ type: "text", value: child.value.slice(last) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
};
|
||||
return (tree: MdNode) => walk(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) => {
|
||||
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
|
||||
// image blows the layout apart. Drop images there — the full description is
|
||||
// one click away on the detail page.
|
||||
const INLINE_MARKDOWN_COMPONENTS: Components = {
|
||||
...MARKDOWN_COMPONENTS,
|
||||
img: () => null,
|
||||
};
|
||||
|
||||
// Memoized: parsing runs the whole remark pipeline, and the text rarely
|
||||
// changes when the component around it re-renders.
|
||||
export const Markdown = memo(function Markdown(
|
||||
{ children, className, inline = false }: MarkdownProps,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={`md${className ? ` ${className}` : ""}${
|
||||
inline ? " md--inline" : ""
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={inline ? INLINE_MARKDOWN_COMPONENTS : MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
62
src/components/editor/MentionDropdown.tsx
Normal file
62
src/components/editor/MentionDropdown.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||
import { Avatar } from "../ui/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;
|
||||
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function MentionDropdown(
|
||||
{ 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
|
||||
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" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // keep textarea focused
|
||||
onSelect(user.username);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={20}
|
||||
/>
|
||||
<span className="mention-dropdown-username">@{user.username}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
612
src/components/editor/TextEditor.tsx
Normal file
612
src/components/editor/TextEditor.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
import { EmojiPickerPopup } from "./EmojiPickerPopup.tsx";
|
||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
|
||||
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
* there); `preventScroll` leaves the page and its scrollers where they are. */
|
||||
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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>(
|
||||
function TextEditor(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
rows,
|
||||
id,
|
||||
className,
|
||||
autoResize = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onKeyDown,
|
||||
maxLength,
|
||||
countFrom,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
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 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();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: (options) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus({ preventScroll: options?.preventScroll });
|
||||
if (options?.atEnd) {
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
}), []);
|
||||
|
||||
/**
|
||||
* 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,
|
||||
syncMention,
|
||||
closeMention,
|
||||
handleMentionKeyDown,
|
||||
handleMentionSelect,
|
||||
} = useMentionAutocomplete(replace);
|
||||
|
||||
const {
|
||||
emojiMode,
|
||||
emojiQuery,
|
||||
syncEmojiTrigger,
|
||||
openEmojiBrowser,
|
||||
setEmojiQuery,
|
||||
selectEmoji,
|
||||
closeEmoji,
|
||||
} = 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 (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
|
||||
// absolutely positioned out of their box.
|
||||
const emojiStyle = useAnchoredFloat(textareaRef, emojiOpen, {
|
||||
width: 320,
|
||||
chromeHeight: 40, // search row + borders
|
||||
minContentHeight: 120,
|
||||
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
|
||||
minContentHeight: 90,
|
||||
maxContentHeight: 220,
|
||||
});
|
||||
|
||||
// Before paint, so the first frame — and anything measuring the layout in
|
||||
// its own layout effect, like a height transition — sees the final size.
|
||||
useLayoutEffect(() => {
|
||||
if (!autoResize) return;
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value, autoResize]);
|
||||
|
||||
// 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 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);
|
||||
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(``);
|
||||
} catch {
|
||||
setNotice(
|
||||
files.length > 1
|
||||
? t`Some images couldn't be uploaded.`
|
||||
: t`The image couldn't be uploaded.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (images.length > 0) {
|
||||
pendingInsertRef.current = {
|
||||
start,
|
||||
end,
|
||||
text: images.join("\n"),
|
||||
focus,
|
||||
};
|
||||
}
|
||||
setUploading(false);
|
||||
}, [authFetch]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(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 (images.length === 0) return;
|
||||
e.preventDefault();
|
||||
void uploadImages(images);
|
||||
},
|
||||
[uploadImages],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
setDragOver(false);
|
||||
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);
|
||||
},
|
||||
[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>) => {
|
||||
if (!hasFiles(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
// Every arrow steps into the grid, left and right included: while a
|
||||
// `:query` is open they pick among its results, not move the caret
|
||||
// (Escape first to get the caret back).
|
||||
if (e.key.startsWith("Arrow") && 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" : ""
|
||||
}`}
|
||||
>
|
||||
<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`}
|
||||
>
|
||||
<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}
|
||||
style={mentionStyle ?? { visibility: "hidden" }}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
{emojiOpen && createPortal(
|
||||
<EmojiPickerPopup
|
||||
floatRef={floatRef}
|
||||
searchRef={emojiSearchRef}
|
||||
viewportRef={emojiViewportRef}
|
||||
style={emojiStyle ?? { visibility: "hidden" }}
|
||||
inline={emojiMode === "inline"}
|
||||
query={emojiQuery}
|
||||
onQueryChange={setEmojiQuery}
|
||||
onSelect={selectEmoji}
|
||||
onClose={() => {
|
||||
closeEmoji();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeEmoji();
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
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 (
|
||||
(!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),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
13
src/components/editor/skinTone.ts
Normal file
13
src/components/editor/skinTone.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// The emoji picker's skin tone, remembered across pickers and reloads.
|
||||
|
||||
import type { SkinTone } from "frimousse";
|
||||
|
||||
export const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
export function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user