aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/static
diff options
context:
space:
mode:
Diffstat (limited to 'services/nuldoc/static')
-rw-r--r--services/nuldoc/static/slides/slide.js381
-rw-r--r--services/nuldoc/static/slides/slides.css145
2 files changed, 469 insertions, 57 deletions
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;
+}