261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* MentionTextarea — a textarea with inline "@" mention autocomplete.
|
|
*
|
|
* As the user types an `@token` at the caret, a suggestion list opens above
|
|
* the field (prioritised following → followers → others). Picking a user (tap
|
|
* or ↑/↓ + Enter/Tab) replaces the token with `@handle `. The list is portaled
|
|
* with `pointer-events-auto` so it works inside a modal vaul drawer, and
|
|
* dismisses on blur — no document listener that could fight the drawer.
|
|
*
|
|
* Enter (without Shift) submits via `onSubmit` ONLY when no suggestion list is
|
|
* open; while the list is open, Enter/Tab pick the highlighted user. The
|
|
* underlying <textarea> ref is forwarded so callers can focus it / splice text
|
|
* (e.g. the emoji picker).
|
|
*/
|
|
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Loader2 } from "lucide-react";
|
|
import { posts, type UserSummary } from "@/lib/social";
|
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
|
|
|
const SUGGEST_LIMIT = 8;
|
|
// "@" at line start or after whitespace, then up to 30 handle chars, at the
|
|
// end of the text before the caret. Avoids matching emails (foo@bar).
|
|
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
|
|
|
|
type Mention = { start: number; query: string };
|
|
/** Exactly one of `top`/`bottom` is set — see the flip logic in detect(). */
|
|
type PanelPos = { left: number; width: number; top?: number; bottom?: number };
|
|
|
|
/** Keep in sync with the panel's `max-h-56` (14rem). Used to decide whether
|
|
* the suggestion list fits above the field or has to flip below it. */
|
|
const PANEL_MAX_H = 224;
|
|
|
|
export const MentionTextarea = forwardRef<
|
|
HTMLTextAreaElement,
|
|
{
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
/** Called on Enter (no Shift) when the suggestion list is closed. Omit for
|
|
* multi-line fields — without it, Enter inserts a newline as usual. */
|
|
onSubmit?: () => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
maxLength?: number;
|
|
rows?: number;
|
|
/** Forwarded to the <textarea> so a <Label htmlFor> can address it. */
|
|
id?: string;
|
|
}
|
|
>(function MentionTextarea(
|
|
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
|
|
ref,
|
|
) {
|
|
const innerRef = useRef<HTMLTextAreaElement>(null);
|
|
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
|
|
|
|
const [mention, setMention] = useState<Mention | null>(null);
|
|
const [items, setItems] = useState<UserSummary[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [active, setActive] = useState(0);
|
|
const [pos, setPos] = useState<PanelPos | null>(null);
|
|
const reqRef = useRef(0);
|
|
const blurTimer = useRef<number | null>(null);
|
|
|
|
// Detect the active @token from the text up to the caret.
|
|
function detect(el: HTMLTextAreaElement) {
|
|
const caret = el.selectionStart ?? 0;
|
|
const m = MENTION_RE.exec(el.value.slice(0, caret));
|
|
if (!m) {
|
|
setMention(null);
|
|
return;
|
|
}
|
|
setMention({ start: caret - m[2].length - 1, query: m[2] });
|
|
setActive(0);
|
|
const r = el.getBoundingClientRect();
|
|
// Prefer opening ABOVE the field: that's what the comment composer needs
|
|
// (it sits at the bottom of a drawer, often above the on-screen keyboard).
|
|
// But flip BELOW when the field is too near the top of the viewport for the
|
|
// panel to fit — otherwise the list renders off-screen and the feature
|
|
// looks broken (e.g. the bio field high up on the profile editor).
|
|
const fitsAbove = r.top >= PANEL_MAX_H + 8;
|
|
setPos({
|
|
left: r.left,
|
|
width: r.width,
|
|
...(fitsAbove
|
|
? { bottom: window.innerHeight - r.top + 6 }
|
|
: { top: r.bottom + 6 }),
|
|
});
|
|
}
|
|
|
|
// Fetch suggestions (debounced) whenever the active token changes.
|
|
useEffect(() => {
|
|
if (!mention) return;
|
|
const token = ++reqRef.current;
|
|
const t = window.setTimeout(() => {
|
|
setLoading(true);
|
|
posts
|
|
.mentionSuggest(mention.query, { limit: SUGGEST_LIMIT, offset: 0 })
|
|
.then((res) => {
|
|
if (reqRef.current === token) setItems(res);
|
|
})
|
|
.catch(() => {
|
|
if (reqRef.current === token) setItems([]);
|
|
})
|
|
.finally(() => {
|
|
if (reqRef.current === token) setLoading(false);
|
|
});
|
|
}, 150);
|
|
return () => window.clearTimeout(t);
|
|
}, [mention]);
|
|
|
|
function select(u: UserSummary) {
|
|
const el = innerRef.current;
|
|
if (!el || !mention) return;
|
|
const caret = el.selectionStart ?? value.length;
|
|
const insert = `@${u.handle} `;
|
|
const next = value.slice(0, mention.start) + insert + value.slice(caret);
|
|
onChange(next);
|
|
setMention(null);
|
|
const newCaret = mention.start + insert.length;
|
|
requestAnimationFrame(() => {
|
|
const node = innerRef.current;
|
|
if (!node) return;
|
|
node.focus();
|
|
node.setSelectionRange(newCaret, newCaret);
|
|
});
|
|
}
|
|
|
|
const listOpen = !!mention && (loading || items.length > 0);
|
|
|
|
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
if (listOpen && items.length > 0) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActive((a) => Math.min(a + 1, items.length - 1));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActive((a) => Math.max(a - 1, 0));
|
|
return;
|
|
}
|
|
if (e.key === "Enter" || e.key === "Tab") {
|
|
e.preventDefault();
|
|
select(items[active] ?? items[0]);
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setMention(null);
|
|
return;
|
|
}
|
|
}
|
|
// Only hijack Enter when the caller actually wants submit-on-Enter.
|
|
// Without the `onSubmit` guard we'd swallow the keystroke for multi-line
|
|
// callers (e.g. the profile bio), leaving them unable to type a newline.
|
|
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
|
|
e.preventDefault();
|
|
onSubmit();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex-1">
|
|
<textarea
|
|
ref={innerRef}
|
|
id={id}
|
|
value={value}
|
|
rows={rows}
|
|
maxLength={maxLength}
|
|
placeholder={placeholder}
|
|
className={className}
|
|
autoComplete="off"
|
|
onChange={(e) => {
|
|
onChange(e.target.value);
|
|
detect(e.target);
|
|
}}
|
|
onKeyUp={(e) => detect(e.currentTarget)}
|
|
onClick={(e) => detect(e.currentTarget)}
|
|
onKeyDown={onKeyDown}
|
|
onBlur={() => {
|
|
// Defer so a result tap (mousedown→click) lands before close.
|
|
blurTimer.current = window.setTimeout(() => setMention(null), 120);
|
|
}}
|
|
/>
|
|
|
|
{listOpen && pos && typeof document !== "undefined"
|
|
? createPortal(
|
|
<div
|
|
role="listbox"
|
|
aria-label="Mention suggestions"
|
|
style={{
|
|
position: "fixed",
|
|
left: pos.left,
|
|
width: pos.width,
|
|
...(pos.top !== undefined
|
|
? { top: pos.top }
|
|
: { bottom: pos.bottom }),
|
|
}}
|
|
// pointer-events-auto: the comments drawer is a modal vaul sheet
|
|
// (body gets pointer-events:none); without this the list would be
|
|
// click-through.
|
|
className="pointer-events-auto z-[101] max-h-56 overflow-y-auto overscroll-contain rounded-lg border border-border bg-card shadow-xl"
|
|
onTouchMove={(e) => e.stopPropagation()}
|
|
>
|
|
{items.map((u, i) => (
|
|
<button
|
|
key={u.id ?? u.handle}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === active}
|
|
// Keep textarea focus + cancel the pending blur-close so the
|
|
// click registers before the list would otherwise close.
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
if (blurTimer.current)
|
|
window.clearTimeout(blurTimer.current);
|
|
}}
|
|
onMouseEnter={() => setActive(i)}
|
|
onClick={() => select(u)}
|
|
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors ${
|
|
i === active ? "bg-muted/70" : "hover:bg-muted/60"
|
|
}`}
|
|
>
|
|
<UserAvatar
|
|
src={u.avatar_url}
|
|
name={u.name}
|
|
className="size-8"
|
|
textClassName="text-xs"
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-medium text-foreground">
|
|
{u.name}
|
|
</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
@{u.handle}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
{loading ? (
|
|
<div className="flex justify-center py-2">
|
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : null}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
});
|