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:
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user