diff --git a/apps/web/src/components/page-editor/PageOutline.tsx b/apps/web/src/components/page-editor/PageOutline.tsx new file mode 100644 index 00000000..6a87c52a --- /dev/null +++ b/apps/web/src/components/page-editor/PageOutline.tsx @@ -0,0 +1,83 @@ +import { useEditorState, type Editor } from '@tiptap/react'; + +interface Props { + editor: Editor | null; +} + +interface Heading { + level: number; + text: string; + pos: number; +} + +function readHeadings(editor: Editor | null): Heading[] { + if (!editor) return []; + const out: Heading[] = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + out.push({ level: node.attrs.level as number, text: node.textContent, pos }); + } + return true; + }); + return out; +} + +function sameHeadings(a: Heading[], b: Heading[] | null): boolean { + if (!b || a.length !== b.length) return false; + return a.every((h, i) => h.level === b[i].level && h.text === b[i].text && h.pos === b[i].pos); +} + +/** + * Table of contents derived from the editor's headings. Clicking an entry moves + * the caret to that heading and scrolls it into view. + */ +export function PageOutline({ editor }: Props) { + const headings = + useEditorState({ + editor, + selector: (snapshot) => readHeadings(snapshot.editor), + equalityFn: sameHeadings, + }) ?? []; + + if (headings.length === 0) { + return ( +

+ Headings you add to the page show up here. +

+ ); + } + + const minLevel = Math.min(...headings.map((h) => h.level)); + + const goTo = (pos: number) => { + editor + ?.chain() + .focus() + .setTextSelection(pos + 1) + .scrollIntoView() + .run(); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/page-editor/index.ts b/apps/web/src/components/page-editor/index.ts index 45893b52..ee0d633c 100644 --- a/apps/web/src/components/page-editor/index.ts +++ b/apps/web/src/components/page-editor/index.ts @@ -1,4 +1,5 @@ export { usePageEditor, type UsePageEditorOptions } from './usePageEditor'; export { PageEditorToolbar } from './PageEditorToolbar'; export { PageEditorContent } from './PageEditorContent'; +export { PageOutline } from './PageOutline'; export { EmojiLogoPicker, type PageLogo } from './EmojiLogoPicker'; diff --git a/apps/web/src/pages/PageDetailPage.tsx b/apps/web/src/pages/PageDetailPage.tsx index 7eed05b3..697d6096 100644 --- a/apps/web/src/pages/PageDetailPage.tsx +++ b/apps/web/src/pages/PageDetailPage.tsx @@ -8,6 +8,7 @@ import { FileText, History, Link2, + ListTree, Lock, MoreHorizontal, PanelRight, @@ -22,6 +23,7 @@ import { EmojiLogoPicker, PageEditorContent, PageEditorToolbar, + PageOutline, usePageEditor, type PageLogo, } from '../components/page-editor'; @@ -67,7 +69,7 @@ function pageLogoFrom(page: PageApiResponse | null): PageLogo | undefined { return props; } -type SidePanel = 'closed' | 'subpages' | 'versions'; +type SidePanel = 'closed' | 'outline' | 'subpages' | 'versions'; export function PageDetailPage() { const { workspaceSlug, projectId, pageId } = useParams<{ @@ -657,7 +659,7 @@ export function PageDetailPage() { - {sidePanel === 'subpages' ? ( + {sidePanel === 'outline' ? ( +
+ +
+ ) : sidePanel === 'subpages' ? (