From d7c6a239c15513058d0df1eba65e8e6bdc76087b Mon Sep 17 00:00:00 2001 From: pat Date: Tue, 1 Sep 2026 03:44:04 +0000 Subject: [PATCH] Add sticky chapter outline for the reading demo --- .../components/reader/ChapterOutline.jsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 marketing-site/components/reader/ChapterOutline.jsx diff --git a/marketing-site/components/reader/ChapterOutline.jsx b/marketing-site/components/reader/ChapterOutline.jsx new file mode 100644 index 0000000..d7a8d08 --- /dev/null +++ b/marketing-site/components/reader/ChapterOutline.jsx @@ -0,0 +1,99 @@ +import { useEffect, useMemo, useState } from "react"; + +function OutlineBody({ groups, complete, count, total, active, showTitle }) { + return ( + <> +

+ {count}/{total} Try-its complete +

+ {showTitle ?

In this chapter

: null} + {groups.map((g) => ( +
+

{g.label}

+ +
+ ))} + + ); +} + +export default function ChapterOutline({ + groups, + complete = {}, + count = 0, + total = 0, + variant = "desktop", +}) { + const ids = useMemo( + () => groups.flatMap((g) => g.items.map((item) => item.id)), + [groups] + ); + const [active, setActive] = useState(ids[0] || ""); + + useEffect(() => { + const els = ids.map((id) => document.getElementById(id)).filter(Boolean); + if (!els.length || typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + if (visible[0]?.target?.id) setActive(visible[0].target.id); + }, + { rootMargin: "-88px 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 1] } + ); + els.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, [ids]); + + if (variant === "mobile") { + return ( +
+ In this chapter + +
+ ); + } + + return ( + + ); +}