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.
This commit is contained in:
ginnoir
2026-07-04 19:25:15 -05:00
parent 67f67525ff
commit a4be5d5061
23 changed files with 1942 additions and 206 deletions
@@ -0,0 +1,91 @@
"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 }}
/>
);
}