Replace plain textarea with shared tiptap editor and sanitized html rendering. Adds interactive checklists on read surfaces and mobile overflow fixes.
92 lines
2.6 KiB
TypeScript
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 }}
|
|
/>
|
|
);
|
|
}
|