Skip to content
File

Blob: src/client/components/editor/editor-outline.tsx

typescript60 lines
1import { useTiptap, useTiptapState } from "@tiptap/react";
2import { OutlinePresentation, type OutlinePresentationVariant } from "@/shared/editor/presentation/outline";
3import {
4 collectHeadings,
5 jumpToHeading,
6 resolveActiveBySelection,
7 resolveSelectionHeading,
8 type HeadingOutlineItem,
9} from "./lib/heading-outline";
10import { useViewportActiveHeading } from "./lib/use-viewport-active-heading";
11import "@/styles/editor/outline.css";
12 
13interface EditorOutlineProps {
14 variant?: OutlinePresentationVariant;
15 title?: string;
16}
17 
18function 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 
28export 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}