217 lines
6.6 KiB
TypeScript
217 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* A textarea with a formatting toolbar: bold, italic, underline, strike, link.
|
|
*
|
|
* The buttons don't make the field "rich" — they wrap the selection in the
|
|
* same plain-text markers an admin could type by hand (*bold*, _italic_,
|
|
* __underline__, ~strike~, [label](url)). The stored text stays plain, so it
|
|
* travels unchanged to every place it ends up: rendered in email by the BFF,
|
|
* stripped for push, rendered again in the app.
|
|
*
|
|
* Preview shows exactly what those markers become, because a reply is sent
|
|
* the moment it's submitted and the recipient's inbox is not the place to
|
|
* discover a stray asterisk.
|
|
*/
|
|
|
|
import { useRef, useState } from "react";
|
|
import {
|
|
Bold,
|
|
Eye,
|
|
Italic,
|
|
Link2,
|
|
PencilLine,
|
|
Strikethrough,
|
|
Underline,
|
|
} from "lucide-react";
|
|
import { Textarea } from "@/components/ui/input";
|
|
import { RichText } from "@/lib/rich-text";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Wrap = { open: string; close: string; label: string };
|
|
|
|
const WRAPS = {
|
|
bold: { open: "*", close: "*", label: "Bold" },
|
|
italic: { open: "_", close: "_", label: "Italic" },
|
|
underline: { open: "__", close: "__", label: "Underline" },
|
|
strike: { open: "~", close: "~", label: "Strikethrough" },
|
|
} satisfies Record<string, Wrap>;
|
|
|
|
/** Placeholder put in a new link, selected so it can be typed over. */
|
|
const URL_PLACEHOLDER = "https://";
|
|
|
|
export function RichTextarea({
|
|
value,
|
|
onValueChange,
|
|
className,
|
|
...props
|
|
}: {
|
|
value: string;
|
|
onValueChange: (next: string) => void;
|
|
} & Omit<
|
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
"value" | "onChange"
|
|
>) {
|
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
const [previewing, setPreviewing] = useState(false);
|
|
|
|
/** Replace the selection, then put the caret/selection where it's useful. */
|
|
const replaceSelection = (
|
|
build: (selected: string) => string,
|
|
select: (start: number, built: string) => [number, number],
|
|
) => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const { selectionStart: start, selectionEnd: end } = el;
|
|
const built = build(value.slice(start, end));
|
|
const next = value.slice(0, start) + built + value.slice(end);
|
|
// Respect maxLength: a wrap that would overflow it is refused rather than
|
|
// silently truncating the end of the message.
|
|
if (props.maxLength && next.length > props.maxLength) return;
|
|
onValueChange(next);
|
|
const [s, e] = select(start, built);
|
|
// After React commits the new value; setting it now would be overwritten.
|
|
requestAnimationFrame(() => {
|
|
el.focus();
|
|
el.setSelectionRange(s, e);
|
|
});
|
|
};
|
|
|
|
const wrap = (w: Wrap) =>
|
|
replaceSelection(
|
|
(sel) => `${w.open}${sel}${w.close}`,
|
|
// Keep the wrapped text selected, so a second click (say, bold then
|
|
// italic) applies to the same words.
|
|
(start, built) => [
|
|
start + w.open.length,
|
|
start + built.length - w.close.length,
|
|
],
|
|
);
|
|
|
|
const link = () =>
|
|
replaceSelection(
|
|
(sel) => `[${sel || "link text"}](${URL_PLACEHOLDER})`,
|
|
// Select the URL placeholder: the label is usually what was selected,
|
|
// and the address is the part still to be filled in.
|
|
(start, built) => {
|
|
const urlStart = start + built.lastIndexOf(URL_PLACEHOLDER);
|
|
return [urlStart, urlStart + URL_PLACEHOLDER.length];
|
|
},
|
|
);
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (!(e.metaKey || e.ctrlKey)) return;
|
|
const key = e.key.toLowerCase();
|
|
const action =
|
|
key === "b"
|
|
? () => wrap(WRAPS.bold)
|
|
: key === "i"
|
|
? () => wrap(WRAPS.italic)
|
|
: key === "u"
|
|
? () => wrap(WRAPS.underline)
|
|
: key === "k"
|
|
? link
|
|
: null;
|
|
if (!action) return;
|
|
e.preventDefault();
|
|
action();
|
|
};
|
|
|
|
const tool = (
|
|
label: string,
|
|
icon: React.ReactNode,
|
|
onClick: () => void,
|
|
shortcut?: string,
|
|
) => (
|
|
<button
|
|
type="button"
|
|
// mousedown, not click: a click would move focus to the button first
|
|
// and the textarea would lose the selection being formatted.
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
onClick();
|
|
}}
|
|
disabled={previewing || props.disabled}
|
|
aria-label={label}
|
|
title={shortcut ? `${label} (${shortcut})` : label}
|
|
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden rounded-lg border border-input bg-card focus-within:ring-2 focus-within:ring-ring",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-0.5 border-b border-border px-1.5 py-1">
|
|
{tool(
|
|
"Bold",
|
|
<Bold className="size-4" />,
|
|
() => wrap(WRAPS.bold),
|
|
"⌘B",
|
|
)}
|
|
{tool(
|
|
"Italic",
|
|
<Italic className="size-4" />,
|
|
() => wrap(WRAPS.italic),
|
|
"⌘I",
|
|
)}
|
|
{tool(
|
|
"Underline",
|
|
<Underline className="size-4" />,
|
|
() => wrap(WRAPS.underline),
|
|
"⌘U",
|
|
)}
|
|
{tool("Strikethrough", <Strikethrough className="size-4" />, () =>
|
|
wrap(WRAPS.strike),
|
|
)}
|
|
{tool("Link", <Link2 className="size-4" />, link, "⌘K")}
|
|
<button
|
|
type="button"
|
|
onClick={() => setPreviewing((p) => !p)}
|
|
aria-pressed={previewing}
|
|
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
>
|
|
{previewing ? (
|
|
<>
|
|
<PencilLine className="size-3.5" /> Edit
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="size-3.5" /> Preview
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{previewing ? (
|
|
<div
|
|
className="min-h-[7.5rem] whitespace-normal break-words px-3 py-2 text-sm text-foreground"
|
|
aria-live="polite"
|
|
>
|
|
{value.trim() ? (
|
|
<RichText text={value} />
|
|
) : (
|
|
<span className="text-muted-foreground">Nothing to preview.</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Textarea
|
|
ref={ref}
|
|
value={value}
|
|
onChange={(e) => onValueChange(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
// The frame lives on the wrapper so the toolbar and field read as
|
|
// one control; the field itself is bare.
|
|
className="rounded-none border-0 bg-transparent focus-visible:ring-0"
|
|
{...props}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|