"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; /** 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, "value" | "onChange" >) { const ref = useRef(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) => { 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, ) => ( ); return (
{tool( "Bold", , () => wrap(WRAPS.bold), "⌘B", )} {tool( "Italic", , () => wrap(WRAPS.italic), "⌘I", )} {tool( "Underline", , () => wrap(WRAPS.underline), "⌘U", )} {tool("Strikethrough", , () => wrap(WRAPS.strike), )} {tool("Link", , link, "⌘K")}
{previewing ? (
{value.trim() ? ( ) : ( Nothing to preview. )}
) : (