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:
co-authored by
Claude Opus 5.5
parent
a83ac02a90
commit
d5e836cc79
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user