diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt index 2e122fbe1..8e5467da6 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt @@ -173,7 +173,7 @@ class PdfViewerEdgeToEdgeTest { scenario, """ (function() { - const canvas = document.getElementById('content'); + const canvas = document.getElementById('container'); const value = parseFloat(getComputedStyle(canvas)['$propertyName']) || 0; return value * globalThis.devicePixelRatio; })() diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt index f357bf01d..b998c878b 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -321,8 +321,8 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun assertCanvasRendered(scenario: ActivityScenario) { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) > 0 " + - "&& parseInt(document.getElementById('content').style.height) > 0" + "parseInt(globalThis.currentPageCanvas().style.width) > 0 " + + "&& parseInt(globalThis.currentPageCanvas().style.height) > 0" ) assertTrue("Canvas should have non-zero CSS dimensions after rendering", result == "true") } @@ -338,28 +338,28 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun getCanvasWidth(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "document.getElementById('content').width" + "globalThis.currentPageCanvas().width" ) return result.toInt() } fun getCanvasHeight(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "document.getElementById('content').height" + "globalThis.currentPageCanvas().height" ) return result.toInt() } fun getCanvasCssWidth(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) || 0" + "parseInt(globalThis.currentPageCanvas().style.width) || 0" ) return result.toInt() } fun getCanvasCssHeight(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.height) || 0" + "parseInt(globalThis.currentPageCanvas().style.height) || 0" ) return result.toInt() } @@ -564,9 +564,9 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun assertTextLayerAligned(scenario: ActivityScenario) { val result = PdfViewerTestUtils.evaluateJs(scenario, """ (function() { - var text = document.getElementById('text'); + var text = globalThis.currentPageTextLayer(); var container = document.getElementById('container'); - var canvas = document.getElementById('content'); + var canvas = globalThis.currentPageCanvas(); if (!text || !container || !canvas) return 'missing_elements'; if (text.hidden) return 'text_hidden'; var scaleFactor = container.style.getPropertyValue('--scale-factor'); diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt index 1dcfbadbb..1d931f551 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt @@ -124,10 +124,10 @@ object PdfViewerTestUtils { ) { try { val result = evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) > 0 " + - "&& parseInt(document.getElementById('content').style.height) > 0 " + + "parseInt(globalThis.currentPageCanvas().style.width) > 0 " + + "&& parseInt(globalThis.currentPageCanvas().style.height) > 0 " + "&& parseFloat(document.getElementById('container').style.getPropertyValue('--scale-factor')) > 0 " + - "&& !document.getElementById('text').hidden" + "&& !globalThis.currentPageTextLayer().hidden" ) Log.d("WAIT", " canvas check result=$result, elapsed=${System.currentTimeMillis() - start}ms") result == "true" @@ -153,7 +153,7 @@ object PdfViewerTestUtils { ) { try { val result = evaluateJs(scenario, - "document.getElementById('text').textContent" + "globalThis.currentPageTextLayer().textContent" ) Log.d("WAIT", " text layer result=${result.take(80)}, elapsed=${System.currentTimeMillis() - start}ms") result.contains(expected) @@ -165,7 +165,7 @@ object PdfViewerTestUtils { Log.d("WAIT", "assertTextLayerContent: done in ${System.currentTimeMillis() - start}ms") } catch (_: AssertionError) { val actual = try { - evaluateJs(scenario, "document.getElementById('text').textContent") + evaluateJs(scenario, "globalThis.currentPageTextLayer().textContent") } catch (e: Throwable) { "JS evaluation failed: ${e.message}" } @@ -251,8 +251,8 @@ object PdfViewerTestUtils { "within ${timeout}ms" } ) { - val w = evaluateJs(scenario, "document.getElementById('content').width").toIntOrNull() - val h = evaluateJs(scenario, "document.getElementById('content').height").toIntOrNull() + val w = evaluateJs(scenario, "globalThis.currentPageCanvas().width").toIntOrNull() + val h = evaluateJs(scenario, "globalThis.currentPageCanvas().height").toIntOrNull() w != null && h != null && (w != previousWidth || h != previousHeight) } } @@ -272,11 +272,11 @@ object PdfViewerTestUtils { ) { val w = evaluateJs( scenario, - "parseInt(document.getElementById('content').style.width) || 0" + "parseInt(globalThis.currentPageCanvas().style.width) || 0" ).toIntOrNull() val h = evaluateJs( scenario, - "parseInt(document.getElementById('content').style.height) || 0" + "parseInt(globalThis.currentPageCanvas().style.height) || 0" ).toIntOrNull() w != null && h != null && (w != previousWidth || h != previousHeight) } @@ -366,7 +366,7 @@ object PdfViewerTestUtils { scenario, """ (function() { var range = document.createRange(); - range.selectNodeContents(document.getElementById('text')); + range.selectNodeContents(globalThis.currentPageTextLayer()); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt index b192837e2..edd4e38cf 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt @@ -24,6 +24,12 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { @JavascriptInterface fun getPage(): Int = viewModel.page.value + @JavascriptInterface + fun setCurrentPage(page: Int) { + viewModel.setPage(page) + viewModel.showPageIndicator() + } + @JavascriptInterface fun getZoomRatio(): Float = viewModel.zoomRatio @@ -62,6 +68,12 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { @JavascriptInterface fun getDocumentOrientationDegrees(): Int = viewModel.documentOrientationDegrees.value + @JavascriptInterface + fun getPageFitMode(): Int = viewModel.pageFitMode.value + + @JavascriptInterface + fun getContinuousMode(): Boolean = viewModel.continuousMode.value + @JavascriptInterface fun setNumPages(numPages: Int) { viewModel.setNumPages(numPages) diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt index b96c5ceed..c5484a7cd 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -216,6 +217,8 @@ fun PdfViewerScreen( val webViewCrashed by viewModel.webViewCrashed.collectAsStateWithLifecycle() val numPages by viewModel.numPages.collectAsStateWithLifecycle() val page by viewModel.page.collectAsStateWithLifecycle() + val pageFitMode by viewModel.pageFitMode.collectAsStateWithLifecycle() + val continuousMode by viewModel.continuousMode.collectAsStateWithLifecycle() val documentName by viewModel.documentName.collectAsStateWithLifecycle() val documentProperties by viewModel.documentProperties.collectAsStateWithLifecycle() val outlineStatus by viewModel.outline.collectAsStateWithLifecycle() @@ -313,6 +316,29 @@ fun PdfViewerScreen( DisposableEffect(webView) { val wv = webView ?: return@DisposableEffect onDispose {} + var zoomRenderInFlight = false + var zoomRenderPending = false + var zoomRenderEndPending = false + + fun dispatchPendingZoomRender() { + if (zoomRenderInFlight || !zoomRenderPending) return + + val zoom = if (zoomRenderEndPending) 1 else 2 + zoomRenderPending = false + zoomRenderEndPending = false + zoomRenderInFlight = true + wv.evaluateJavascript("onRenderPage($zoom)") { + zoomRenderInFlight = false + dispatchPendingZoomRender() + } + } + + fun requestZoomRender(end: Boolean) { + zoomRenderPending = true + zoomRenderEndPending = zoomRenderEndPending || end + dispatchPendingZoomRender() + } + GestureHelper.attach(context, wv, object : GestureHelper.GestureListener { override fun onTapUp(): Boolean { if (viewModel.uri.value == null) return false @@ -349,15 +375,16 @@ fun PdfViewerScreen( } override fun onZoom(scaleFactor: Float, focusX: Float, focusY: Float) { + viewModel.setPageFitMode(0) viewModel.zoomRatio = (viewModel.zoomRatio * scaleFactor) .coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO) viewModel.zoomFocusX = focusX viewModel.zoomFocusY = focusY - wv.evaluateJavascript("onRenderPage(2)", null) + requestZoomRender(end = false) } override fun onZoomEnd() { - wv.evaluateJavascript("onRenderPage(1)", null) + requestZoomRender(end = true) } }) onDispose { @@ -478,6 +505,8 @@ fun PdfViewerScreen( enabled = enabled, page = page, numPages = numPages, + pageFitMode = pageFitMode, + continuousMode = continuousMode, hasOutline = viewModel.hasOutline(), hasDocumentProperties = documentProperties != null, hasUri = uri != null, @@ -495,6 +524,12 @@ fun PdfViewerScreen( onFirst = { jumpToPage(viewModel, webView, 1) }, onLast = { jumpToPage(viewModel, webView, numPages) }, onJumpToPage = { showJumpToPage = true }, + onFitFree = { setPageFitMode(viewModel, webView, 0) }, + onFitPage = { setPageFitMode(viewModel, webView, 1) }, + onFitWidth = { setPageFitMode(viewModel, webView, 2) }, + onContinuousModeChange = { + setContinuousMode(viewModel, webView, !continuousMode) + }, onRotateClockwise = { rotateDocument(viewModel, webView, 90) }, onRotateCounterClockwise = { rotateDocument(viewModel, webView, -90) }, onOutline = { showOutline = true }, @@ -722,6 +757,19 @@ internal fun jumpToPage(viewModel: PdfViewModel, webView: WebView?, selectedPage } } +private fun setPageFitMode(viewModel: PdfViewModel, webView: WebView?, mode: Int) { + webView ?: return + viewModel.setPageFitMode(mode) + viewModel.zoomRatio = 0f + webView.evaluateJavascript("onRenderPage(0)", null) +} + +private fun setContinuousMode(viewModel: PdfViewModel, webView: WebView?, enabled: Boolean) { + webView ?: return + viewModel.setContinuousMode(enabled) + webView.evaluateJavascript("setContinuousMode($enabled)", null) +} + private fun rotateDocument(viewModel: PdfViewModel, webView: WebView?, offset: Int) { webView ?: return var degrees = (viewModel.documentOrientationDegrees.value + offset) % 360 @@ -759,6 +807,8 @@ private fun PdfTopAppBar( enabled: Boolean, page: Int, numPages: Int, + pageFitMode: Int, + continuousMode: Boolean, hasOutline: Boolean, hasDocumentProperties: Boolean, hasUri: Boolean, @@ -770,6 +820,10 @@ private fun PdfTopAppBar( onFirst: () -> Unit, onLast: () -> Unit, onJumpToPage: () -> Unit, + onFitFree: () -> Unit, + onFitPage: () -> Unit, + onFitWidth: () -> Unit, + onContinuousModeChange: () -> Unit, onRotateClockwise: () -> Unit, onRotateCounterClockwise: () -> Unit, onOutline: () -> Unit, @@ -852,6 +906,46 @@ private fun PdfTopAppBar( ) } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_free)) }, + onClick = { onMenuToggle(false); onFitFree() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == 0) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_page)) }, + onClick = { onMenuToggle(false); onFitPage() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == 1) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_width)) }, + onClick = { onMenuToggle(false); onFitWidth() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == 2) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_continuous_scroll)) }, + onClick = { onMenuToggle(false); onContinuousModeChange() }, + enabled = enabled, + leadingIcon = { + if (continuousMode) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) } DropdownMenuItem( text = { Text(stringResource(R.string.action_rotate_clockwise)) }, diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 64d30d8c2..9013e8342 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -41,6 +41,8 @@ class PdfViewModel( private const val STATE_URI: String = "uri" private const val STATE_PAGE: String = "page" private const val STATE_DOCUMENT_ORIENTATION_DEGREES: String = "documentOrientationDegrees" + private const val STATE_PAGE_FIT_MODE: String = "pageFitMode" + private const val STATE_CONTINUOUS_MODE: String = "continuousMode" private const val STATE_DOCUMENT_PROPERTIES = "documentProperties" private const val STATE_DOCUMENT_NAME = "documentName" } @@ -57,6 +59,13 @@ class PdfViewModel( savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value } + val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, 1) + fun setPageFitMode(value: Int) { savedStateHandle[STATE_PAGE_FIT_MODE] = value } + + val continuousMode: StateFlow = + savedStateHandle.getStateFlow(STATE_CONTINUOUS_MODE, true) + fun setContinuousMode(value: Boolean) { savedStateHandle[STATE_CONTINUOUS_MODE] = value } + val documentProperties: StateFlow?> = savedStateHandle.getStateFlow(STATE_DOCUMENT_PROPERTIES, null) @@ -285,6 +294,8 @@ class PdfViewModel( _numPages.value = 0 zoomRatio = 0f setDocumentOrientationDegrees(0) + setPageFitMode(1) + setContinuousMode(true) encryptedDocumentPassword = "" clearOutline() clearDocumentProperties() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a680b81f1..cead4ff67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,10 @@ First page Last page Jump to page + Free zoom + Fit page + Fit width + Continuous scrolling Rotate clockwise Rotate counterclockwise Share diff --git a/viewer/css/pdf_viewer.css b/viewer/css/pdf_viewer.css index 0a170a152..06ea14365 100644 --- a/viewer/css/pdf_viewer.css +++ b/viewer/css/pdf_viewer.css @@ -20,22 +20,38 @@ body { --scale-round-x: 1px; --scale-round-y: 1px; + min-height: 100%; width: 100%; - height: 100%; - display: grid; - place-items: center; + box-sizing: border-box; } -#container canvas, -#container .textLayer { - /* overlay child elements on top of each other */ - grid-row-start: 1; - grid-column-start: 1; +#pages { + display: block; } -canvas { - display: inline-block; +/* + * Continuous vertical paging: each page is a centered wrapper holding its + * canvas and text layer. The wrapper's height is set from JS to the page's + * viewport height so the document is as tall as the sum of its pages and + * scrolls naturally. + */ +.page-wrapper { + display: flex; + justify-content: center; position: relative; + padding: 0; + margin: 14px 0; +} + +.page-wrapper canvas { + display: block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.page-wrapper .textLayer { + position: absolute; + top: 0; + left: 0; } [data-main-rotation="90"] { diff --git a/viewer/index.html b/viewer/index.html index 65eefa764..139f8c61d 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -8,8 +8,7 @@
- -
+
diff --git a/viewer/js/index.js b/viewer/js/index.js index 545f6fbaa..9bb16467e 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -9,288 +9,458 @@ import { getSimplifiedOutline } from "./outline.js"; GlobalWorkerOptions.workerSrc = "/viewer/js/worker.js"; +// Continuous vertical paging. +// +// One wrapper per page is laid out in a vertical stack so the document scrolls +// naturally. Pages are rendered lazily (IntersectionObserver) only when near +// the viewport and cleared when far away, so memory stays bounded on large +// documents. +// +// CSP note (style-src 'self', no unsafe-inline): every dynamic style is set +// via an IDL property write (el.style.x = ...) or a CSS custom property +// (setProperty). We never set the style attribute directly, never assign cssText, +// never create a style element, and never use an inline style attribute in HTML +// — those are exactly what CSP blocks here. See check-csp.mjs which enforces it. + let pdfDoc = null; let outlineAbort = new AbortController(); -let pageRendering = false; -let renderPending = false; -let renderPendingZoom = 0; -const canvas = document.getElementById("content"); -const container = document.getElementById("container"); -let orientationDegrees = 0; -let zoomRatio = 1; -let textLayerDiv = document.getElementById("text"); -let task = null; -let newPageNumber = 0; -let newZoomRatio = 1; -let useRender; +// pages[i] describes page (i+1): +// { wrapper, canvas, textLayer, pdfPage, viewport, rendered, rendering, task } +const pages = []; -const cache = []; -const maxCached = 6; +let zoomRatio = 0; // free-zoom ratio (0 = derive from fit mode) +let orientationDegrees = 0; +let lastReportedPage = 0; // last page pushed back to the ViewModel +let renderObserver = null; +let scrollTimer = null; +let pageBuildGeneration = 0; +let pendingScrollPage = 0; -let isTextLayerVisible = false; -let userZoomed = false; - -function maybeRenderNextPage() { - if (renderPending) { - pageRendering = false; - renderPending = false; - renderPage(channel.getPage(), renderPendingZoom, false); - return true; - } - return false; +const container = document.getElementById("container"); +const pagesEl = document.getElementById("pages"); + +function readLayout() { + const i = insets(); + return { + insets: i, + available: { + width: document.body.clientWidth - i.left - i.right, + height: document.body.clientHeight - i.top - i.bottom, + }, + mode: fitMode(), + minZoom: channel.getMinZoomRatio(), + maxZoom: channel.getMaxZoomRatio(), + }; } -function handleRenderingError(error) { - console.log("rendering error: " + error); +function clampZoom(value, layout = null) { + const min = layout ? layout.minZoom : channel.getMinZoomRatio(); + const max = layout ? layout.maxZoom : channel.getMaxZoomRatio(); + return Math.max(Math.min(value, max), min); +} - pageRendering = false; - maybeRenderNextPage(); +function fitMode() { + // 0 = free zoom, 1 = fit page, 2 = fit width + return channel.getPageFitMode(); } -function doPrerender(pageNumber, prerenderTrigger) { - if (useRender) { - if (pageNumber + 1 <= pdfDoc.numPages) { - renderPage(pageNumber + 1, false, true, pageNumber); - } else if (pageNumber - 1 > 0) { - renderPage(pageNumber - 1, false, true, pageNumber); - } - } else if (pageNumber === prerenderTrigger + 1) { - if (prerenderTrigger - 1 > 0) { - renderPage(prerenderTrigger - 1, false, true, prerenderTrigger); - } - } +function continuousMode() { + return channel.getContinuousMode(); } -function display(newCanvas, zoom) { - canvas.height = newCanvas.height; - canvas.width = newCanvas.width; - canvas.style.height = newCanvas.style.height; - canvas.style.width = newCanvas.style.width; - canvas.getContext("2d", { alpha: false }).drawImage(newCanvas, 0, 0); - if (!zoom) { - scrollTo(0, 0); +// In single-page mode only the current page's wrapper is displayed, so the +// document is exactly one page tall (no continuous flow) and navigation is via +// next/previous/jump/fling — the original behaviour. In continuous mode every +// wrapper is displayed and the document scrolls vertically through all pages. +function applyContinuousMode() { + const cont = continuousMode(); + const current = channel.getPage(); + for (const p of pages) { + if (!p) continue; + const num = Number(p.wrapper.dataset.page); + p.wrapper.style.display = (cont || num === current) ? "" : "none"; } } -function setLayerTransform(pageWidth, pageHeight, layerDiv) { - const cs = globalThis.getComputedStyle(canvas); - const insetLeft = parseFloat(cs.paddingLeft) || 0; - const insetTop = parseFloat(cs.paddingTop) || 0; - const insetRight = parseFloat(cs.paddingRight) || 0; - const insetBottom = parseFloat(cs.paddingBottom) || 0; - - const isOverflownY = canvas.clientHeight > document.body.clientHeight; - const isOverflownX = canvas.clientWidth > document.body.clientWidth; - // Translate the text layer to stay aligned with the rendered page including canvas insets and - // grid centering effects. - const translate = { - X: isOverflownX - ? insetLeft - (document.body.clientWidth - pageWidth) / 2 - : (insetLeft - insetRight) / 2, - Y: isOverflownY - ? insetTop - (document.body.clientHeight - pageHeight) / 2 - : (insetTop - insetBottom) / 2 +function insets() { + const ratio = globalThis.devicePixelRatio; + return { + ratio, + left: (channel.getInsetLeft() / ratio) || 0, + right: (channel.getInsetRight() / ratio) || 0, + top: (channel.getInsetTop() / ratio) || 0, + bottom: (channel.getInsetBottom() / ratio) || 0, }; - layerDiv.style.translate = `${translate.X}px ${translate.Y}px`; } -function getDefaultZoomRatio(page, degrees) { - const totalRotation = (degrees + page.rotate) % 360; - const viewport = page.getViewport({scale: 1, rotation: totalRotation}); - const widthZoomRatio = document.body.clientWidth / viewport.width; - const heightZoomRatio = document.body.clientHeight / viewport.height; - return Math.max(Math.min(widthZoomRatio, heightZoomRatio, channel.getMaxZoomRatio()), channel.getMinZoomRatio()); +function availSize(layout = null) { + if (layout) return layout.available; + const i = insets(); + return { + width: document.body.clientWidth - i.left - i.right, + height: document.body.clientHeight - i.top - i.bottom, + }; } -function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { - pageRendering = true; - useRender = !prerender; - - newPageNumber = pageNumber; - newZoomRatio = channel.getZoomRatio(); - orientationDegrees = channel.getDocumentOrientationDegrees(); - console.log("page: " + pageNumber + ", zoom: " + newZoomRatio + - ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); - for (let i = 0; i < cache.length; i++) { - const cached = cache[i]; - if (cached.pageNumber === pageNumber && cached.zoomRatio === newZoomRatio && - cached.orientationDegrees === orientationDegrees) { - if (useRender) { - cache.splice(i, 1); - cache.push(cached); - - display(cached.canvas, zoom); - - textLayerDiv.replaceWith(cached.textLayerDiv); - textLayerDiv = cached.textLayerDiv; - setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv); - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } +function totalRotation(pdfPage) { + return (orientationDegrees + pdfPage.rotate) % 360; +} - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - return; - } +// Zoom ratio for a page under the active fit mode, or the free-zoom ratio. +function pageZoom(pdfPage, layout = null) { + const m = layout ? layout.mode : fitMode(); + if (m !== 0 || zoomRatio === 0) { + const vp1 = pdfPage.getViewport({scale: 1, rotation: totalRotation(pdfPage)}); + const a = availSize(layout); + const wZoom = a.width / vp1.width; + const hZoom = a.height / vp1.height; + // fit width (2) fills width; fit page (1) and free-default both fit page. + const z = (m === 2) ? wZoom : Math.min(wZoom, hZoom); + return clampZoom(z, layout); } + return clampZoom(zoomRatio, layout); +} - pdfDoc.getPage(pageNumber).then(function(page) { - if (maybeRenderNextPage()) { - return; - } +function pageViewport(pdfPage, layout = null) { + return pdfPage.getViewport({ + scale: pageZoom(pdfPage, layout), + rotation: totalRotation(pdfPage), + }); +} - const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); +// Keep content out from under the system bars by padding the scroll host. +function applyContainerInsets(layout = null) { + const i = layout ? layout.insets : insets(); + container.style.paddingLeft = i.left + "px"; + container.style.paddingRight = i.right + "px"; + container.style.paddingTop = i.top + "px"; + container.style.paddingBottom = i.bottom + "px"; +} - if (newZoomRatio === 0) { - zoomRatio = defaultZoomRatio; - newZoomRatio = defaultZoomRatio; - channel.setZoomRatio(defaultZoomRatio); - } +// (Re)size a wrapper to its viewport. CSP-safe: property writes only. +function sizeWrapper(p, layout = null) { + const vp = pageViewport(p.pdfPage, layout); + p.viewport = vp; + const a = availSize(layout); + p.wrapper.style.width = a.width + "px"; + p.wrapper.style.height = vp.height + "px"; + p.canvas.style.width = vp.width + "px"; + p.canvas.style.height = vp.height + "px"; +} - const totalRotation = (orientationDegrees + page.rotate) % 360; - const viewport = page.getViewport({scale: newZoomRatio, rotation: totalRotation}); +// Overlay the text layer exactly on the (horizontally-centered) canvas. +function alignTextLayer(p, layout = null) { + if (!p.viewport) return; + const a = availSize(layout); + const offsetX = (a.width - p.viewport.width) / 2; + p.textLayer.style.translate = offsetX + "px 0px"; + p.textLayer.style.width = p.viewport.width + "px"; + p.textLayer.style.height = p.viewport.height + "px"; +} - const scaleFactor = newZoomRatio / zoomRatio; - const ratio = globalThis.devicePixelRatio; +function clearPage(p) { + if (p.task) { + try { + p.task.cancel(); + } catch { + // cancellation may throw if the task already completed + } + p.task = null; + } + p.rendering = false; + p.rendered = false; + p.canvas.width = 0; // free the backing store + p.canvas.height = 0; + p.textLayer.replaceChildren(); +} - if (useRender) { - if (newZoomRatio !== zoomRatio) { - canvas.style.height = viewport.height + "px"; - canvas.style.width = viewport.width + "px"; - } - zoomRatio = newZoomRatio; +function renderPageContent(p, layout = null) { + if (p.rendered) return; + // Cancel any in-flight render so we restart at the current viewport: during + // a multi-event pinch the viewport changes on every event, and an unchecked + // in-flight task would otherwise complete with a stale viewport (stretched + // bitmap, misaligned text) — see review P1. + if (p.task) { + try { + p.task.cancel(); + } catch { + // task already settled } + p.task = null; + } + p.rendering = true; + p.rendered = false; + // Sync wrapper/canvas layout to the viewport we render at, so a rotation or + // zoom change can never leave a stale size. + sizeWrapper(p, layout); + // Generation tag: callbacks from a cancelled/superseded render no-op. + const gen = (p.renderGen = (p.renderGen || 0) + 1); + + const ratio = layout ? layout.insets.ratio : insets().ratio; + const vp = p.viewport; + const renderedZoom = pageZoom(p.pdfPage, layout); + const renderPixels = (vp.width * ratio) * (vp.height * ratio); + + let renderVp = vp; + const maxRenderPixels = channel.getMaxRenderPixels(); + if (renderPixels > maxRenderPixels) { + const adjusted = Math.sqrt(maxRenderPixels / renderPixels); + renderVp = p.pdfPage.getViewport({ + scale: renderedZoom * adjusted, + rotation: totalRotation(p.pdfPage), + }); + } - if (zoom === 2) { - textLayerDiv.hidden = true; - pageRendering = false; + p.canvas.width = Math.floor(renderVp.width * ratio); + p.canvas.height = Math.floor(renderVp.height * ratio); + const ctx = p.canvas.getContext("2d", {alpha: false}); + ctx.scale(ratio, ratio); + + const renderTask = p.pdfPage.render({canvasContext: ctx, viewport: renderVp}); + p.task = renderTask; + renderTask.promise.then(() => { + if (gen !== p.renderGen) return; + p.zoom = renderedZoom; + // pdf.js TextLayer reads --scale-factor from its container; set it before + // construction so each page's text layer uses its own zoom. + p.textLayer.style.setProperty("--scale-factor", renderedZoom.toString()); + const textLayer = new TextLayer({ + textContentSource: p.pdfPage.streamTextContent(), + container: p.textLayer, + viewport: vp, + }); + p.task = {promise: textLayer.render(), cancel: () => textLayer.cancel()}; + return p.task.promise; + }).then(() => { + if (gen !== p.renderGen) return; + p.rendered = true; + p.rendering = false; + alignTextLayer(p); + p.textLayer.hidden = false; + }).catch((err) => { + if (gen !== p.renderGen) return; // expected when cancelled by a newer render + p.rendering = false; + console.log("render error: " + err); + }); +} - // zoom focus relative to page origin, rather than screen origin - const globalFocusX = channel.getZoomFocusX() / ratio + globalThis.scrollX; - const globalFocusY = channel.getZoomFocusY() / ratio + globalThis.scrollY; +// Render pages near the viewport, clear far ones. +function setupObserver() { + if (renderObserver) renderObserver.disconnect(); + renderObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const p = pages[Number(entry.target.dataset.page) - 1]; + if (!p) continue; + if (entry.isIntersecting && p.wrapper.style.display !== "none") { + renderPageContent(p); + } else { + clearPage(p); + } + } + }, {root: null, rootMargin: "150% 0px", threshold: 0}); + for (const p of pages) { + if (p) renderObserver.observe(p.wrapper); + } +} - const translationFactor = scaleFactor - 1; - const scrollX = globalFocusX * translationFactor; - const scrollY = globalFocusY * translationFactor; - scrollBy(scrollX, scrollY); +function relayoutAll() { + const layout = readLayout(); + applyContainerInsets(layout); + for (const p of pages) { + if (!p) continue; + sizeWrapper(p, layout); + alignTextLayer(p, layout); + } + return layout; +} - return; +// Re-render every currently-visible page (e.g. after zoom / rotation / resize). +function rerenderVisible(layout = null) { + const currentLayout = layout || readLayout(); + for (const p of pages) { + if (!p) continue; + if (p.wrapper.style.display === "none") { + clearPage(p); + continue; + } + const rect = p.wrapper.getBoundingClientRect(); + const near = rect.bottom > -window.innerHeight * 1.5 && + rect.top < window.innerHeight * 2.5; + if (near) { + if (p.rendered) clearPage(p); + sizeWrapper(p, currentLayout); + renderPageContent(p, currentLayout); + } else { + clearPage(p); } + } +} - const resolutionY = viewport.height * ratio; - const resolutionX = viewport.width * ratio; - const renderPixels = resolutionY * resolutionX; - - let newViewport = viewport; - const maxRenderPixels = channel.getMaxRenderPixels(); - if (renderPixels > maxRenderPixels) { - console.log(`resolution ${renderPixels} exceeds maximum allowed ${maxRenderPixels}`); - const adjustedScale = Math.sqrt(maxRenderPixels / renderPixels); - newViewport = page.getViewport({ - scale: newZoomRatio * adjustedScale, - rotation: totalRotation - }); +function mostVisiblePage() { + let best = null; + let bestArea = 0; + const vh = window.innerHeight; + for (const p of pages) { + if (!p || p.wrapper.style.display === "none") continue; + const rect = p.wrapper.getBoundingClientRect(); + const top = Math.max(rect.top, 0); + const bottom = Math.min(rect.bottom, vh); + const area = Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + best = p; } + } + return best; +} - const newCanvas = document.createElement("canvas"); - newCanvas.height = newViewport.height * ratio; - newCanvas.width = newViewport.width * ratio; - // use original viewport height for CSS zoom - newCanvas.style.height = viewport.height + "px"; - newCanvas.style.width = viewport.width + "px"; - const newContext = newCanvas.getContext("2d", { alpha: false }); - newContext.scale(ratio, ratio); - - // Add padding to the canvas to allow the page to be scrolled bellow/above any - // system/app ui that might be visible. - canvas.style.paddingLeft = (channel.getInsetLeft() / ratio) + "px"; - canvas.style.paddingTop = (channel.getInsetTop() / ratio) + "px"; - canvas.style.paddingRight = (channel.getInsetRight() / ratio) + "px"; - canvas.style.paddingBottom = (channel.getInsetBottom() / ratio) + "px"; - - task = page.render({ - canvasContext: newContext, - viewport: newViewport - }); +// Exposed for instrumentation tests: the canvas / text layer of the page +// currently most in view (continuous scroll has one per page). +globalThis.currentPageCanvas = function () { + const p = mostVisiblePage(); + return p ? p.canvas : null; +}; - task.promise.then(function() { - task = null; +globalThis.currentPageTextLayer = function () { + const p = mostVisiblePage(); + return p ? p.textLayer : null; +}; - let rendered = false; - function render() { - if (!useRender || rendered) { - return; - } - display(newCanvas, zoom); - rendered = true; - } - render(); - - const newTextLayerDiv = textLayerDiv.cloneNode(); - const textLayer = new TextLayer({ - textContentSource: page.streamTextContent(), - container: newTextLayerDiv, - viewport: viewport - }); - task = { - promise: textLayer.render(), - cancel: () => textLayer.cancel() - }; - task.promise.then(function() { - task = null; - - render(); - - setLayerTransform(viewport.width, viewport.height, newTextLayerDiv); - if (useRender) { - textLayerDiv.replaceWith(newTextLayerDiv); - textLayerDiv = newTextLayerDiv; - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } - - if (cache.length === maxCached) { - cache.shift(); - } - cache.push({ - pageNumber: pageNumber, - zoomRatio: newZoomRatio, - orientationDegrees: orientationDegrees, - canvas: newCanvas, - textLayerDiv: newTextLayerDiv, - pageWidth: viewport.width, - pageHeight: viewport.height - }); - - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - }).catch(handleRenderingError); - }).catch(handleRenderingError); - }); +// Report the most-visible page back to the ViewModel (drives the page indicator +// and next/previous enablement). +function updateCurrentPage() { + const best = mostVisiblePage(); + if (!best) return; + const num = Number(best.wrapper.dataset.page); + if (pendingScrollPage !== 0) { + if (!pages[pendingScrollPage - 1] || num !== pendingScrollPage) return; + pendingScrollPage = 0; + } + const layout = readLayout(); + const m = layout.mode; + if (m === 0 && zoomRatio !== 0) { + // Free zoom: the ViewModel zoom (driven by the pinch handler) is + // authoritative — reflect it on the container, never overwrite it. + container.style.setProperty("--scale-factor", zoomRatio.toString()); + } else { + // Fit mode: each page's fit zoom is authoritative — push it to the VM + // so the page indicator / tests read the right value. + // p.zoom records the last completed render and may still describe the + // previous fit mode or rotation. Publish the current layout ratio. + const z = pageZoom(best.pdfPage, layout); + container.style.setProperty("--scale-factor", z.toString()); + channel.setZoomRatio(z); + } + if (num !== lastReportedPage) { + lastReportedPage = num; + channel.setCurrentPage(num); + } } +globalThis.scrollToPage = function (pageNumber) { + if (!Number.isInteger(pageNumber) || pageNumber < 1 || + (pdfDoc && pageNumber > pdfDoc.numPages)) return; + + // Publish the requested page immediately, even if progressive page setup + // has not reached it yet. Scroll tracking must not replace that request + // while its wrapper is still being built. + pendingScrollPage = pageNumber; + lastReportedPage = pageNumber; + channel.setCurrentPage(pageNumber); + + const p = pages[pageNumber - 1]; + if (!p) return; + const layout = readLayout(); + sizeWrapper(p, layout); + alignTextLayer(p, layout); + if (!continuousMode()) { + // single-page mode: show only the target page + for (const q of pages) { + if (!q) continue; + q.wrapper.style.display = (q === p) ? "" : "none"; + } + p.wrapper.scrollIntoView({block: "start"}); + } else { + // continuous mode: centre the page in the visible band (below the app bar, + // above the nav bar) so next/previous lands squarely on the page rather + // than top-aligning it under the floating toolbar. + const dpr = globalThis.devicePixelRatio; + const visibleTop = channel.getInsetTop() / dpr; + const visibleH = window.innerHeight - visibleTop - channel.getInsetBottom() / dpr; + const rect = p.wrapper.getBoundingClientRect(); + const desiredTop = visibleTop + Math.max(0, (visibleH - rect.height) / 2); + globalThis.scrollBy(0, rect.top - desiredTop); + } + const best = mostVisiblePage(); + if (best && Number(best.wrapper.dataset.page) === pageNumber) { + pendingScrollPage = 0; + } +}; + +// Driven from the Java side (former single-page render entry point). +// zoom: 0 = full re-layout (fit/orientation/page jump), 1 = zoom end, 2 = zooming globalThis.onRenderPage = function (zoom) { - if (zoom === 1 || zoom === 2) { - userZoomed = true; + orientationDegrees = channel.getDocumentOrientationDegrees(); + + if (zoom === 2 || zoom === 1) { + // pinch: adopt the new free-zoom ratio and re-render visible pages while + // keeping the focal point under the user's fingers (review P2 focal). + const dpr = globalThis.devicePixelRatio; + const best = mostVisiblePage(); + // Rendering is asynchronous and is commonly cancelled by the next + // pinch event, so p.zoom can lag behind the ratio already requested. + const prevZoom = zoomRatio || (best ? (best.zoom || pageZoom(best.pdfPage)) : 1); + const newZoom = channel.getZoomRatio(); + zoomRatio = newZoom; + container.style.setProperty("--scale-factor", newZoom.toString()); + + // Focal point in document coordinates, captured before re-layout. + const focusX = channel.getZoomFocusX() / dpr + globalThis.scrollX; + const focusY = channel.getZoomFocusY() / dpr + globalThis.scrollY; + + // Placeholder geometry belongs to the requested zoom even when its + // canvas is far enough away to remain unrendered. + const layout = relayoutAll(); + rerenderVisible(layout); + + const translationFactor = (newZoom / prevZoom) - 1; + globalThis.scrollBy(focusX * translationFactor, focusY * translationFactor); + return; } - if (pageRendering) { - if (newPageNumber === channel.getPage() && newZoomRatio === channel.getZoomRatio() && - orientationDegrees === channel.getDocumentOrientationDegrees()) { - useRender = true; - return; - } + // zoom === 0: a fit-mode / orientation / page change. + if (fitMode() !== 0) { + // a fit mode owns the zoom now; drop stale free-zoom state so that + // re-entering Free zoom re-derives instead of reusing it (review P2). + zoomRatio = 0; + } + // Read the Java-side target before geometry changes can make the scroll + // handler report and overwrite a different most-visible page. + const target = channel.getPage(); + const targetPage = pages[target - 1]; + const anchorTop = targetPage && targetPage.wrapper.style.display !== "none" + ? targetPage.wrapper.getBoundingClientRect().top + : null; + const isPageNavigation = target !== lastReportedPage; + + if (!targetPage && isPageNavigation) { + pendingScrollPage = target; + lastReportedPage = target; + } - renderPending = true; - renderPendingZoom = zoom; - if (task !== null) { - task.cancel(); - task = null; - } - } else { - renderPage(channel.getPage(), zoom, false); + const layout = relayoutAll(); + if (targetPage && isPageNavigation) { + // next/prev/jump-to-page from the menu — scroll the target into view. + globalThis.scrollToPage(target); + } else if (targetPage && anchorTop !== null) { + // Preserve the target wrapper's viewport anchor when the heights of + // earlier pages change due to fitting or rotation. + const newTop = targetPage.wrapper.getBoundingClientRect().top; + globalThis.scrollBy(0, newTop - anchorTop); } + updateCurrentPage(); + rerenderVisible(layout); }; globalThis.isTextSelected = function () { @@ -298,17 +468,17 @@ globalThis.isTextSelected = function () { }; globalThis.getDocumentOutline = function () { - pdfDoc.getOutline().then(function(outline) { - getSimplifiedOutline(outline, outlineAbort, pdfDoc).then(function(outlineEntries) { - if (outlineEntries !== null) { - channel.setDocumentOutline(JSON.stringify(outlineEntries)); + pdfDoc.getOutline().then(function (outline) { + getSimplifiedOutline(outline, outlineAbort, pdfDoc).then(function (entries) { + if (entries !== null) { + channel.setDocumentOutline(JSON.stringify(entries)); } else { channel.setDocumentOutline(null); } - }).catch(function(error) { + }).catch(function (error) { console.log("getSimplifiedOutline error: " + error); }); - }).catch(function(error) { + }).catch(function (error) { console.log("pdfDoc.getOutline error: " + error); }); }; @@ -318,17 +488,28 @@ globalThis.abortDocumentOutline = function () { outlineAbort = new AbortController(); }; +let isTextLayerVisible = false; globalThis.toggleTextLayerVisibility = function () { - let textLayerForeground = "red"; - if (isTextLayerVisible) { - textLayerForeground = "transparent"; - } - document.documentElement.style.setProperty("--text-layer-foreground", textLayerForeground); + const foreground = isTextLayerVisible ? "transparent" : "red"; + document.documentElement.style.setProperty("--text-layer-foreground", foreground); isTextLayerVisible = !isTextLayerVisible; }; +globalThis.getPageFitMode = function () { + return channel.getPageFitMode(); +}; + +globalThis.setContinuousMode = function () { + // The ViewModel was already updated by the Java caller; reflect it in the DOM. + const target = channel.getPage(); + applyContinuousMode(); + relayoutAll(); + // Showing or hiding preceding wrappers changes this page's document offset. + globalThis.scrollToPage(target); + rerenderVisible(); +}; + globalThis.loadDocument = function () { - userZoomed = false; const pdfPassword = channel.getPassword(); const loadingTask = getDocument({ url: "https://localhost/placeholder.pdf", @@ -369,29 +550,129 @@ globalThis.loadDocument = function () { }).catch(function (error) { console.log("getMetadata error: " + error); }); - pdfDoc.getOutline().then(function(outline) { + pdfDoc.getOutline().then(function (outline) { channel.setHasDocumentOutline(outline && outline.length > 0); - }).catch(function(error) { + }).catch(function (error) { console.log("getOutline error: " + error); }); - renderPage(channel.getPage(), false, false); + + // Apply the saved document rotation before sizing any page, otherwise + // every wrapper is built at rotation 0 and only nearby pages get + // corrected later (review P2 rotation). + orientationDegrees = channel.getDocumentOrientationDegrees(); + + // Reset continuous-scroll state — loadDocument runs again when opening a + // second document or re-entering a password, so the old pages must go. + for (const old of pages) { + if (old) clearPage(old); + } + if (renderObserver) { + renderObserver.disconnect(); + renderObserver = null; + } + pages.length = 0; + pagesEl.replaceChildren(); + zoomRatio = 0; + lastReportedPage = 0; + pendingScrollPage = 0; + const buildGeneration = ++pageBuildGeneration; + const startPage = channel.getPage() || 1; + + buildPages(startPage, buildGeneration).catch((error) => { + console.error("buildPages error: " + error); + }); }, function (reason) { console.error(reason.name + ": " + reason.message); channel.onLoadError(); }); }; -globalThis.onresize = () => { - setLayerTransform(canvas.clientWidth, canvas.clientHeight, textLayerDiv); - if (pdfDoc !== null && !userZoomed) { - const pageNumber = channel.getPage(); - pdfDoc.getPage(pageNumber).then(function(page) { - const degrees = channel.getDocumentOrientationDegrees(); - const newDefaultZoom = getDefaultZoomRatio(page, degrees); - channel.setZoomRatio(newDefaultZoom); - globalThis.onRenderPage(0); - }).catch(function(err) { - console.log("onresize error: " + err); - }); +function createPageEntry(pdfPage, pageNumber) { + const wrapper = document.createElement("div"); + wrapper.className = "page-wrapper"; + wrapper.dataset.page = String(pageNumber); + + const canvas = document.createElement("canvas"); + const textLayer = document.createElement("div"); + textLayer.className = "textLayer"; + textLayer.hidden = true; + + wrapper.appendChild(canvas); + wrapper.appendChild(textLayer); + + return { + wrapper, canvas, textLayer, pdfPage, + viewport: null, rendered: false, rendering: false, task: null, + }; +} + +// Fetch page metadata in document order with concurrency bounded to one. The +// first readable page can be displayed immediately, and a failed later page +// does not reject initialization of the rest of the document. +async function buildPages(startPage, generation) { + const documentToBuild = pdfDoc; + const total = pdfDoc.numPages; + let viewerReady = false; + + for (let i = 1; i <= total; i++) { + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + let pdfPage; + try { + pdfPage = await documentToBuild.getPage(i); + } catch (error) { + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + console.error(`getPage(${i}) error: ${error}`); + continue; + } + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + + const entry = createPageEntry(pdfPage, i); + sizeWrapper(entry); + pages[i - 1] = entry; + pagesEl.appendChild(entry.wrapper); + + const requestedPage = channel.getPage() || startPage; + entry.wrapper.style.display = (continuousMode() || i === requestedPage) ? "" : "none"; + + if (!viewerReady) { + applyContainerInsets(); + setupObserver(); + viewerReady = true; + // Continuous mode can show useful content while a later restored + // target is still being initialized. + if (i !== requestedPage) rerenderVisible(); + } else if (renderObserver) { + renderObserver.observe(entry.wrapper); + } + + if (i === requestedPage) { + globalThis.scrollToPage(requestedPage); + updateCurrentPage(); + rerenderVisible(); + } + } + + const requestedPage = channel.getPage() || startPage; + if (viewerReady && !pages[requestedPage - 1]) { + const fallback = pages.find((page) => page); + if (fallback) { + globalThis.scrollToPage(Number(fallback.wrapper.dataset.page)); + updateCurrentPage(); + rerenderVisible(); + } } +} + +// Scroll → track current page (throttled); resize → relayout. +globalThis.onscroll = function () { + if (scrollTimer) return; + scrollTimer = setTimeout(() => { + scrollTimer = null; + updateCurrentPage(); + }, 150); +}; + +globalThis.onresize = function () { + relayoutAll(); + rerenderVisible(); }; diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js new file mode 100644 index 000000000..1797b67ba --- /dev/null +++ b/viewer/js/index.test.js @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const pdfJs = vi.hoisted(() => ({ loadingTask: null })); + +vi.mock("pdfjs-dist", () => ({ + GlobalWorkerOptions: {}, + PasswordResponses: { NEED_PASSWORD: 1, INCORRECT_PASSWORD: 2 }, + TextLayer: class { + render() { + return Promise.resolve(); + } + + cancel() {} + }, + getDocument: () => pdfJs.loadingTask, +})); + +function styleDeclaration() { + const properties = new Map(); + return { + display: "", + height: "", + width: "", + translate: "", + setProperty(name, value) { + properties.set(name, value); + }, + getPropertyValue(name) { + return properties.get(name) || ""; + }, + }; +} + +class FakeElement { + constructor(tagName, environment) { + this.tagName = tagName; + this.environment = environment; + this.children = []; + this.dataset = {}; + this.style = styleDeclaration(); + this.hidden = false; + this.width = 0; + this.height = 0; + } + + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + + replaceChildren(...children) { + this.children = []; + for (const child of children) this.appendChild(child); + } + + getContext() { + return { scale() {} }; + } + + getBoundingClientRect() { + if (this.style.display === "none") { + return { top: 0, bottom: 0, width: 0, height: 0 }; + } + const siblings = this.parentElement ? this.parentElement.children : []; + let documentTop = 0; + for (const sibling of siblings) { + if (sibling === this) break; + if (sibling.style.display !== "none") { + documentTop += Number.parseFloat(sibling.style.height) || 0; + } + } + const height = Number.parseFloat(this.style.height) || 0; + const top = documentTop - globalThis.scrollY; + return { top, bottom: top + height, height, width: Number.parseFloat(this.style.width) || 0 }; + } + + scrollIntoView() { + const rect = this.getBoundingClientRect(); + globalThis.scrollY += rect.top; + this.environment.scrolledPages.push(Number(this.dataset.page)); + } +} + +function fakePage(pageNumber, state, { width = 100, height = 200 } = {}) { + return { + rotate: 0, + getViewport({ scale, rotation }) { + const sideways = Math.abs(rotation % 180) === 90; + return { + width: (sideways ? height : width) * scale, + height: (sideways ? width : height) * scale, + }; + }, + render() { + state.renderCalls.push(pageNumber); + return { promise: Promise.resolve(), cancel() {} }; + }, + streamTextContent() { + return {}; + }, + }; +} + +async function flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function setupViewer({ + continuous = true, + currentPage = 1, + fitMode = 1, + pageCount = 3, + getPage, +} = {}) { + vi.resetModules(); + const state = { + continuous, + currentPage, + fitMode, + zoom: 0.5, + orientation: 0, + renderCalls: [], + scrollCalls: [], + scrolledPages: [], + zoomReports: [], + }; + const environment = { scrolledPages: state.scrolledPages }; + const container = new FakeElement("div", environment); + const pages = new FakeElement("div", environment); + container.appendChild(pages); + const body = new FakeElement("body", environment); + body.clientWidth = 100; + body.clientHeight = 100; + const documentElement = new FakeElement("html", environment); + + globalThis.document = { + body, + documentElement, + createElement: (tagName) => new FakeElement(tagName, environment), + getElementById: (id) => id === "container" ? container : pages, + }; + globalThis.window = globalThis; + globalThis.innerHeight = 100; + globalThis.devicePixelRatio = 1; + globalThis.scrollX = 0; + globalThis.scrollY = 0; + globalThis.scrollBy = (x, y) => { + state.scrollCalls.push([x, y]); + globalThis.scrollX += x; + globalThis.scrollY += y; + }; + globalThis.getSelection = () => ({ toString: () => "" }); + globalThis.IntersectionObserver = class { + observe() {} + disconnect() {} + }; + globalThis.channel = { + getMaxZoomRatio: () => 10, + getMinZoomRatio: () => 0.1, + getPageFitMode: () => state.fitMode, + getContinuousMode: () => state.continuous, + getPage: () => state.currentPage, + getInsetLeft: () => 0, + getInsetRight: () => 0, + getInsetTop: () => 0, + getInsetBottom: () => 0, + getDocumentOrientationDegrees: () => state.orientation, + getMaxRenderPixels: () => 10_000_000, + getZoomRatio: () => state.zoom, + getZoomFocusX: () => 10, + getZoomFocusY: () => 20, + setZoomRatio: (zoom) => { + state.zoom = zoom; + state.zoomReports.push(zoom); + }, + setCurrentPage: (page) => { + state.currentPage = page; + }, + getPassword: () => "", + onLoaded() {}, + setNumPages() {}, + setDocumentProperties() {}, + setHasDocumentOutline() {}, + onLoadError() {}, + }; + + const pdfDocument = { + numPages: pageCount, + getPage: getPage + ? (pageNumber) => getPage(pageNumber, state) + : (pageNumber) => Promise.resolve(fakePage(pageNumber, state)), + getMetadata: () => Promise.resolve({ info: {} }), + getOutline: () => Promise.resolve([]), + }; + pdfJs.loadingTask = { promise: Promise.resolve(pdfDocument) }; + + await import("./index.js"); + globalThis.loadDocument(); + await flushPromises(); + return { state, pagesElement: pages, pdfDocument }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("continuous page layout", () => { + it("renders only the displayed page in single-page mode", async () => { + const { state } = await setupViewer({ continuous: false, currentPage: 2 }); + + expect(state.renderCalls).toEqual([2]); + }); + + it("compensates each pinch event from the previously requested zoom", async () => { + const { state } = await setupViewer({ continuous: false, currentPage: 1 }); + state.fitMode = 0; + + state.zoom = 0.6; + globalThis.onRenderPage(2); + state.zoom = 0.7; + globalThis.onRenderPage(2); + globalThis.onRenderPage(1); + + expect(state.scrollCalls).toHaveLength(3); + expect(state.scrollCalls[0][0]).toBeCloseTo(2); + expect(state.scrollCalls[0][1]).toBeCloseTo(4); + expect(state.scrollCalls[1][0]).toBeCloseTo(2); + expect(state.scrollCalls[1][1]).toBeCloseTo(4); + expect(state.scrollCalls[2]).toEqual([0, 0]); + }); + + it("resizes far placeholders when free zoom changes", async () => { + const { state, pagesElement } = await setupViewer({ pageCount: 5 }); + state.fitMode = 0; + state.zoom = 1; + + globalThis.onRenderPage(2); + + expect(pagesElement.children[4].style.height).toBe("200px"); + }); + + it("publishes the fit zoom computed for the new layout", async () => { + const { state } = await setupViewer({ pageCount: 1, fitMode: 1 }); + state.fitMode = 2; + + globalThis.onRenderPage(0); + + expect(state.zoomReports.at(-1)).toBe(1); + }); + + it("keeps the requested page anchored across a relayout", async () => { + const { state } = await setupViewer({ + continuous: true, + currentPage: 3, + pageCount: 3, + fitMode: 1, + }); + expect(globalThis.scrollY).toBe(200); + state.fitMode = 2; + + globalThis.onRenderPage(0); + + expect(state.currentPage).toBe(3); + expect(globalThis.scrollY).toBe(400); + }); + + it("re-anchors the current page when continuous mode changes", async () => { + const { state } = await setupViewer({ + continuous: false, + currentPage: 3, + pageCount: 3, + }); + expect(globalThis.scrollY).toBe(0); + + state.continuous = true; + globalThis.setContinuousMode(); + expect(globalThis.scrollY).toBe(200); + + state.continuous = false; + globalThis.setContinuousMode(); + expect(globalThis.scrollY).toBe(0); + expect(state.currentPage).toBe(3); + }); + + it("starts rendering progressively and survives a later page failure", async () => { + let rejectSecondPage; + const secondPage = new Promise((resolve, reject) => { + rejectSecondPage = reject; + }); + const { state, pagesElement } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => { + if (pageNumber === 2) return secondPage; + return Promise.resolve(fakePage(pageNumber, viewerState)); + }, + }); + + expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1"]); + expect(state.renderCalls).toContain(1); + + rejectSecondPage(new Error("damaged page")); + await flushPromises(); + + expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1", "3"]); + }); + + it("keeps a late-page request sticky until progressive setup reaches it", async () => { + let resolveSecondPage; + const secondPage = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + const { state } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => pageNumber === 2 + ? secondPage + : Promise.resolve(fakePage(pageNumber, viewerState)), + }); + + globalThis.scrollToPage(3); + globalThis.onscroll(); + await delay(200); + + expect(state.currentPage).toBe(3); + + resolveSecondPage(fakePage(2, state)); + await flushPromises(); + + expect(globalThis.scrollY).toBeGreaterThan(0); + expect(state.currentPage).toBe(3); + }); +});