File
Blob: src/client/components/editor/editor-outline.tsx
| 1 | import { useTiptap, useTiptapState } from "@tiptap/react"; |
| 2 | import { OutlinePresentation, type OutlinePresentationVariant } from "@/shared/editor/presentation/outline"; |
| 3 | import { |
| 4 | collectHeadings, |
| 5 | jumpToHeading, |
| 6 | resolveActiveBySelection, |
| 7 | resolveSelectionHeading, |
| 8 | type HeadingOutlineItem, |
| 9 | } from "./lib/heading-outline"; |
| 10 | import { useViewportActiveHeading } from "./lib/use-viewport-active-heading"; |
| 11 | import "@/styles/editor/outline.css"; |
| 12 | |
| 13 | interface EditorOutlineProps { |
| 14 | variant?: OutlinePresentationVariant; |
| 15 | title?: string; |
| 16 | } |
| 17 | |
| 18 | function headingsEqual(a: HeadingOutlineItem[], b: HeadingOutlineItem[] | null) { |
| 19 | if (!b || a.length !== b.length) return false; |
| 20 | for (let i = 0; i < a.length; i += 1) { |
| 21 | if (a[i].pos !== b[i].pos || a[i].level !== b[i].level || a[i].text !== b[i].text) { |
| 22 | return false; |
| 23 | } |
| 24 | } |
| 25 | return true; |
| 26 | } |
| 27 | |
| 28 | export function EditorOutline({ variant = "card", title = "On this page" }: EditorOutlineProps) { |
| 29 | const { editor } = useTiptap(); |
| 30 | const headings = useTiptapState(({ editor: currentEditor }) => collectHeadings(currentEditor), headingsEqual); |
| 31 | const selectionPos = useTiptapState(({ editor: currentEditor }) => currentEditor?.state.selection.from ?? 0); |
| 32 | const cursorHeading = useTiptapState(({ editor: currentEditor }) => resolveSelectionHeading(currentEditor)); |
| 33 | const isFocused = useTiptapState(({ editor: currentEditor }) => currentEditor?.isFocused ?? false); |
| 34 | const viewportActive = useViewportActiveHeading(editor, headings); |
| 35 | |
| 36 | if (!editor || headings.length === 0) return null; |
| 37 | |
| 38 | // Cursor-based signals apply only while the editor owns focus; otherwise |
| 39 | // ProseMirror selection is stale and viewport alone determines the active heading. |
| 40 | // While focused, selection wins over viewport visibility because it reflects |
| 41 | // the user's current editing intent even when another section is on screen. |
| 42 | const selectionFallback = resolveActiveBySelection(headings, selectionPos); |
| 43 | const activePos = isFocused ? (cursorHeading ?? selectionFallback ?? viewportActive) : viewportActive; |
| 44 | |
| 45 | return ( |
| 46 | <OutlinePresentation |
| 47 | items={headings} |
| 48 | activeId={activePos === null ? null : `${activePos}`} |
| 49 | mode="button" |
| 50 | variant={variant} |
| 51 | title={title} |
| 52 | onItemMouseDown={(item, event) => { |
| 53 | event.preventDefault(); |
| 54 | const pos = Number.parseInt(item.id, 10); |
| 55 | if (Number.isFinite(pos)) jumpToHeading(editor, pos); |
| 56 | }} |
| 57 | /> |
| 58 | ); |
| 59 | } |