Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/web/src/components/page-editor/PageOutline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="px-2 py-3 text-xs text-(--txt-tertiary)">
Headings you add to the page show up here.
</p>
);
}

const minLevel = Math.min(...headings.map((h) => h.level));

const goTo = (pos: number) => {
editor
?.chain()
.focus()
.setTextSelection(pos + 1)
.scrollIntoView()
.run();
};

return (
<nav aria-label="Page outline" className="p-1">
<ul className="space-y-0.5">
{headings.map((h, i) => {
const label = h.text.trim() || 'Untitled heading';
return (
<li key={`${h.pos}-${i}`}>
<button
type="button"
onClick={() => goTo(h.pos)}
style={{ paddingLeft: `${(h.level - minLevel) * 12 + 8}px` }}
className="block w-full truncate rounded py-1 pr-2 text-left text-xs text-(--txt-secondary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-primary)"
title={label}
>
{label}
</button>
</li>
);
})}
</ul>
</nav>
);
}
1 change: 1 addition & 0 deletions apps/web/src/components/page-editor/index.ts
Original file line number Diff line number Diff line change
@@ -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';
28 changes: 23 additions & 5 deletions apps/web/src/pages/PageDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
FileText,
History,
Link2,
ListTree,
Lock,
MoreHorizontal,
PanelRight,
Expand All @@ -22,6 +23,7 @@ import {
EmojiLogoPicker,
PageEditorContent,
PageEditorToolbar,
PageOutline,
usePageEditor,
type PageLogo,
} from '../components/page-editor';
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -657,7 +659,7 @@ export function PageDetailPage() {
<Tooltip content={sidebarOpen ? 'Hide side panel' : 'Show side panel'}>
<button
type="button"
onClick={() => switchSidePanelTo(sidebarOpen ? 'closed' : 'subpages')}
onClick={() => switchSidePanelTo(sidebarOpen ? 'closed' : 'outline')}
aria-label={sidebarOpen ? 'Hide side panel' : 'Show side panel'}
className="grid size-7 place-items-center rounded text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-primary)"
>
Expand Down Expand Up @@ -727,11 +729,23 @@ export function PageDetailPage() {
{sidebarOpen ? (
<aside className="hidden w-72 shrink-0 flex-col border-l border-(--border-subtle) bg-(--bg-surface-1) lg:flex">
<div className="flex shrink-0 items-center gap-1 border-b border-(--border-subtle) px-2 py-1.5">
<button
type="button"
onClick={() => switchSidePanelTo('outline')}
className={cn(
Comment thread
martian56 marked this conversation as resolved.
'flex-1 rounded px-2 py-1.5 text-center text-xs font-medium tracking-wide uppercase transition-colors',
sidePanel === 'outline'
? 'bg-(--bg-layer-1) text-(--txt-primary)'
: 'text-(--txt-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-primary)',
)}
>
<ListTree size={11} className="mr-1 inline-block" /> Outline
</button>
<button
type="button"
onClick={() => switchSidePanelTo('subpages')}
className={cn(
Comment thread
martian56 marked this conversation as resolved.
'flex-1 rounded px-2 py-1.5 text-left text-xs font-medium tracking-wide uppercase transition-colors',
'flex-1 rounded px-2 py-1.5 text-center text-xs font-medium tracking-wide uppercase transition-colors',
sidePanel === 'subpages'
? 'bg-(--bg-layer-1) text-(--txt-primary)'
: 'text-(--txt-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-primary)',
Expand All @@ -743,7 +757,7 @@ export function PageDetailPage() {
type="button"
onClick={() => switchSidePanelTo('versions')}
className={cn(
'flex-1 rounded px-2 py-1.5 text-left text-xs font-medium tracking-wide uppercase transition-colors',
'flex-1 rounded px-2 py-1.5 text-center text-xs font-medium tracking-wide uppercase transition-colors',
sidePanel === 'versions'
? 'bg-(--bg-layer-1) text-(--txt-primary)'
: 'text-(--txt-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-primary)',
Expand All @@ -753,7 +767,11 @@ export function PageDetailPage() {
</button>
</div>

{sidePanel === 'subpages' ? (
{sidePanel === 'outline' ? (
<div className="min-h-0 flex-1 overflow-y-auto p-1">
<PageOutline editor={editor} />
</div>
) : sidePanel === 'subpages' ? (
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<button
type="button"
Expand Down
Loading