Files
famapp/src/components/rich-text/rich-text-content.tsx
T
ginnoir a4be5d5061 feat(notes): rich-text editor with tiptap (task 85)
Replace plain textarea with shared tiptap editor and sanitized html rendering.

Adds interactive checklists on read surfaces and mobile overflow fixes.
2026-07-04 19:25:15 -05:00

92 lines
2.6 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useTransition } from "react";
import { normalizeNoteBody } from "./normalize-body";
import { sanitizeRichTextHtml } from "./sanitize";
import "./rich-text.css";
type ToggleHandler = (taskIndex: number, checked: boolean) => Promise<void>;
type Props = {
html: string;
interactiveChecklists?: boolean;
onToggleChecklist?: ToggleHandler;
className?: string;
clampLines?: number;
};
export function RichTextContent({
html,
interactiveChecklists = false,
onToggleChecklist,
className,
clampLines,
}: Props) {
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [isPending, startTransition] = useTransition();
const safeHtml = useMemo(() => {
const normalized = normalizeNoteBody(html);
return sanitizeRichTextHtml(normalized);
}, [html]);
useEffect(() => {
const root = containerRef.current;
if (!root || !interactiveChecklists || !onToggleChecklist) return;
const items = [...root.querySelectorAll<HTMLLIElement>('li[data-type="taskItem"]')];
for (const [index, item] of items.entries()) {
const checked = item.getAttribute("data-checked") === "true";
let label = item.querySelector("label");
if (!label) {
label = document.createElement("label");
item.insertBefore(label, item.firstChild);
}
let input = label.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (!input) {
input = document.createElement("input");
input.type = "checkbox";
label.prepend(input);
}
input.checked = checked;
input.disabled = isPending;
input.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const nextChecked = !input!.checked;
startTransition(async () => {
await onToggleChecklist(index, nextChecked);
router.refresh();
});
};
}
}, [safeHtml, interactiveChecklists, onToggleChecklist, isPending, router]);
if (!safeHtml) {
return <p className="text-sm text-[var(--ink-mute)]">No body text.</p>;
}
return (
<div
ref={containerRef}
className={["rich-text-content", className].filter(Boolean).join(" ")}
style={
clampLines
? {
display: "-webkit-box",
WebkitLineClamp: clampLines,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}
: undefined
}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
);
}