const viewers = document.querySelectorAll('.ecclesia-pdf-viewer[data-config]'); for (const root of viewers) { initialise(root).catch(() => showError(root)); } async function initialise(root) { const config = JSON.parse(root.dataset.config || '{}'); const pdfjs = await import(config.pdfjsUrl); pdfjs.GlobalWorkerOptions.workerSrc = config.workerUrl; const scroll = root.querySelector('.ecclesia-pdf-scroll'); const pages = root.querySelector('.ecclesia-pdf-pages'); const status = root.querySelector('.ecclesia-pdf-status'); const currentEl = root.querySelector('.ecclesia-current-page'); const totalEl = root.querySelector('.ecclesia-total-pages'); let zoom = 1; let pdf; const records = []; const loadingTask = pdfjs.getDocument({ url: config.url, cMapUrl: config.cMapUrl, cMapPacked: true, standardFontDataUrl: config.fontUrl, wasmUrl: config.wasmUrl, iccUrl: config.iccUrl }); pdf = await loadingTask.promise; totalEl.textContent = String(pdf.numPages); status.hidden = true; const firstPage = await pdf.getPage(1); const firstViewport = firstPage.getViewport({ scale: 1 }); for (let number = 1; number <= pdf.numPages; number += 1) { const element = document.createElement('div'); element.className = 'ecclesia-pdf-page'; element.dataset.page = String(number); element.setAttribute('role', 'group'); element.setAttribute('aria-label', `${config.strings.page} ${number}`); pages.append(element); records.push({ number, page: number === 1 ? firstPage : null, element, ratio: firstViewport.width / firstViewport.height, rendered: false, rendering: false, token: 0 }); } function targetWidth(record) { const available = Math.max(240, scroll.clientWidth - 28); return Math.max(240, available * zoom); } function sizePlaceholder(record) { const width = targetWidth(record); record.element.style.width = `${width}px`; record.element.style.height = `${width / record.ratio}px`; } async function render(record) { if (record.rendered || record.rendering) return; record.rendering = true; const ownToken = ++record.token; if (!record.page) { record.page = await pdf.getPage(record.number); const naturalViewport = record.page.getViewport({ scale: 1 }); record.ratio = naturalViewport.width / naturalViewport.height; sizePlaceholder(record); } const cssWidth = targetWidth(record); const base = record.page.getViewport({ scale: 1 }); const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); const viewport = record.page.getViewport({ scale: (cssWidth / base.width) * pixelRatio }); const canvas = document.createElement('canvas'); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); canvas.setAttribute('aria-hidden', 'true'); try { await record.page.render({ canvasContext: canvas.getContext('2d', { alpha: false }), viewport }).promise; if (ownToken !== record.token) return; record.element.replaceChildren(canvas); await addExternalLinks(record, viewport, pixelRatio); record.rendered = true; } finally { record.rendering = false; if (!record.rendered) window.requestAnimationFrame(renderNearViewport); } } async function addExternalLinks(record, viewport, pixelRatio) { const annotations = await record.page.getAnnotations({ intent: 'display' }); for (const annotation of annotations) { if (annotation.subtype !== 'Link' || !annotation.url) continue; let parsed; try { parsed = new URL(annotation.url, window.location.href); } catch (_) { continue; } if (!['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol)) continue; const rect = viewport.convertToViewportRectangle(annotation.rect); const link = document.createElement('a'); link.href = parsed.href; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.setAttribute('aria-label', annotation.titleObj?.str || annotation.url); link.style.left = `${Math.min(rect[0], rect[2]) / pixelRatio}px`; link.style.top = `${Math.min(rect[1], rect[3]) / pixelRatio}px`; link.style.width = `${Math.abs(rect[2] - rect[0]) / pixelRatio}px`; link.style.height = `${Math.abs(rect[3] - rect[1]) / pixelRatio}px`; record.element.append(link); } } function discard(record) { record.token += 1; if (record.element.childNodes.length) record.element.replaceChildren(); record.rendered = false; sizePlaceholder(record); } function renderNearViewport() { const scrollRect = scroll.getBoundingClientRect(); const margin = scrollRect.height * 1.5; for (const record of records) { const pageRect = record.element.getBoundingClientRect(); if (pageRect.bottom >= scrollRect.top - margin && pageRect.top <= scrollRect.bottom + margin) { render(record); } } } records.forEach(sizePlaceholder); const renderObserver = new IntersectionObserver((entries) => { for (const entry of entries) { const record = records[Number(entry.target.dataset.page) - 1]; if (entry.isIntersecting) render(record); else if (record.rendered) discard(record); } }, { root: scroll, rootMargin: '150% 0px 150% 0px' }); records.forEach((record) => renderObserver.observe(record.element)); const pageObserver = new IntersectionObserver((entries) => { const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio); if (visible[0]) currentEl.textContent = visible[0].target.dataset.page; }, { root: scroll, threshold: [0.1, 0.3, 0.6] }); records.forEach((record) => pageObserver.observe(record.element)); render(records[0]); function resizeAll() { records.forEach((record) => { discard(record); sizePlaceholder(record); }); window.requestAnimationFrame(renderNearViewport); } const resizeObserver = new ResizeObserver(resizeAll); resizeObserver.observe(scroll); root.querySelector('.ecclesia-zoom-out').addEventListener('click', () => { zoom = Math.max(0.5, zoom - 0.2); resizeAll(); }); root.querySelector('.ecclesia-zoom-in').addEventListener('click', () => { zoom = Math.min(3, zoom + 0.2); resizeAll(); }); root.querySelector('.ecclesia-fit-width').addEventListener('click', () => { zoom = 1; resizeAll(); }); root.querySelector('.ecclesia-fullscreen')?.addEventListener('click', async () => { if (!document.fullscreenElement && root.requestFullscreen) await root.requestFullscreen(); else if (document.exitFullscreen) await document.exitFullscreen(); }); document.addEventListener('fullscreenchange', () => setTimeout(resizeAll, 50)); } function showError(root) { const config = JSON.parse(root.dataset.config || '{}'); const status = root.querySelector('.ecclesia-pdf-status'); status.hidden = false; status.setAttribute('role', 'alert'); status.textContent = config.strings?.error || 'The PDF could not be loaded.'; }