"use client"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { CitationsProvider } from "../../citations/CitationsContext"; import { buildReportSlides, READER_VIEWS, reportPageAnchorIds, slideIndexForPlace, type ReaderView, type SlideView, } from "../../../lib/report/slides"; import type { ReportPageView } from "../../../lib/report/views"; import { ReportOverview } from "./ReportOverview"; import { ReportSlides } from "./ReportSlides"; import { defaultSlidesHost, SlidesHostProvider, type SlidesHost } from "./SlidesHost"; import { readerUrl, useReaderPlace } from "./useReaderPlace"; import { ViewSwitch } from "./ViewSwitch"; // ONE ARTICLE, THREE SHAPES — the export site's reader of a report page. // // The article is the page as it was built (static HTML: what search engines // and a reader with no script get). This island is the view switch (the // host keeps it in reach — the export site sticks it under its header) and, // when the URL asks for another view (`?rv=slides`, `?rv=iso` — // ./useReaderPlace.ts), lays that view over the page, built in the browser // from the report's own page.json (fetched once, on demand — the article's // HTML never carries the slides twice). // // THE PLACE IS KEPT. From the article, the slide is the one for the part of // the article being read (the last anchor at the top of the reading area); // from a slide or the overview, the article opens at its anchor, scrolled to // and focused. The view and the place are the URL's — `#s-` in the slides, an // anchor id elsewhere — so a shared link opens the same view at the same // place, and Back undoes a switch. // // While a view is laid over the article, the article is inert and does not // scroll; a citation's card (InlineCite, portalled to ) opens above. // The article's places, in document order: what each slide stands for. const PLACE_SELECTOR = "#report-head, #in-brief, #found, [data-section], article[data-claim], #report-end"; // The anchor of the part of the article being read: the last place whose top // has reached the top of the reading area — just under the page's sticky bars // (the site's header and this switch), where a jump to a place lands it (its // scroll-margin-top) — give or take a line; the first place, at the top. So a // jump to a section and a switch opens that section's slide, however short // the section's lead before its first claim. Near the end, where the last // places cannot scroll up that far, the place the URL names (a jump, Esc // from a slide) wins while it is still on screen below that line. const READING_SLACK = 32; function anchorInView(): string | null { const places = Array.from(document.querySelectorAll(PLACE_SELECTOR)); const landed = places[0] ? parseFloat(getComputedStyle(places[0]).scrollMarginTop) || 0 : 0; const line = landed + READING_SLACK; let best = -1; for (const [i, el] of places.entries()) { if (!el.id) continue; if (best < 0 || el.getBoundingClientRect().top <= line) best = i; else break; } const named = places.findIndex((el) => el.id !== "" && `#${el.id}` === decodeURIComponent(window.location.hash)); if (named > best && places[named].getBoundingClientRect().top < window.innerHeight) best = named; return best < 0 ? null : places[best].id; } // Scroll the article to an anchor and put the focus there. function landOn(anchorId: string) { const el = document.getElementById(anchorId); if (!el) return; el.scrollIntoView({ block: "start" }); if (!el.matches("a, button, input, select, textarea, [tabindex]")) el.setAttribute("tabindex", "-1"); el.focus({ preventScroll: true }); } type Loaded = { view: ReportPageView; slides: SlideView[] } | { error: string }; export function ReportReader({ pagePath, title, series, views = READER_VIEWS, }: { // The report's page view (/reports//page.json). pagePath: string; title: string; series?: string; views?: readonly ReaderView[]; }) { const { place, go } = useReaderPlace(views); const open = place.view !== "article"; const [loaded, setLoaded] = useState(null); const overlayRef = useRef(null); const stageRef = useRef(null); const landing = useRef(null); // The report's view, once, when a view needs it. useEffect(() => { if (!open || loaded) return; let live = true; fetch(pagePath) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))) .then((view: ReportPageView) => { if (live) setLoaded({ view, slides: buildReportSlides(view) }); }) .catch(() => { if (live) setLoaded({ error: "The slides could not be loaded." }); }); return () => { live = false; }; }, [open, loaded, pagePath]); const data = loaded && "view" in loaded ? loaded : null; const order = useMemo(() => (data ? reportPageAnchorIds(data.view) : []), [data]); const slideIndex = data && place.view === "slides" ? slideIndexForPlace(data.slides, place, order) : 0; // A slides URL that names a place, not a slide (a switch from the article, // a shared link to a section): the slide for that place, in the URL. useEffect(() => { if (data && place.view === "slides" && place.slide === null && data.slides.length > 0) { go({ view: "slides", slide: slideIndex + 1 }, false); } }, [data, place, slideIndex, go]); // While a view is open: the article is inert and still. useLayoutEffect(() => { if (!open) return; const overlay = overlayRef.current; const made: Element[] = []; for (const el of Array.from(document.body.children)) { if (el === overlay || el.hasAttribute("inert") || el.tagName === "SCRIPT") continue; el.setAttribute("inert", ""); made.push(el); } const root = document.documentElement; const overflow = root.style.overflow; root.style.overflow = "hidden"; return () => { for (const el of made) el.removeAttribute("inert"); root.style.overflow = overflow; }; }, [open]); // The focus follows the view: into it when it opens (and when its slides // arrive, and on a switch between views); back to the article's place // when it closes. useEffect(() => { if (open) (stageRef.current ?? overlayRef.current)?.focus({ preventScroll: true }); }, [open, data, place.view]); useEffect(() => { if (open || !landing.current) return; const id = landing.current; landing.current = null; requestAnimationFrame(() => landOn(id)); }, [open]); const toArticle = useCallback( (anchorId: string | null) => { landing.current = anchorId; go({ view: "article", anchor: anchorId }, true); }, [go], ); const switchTo = useCallback( (next: ReaderView) => { if (next === place.view) return; // The place now: the article's part in view, a slide's anchor, the overview's. const anchor = place.view === "article" ? anchorInView() : place.view === "slides" && data ? (data.slides[slideIndex]?.anchorId ?? null) : place.anchor; if (next === "article") toArticle(anchor); else go({ view: next, anchor }, true); }, [place, data, slideIndex, toArticle, go], ); const host = useMemo( () => ({ ...defaultSlidesHost, readHref: (s) => readerUrl({ view: "article", anchor: s.anchorId }), onRead: (s) => toArticle(s.anchorId), }), [toArticle], ); const pageUrl = typeof window === "undefined" ? undefined : `${window.location.origin}${window.location.pathname}`; const overlay = open ? (

{series && {series}: } {title}

{!loaded ? (

Loading…

) : "error" in loaded ? (

{loaded.error}{" "}

) : ( {place.view === "slides" ? ( go({ view: "slides", slide: i + 1 }, false)} onExit={(s) => toArticle(s.anchorId)} pageUrl={pageUrl} /> ) : ( go({ view: "iso", anchor: a }, false)} onOpenArticle={(s) => toArticle(s.anchorId)} onOpenSlide={(s) => go({ view: "slides", slide: s.n }, true)} onExit={(s) => toArticle(s?.anchorId ?? place.anchor)} /> )} )}
) : null; return ( <> {overlay && createPortal(overlay, document.body)} ); }