import { useCallback, useEffect, useRef, useState } from "react"; import { Pill } from "@stc/ui"; import type { DeckRef } from "../../shared/types"; import { Full, Next, Prev } from "../icons"; import type { NotesApi } from "./notes"; /** The pdf.js worker is served by the learner API from the installed pdfjs-dist. */ export const PDF_WORKER_URL = "/api/learner/pdf.worker.mjs"; // biome-ignore lint/suspicious/noExplicitAny: pdf.js document proxy (types are loaded lazily with the library) type PdfDoc = any; let pdfjsPromise: Promise | null = null; function loadPdfjs() { pdfjsPromise ??= import("pdfjs-dist").then((m) => { m.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; return m; }); return pdfjsPromise; } const jobs = new WeakMap }>(); const superseded = () => new Error("superseded"); /** * Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). A newer * render into the same canvas cancels this one (its promise rejects), so pdf.js never draws one canvas twice at once. */ function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number): Promise { const prev = jobs.get(canvas); prev?.task?.cancel(); const job: { task?: { cancel(): void }; done: Promise } = { done: Promise.resolve() }; const run = (async () => { await prev?.done; if (jobs.get(canvas) !== job) throw superseded(); const p = await doc.getPage(page); if (jobs.get(canvas) !== job) throw superseded(); const base = p.getViewport({ scale: 1 }); let scale = cssWidth / base.width; if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height; const dpr = Math.min(window.devicePixelRatio || 1, 2); const vp = p.getViewport({ scale: scale * dpr }); canvas.width = Math.floor(vp.width); canvas.height = Math.floor(vp.height); canvas.style.width = `${Math.floor(vp.width / dpr)}px`; canvas.style.height = `${Math.floor(vp.height / dpr)}px`; const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas }); job.task = task; await task.promise; })(); job.done = run.catch(() => {}); jobs.set(canvas, job); return run; } const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']"); /** * Slide deck viewer: 16:9 stage, previous/next, page counter, thumbnails, fullscreen, arrow keys (when * `keyboard` is set) and a note per slide. Pages are rendered in the browser with pdf.js. */ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, title }: { deck: DeckRef; notes: NotesApi | null; initialPage?: number; keyboard?: boolean; title?: string; }) { const [doc, setDoc] = useState(null); const [pages, setPages] = useState(0); const [page, setPage] = useState(initialPage); const [error, setError] = useState(null); const stage = useRef(null); // Two stacked canvases: the next slide is drawn into the hidden one, which then fades in over the current one // (shifted 4 px from the direction of travel), so the stage never blanks while pdf.js draws. const canvasA = useRef(null); const canvasB = useRef(null); const [view, setView] = useState<{ front: 0 | 1; page: number; dir: "next" | "prev" | null }>({ front: 0, page: 0, dir: null }); const viewRef = useRef(view); viewRef.current = view; useEffect(() => { // pdf.js 6 documents have no destroy(); the loading task owns the document and the worker port. let live = true, task: { destroy(): Promise } | null = null; setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null }); loadPdfjs() .then((pdfjs) => { if (!live) throw new Error("unmounted"); // never start a load (and its worker) after cleanup const t = pdfjs.getDocument({ url: deck.url, withCredentials: true }); task = t; return t.promise; }) .then((d: PdfDoc) => { if (!live) return; setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages)); }) .catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim())); return () => { live = false; void task?.destroy().catch(() => {}); }; }, [deck.url]); // Draw the current page to fit the stage: first into the front canvas, later pages into the back one, then flip. useEffect(() => { if (!doc || !canvasA.current || !canvasB.current || !stage.current) return; let live = true; const canvases = [canvasA.current, canvasB.current] as const; const draw = (into: 0 | 1, then?: () => void) => { const r = stage.current!.getBoundingClientRect(); renderPage(doc, page, canvases[into], r.width, r.height).then(() => { if (live) then?.(); }, () => {}); }; const cur = viewRef.current; if (cur.page === 0 || cur.page === page) draw(cur.front, () => setView((v) => ({ ...v, page }))); else { const back = (1 - cur.front) as 0 | 1; draw(back, () => setView({ front: back, page, dir: page > cur.page ? "next" : "prev" })); } let first = true; // ResizeObserver reports once on observe; that size is already drawn const ro = new ResizeObserver(() => { if (first) { first = false; return; } draw(viewRef.current.front); }); ro.observe(stage.current); return () => { live = false; ro.disconnect(); }; }, [doc, page]); const go = useCallback((p: number) => setPage((cur) => (pages ? Math.min(Math.max(1, p), pages) : cur)), [pages]); useEffect(() => { if (!keyboard) return; const onKey = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return; if (e.key === "ArrowRight") { e.preventDefault(); setPage((p) => Math.min(p + 1, pages || p)); } else if (e.key === "ArrowLeft") { e.preventDefault(); setPage((p) => Math.max(p - 1, 1)); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [keyboard, pages]); useEffect(() => { document.querySelector(`[data-deck="${CSS.escape(deck.id)}"] .thumbs [aria-current="true"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [page, deck.id]); const fullscreen = () => { if (document.fullscreenElement) void document.exitFullscreen(); else stage.current?.requestFullscreen?.().catch(() => {}); }; const slideNotes = notes?.notes.filter((n) => n.anchorKind === "slide" && n.deckId === deck.id) ?? []; const withNote = new Set(slideNotes.map((n) => Number(n.anchorValue))); return (
{error ? {error} Open the PDF : null} {!error && !doc ? Loading slides… : null} {([0, 1] as const).map((i) => { const front = view.front === i; return (
go(page - 1)}> {pages ? `${page} / ${pages}` : "– / –"} = pages} onClick={() => go(page + 1)}> {keyboard ? Arrow keys change slides : null} PDF Fullscreen
{doc ? : null} {notes ? Number(n.anchorValue) === page)} /> : null}
); } function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set }) { const ref = useRef(null); useEffect(() => { const root = ref.current; if (!root) return; const queue: HTMLCanvasElement[] = []; let running = false, live = true; const pump = async () => { if (running) return; running = true; while (live && queue.length) { const c = queue.shift()!; await renderPage(doc, Number(c.dataset.p), c, 104).catch(() => {}); } running = false; }; const io = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; const c = e.target as HTMLCanvasElement; io.unobserve(c); queue.push(c); } void pump(); }, { root, rootMargin: "0px 400px" }); root.querySelectorAll("canvas").forEach((c) => io.observe(c)); return () => { live = false; io.disconnect(); }; }, [doc, pages]); return (
{Array.from({ length: pages }, (_, i) => ( ))}
); } function SlideNote({ notes, deckId, page, existing }: { notes: NotesApi; deckId: string; page: number; existing: { id: string; body: string }[] }) { const first = existing[0]; const [text, setText] = useState(first?.body ?? ""); const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle"); const busy = useRef(false); useEffect(() => { setText(first?.body ?? ""); setSaved("idle"); }, [page, deckId, first?.id, first?.body]); if (!notes.enabled) return null; const dirty = text.trim() !== (first?.body ?? "").trim(); const save = async () => { if (!dirty || busy.current) return; busy.current = true; setSaved("saving"); const body = text.trim(); try { if (first && !body) await notes.remove(first.id); else if (first) await notes.update(first.id, body); else if (body) await notes.create({ anchorKind: "slide", anchorValue: page, deckId, body }); setSaved("saved"); } finally { busy.current = false; } }; return (