/** * Document Outline * * Displays a tree structure of headings from the TipTap editor. * - Shows H1 at root, H2 indented, H3 further indented * - Click-to-navigate to heading position * - Highlights the current section based on cursor position */ import { Button } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { CaretDown, List } from "@phosphor-icons/react"; import type { Editor } from "@tiptap/react"; import * as React from "react"; import { cn } from "../../lib/utils"; import { CaretNext } from "../ArrowIcons.js"; function getIndentClass(level: number) { switch (level) { case 1: return "ps-0"; case 2: return "ps-4"; case 3: return "ps-8"; default: return "ps-0"; } } function getTextClass(level: number) { switch (level) { case 1: return "font-medium"; case 2: return "font-normal"; case 3: return "font-normal text-kumo-subtle"; default: return "font-normal"; } } /** * Heading item extracted from editor document */ export interface HeadingItem { /** Heading level (1-3) */ level: number; /** Heading text content */ text: string; /** Position in document for navigation */ pos: number; /** Unique key for React */ key: string; } /** * Extract headings from the TipTap editor document */ export function extractHeadings(editor: Editor | null): HeadingItem[] { if (!editor) return []; const headings: HeadingItem[] = []; const doc = editor.state.doc; let key = 0; doc.descendants((node, pos) => { if (node.type.name === "heading") { const rawLevel = node.attrs.level; const level = typeof rawLevel === "number" ? rawLevel : 1; const text = node.textContent || ""; if (text.trim()) { headings.push({ level, text, pos, key: `heading-${key++}`, }); } } }); return headings; } /** * Find the current heading based on cursor position */ export function findCurrentHeading(headings: HeadingItem[], cursorPos: number): HeadingItem | null { if (headings.length === 0) return null; // Find the heading that contains or precedes the cursor let current: HeadingItem | null = null; for (const heading of headings) { if (heading.pos <= cursorPos) { current = heading; } else { break; } } return current; } export interface DocumentOutlineProps { /** TipTap editor instance */ editor: Editor | null; /** Additional CSS classes */ className?: string; } /** * Document outline component showing heading tree structure */ export function DocumentOutline({ editor, className }: DocumentOutlineProps) { const { t } = useLingui(); const [isExpanded, setIsExpanded] = React.useState(true); const [headings, setHeadings] = React.useState([]); const [currentPos, setCurrentPos] = React.useState(0); // Extract headings when editor content changes React.useEffect(() => { if (!editor) return; const updateHeadings = () => { setHeadings(extractHeadings(editor)); }; // Initial extraction updateHeadings(); // Update on content changes editor.on("update", updateHeadings); return () => { editor.off("update", updateHeadings); }; }, [editor]); // Track cursor position for current section highlight React.useEffect(() => { if (!editor) return; const updatePosition = () => { const { from } = editor.state.selection; setCurrentPos(from); }; // Initial position updatePosition(); // Update on selection changes editor.on("selectionUpdate", updatePosition); return () => { editor.off("selectionUpdate", updatePosition); }; }, [editor]); const currentHeading = findCurrentHeading(headings, currentPos); const handleHeadingClick = (heading: HeadingItem) => { if (!editor) return; // Navigate to heading and scroll into view editor.chain().focus().setTextSelection(heading.pos).scrollIntoView().run(); }; return (
{isExpanded && (
{headings.length === 0 ? (

{t`No headings in document`}

) : ( headings.map((heading) => { const isCurrent = currentHeading?.key === heading.key; return ( ); }) )}
)}
); }