From 4681e861efcdf9b0b73e3803e70712bc55162863 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 8 Aug 2026 10:38:18 +0900 Subject: feat(slides): improve slide controller --- services/nuldoc/static/slides/slide.js | 381 ++++++++++++++++++++++++++----- services/nuldoc/static/slides/slides.css | 145 +++++++++++- 2 files changed, 469 insertions(+), 57 deletions(-) (limited to 'services/nuldoc/static/slides') diff --git a/services/nuldoc/static/slides/slide.js b/services/nuldoc/static/slides/slide.js index a90fda7a..72f14bba 100644 --- a/services/nuldoc/static/slides/slide.js +++ b/services/nuldoc/static/slides/slide.js @@ -1,98 +1,367 @@ -import { getDocument, GlobalWorkerOptions } from "./pdf.min.mjs"; +import { + AnnotationLayer, + getDocument, + GlobalWorkerOptions, + TextLayer, +} from "./pdf.min.mjs"; + +const PAGE_HASH_PATTERN = /^#p(\d+)$/; +// How long to wait before re-rendering, so a drag-resize does not redraw on every frame. +const RESIZE_DEBOUNCE_MS = 150; +// How long to wait before showing the indicator, so quick renders do not flash it. +const RENDER_INDICATOR_DELAY_MS = 200; +// How many pages around the current one to render ahead of time. +const PREFETCH_RADIUS = 1; + +function pageFromHash(hash) { + const matched = PAGE_HASH_PATTERN.exec(hash); + if (matched === null) return null; + const num = Number(matched[1]); + return num >= 1 ? num : null; +} async function init() { GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; + const container = document.getElementById("slide-container"); + const stage = document.getElementById("slide-stage"); const canvas = document.getElementById("slide"); const ctx = canvas.getContext("2d"); - const url = canvas.dataset.slideLink; + const textLayerDiv = document.getElementById("text-layer"); + const annotationLayerDiv = document.getElementById("annotation-layer"); + const status = document.getElementById("slide-status"); + const prev = document.getElementById("prev"); + const next = document.getElementById("next"); + const pageInput = document.getElementById("page-input"); + const pageCount = document.getElementById("page-count"); + const url = container.dataset.slideLink; - let pageNum = 1; let doc = null; - let renderTask = null; + let pageNum = 1; let renderToken = 0; + let textLayer = null; + let stageWidth = 0; + // `${pageNumber}@${width}` -> { image, layers }. Both are promises. + const cache = new Map(); - const backCanvas = document.createElement("canvas"); - const backCtx = backCanvas.getContext("2d"); + const hideStatus = () => { + status.hidden = true; + status.replaceChildren(); + delete status.dataset.state; + }; - const renderPage = async (num) => { - const token = ++renderToken; - if (renderTask !== null) { - renderTask.cancel(); - renderTask = null; - } + const showStatus = (state, message) => { + status.dataset.state = state; + status.replaceChildren(message); + status.hidden = false; + }; - const page = await doc.getPage(num); - if (token !== renderToken) return; + const showFailure = (message) => { + const description = document.createElement("p"); + description.textContent = message; + const fallback = document.createElement("a"); + fallback.href = url; + fallback.textContent = "PDF を直接開く"; + status.dataset.state = "error"; + status.replaceChildren(description, fallback); + status.hidden = false; + }; - const baseViewport = page.getViewport({ scale: 1.0 }); - const containerWidth = canvas.parentElement.clientWidth; - const scale = containerWidth / baseViewport.width; + const renderToOffscreenCanvas = async (page, width) => { + const scale = width / page.getViewport({ scale: 1 }).width; const viewport = page.getViewport({ scale }); - const outputScale = globalThis.devicePixelRatio || 1; - const cssWidth = Math.floor(viewport.width); - const cssHeight = Math.floor(viewport.height); - backCanvas.width = Math.floor(viewport.width * outputScale); - backCanvas.height = Math.floor(viewport.height * outputScale); + const target = document.createElement("canvas"); + target.width = Math.floor(viewport.width * outputScale); + target.height = Math.floor(viewport.height * outputScale); const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null; - // TODO: error handling - const task = page.render({ - canvasContext: backCtx, + await page.render({ + canvasContext: target.getContext("2d"), viewport, transform, + }).promise; + + return { + canvas: target, + viewport, + cssWidth: Math.floor(viewport.width), + cssHeight: Math.floor(viewport.height), + // Factor the text/annotation layers use to convert PDF units into CSS px. + totalScaleFactor: viewport.width / viewport.rawDims.pageWidth, + }; + }; + + // Cache the rendered result itself; same page at the same width returns instantly. + const entryFor = (num, width) => { + const key = `${num}@${width}`; + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const page = doc.getPage(num); + const entry = { + image: page.then((p) => renderToOffscreenCanvas(p, width)), + layers: page.then(async (p) => ({ + page: p, + textContent: await p.getTextContent(), + annotations: await p.getAnnotations({ intent: "display" }), + })), + }; + // Drop rejected promises, otherwise a failure would never be retried. + const forget = () => { + if (cache.get(key) === entry) cache.delete(key); + }; + entry.image.catch(forget); + entry.layers.catch(forget); + + cache.set(key, entry); + return entry; + }; + + const sweepCache = () => { + for (const key of cache.keys()) { + const [num, width] = key.split("@").map(Number); + if (width !== stageWidth || Math.abs(num - pageNum) > PREFETCH_RADIUS) { + cache.delete(key); + } + } + }; + + const prefetchNeighbors = () => { + for (let d = 1; d <= PREFETCH_RADIUS; d++) { + for (const num of [pageNum + d, pageNum - d]) { + if (num < 1 || num > doc.numPages) continue; + entryFor(num, stageWidth); + } + } + }; + + const drawImage = ({ canvas: source, cssWidth, cssHeight }) => { + if (canvas.width !== source.width || canvas.height !== source.height) { + canvas.width = source.width; + canvas.height = source.height; + } + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${cssHeight}px`; + ctx.drawImage(source, 0, 0); + }; + + const renderOverlays = async (entry, viewport, token) => { + if (textLayer !== null) { + textLayer.cancel(); + textLayer = null; + } + textLayerDiv.replaceChildren(); + annotationLayerDiv.replaceChildren(); + + const { page, textContent, annotations } = await entry.layers; + if (token !== renderToken) return; + + const layer = new TextLayer({ + textContentSource: textContent, + container: textLayerDiv, + viewport, }); - renderTask = task; + textLayer = layer; try { - await task.promise; + await layer.render(); + } catch { + // Cancelled by a move to another page; leave it to the newer render. + return; + } + if (token !== renderToken) return; + + if (annotations.length === 0) return; + await new AnnotationLayer({ + div: annotationLayerDiv, + page, + viewport: viewport.clone({ dontFlip: true }), + linkService, + }).render({ annotations, renderForms: false }); + }; + + const renderPage = async (num) => { + // No measurable width (e.g. hidden); leave it to the ResizeObserver re-render. + if (stageWidth === 0) return; + + const token = ++renderToken; + const entry = entryFor(num, stageWidth); + + const indicator = globalThis.setTimeout(() => { + if (token === renderToken) showStatus("loading", "描画中…"); + }, RENDER_INDICATOR_DELAY_MS); + + try { + const image = await entry.image; + if (token !== renderToken) return; + + globalThis.clearTimeout(indicator); + hideStatus(); + stage.style.setProperty("--total-scale-factor", image.totalScaleFactor); + drawImage(image); + await renderOverlays(entry, image.viewport, token); } catch (e) { - if (e?.name === "RenderingCancelledException") return; - throw e; + if (token !== renderToken) return; + console.error(e); + showFailure("スライドの描画に失敗しました。"); } finally { - if (renderTask === task) renderTask = null; + globalThis.clearTimeout(indicator); + if (token === renderToken) { + sweepCache(); + prefetchNeighbors(); + } } - if (token !== renderToken) return; + }; - if (canvas.width !== backCanvas.width || canvas.height !== backCanvas.height) { - canvas.width = backCanvas.width; - canvas.height = backCanvas.height; - canvas.style.width = cssWidth + "px"; - canvas.style.height = cssHeight + "px"; + const syncControls = () => { + prev.disabled = pageNum <= 1; + next.disabled = doc === null || pageNum >= doc.numPages; + if (document.activeElement !== pageInput) { + pageInput.value = String(pageNum); } - ctx.drawImage(backCanvas, 0, 0); }; - const prev = document.getElementById("prev"); - prev.addEventListener("click", () => { - if (pageNum <= 1) return; - pageNum--; - renderPage(pageNum); - }); - const next = document.getElementById("next"); - next.addEventListener("click", () => { - if (pageNum >= doc.numPages) return; - pageNum++; + const writeHash = (num) => { + const hash = `#p${num}`; + if (globalThis.location.hash === hash) return; + // Pushing an entry per page would make the back button useless. + globalThis.history.replaceState(null, "", hash); + }; + + const goToPage = (num, { updateHash = true } = {}) => { + if (doc === null || !Number.isFinite(num)) return; + pageNum = Math.min(Math.max(Math.trunc(num), 1), doc.numPages); + syncControls(); + if (updateHash) writeHash(pageNum); renderPage(pageNum); - }); + }; + + const linkService = { + addLinkAttributes(link, href, newWindow) { + link.href = href; + link.rel = "noopener noreferrer"; + if (newWindow) link.target = "_blank"; + }, + getDestinationHash() { + return "#"; + }, + getAnchorUrl(hash) { + return hash; + }, + async goToDestination(dest) { + if (doc === null) return; + const explicit = typeof dest === "string" + ? await doc.getDestination(dest) + : dest; + if (!Array.isArray(explicit) || explicit.length === 0) return; + const ref = explicit[0]; + const index = typeof ref === "object" && ref !== null + ? await doc.getPageIndex(ref) + : Number(ref); + goToPage(index + 1); + }, + executeNamedAction(action) { + switch (action) { + case "GoBack": + case "PrevPage": + goToPage(pageNum - 1); + break; + case "GoForward": + case "NextPage": + goToPage(pageNum + 1); + break; + case "FirstPage": + goToPage(1); + break; + case "LastPage": + goToPage(doc.numPages); + break; + default: + break; + } + }, + executeSetOCGState() {}, + }; + + prev.addEventListener("click", () => goToPage(pageNum - 1)); + next.addEventListener("click", () => goToPage(pageNum + 1)); document.addEventListener("keydown", (e) => { + if (e.target === pageInput) return; + if (e.altKey || e.ctrlKey || e.metaKey) return; if (e.key === "ArrowLeft" || e.key === "h") { - if (pageNum <= 1) return; - pageNum--; - renderPage(pageNum); + goToPage(pageNum - 1); } else if (e.key === "ArrowRight" || e.key === "l") { - if (pageNum >= doc.numPages) return; - pageNum++; - renderPage(pageNum); + goToPage(pageNum + 1); + } else { + return; + } + e.preventDefault(); + }); + + const commitPageInput = () => { + const value = Number(pageInput.value); + if (Number.isFinite(value) && pageInput.value !== "") { + goToPage(value); } + pageInput.value = String(pageNum); + }; + pageInput.addEventListener("change", commitPageInput); + pageInput.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + commitPageInput(); + }); + + globalThis.addEventListener("hashchange", () => { + const num = pageFromHash(globalThis.location.hash); + if (num !== null && num !== pageNum) goToPage(num, { updateHash: false }); }); - // TODO: error handling - doc = await getDocument(url).promise; + let resizeTimer = 0; + const measure = () => Math.floor(container.clientWidth); + new ResizeObserver(() => { + globalThis.clearTimeout(resizeTimer); + resizeTimer = globalThis.setTimeout(() => { + const width = measure(); + if (width === 0 || width === stageWidth) return; + stageWidth = width; + cache.clear(); + if (doc !== null) renderPage(pageNum); + }, RESIZE_DEBOUNCE_MS); + }).observe(container); + + stageWidth = measure(); + syncControls(); + showStatus("loading", "読み込み中……"); + + const loadingTask = getDocument(url); + loadingTask.onProgress = ({ loaded, total }) => { + if (doc !== null) return; + const percent = total > 0 + ? Math.min(100, Math.round((loaded / total) * 100)) + : null; + showStatus( + "loading", + percent === null ? "読み込み中……" : `読み込み中…… ${percent}%`, + ); + }; + + try { + doc = await loadingTask.promise; + } catch (e) { + console.error(e); + showFailure("スライドの読み込みに失敗しました。"); + return; + } + + pageCount.textContent = String(doc.numPages); + pageInput.max = String(doc.numPages); + pageNum = Math.min(pageFromHash(globalThis.location.hash) ?? 1, doc.numPages); + syncControls(); renderPage(pageNum); } diff --git a/services/nuldoc/static/slides/slides.css b/services/nuldoc/static/slides/slides.css index 17a9e183..703d66e1 100644 --- a/services/nuldoc/static/slides/slides.css +++ b/services/nuldoc/static/slides/slides.css @@ -4,12 +4,125 @@ main { } .slide-container { + position: relative; margin: 0 1rem; } -.controllers { +.slide-stage { + /* Variables read by pdf.js setLayerDimensions(). --total-scale-factor is + overwritten from JavaScript on every page render. */ + --total-scale-factor: 1; + --scale-round-x: 1px; + --scale-round-y: 1px; + position: relative; + min-height: 6rem; +} + +.slide-stage canvas { + display: block; +} + +/* Text layer: selection, copy, in-page search and screen readers */ + +.textLayer { + position: absolute; + inset: 0; + z-index: 1; + overflow: clip; + line-height: 1; + text-align: initial; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; +} + +.textLayer span, +.textLayer br { + position: absolute; + margin: 0; + color: transparent; + white-space: pre; + cursor: text; + transform-origin: 0 0; +} + +.textLayer ::selection { + background: rgb(0 0 255 / 25%); +} + +.textLayer br::selection { + background: transparent; +} + +/* Annotation layer: makes links inside the slides clickable */ + +.annotationLayer { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + transform-origin: 0 0; +} + +.annotationLayer section { + position: absolute; + margin: 0; + text-align: initial; + pointer-events: auto; + transform-origin: 0 0; +} + +.annotationLayer .linkAnnotation > a { + position: absolute; + inset: 0; + font-size: 1em; + border: none; +} + +.annotationLayer .linkAnnotation > a:hover { + background: rgb(255 255 0 / 20%); + box-shadow: 0 0 0 2px rgb(255 200 0 / 60%); +} + +/* Loading and error overlay */ + +.slide-status { + position: absolute; + inset: 0; + z-index: 3; display: flex; + flex-direction: column; + align-items: center; justify-content: center; + gap: 0.5rem; + background: rgb(255 255 255 / 90%); +} + +.slide-status[hidden] { + display: none; +} + +.slide-status p { + margin: 0; +} + +.slide-status[data-state="error"] { + color: #a00; +} + +.slide-noscript { + margin: 1rem; + text-align: center; +} + +/* Controls */ + +.controllers { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; margin: 1rem 0; } @@ -35,3 +148,33 @@ main { .controllers button:hover { background: #555; } + +.controllers button:disabled { + background: #ccc; + cursor: default; +} + +.page-indicator { + display: flex; + align-items: center; + gap: 0.3rem; + font-variant-numeric: tabular-nums; +} + +.page-input { + width: 4rem; + height: 2rem; + padding: 0 0.3rem; + border: 1px solid #999; + border-radius: 0.3rem; + font: inherit; + text-align: right; +} + +.page-count { + min-width: 2rem; +} + +.slide-download { + font-size: 0.9rem; +} -- cgit v1.3.1