feat: rtf
This commit is contained in:
@@ -9,7 +9,8 @@ import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
import { Textarea } from "@/components/ui/input";
|
||||
import { RichTextarea } from "@/components/ui/rich-textarea";
|
||||
import { RichText } from "@/lib/rich-text";
|
||||
import { cn, fmtDate } from "@/lib/utils";
|
||||
|
||||
const STATUSES = ["all", "open", "resolved"] as const;
|
||||
@@ -121,7 +122,7 @@ export default function FeedbackPage() {
|
||||
{f.reply_message ? (
|
||||
<div className="mt-2 rounded-md bg-muted px-3 py-2 text-xs">
|
||||
<span className="font-medium">Your reply: </span>
|
||||
{f.reply_message}
|
||||
<RichText text={f.reply_message} />
|
||||
</div>
|
||||
) : null}
|
||||
</TD>
|
||||
@@ -235,9 +236,9 @@ function ReplyModal({
|
||||
) : null}
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Your reply</span>
|
||||
<Textarea
|
||||
<RichTextarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onValueChange={setMessage}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
placeholder="Write your reply… (emailed to the submitter; use Resolve separately to close it)"
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Textarea } from "@/components/ui/input";
|
||||
import { RichTextarea } from "@/components/ui/rich-textarea";
|
||||
import { RichText } from "@/lib/rich-text";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
import { cn, fmtDate } from "@/lib/utils";
|
||||
|
||||
@@ -221,8 +222,8 @@ export default function ReportsPage() {
|
||||
✓ Resolved
|
||||
</span>
|
||||
{r.resolution_note ? (
|
||||
<p className="max-w-[220px] whitespace-pre-line text-right text-xs text-muted-foreground">
|
||||
{r.resolution_note}
|
||||
<p className="max-w-[220px] text-right text-xs text-muted-foreground">
|
||||
<RichText text={r.resolution_note} />
|
||||
</p>
|
||||
) : null}
|
||||
{r.target_removed ? undoButton(r) : null}
|
||||
@@ -291,9 +292,9 @@ function ResolveModal({
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Resolution note</span>
|
||||
<Textarea
|
||||
<RichTextarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onValueChange={setNote}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
placeholder="What did you do / decide? (shown in the reports table)"
|
||||
|
||||
@@ -18,7 +18,8 @@ import { Modal } from "@/components/ui/modal";
|
||||
import { RowMenu, type MenuItem } from "@/components/ui/menu";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RichTextarea } from "@/components/ui/rich-textarea";
|
||||
import { fmtDate } from "@/lib/utils";
|
||||
import { DiscipleLinkModal } from "./disciple-link-modal";
|
||||
|
||||
@@ -404,9 +405,9 @@ function NotifyModal({
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Message</span>
|
||||
<Textarea
|
||||
<RichTextarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onValueChange={setBody}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
placeholder="Write your message…"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Renders the light formatting the admin message fields allow:
|
||||
*
|
||||
* *bold* _italic_ __underline__ ~strike~ [label](https://…)
|
||||
*
|
||||
* plus bare http(s) URLs. The rules mirror pkg/richtext in the BFF, which
|
||||
* renders the same markers into email — so the preview shown here is what
|
||||
* the recipient's inbox gets.
|
||||
*
|
||||
* Output is React nodes, never an HTML string: React escapes all text, so
|
||||
* author input cannot become markup, and links are limited to http(s).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const LINK_RE =
|
||||
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g;
|
||||
const TRAILING_PUNCT = /[.,;:!?)]+$/;
|
||||
|
||||
type Marker = { re: RegExp; wrap: (n: ReactNode, k: string) => ReactNode };
|
||||
|
||||
// Order matters: __underline__ before _italic_, so the double underscore
|
||||
// isn't read as two italic markers.
|
||||
const MARKERS: Marker[] = [
|
||||
{ re: /__([^_\n]+?)__/, wrap: (n, k) => <u key={k}>{n}</u> },
|
||||
{ re: /\*([^*\n]+?)\*/, wrap: (n, k) => <strong key={k}>{n}</strong> },
|
||||
{ re: /~([^~\n]+?)~/, wrap: (n, k) => <s key={k}>{n}</s> },
|
||||
{ re: /_([^_\n]+?)_/, wrap: (n, k) => <em key={k}>{n}</em> },
|
||||
];
|
||||
|
||||
const isWord = (c: string | undefined) => !!c && /\w/.test(c);
|
||||
|
||||
/** Markers within a run of text that contains no links. */
|
||||
function renderMarkers(text: string, keyBase: string): ReactNode[] {
|
||||
// Earliest match across all markers wins, so nesting resolves outside-in.
|
||||
let best: { idx: number; len: number; inner: string; m: Marker } | null =
|
||||
null;
|
||||
for (const m of MARKERS) {
|
||||
const re = new RegExp(m.re.source, "g");
|
||||
let hit: RegExpExecArray | null;
|
||||
while ((hit = re.exec(text))) {
|
||||
// Italic needs a non-word character either side, so snake_case and
|
||||
// handles keep their underscores.
|
||||
if (m.re.source.startsWith("_(")) {
|
||||
const before = text[hit.index - 1];
|
||||
const after = text[hit.index + hit[0].length];
|
||||
if (isWord(before) || isWord(after)) continue;
|
||||
}
|
||||
if (!best || hit.index < best.idx) {
|
||||
best = { idx: hit.index, len: hit[0].length, inner: hit[1], m };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!best) return text ? [text] : [];
|
||||
|
||||
const before = text.slice(0, best.idx);
|
||||
const after = text.slice(best.idx + best.len);
|
||||
const k = `${keyBase}-${best.idx}`;
|
||||
return [
|
||||
...(before ? [before] : []),
|
||||
best.m.wrap(renderMarkers(best.inner, `${k}i`), k),
|
||||
...renderMarkers(after, `${k}a`),
|
||||
];
|
||||
}
|
||||
|
||||
/** Renders formatted text, newlines included. */
|
||||
export function RichText({ text }: { text: string }) {
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, li) => {
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
for (const m of line.matchAll(LINK_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
out.push(...renderMarkers(line.slice(last, idx), `${li}-${last}`));
|
||||
let href = m[2] ?? m[3];
|
||||
let label = m[1] ?? href;
|
||||
let tail = "";
|
||||
if (!m[1]) {
|
||||
// Sentence punctuation after a bare URL isn't part of it.
|
||||
tail = href.match(TRAILING_PUNCT)?.[0] ?? "";
|
||||
href = href.slice(0, href.length - tail.length);
|
||||
label = href;
|
||||
}
|
||||
out.push(
|
||||
<a
|
||||
key={`${li}-l${idx}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{label}
|
||||
</a>,
|
||||
);
|
||||
if (tail) out.push(tail);
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
out.push(...renderMarkers(line.slice(last), `${li}-${last}`));
|
||||
return (
|
||||
<span key={li}>
|
||||
{li > 0 && <br />}
|
||||
{out}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user