feat(learner): cross-fade deck slides

The deck viewer draws the next slide into a second canvas and fades it in over the current one, 4 px from the
direction of travel; the stage never blanks while pdf.js draws. A newer draw into a canvas cancels the older one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:50:54 +01:00
co-authored by Claude Opus 5.5
parent a83ac02a90
commit d5e836cc79
3 changed files with 105 additions and 23 deletions
+35
View File
@@ -189,4 +189,39 @@ run("navigation motion (headless Chrome)", () => {
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
expect(closing.at(-1)).toBe(0);
}, 30_000);
test("slides cross-fade in the direction of travel and the stage never blanks", async () => {
await open("/c/sample/m1-video", ".stage canvas[data-front]");
await until(() => chrome.eval<boolean>(`document.querySelector(".deckbar .count").textContent.trim() === "1 / 3"`), "deck loaded");
// Next slide; then sample every frame: the animations running and the most opaque canvas.
const frames = await chrome.eval<{ anims: string[]; maxOpacity: number }[]>(`(() => {
document.querySelector('.deckbar [aria-label="Next slide"]').click();
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => {
const cs = [...document.querySelectorAll(".stage canvas")];
out.push({ anims: document.getAnimations().map((a) => a.animationName).filter(Boolean), maxOpacity: Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))) });
if (performance.now() - t0 < 600) requestAnimationFrame(f); else r(out);
}; f(); });
})()`);
const names = new Set(frames.flatMap((f) => f.anims));
expect(names.has("stc-slide-next")).toBe(true);
expect(names.has("stc-hold-out")).toBe(true);
expect(frames.every((f) => f.maxOpacity === 1)).toBe(true);
expect(await text(".deckbar .count")).toBe("2 / 3");
expect(await chrome.eval<number>(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1);
expect(await chrome.eval<string>(`document.querySelector('.stage canvas[data-front]').getAttribute("aria-label")`)).toBe("Slide 2 of 3");
// The arrow keys still turn slides, backwards with the mirrored motion.
await reset();
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 });
await until(async () => (await text(".deckbar .count")) === "1 / 3", "previous slide");
await Bun.sleep(300);
expect((await m()).anims.some((a) => a.startsWith("stc-slide-prev@"))).toBe(true);
// Two quick clicks: the second supersedes the first draw and the stage ends on the last slide.
await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 5); return true; })()`);
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn");
expect(await text(".deckbar .count")).toBe("3 / 3");
}, 30_000);
});
@@ -16,20 +16,38 @@ function loadPdfjs() {
return pdfjsPromise;
}
/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */
async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) {
const p = await doc.getPage(page);
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 });
await task.promise;
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']");
@@ -46,12 +64,18 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
const [page, setPage] = useState(initialPage);
const [error, setError] = useState<string | null>(null);
const stage = useRef<HTMLElement>(null);
const canvas = useRef<HTMLCanvasElement>(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);
setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null });
loadPdfjs()
.then((pdfjs) => {
const t = pdfjs.getDocument({ url: deck.url, withCredentials: true });
@@ -66,16 +90,23 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
return () => { live = false; void task?.destroy().catch(() => {}); };
}, [deck.url]);
// Render the current page to fit the stage.
// Draw the current page to fit the stage: first into the front canvas, later pages into the back one, then flip.
useEffect(() => {
if (!doc || !canvas.current || !stage.current) return;
if (!doc || !canvasA.current || !canvasB.current || !stage.current) return;
let live = true;
const draw = () => {
const canvases = [canvasA.current, canvasB.current] as const;
const draw = (into: 0 | 1, then?: () => void) => {
const r = stage.current!.getBoundingClientRect();
if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
renderPage(doc, page, canvases[into], r.width, r.height).then(() => { if (live) then?.(); }, () => {});
};
draw();
const ro = new ResizeObserver(() => draw());
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]);
@@ -109,7 +140,16 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
<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}
<canvas ref={canvas} hidden={!doc || !!error} role="img" aria-label={`Slide ${page} of ${pages}`} />
{([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 ${page} of ${pages}` : undefined}
/>
);
})}
</figure>
<div className="deckbar">
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
@@ -335,6 +335,13 @@ export const learnerCss = String.raw`
New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put.
With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
/* deck slides: the new slide fades in over the old one, 4 px from the side it comes from */
.lr .stage canvas { grid-area: 1 / 1; }
.lr .stage canvas:not([data-front]) { opacity: 0; }
.lr .stage canvas[data-front] { position: relative; z-index: 1; }
.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; }
.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; }
.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; }
/* a module list the user opens */
.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */