"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-<n>`); 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<HTMLDivElement>(null); const stageRef = useRef<HTMLElement | null>(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 ( <section ref={(el) => { 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} > <div ref={viewportRef} className="rs-viewport" data-fit={fit} onPointerDown={(e) => { 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" ? ( <div key={slide.key} className="rs-scaled" data-dir={dir} style={{ width: CANVAS_W * scale, height: CANVAS_H * scale }} > <Slide slide={slide} view={view} total={total} pageUrl={pageUrl} style={{ transform: `scale(${scale})` }} /> </div> ) : ( <div key={slide.key} className="rs-scaled" data-dir={dir}> <Slide slide={slide} view={view} total={total} pageUrl={pageUrl} fit="reflow" /> </div> ))} </div> <div className="rs-controls"> <button type="button" className="rs-btn" onClick={() => go(at - 1)} disabled={at === 0} aria-label="Previous slide"> <ChevronLeft aria-hidden="true" width={16} height={16} /> <span className="rs-btn-text">Previous</span> </button> <p className="rs-label" aria-live="polite" aria-atomic="true" data-slide-label=""> <span className="sr-only">{label}</span> <span aria-hidden="true"> <b> {at + 1} / {total} </b>{" "} · {slide.title} </span> </p> <button type="button" className="rs-btn" onClick={() => go(at + 1)} disabled={at === total - 1} aria-label="Next slide"> <span className="rs-btn-text">Next</span> <ChevronRight aria-hidden="true" width={16} height={16} /> </button> </div> <div className="rs-rail" aria-label="All slides"> {slides.map((s, i) => ( <button key={s.key} type="button" tabIndex={-1} data-state={i < at ? "past" : i === at ? "current" : "next"} aria-label={`Go to slide ${s.n}: ${s.title}`} aria-current={i === at ? "step" : undefined} title={`${s.n}. ${s.title}`} onClick={() => go(i)} /> ))} </div> </section> ); });