Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over 150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1 and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops. Scroll restore stops on user input; pdf.js never starts a load after cleanup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
242 lines
12 KiB
TypeScript
242 lines
12 KiB
TypeScript
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<typeof import("pdfjs-dist")> | null = null;
|
||
function loadPdfjs() {
|
||
pdfjsPromise ??= import("pdfjs-dist").then((m) => { m.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; return m; });
|
||
return pdfjsPromise;
|
||
}
|
||
|
||
const jobs = new WeakMap<HTMLCanvasElement, { task?: { cancel(): void }; done: Promise<unknown> }>();
|
||
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<void> {
|
||
const prev = jobs.get(canvas);
|
||
prev?.task?.cancel();
|
||
const job: { task?: { cancel(): void }; done: Promise<unknown> } = { 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<PdfDoc | null>(null);
|
||
const [pages, setPages] = useState(0);
|
||
const [page, setPage] = useState(initialPage);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const stage = useRef<HTMLElement>(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<HTMLCanvasElement>(null);
|
||
const canvasB = useRef<HTMLCanvasElement>(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<void> } | 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 (
|
||
<section className="deck" data-deck={deck.id} aria-label={`Slides: ${title ?? deck.title}`}>
|
||
<figure className="stage" ref={stage}>
|
||
{error ? <span className="msg">{error} <a href={deck.url} target="_blank" rel="noopener">Open the PDF</a></span> : null}
|
||
{!error && !doc ? <span className="msg">Loading slides…</span> : null}
|
||
{([0, 1] as const).map((i) => {
|
||
const front = view.front === i;
|
||
return (
|
||
<canvas
|
||
key={i} ref={i ? canvasB : canvasA} hidden={!doc || !!error}
|
||
data-front={front ? "" : undefined} data-page={front && view.page ? view.page : undefined} data-leaving={!front && view.dir ? "" : undefined} data-dir={view.dir ?? undefined}
|
||
role={front ? "img" : undefined} aria-hidden={front ? undefined : true} aria-label={front ? `Slide ${view.page || page} of ${pages}` : undefined}
|
||
/>
|
||
);
|
||
})}
|
||
</figure>
|
||
<div className="deckbar">
|
||
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
|
||
<span className="count tnum" aria-live="polite">{pages ? `${page} / ${pages}` : "– / –"}</span>
|
||
<Pill className="sm" aria-label="Next slide" disabled={!pages || page >= pages} onClick={() => go(page + 1)}><Next /></Pill>
|
||
<span className="gap" />
|
||
{keyboard ? <span className="hint">Arrow keys change slides</span> : null}
|
||
<a className="stc-pill stc-pill--ghost sm" href={deck.url} target="_blank" rel="noopener">PDF</a>
|
||
<Pill className="sm" onClick={fullscreen}><Full />Fullscreen</Pill>
|
||
</div>
|
||
{doc ? <Thumbs doc={doc} pages={pages} page={page} onPick={go} withNote={withNote} /> : null}
|
||
{notes ? <SlideNote notes={notes} deckId={deck.id} page={page} existing={slideNotes.filter((n) => Number(n.anchorValue) === page)} /> : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set<number> }) {
|
||
const ref = useRef<HTMLDivElement>(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 (
|
||
<div className="thumbs" ref={ref} role="group" aria-label="Slides">
|
||
{Array.from({ length: pages }, (_, i) => (
|
||
<button key={i} type="button" aria-label={`Slide ${i + 1}${withNote.has(i + 1) ? ", has a note" : ""}`} aria-current={page === i + 1} className={withNote.has(i + 1) ? "has-note" : undefined} onClick={() => onPick(i + 1)}>
|
||
<canvas data-p={i + 1} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="slide-note">
|
||
<label htmlFor={`sn-${deckId}`}>Your note on slide {page}</label>
|
||
<textarea id={`sn-${deckId}`} className="note-ta" value={text} onChange={(e) => { setText(e.target.value); setSaved("idle"); }} onBlur={save}
|
||
placeholder="Only you see this. It comes back when you return to this slide." />
|
||
<div className="note-actions">
|
||
<span className="msg">{saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : ""}</span>
|
||
{existing.length > 1 ? <span className="msg">{existing.length - 1} more on this slide in your notes list</span> : null}
|
||
<Pill className="sm" disabled={!dirty} onClick={save}>Save note</Pill>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|