"use client";
import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { SlideView } from "../../../lib/report/slides";
import type { ReportPageView } from "../../../lib/report/views";
import { Slide, type SlideFit } from "./Slide";
// THE SLIDES VIEW — one slide at a time on a 16:9 stage, letterboxed in
// whatever box it is given (the export site's overlay fills the viewport;
// umtool's sits in the reading column). The canvas (1280 × 720) is scaled as
// one piece to fit; a narrow portrait box reflows the slide instead, its
// evidence under its words.
//
// Keys (not while typing): ← → and PgUp PgDn step, Home End jump, Esc leaves
// (`onExit`: the caller goes back to the article at this slide's anchor).
// Touch: a swipe steps. The rail under the stage is one tick per slide.
//
// THE LIVE LABEL ("Slide 4 of 9:
") is what a screen reader hears on
// every step; the stage is a region named for the report. Which slide is
// shown is the caller's (`index`, from the URL's `#s-`); the stage asks
// for a change through `onIndex`.
const CANVAS_W = 1280;
const CANVAS_H = 720;
const SWIPE_PX = 48;
function kindLabel(s: SlideView): string {
switch (s.kind) {
case "title":
return "Title";
case "summary":
return "In brief";
case "entry":
return "Timeline";
case "found":
return "Findings";
case "section":
return "Section";
case "claim":
return "Claim";
case "sources":
return "Sources";
}
}
export function slideLabel(s: SlideView, total: number): string {
return `Slide ${s.n} of ${total}: ${kindLabel(s)} — ${s.title}`;
}
function isTyping(t: EventTarget | null): boolean {
const el = t as HTMLElement | null;
return !!el?.closest?.("input, textarea, select, [contenteditable=true]");
}
export const ReportSlides = forwardRef<
HTMLElement,
{
slides: readonly SlideView[];
view: ReportPageView;
// 0-based.
index: number;
onIndex: (index: number) => void;
onExit?: (slide: SlideView) => void;
pageUrl?: string;
// Keys are listened for on the window (the overlay owns the page); a host
// that shares the page with other keys (umtool) scopes them to the stage.
keyScope?: "window" | "stage";
}
>(function ReportSlides({ slides, view, index, onIndex, onExit, pageUrl, keyScope = "window" }, ref) {
const total = slides.length;
const at = Math.min(Math.max(index, 0), Math.max(total - 1, 0));
const slide = slides[at];
const viewportRef = useRef(null);
const stageRef = useRef(null);
const [box, setBox] = useState<{ w: number; h: number } | null>(null);
const prevAt = useRef(at);
const dir = at > prevAt.current ? "next" : at < prevAt.current ? "prev" : undefined;
useEffect(() => {
prevAt.current = at;
}, [at]);
// The stage's size: the scale, and whether to reflow.
useLayoutEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => {
const cs = getComputedStyle(el);
const w = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const h = el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
setBox((b) => (b && b.w === w && b.h === h ? b : { w, h }));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const fit: SlideFit = box && box.w < 640 && box.h > box.w ? "reflow" : "canvas";
const scale = box ? Math.max(0.1, Math.min(box.w / CANVAS_W, box.h / CANVAS_H)) : 0;
const go = useCallback((i: number) => onIndex(Math.min(Math.max(i, 0), total - 1)), [onIndex, total]);
const onKey = useCallback(
(e: KeyboardEvent | React.KeyboardEvent) => {
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return;
switch (e.key) {
case "ArrowRight":
case "PageDown":
e.preventDefault();
go(at + 1);
break;
case "ArrowLeft":
case "PageUp":
e.preventDefault();
go(at - 1);
break;
case "Home":
e.preventDefault();
go(0);
break;
case "End":
e.preventDefault();
go(total - 1);
break;
case "Escape":
// A citation's open preview takes Escape first (InlineCite).
if (onExit && slide) {
e.preventDefault();
onExit(slide);
}
break;
}
},
[at, go, onExit, slide, total],
);
useEffect(() => {
if (keyScope !== "window") return;
const h = (e: KeyboardEvent) => onKey(e);
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [keyScope, onKey]);
// A swipe steps.
const swipe = useRef<{ x: number; y: number; id: number } | null>(null);
if (!slide) return null;
const label = slideLabel(slide, total);
return (
{
stageRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
}}
className="rs-root rs-stage"
aria-roledescription="slides"
aria-label={`Slides: ${view.title}`}
tabIndex={-1}
data-report-slides={at + 1}
onKeyDown={keyScope === "stage" ? onKey : undefined}
>
{
if (e.pointerType === "mouse") return;
swipe.current = { x: e.clientX, y: e.clientY, id: e.pointerId };
}}
onPointerUp={(e) => {
const s = swipe.current;
swipe.current = null;
if (!s || s.id !== e.pointerId) return;
const dx = e.clientX - s.x;
const dy = e.clientY - s.y;
if (Math.abs(dx) > SWIPE_PX && Math.abs(dx) > Math.abs(dy) * 1.5) go(dx < 0 ? at + 1 : at - 1);
}}
onPointerCancel={() => {
swipe.current = null;
}}
onClickCapture={(e) => {
// A citation's number opens its card here (focus does): its
// reference list is the article's, behind the slides.
const a = (e.target as HTMLElement).closest?.("a[data-cite-number]") as HTMLAnchorElement | null;
if (a) {
e.preventDefault();
a.focus();
}
}}
>
{box &&
(fit === "canvas" ? (