Skip to content
File

Blob: src/shared/editor/presentation/outline.tsx

typescript85 lines
1import { ChevronRight, FileText } from "lucide-react";
2import type { MouseEvent } from "react";
3import type { OutlineItem } from "@/shared/editor/model/outline";
4 
5export type OutlinePresentationMode = "button" | "link";
6export type OutlinePresentationVariant = "card" | "rail";
7 
8export interface OutlinePresentationProps {
9 items: OutlineItem[];
10 activeId?: string | null;
11 title?: string;
12 mode: OutlinePresentationMode;
13 variant?: OutlinePresentationVariant;
14 onItemMouseDown?: (item: OutlineItem, event: MouseEvent<HTMLButtonElement>) => void;
15}
16 
17export function OutlinePresentation({
18 items,
19 activeId,
20 title = "On this page",
21 mode,
22 variant = "card",
23 onItemMouseDown,
24}: OutlinePresentationProps) {
25 if (items.length === 0) return null;
26 
27 const className = ["tiptap-outline", variant === "rail" ? "tiptap-outline--rail" : ""].filter(Boolean).join(" ");
28 
29 return (
30 <nav className={className} aria-label={title} data-outline="">
31 <div className="tiptap-outline__header">
32 <FileText className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
33 <span>{title}</span>
34 </div>
35 <ul className="tiptap-outline__list">
36 {items.map((item) => {
37 const active = item.id === activeId;
38 return (
39 <li key={item.id} className="tiptap-outline__item">
40 {mode === "link" ? (
41 <a
42 className="tiptap-outline__button tiptap-outline__link"
43 data-outline-id={item.id}
44 data-active={active ? "true" : "false"}
45 aria-current={active ? "location" : undefined}
46 title={item.text}
47 href={item.href ?? `#${item.id}`}
48 style={outlineItemStyle(item.level)}
49 >
50 <ChevronRight className="tiptap-outline__chevron h-3 w-3 shrink-0" />
51 <span className="tiptap-outline__text">{item.text}</span>
52 </a>
53 ) : (
54 <button
55 type="button"
56 className="tiptap-outline__button"
57 data-outline-id={item.id}
58 data-active={active ? "true" : "false"}
59 aria-current={active ? "location" : undefined}
60 title={item.text}
61 onMouseDown={(event) => onItemMouseDown?.(item, event)}
62 style={outlineItemStyle(item.level)}
63 >
64 <ChevronRight className="tiptap-outline__chevron h-3 w-3 shrink-0" />
65 <span className="tiptap-outline__text">{item.text}</span>
66 </button>
67 )}
68 </li>
69 );
70 })}
71 </ul>
72 </nav>
73 );
74}
75 
76function outlineItemStyle(level: number) {
77 return {
78 // Keep the CSS padding (0.5rem) as the base so the chevron always has
79 // breathing room from the highlight's left edge, even for top-level headings.
80 paddingInlineStart: `calc(0.5rem + ${Math.max(0, level - 1) * 0.875}rem)`,
81 };
82}
83 
84export type { OutlineItem } from "@/shared/editor/model/outline";