Files
STCourses/packages/feature-learner/src/web/lesson/DeckViewer.tsx
T
Lucas WintherandClaude Opus 5.5 b5e00af4f7 fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19)
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]>
2026-10-30 21:49:49 +01:00

242 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}