File size: 11,623 Bytes
cb77cbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
// Table of contents ("line" variant): a hairline rail that fills as you read,
// an indicator on the active item, read sections dimmed, reading time left.
// Sub-headings are expanded unless the nav has data-toc-expanded="false"
// (frontmatter `tocExpanded: false`), then only the active section expands.
// Below the collapse breakpoint the nav is hidden and a slim top bar opens it
// as a drawer.
import {
  articleHeadings,
  countWords,
  getArticleRoot,
  headingLabel,
  onLayoutChange,
  prefersReducedMotion,
} from "./headings";

type Head = { el: HTMLElement; id: string; level: 2 | 3; label: string; top: number };
type Section = Head & { subs: Head[]; start: number; end: number };

const WORDS_PER_MINUTE = 230;

const esc = (s: string) =>
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const docTop = (el: Element) => el.getBoundingClientRect().top + window.scrollY;

export function initToc() {
  const nav = document.getElementById("toc");
  const main = getArticleRoot();
  if (!nav || !main) return;
  const root = document.documentElement;
  const grid = nav.closest<HTMLElement>(".content-grid");
  const listHost = nav.querySelector<HTMLElement>("[data-toc-list]");
  const topbar = document.querySelector<HTMLElement>("[data-toc-topbar]");
  const openBtn = topbar?.querySelector<HTMLButtonElement>("[data-toc-open]") ?? null;
  const backdrop = document.querySelector<HTMLElement>("[data-toc-backdrop]");

  // Outline: h2 sections with their h3 children (h3 become sections if there is no h2)
  const heads: Head[] = articleHeadings(main, "h2, h3").map((el) => ({
    el,
    id: el.id,
    level: el.tagName === "H2" ? 2 : 3,
    label: headingLabel(el),
    top: 0,
  }));
  const topLevel = heads.some((h) => h.level === 2) ? 2 : 3;
  const secs: Section[] = [];
  heads.forEach((h) => {
    if (h.level === topLevel) secs.push(Object.assign(h, { subs: [], start: 0, end: 0 }));
    else if (secs.length) secs[secs.length - 1].subs.push(h);
  });

  const totalMins = Math.max(1, Math.round(countWords(main) / WORDS_PER_MINUTE));
  document.querySelectorAll("[data-read-time]").forEach((el) => {
    el.textContent = `${totalMins} min read`;
  });

  // --vw feeds the rail and margin widths in _grid.css (viewport without scrollbar)
  const setVw = () => root.style.setProperty("--vw", `${root.clientWidth}px`);

  if (!secs.length || !listHost) {
    nav.hidden = true;
    if (topbar) topbar.hidden = true;
    onLayoutChange(setVw);
    return;
  }

  listHost.innerHTML = secs
    .map(
      (s, i) =>
        `<li class="toc-sec" data-sec="${i}"><a href="#${esc(s.id)}">${esc(s.label)}</a>${
          s.subs.length
            ? `<div class="toc-sub"><ol>${s.subs
                .map((h) => `<li><a href="#${esc(h.id)}">${esc(h.label)}</a></li>`)
                .join("")}</ol></div>`
            : ""
        }</li>`,
    )
    .join("");

  const list = nav.querySelector<HTMLElement>(".toc-list");
  const scroller = nav.querySelector<HTMLElement>("[data-toc-scroll]");
  const indicator = nav.querySelector<HTMLElement>(".toc-indicator");
  const fill = nav.querySelector<HTMLElement>(".toc-fill");
  const metaEls = document.querySelectorAll<HTMLElement>("[data-toc-meta]");
  const secEl = topbar?.querySelector<HTMLElement>("[data-toc-sec]");
  const subEl = topbar?.querySelector<HTMLElement>("[data-toc-sub]");
  const progressEl = topbar?.querySelector<HTMLElement>("[data-toc-progress]");
  const fullBleeds = [...main.querySelectorAll<HTMLElement>(":scope > .full-width")];

  const state = {
    active: null as Head | null,
    si: -1,
    lock: null as { id: string; until: number } | null,
    collapsed: false,
    start: 0,
    end: 0,
  };

  function measure() {
    const mainBottom = docTop(main!) + main!.offsetHeight;
    heads.forEach((h) => (h.top = docTop(h.el)));
    secs.forEach((s, i) => {
      s.start = s.top;
      s.end = secs[i + 1] ? secs[i + 1].top : mainBottom;
    });
    state.start = docTop(main!);
    state.end = mainBottom;
  }

  // Reading line at 30% of the viewport, sliding to the bottom over the last
  // screen so that short final sections still become active.
  function readPos() {
    const vh = window.innerHeight;
    const left = root.scrollHeight - vh - window.scrollY;
    let line = vh * 0.3;
    if (left < vh * 0.7) line += (vh * 0.7 - 16) * (1 - Math.max(0, left) / (vh * 0.7));
    return window.scrollY + line;
  }

  function positionLine(f: number[]) {
    const a = nav!.querySelector<HTMLElement>("a[aria-current]");
    const li = list?.querySelector<HTMLElement>(`.toc-sec[data-sec="${state.si}"]`);
    if (!list || !a || !li || !indicator || !fill) return;
    const lr = list.getBoundingClientRect();
    const ar = a.getBoundingClientRect();
    const r = li.getBoundingClientRect();
    indicator.style.transform = `translateY(${(ar.top - lr.top).toFixed(1)}px)`;
    indicator.style.height = `${ar.height.toFixed(1)}px`;
    fill.style.height = `${(r.top - lr.top + f[state.si] * r.height).toFixed(1)}px`;
    if (!nav!.classList.contains("is-ready")) requestAnimationFrame(() => nav!.classList.add("is-ready"));
  }

  // Keep the active item visible when the TOC is taller than the viewport
  function keepInView() {
    const a = nav!.querySelector<HTMLElement>("a[aria-current]");
    if (!scroller || !a || scroller.scrollHeight <= scroller.clientHeight + 1) return;
    const sr = scroller.getBoundingClientRect();
    const ar = a.getBoundingClientRect();
    if (ar.top < sr.top + 24 || ar.bottom > sr.bottom - 24) {
      scroller.scrollTo({
        top: scroller.scrollTop + (ar.top - sr.top) - sr.height / 2 + ar.height / 2,
        behavior: prefersReducedMotion() ? "auto" : "smooth",
      });
    }
  }

  let lineAnim = 0;
  function update(force = false) {
    const pos = readPos();
    let active = heads[0];
    for (const h of heads) if (h.top <= pos + 1) active = h;
    if (state.lock) {
      if (performance.now() < state.lock.until)
        active = heads.find((h) => h.id === state.lock!.id) || active;
      else state.lock = null;
    }
    let si = 0;
    secs.forEach((s, i) => {
      if (s.top <= active.top) si = i;
    });
    const f = secs.map((s) => clamp01((pos - s.start) / (s.end - s.start)));
    const overall = clamp01((pos - state.start) / (state.end - state.start));
    const changed = force || active !== state.active;
    state.active = active;
    state.si = si;

    if (changed) {
      nav!.querySelectorAll("a[aria-current]").forEach((a) => a.removeAttribute("aria-current"));
      nav!.querySelectorAll(`a[href="#${CSS.escape(active.id)}"]`).forEach((a) => a.setAttribute("aria-current", "true"));
    }
    const minsLeft = Math.ceil(totalMins * (1 - overall));
    const meta =
      overall >= 0.995 ? "Finished" : pos <= secs[0].start + 2 ? `${totalMins} min read` : `${minsLeft} min left`;
    metaEls.forEach((m) => (m.textContent = meta));

    nav!.querySelectorAll<HTMLElement>(".toc-sec").forEach((li, i) => {
      li.classList.toggle("is-active", i === si);
      li.classList.toggle("is-open", i === si);
      li.classList.toggle("is-read", i < si);
    });
    positionLine(f);
    if (changed && !prefersReducedMotion()) {
      // Follow the sub-heading expand / collapse transition
      cancelAnimationFrame(lineAnim);
      const t0 = performance.now();
      const step = () => {
        positionLine(f);
        if (performance.now() - t0 < 280) lineAnim = requestAnimationFrame(step);
      };
      lineAnim = requestAnimationFrame(step);
    }

    // Slim top bar, only when the TOC column is hidden and the article has started
    if (secEl) secEl.textContent = secs[si].label;
    if (subEl) subEl.textContent = active.level !== secs[si].level ? active.label : "";
    if (progressEl) progressEl.style.transform = `scaleX(${overall.toFixed(4)})`;
    const started = (grid?.getBoundingClientRect().top ?? 0) < 0;
    root.classList.toggle("toc-topbar-visible", state.collapsed && started);

    // Stuck: the sticky TOC reached its top offset ("x min read" shows up)
    const stickyTop = parseFloat(getComputedStyle(nav!).top) || 0;
    nav!.classList.toggle("is-stuck", !state.collapsed && nav!.getBoundingClientRect().top <= stickyTop + 0.5);

    // Fade the TOC while a full-bleed block passes over its column
    if (!root.classList.contains("toc-open") && !state.collapsed && fullBleeds.length) {
      const nr = nav!.getBoundingClientRect();
      const occluded = fullBleeds.some((el) => {
        const r = el.getBoundingClientRect();
        return r.top < nr.bottom + 8 && r.bottom > nr.top - 8;
      });
      nav!.classList.toggle("is-occluded", occluded && !nav!.matches(":focus-within"));
    } else nav!.classList.remove("is-occluded");

    if (changed) keepInView();
  }

  function refresh() {
    setVw();
    if (!root.classList.contains("toc-open")) {
      state.collapsed = getComputedStyle(nav!).display === "none";
    }
    measure();
    update(true);
  }

  function openDrawer() {
    root.classList.add("toc-open");
    openBtn?.setAttribute("aria-expanded", "true");
    requestAnimationFrame(() => {
      update(true);
      (nav!.querySelector<HTMLElement>("a[aria-current]") || nav!.querySelector<HTMLElement>("a"))?.focus({
        preventScroll: true,
      });
    });
  }
  function closeDrawer() {
    if (!root.classList.contains("toc-open")) return;
    root.classList.remove("toc-open");
    openBtn?.setAttribute("aria-expanded", "false");
  }
  openBtn?.addEventListener("click", openDrawer);
  backdrop?.addEventListener("click", closeDrawer);
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape") closeDrawer();
  });

  // Links keep native fragment navigation; the indicator stays on the target
  // while the smooth scroll runs, then focus moves to the heading.
  nav.addEventListener("click", (e) => {
    const a = (e.target as Element | null)?.closest?.('a[href^="#"]');
    if (!a) return;
    const id = decodeURIComponent(a.getAttribute("href")!.slice(1));
    const t = document.getElementById(id);
    if (!t) return;
    state.lock = { id, until: performance.now() + 1500 };
    if (!t.hasAttribute("tabindex")) t.setAttribute("tabindex", "-1");
    closeDrawer();
    if (prefersReducedMotion()) {
      e.preventDefault();
      t.scrollIntoView();
      history.replaceState(null, "", `#${id}`);
    }
    requestAnimationFrame(() => t.focus({ preventScroll: true }));
    update(true);
  });
  nav.addEventListener("keydown", (e) => {
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
    const links = [...nav.querySelectorAll<HTMLElement>('a[href^="#"]')].filter(
      (a) => a.offsetParent !== null && getComputedStyle(a).visibility !== "hidden",
    );
    const i = links.indexOf(document.activeElement as HTMLElement);
    let j = e.key === "Home" ? 0 : e.key === "End" ? links.length - 1 : i + (e.key === "ArrowDown" ? 1 : -1);
    j = Math.max(0, Math.min(links.length - 1, j));
    links[j]?.focus();
    e.preventDefault();
    e.stopPropagation();
  });

  ["wheel", "touchstart"].forEach((ev) =>
    window.addEventListener(ev, () => (state.lock = null), { passive: true }),
  );
  window.addEventListener("scrollend", () => {
    state.lock = null;
    update();
  });
  let scrollRaf = 0;
  window.addEventListener(
    "scroll",
    () => {
      cancelAnimationFrame(scrollRaf);
      scrollRaf = requestAnimationFrame(() => update());
    },
    { passive: true },
  );

  onLayoutChange(refresh);
}