Skip to content
File

Blob: src/shared/editor/model/outline.ts

typescript108 lines
1export type OutlineLevel = 1 | 2 | 3 | 4 | 5 | 6;
2 
3export interface OutlineItem {
4 id: string;
5 text: string;
6 level: OutlineLevel;
7 href?: string;
8}
9 
10export interface OutlineViewportHeading<TId extends string | number> {
11 id: TId;
12 top: number;
13 bottom: number;
14 hidden?: boolean;
15}
16 
17export interface OutlineViewportBounds {
18 top: number;
19 height: number;
20}
21 
22const OUTLINE_ACTIVATION_ZONE_FRACTION = 0.4;
23const OUTLINE_ACTIVATION_ZONE_MAX_PX = 480;
24const OUTLINE_MIN_VISIBLE_BELOW_PX = 16;
25 
26export function normalizeOutlineText(value: unknown): string {
27 return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
28}
29 
30export function readOutlineLevel(value: unknown): OutlineLevel {
31 const level = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
32 switch (level) {
33 case 1:
34 case 2:
35 case 3:
36 case 4:
37 case 5:
38 case 6:
39 return level;
40 default:
41 return 1;
42 }
43}
44 
45export function createUniqueOutlineAnchorId(text: string, used: Set<string>): string {
46 const base = slugifyOutlineText(normalizeOutlineText(text)) || "section";
47 let id = base;
48 let suffix = 2;
49 
50 while (used.has(id)) {
51 id = `${base}-${suffix}`;
52 suffix += 1;
53 }
54 
55 used.add(id);
56 return id;
57}
58 
59function slugifyOutlineText(text: string): string {
60 return text
61 .toLowerCase()
62 .replace(/[^a-z0-9]+/g, "-")
63 .replace(/^-|-$/g, "");
64}
65 
66export function resolveViewportActiveOutlineHeading<TId extends string | number>(
67 headings: readonly OutlineViewportHeading<TId>[],
68 viewport: OutlineViewportBounds,
69): TId | null {
70 const viewportTop = viewport.top;
71 const viewportBottom = viewportTop + viewport.height;
72 const activationBottom =
73 viewportTop + Math.min(viewport.height * OUTLINE_ACTIVATION_ZONE_FRACTION, OUTLINE_ACTIVATION_ZONE_MAX_PX);
74 const visibleBelowCutoff = viewportBottom - OUTLINE_MIN_VISIBLE_BELOW_PX;
75 
76 let intersectingTop: TId | null = null;
77 let firstInActivation: TId | null = null;
78 let firstVisibleBelow: TId | null = null;
79 let lastAboveTop: TId | null = null;
80 
81 // Priority chain:
82 // 1. A heading still crossing the top edge (we're reading just below it).
83 // 2. First heading inside the activation zone (top 40%, <=480px).
84 // 3. First heading sufficiently visible below the activation zone, which
85 // handles the case where the previous section is fully behind us and the
86 // next heading is the only anchor on screen.
87 // 4. Last heading that scrolled past the top (long-section fallback).
88 for (const heading of headings) {
89 if (heading.hidden) continue;
90 
91 if (heading.bottom <= viewportTop) {
92 lastAboveTop = heading.id;
93 } else if (heading.top < viewportTop) {
94 intersectingTop = heading.id;
95 lastAboveTop = heading.id;
96 } else if (heading.top <= activationBottom) {
97 if (firstInActivation === null) firstInActivation = heading.id;
98 } else if (heading.top <= visibleBelowCutoff) {
99 firstVisibleBelow = heading.id;
100 break;
101 } else {
102 break;
103 }
104 }
105 
106 return intersectingTop ?? firstInActivation ?? firstVisibleBelow ?? lastAboveTop;
107}