diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 2560e90..4e37173 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -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(`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(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1); + expect(await chrome.eval(`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(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn"); + expect(await text(".deckbar .count")).toBe("3 / 3"); + }, 30_000); }); diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx index 295554b..a644837 100644 --- a/packages/feature-learner/src/web/lesson/DeckViewer.tsx +++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx @@ -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 }>(); +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']"); @@ -46,12 +64,18 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl const [page, setPage] = useState(initialPage); const [error, setError] = useState(null); const stage = useRef(null); - const canvas = 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); + 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
{error ? {error} Open the PDF : null} {!error && !doc ? Loading slides… : null} -
go(page - 1)}> diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index f9119ba..6eadd37 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -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 */