diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index cb4baf2e..c61f80ec 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java @@ -65,6 +65,7 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private static final String STATE_ZOOM_RATIO = "zoomRatio"; private static final String STATE_DOCUMENT_ORIENTATION_DEGREES = "documentOrientationDegrees"; private static final String STATE_ENCRYPTED_DOCUMENT_PASSWORD = "encrypted_document_password"; + private static final String STATE_VERTICAL_SCROLL_MODE = "vertical_scroll_mode"; private static final String KEY_PROPERTIES = "properties"; private static final int MIN_WEBVIEW_RELEASE = 133; @@ -130,6 +131,7 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private String mEncryptedDocumentPassword; private List mDocumentProperties; private InputStream mInputStream; + private boolean isVerticalScrollMode = false; private PdfviewerBinding binding; private TextView mTextView; @@ -265,6 +267,22 @@ public void onLoaded() { public String getPassword() { return mEncryptedDocumentPassword != null ? mEncryptedDocumentPassword : ""; } + + @JavascriptInterface + public boolean isVerticalScrollMode() { + return isVerticalScrollMode; + } + + @JavascriptInterface + public void onPageChanged(final int pageNumber) { + if (isVerticalScrollMode) { + mPage = pageNumber; + runOnUiThread(() -> { + invalidateOptionsMenu(); + showPageNumber(); + }); + } + } } private void showWebViewCrashed() { @@ -502,6 +520,7 @@ public void onZoomEnd() { mZoomRatio = savedInstanceState.getFloat(STATE_ZOOM_RATIO); mDocumentOrientationDegrees = savedInstanceState.getInt(STATE_DOCUMENT_ORIENTATION_DEGREES); mEncryptedDocumentPassword = savedInstanceState.getString(STATE_ENCRYPTED_DOCUMENT_PASSWORD); + isVerticalScrollMode = savedInstanceState.getBoolean(STATE_VERTICAL_SCROLL_MODE, false); } binding.webviewAlertReload.setOnClickListener(v -> { @@ -675,12 +694,22 @@ private static void enableDisableMenuItem(MenuItem item, boolean enable) { public void onJumpToPageInDocument(final int selected_page) { if (selected_page >= 1 && selected_page <= mNumPages && mPage != selected_page) { mPage = selected_page; - renderPage(0); + if (isVerticalScrollMode) { + binding.webview.evaluateJavascript("scrollToPageInDocument(" + selected_page + ")", null); + } else { + renderPage(0); + } showPageNumber(); invalidateOptionsMenu(); } } + private void toggleVerticalScrollMode() { + isVerticalScrollMode = !isVerticalScrollMode; + binding.webview.evaluateJavascript("setVerticalScrollMode(" + isVerticalScrollMode + ")", null); + invalidateOptionsMenu(); + } + private void showSystemUi() { ViewKt.showSystemUi(binding.getRoot(), getWindow()); getSupportActionBar().show(); @@ -700,6 +729,7 @@ public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { savedInstanceState.putFloat(STATE_ZOOM_RATIO, mZoomRatio); savedInstanceState.putInt(STATE_DOCUMENT_ORIENTATION_DEGREES, mDocumentOrientationDegrees); savedInstanceState.putString(STATE_ENCRYPTED_DOCUMENT_PASSWORD, mEncryptedDocumentPassword); + savedInstanceState.putBoolean(STATE_VERTICAL_SCROLL_MODE, isVerticalScrollMode); } private void showPageNumber() { @@ -731,7 +761,7 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) { R.id.action_next, R.id.action_previous, R.id.action_first, R.id.action_last, R.id.action_rotate_clockwise, R.id.action_rotate_counterclockwise, R.id.action_view_document_properties, R.id.action_share, R.id.action_save_as, - R.id.action_outline)); + R.id.action_outline, R.id.action_toggle_vertical_scroll)); if (BuildConfig.DEBUG) { ids.add(R.id.debug_action_toggle_text_layer_visibility); ids.add(R.id.debug_action_crash_webview); @@ -757,8 +787,13 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) { enableDisableMenuItem(menu.findItem(R.id.action_open), !webViewCrashed && getWebViewRelease() >= MIN_WEBVIEW_RELEASE); enableDisableMenuItem(menu.findItem(R.id.action_share), mUri != null); - enableDisableMenuItem(menu.findItem(R.id.action_next), mPage < mNumPages); - enableDisableMenuItem(menu.findItem(R.id.action_previous), mPage > 1); + + // In vertical scroll mode, disable page navigation buttons + enableDisableMenuItem(menu.findItem(R.id.action_next), !isVerticalScrollMode && mPage < mNumPages); + enableDisableMenuItem(menu.findItem(R.id.action_previous), !isVerticalScrollMode && mPage > 1); + enableDisableMenuItem(menu.findItem(R.id.action_first), !isVerticalScrollMode); + enableDisableMenuItem(menu.findItem(R.id.action_last), !isVerticalScrollMode); + enableDisableMenuItem(menu.findItem(R.id.action_save_as), mUri != null); enableDisableMenuItem(menu.findItem(R.id.action_view_document_properties), mDocumentProperties != null); @@ -822,6 +857,9 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } else if (itemId == R.id.action_save_as) { saveDocument(); + } else if (itemId == R.id.action_toggle_vertical_scroll) { + toggleVerticalScrollMode(); + return true; } else if (itemId == R.id.debug_action_toggle_text_layer_visibility) { binding.webview.evaluateJavascript("toggleTextLayerVisibility()", null); return true; diff --git a/app/src/main/res/drawable/ic_view_agenda_24dp.xml b/app/src/main/res/drawable/ic_view_agenda_24dp.xml new file mode 100644 index 00000000..c8ec1452 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_agenda_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/pdf_viewer.xml b/app/src/main/res/menu/pdf_viewer.xml index 16cd9b28..02743c3c 100644 --- a/app/src/main/res/menu/pdf_viewer.xml +++ b/app/src/main/res/menu/pdf_viewer.xml @@ -43,6 +43,12 @@ android:title="@string/action_jump_to_page" app:showAsAction="ifRoom" /> + + Outline Properties Close + Vertical scroll View nested outline entries No outline available diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ffbb825d..14e6b078 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7,683 +7,847 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -697,57 +861,71 @@ + + + + + + + + + + + + + + @@ -755,143 +933,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -921,9 +1137,11 @@ + + @@ -961,467 +1179,579 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1432,260 +1762,321 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1699,468 +2090,583 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2171,34 +2677,41 @@ + + + + + + + @@ -2209,24 +2722,29 @@ + + + + + @@ -2240,95 +2758,117 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -2342,276 +2882,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2625,271 +3232,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2900,46 +3573,57 @@ + + + + + + + + + + + @@ -2948,6 +3632,7 @@ + @@ -2956,90 +3641,111 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/viewer/css/pdf_viewer.css b/viewer/css/pdf_viewer.css index d4b7e477..246e9aaa 100644 --- a/viewer/css/pdf_viewer.css +++ b/viewer/css/pdf_viewer.css @@ -11,6 +11,7 @@ canvas { body { background-color: #c0c0c0; + overflow-x: hidden; } #container { @@ -33,6 +34,59 @@ body { grid-column-start: 1; } +/* Vertical scrolling mode styles */ +#all-pages-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 20px; + width: 100%; + min-height: 100vh; + box-sizing: border-box; +} + +.page-container { + position: relative; + margin-bottom: 20px; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + background: white; + max-width: 100%; +} + +.page-container::before { + content: "Page " attr(data-page-number); + position: absolute; + top: -25px; + left: 50%; + transform: translateX(-50%); + background: rgba(0,0,0,0.7); + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-family: sans-serif; + z-index: 10; +} + +.page-canvas { + display: block; + max-width: 100%; + height: auto; +} + +.page-text-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.2; + line-height: 1.0; + pointer-events: none; +} + canvas { display: inline-block; position: relative; diff --git a/viewer/js/index.js b/viewer/js/index.js index 3eed8fee..b77ef63a 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -28,11 +28,22 @@ const maxCached = 6; let isTextLayerVisible = false; +// Vertical scrolling mode variables +let isVerticalScrollMode = false; +let allPagesContainer = null; +let pageElements = []; +let currentPageInView = 1; +let scrollThrottledHandler = null; + function maybeRenderNextPage() { if (renderPending) { pageRendering = false; renderPending = false; - renderPage(channel.getPage(), renderPendingZoom, false); + if (isVerticalScrollMode) { + renderAllPages(renderPendingZoom); + } else { + renderPage(channel.getPage(), renderPendingZoom, false); + } return true; } return false; @@ -86,6 +97,257 @@ function getDefaultZoomRatio(page, orientationDegrees) { return Math.max(Math.min(widthZoomRatio, heightZoomRatio, channel.getMaxZoomRatio()), channel.getMinZoomRatio()); } +// Vertical scrolling mode functions +function createAllPagesContainer() { + if (allPagesContainer) { + allPagesContainer.remove(); + } + + allPagesContainer = document.createElement("div"); + allPagesContainer.id = "all-pages-container"; + allPagesContainer.style.cssText = ` + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 20px; + width: 100%; + min-height: 100vh; + `; + + container.appendChild(allPagesContainer); + + // Hide the single page canvas and text layer + canvas.style.display = "none"; + textLayerDiv.style.display = "none"; +} + +function createPageElement(pageNumber) { + const pageContainer = document.createElement("div"); + pageContainer.className = "page-container"; + pageContainer.dataset.pageNumber = pageNumber; + pageContainer.style.cssText = ` + position: relative; + margin-bottom: 20px; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + background: white; + `; + + const pageCanvas = document.createElement("canvas"); + pageCanvas.className = "page-canvas"; + pageCanvas.style.cssText = ` + display: block; + max-width: 100%; + height: auto; + `; + + const pageTextLayer = document.createElement("div"); + pageTextLayer.className = "textLayer page-text-layer"; + pageTextLayer.style.cssText = ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.2; + line-height: 1.0; + --scale-factor: 1; + --user-unit: 1; + --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); + --scale-round-x: 1px; + --scale-round-y: 1px; + `; + + pageContainer.appendChild(pageCanvas); + pageContainer.appendChild(pageTextLayer); + + return { pageContainer, pageCanvas, pageTextLayer }; +} + +async function renderAllPages(zoom = false) { + if (!pdfDoc || !isVerticalScrollMode) return; + + pageRendering = true; + + createAllPagesContainer(); + pageElements = []; + + const numPages = pdfDoc.numPages; + const containerWidth = container.clientWidth - 40; // Account for padding + + // Calculate appropriate zoom for vertical layout + const firstPage = await pdfDoc.getPage(1); + const defaultZoom = getDefaultZoomRatio(firstPage, orientationDegrees); + const verticalZoom = Math.min(defaultZoom * 0.8, containerWidth / firstPage.getViewport({scale: 1}).width); + + if (!zoom) { + zoomRatio = verticalZoom; + newZoomRatio = verticalZoom; + channel.setZoomRatio(verticalZoom); + } else { + zoomRatio = channel.getZoomRatio(); + newZoomRatio = zoomRatio; + } + + for (let pageNum = 1; pageNum <= numPages; pageNum++) { + try { + const page = await pdfDoc.getPage(pageNum); + const { pageContainer, pageCanvas, pageTextLayer } = createPageElement(pageNum); + + allPagesContainer.appendChild(pageContainer); + pageElements.push({ pageContainer, pageCanvas, pageTextLayer, pageNum }); + + await renderSinglePageInVerticalMode(page, pageCanvas, pageTextLayer); + + } catch (error) { + console.error(`Error rendering page ${pageNum}:`, error); + } + } + + pageRendering = false; + setupScrollListener(); + updateCurrentPageFromScroll(); +} + +async function renderSinglePageInVerticalMode(page, pageCanvas, pageTextLayer) { + const totalRotation = (orientationDegrees + page.rotate) % 360; + const viewport = page.getViewport({scale: newZoomRatio, rotation: totalRotation}); + + const ratio = globalThis.devicePixelRatio; + + pageCanvas.height = viewport.height * ratio; + pageCanvas.width = viewport.width * ratio; + pageCanvas.style.height = viewport.height + "px"; + pageCanvas.style.width = viewport.width + "px"; + + const context = pageCanvas.getContext("2d", { alpha: false }); + context.scale(ratio, ratio); + + // Render the page + await page.render({ + canvasContext: context, + viewport: viewport + }).promise; + + // Render text layer + const textLayer = new TextLayer({ + textContentSource: page.streamTextContent(), + container: pageTextLayer, + viewport: viewport + }); + + await textLayer.render(); + + // Set text layer transform + pageTextLayer.style.transform = `scale(${newZoomRatio})`; + pageTextLayer.style.transformOrigin = "0 0"; +} + +function setupScrollListener() { + if (!isVerticalScrollMode) return; + + // Remove existing listener first + if (scrollThrottledHandler) { + window.removeEventListener("scroll", scrollThrottledHandler); + } + + scrollThrottledHandler = throttle(() => { + updateCurrentPageFromScroll(); + }, 100); + + window.addEventListener("scroll", scrollThrottledHandler); +} + +function updateCurrentPageFromScroll() { + if (!isVerticalScrollMode || pageElements.length === 0) return; + + const scrollY = window.scrollY; + const windowHeight = window.innerHeight; + const scrollCenter = scrollY + windowHeight / 2; + + let closestPage = 1; + let closestDistance = Infinity; + + pageElements.forEach(({ pageContainer, pageNum }) => { + const rect = pageContainer.getBoundingClientRect(); + const pageCenter = rect.top + scrollY + rect.height / 2; + const distance = Math.abs(scrollCenter - pageCenter); + + if (distance < closestDistance) { + closestDistance = distance; + closestPage = pageNum; + } + }); + + if (closestPage !== currentPageInView) { + currentPageInView = closestPage; + // Notify Android about page change + if (typeof channel !== "undefined" && channel.onPageChanged) { + try { + channel.onPageChanged(currentPageInView); + } catch (e) { + console.log("Error calling onPageChanged:", e); + } + } + } +} + +function scrollToPage(pageNumber) { + if (!isVerticalScrollMode || !pageElements.length) return; + + const pageElement = pageElements.find(p => p.pageNum === pageNumber); + if (pageElement) { + pageElement.pageContainer.scrollIntoView({ + behavior: "smooth", + block: "center" + }); + } +} + +function switchToVerticalMode() { + isVerticalScrollMode = true; + renderAllPages(); +} + +function switchToPageMode() { + isVerticalScrollMode = false; + + // Clean up vertical mode elements + if (allPagesContainer) { + allPagesContainer.remove(); + allPagesContainer = null; + } + + pageElements = []; + + // Show single page elements + canvas.style.display = "inline-block"; + textLayerDiv.style.display = "block"; + + // Remove scroll listener + if (scrollThrottledHandler) { + window.removeEventListener("scroll", scrollThrottledHandler); + scrollThrottledHandler = null; + } + + // Render current page + renderPage(channel.getPage(), false, false); +} + +function throttle(func, limit) { + let inThrottle; + return function() { + const args = arguments; + const context = this; + if (!inThrottle) { + func.apply(context, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + /** * Does BFS traversal of all of the nodes in the outline tree to convert the tree so that the * nodes are of a simpler form. The simple outline nodes have the following structure: @@ -337,6 +599,16 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { } globalThis.onRenderPage = function (zoom) { + if (isVerticalScrollMode) { + if (pageRendering) { + renderPending = true; + renderPendingZoom = zoom; + } else { + renderAllPages(zoom); + } + return; + } + if (pageRendering) { if (newPageNumber === channel.getPage() && newZoomRatio === channel.getZoomRatio() && orientationDegrees === channel.getDocumentOrientationDegrees()) { @@ -389,6 +661,29 @@ globalThis.toggleTextLayerVisibility = function () { isTextLayerVisible = !isTextLayerVisible; }; +// Vertical scrolling mode global functions +globalThis.setVerticalScrollMode = function(enabled) { + if (enabled) { + switchToVerticalMode(); + } else { + switchToPageMode(); + } +}; + +globalThis.isVerticalScrollMode = function() { + return isVerticalScrollMode; +}; + +globalThis.getCurrentPageInView = function() { + return isVerticalScrollMode ? currentPageInView : channel.getPage(); +}; + +globalThis.scrollToPageInDocument = function(pageNumber) { + if (isVerticalScrollMode) { + scrollToPage(pageNumber); + } +}; + globalThis.loadDocument = function () { const pdfPassword = channel.getPassword(); const loadingTask = getDocument({ @@ -430,7 +725,14 @@ globalThis.loadDocument = function () { }).catch(function(error) { console.log("getOutline error: " + error); }); - renderPage(channel.getPage(), false, false); + + // Check if we should start in vertical scroll mode + const shouldUseVerticalMode = channel.isVerticalScrollMode && channel.isVerticalScrollMode(); + if (shouldUseVerticalMode) { + switchToVerticalMode(); + } else { + renderPage(channel.getPage(), false, false); + } }, function (reason) { console.error(reason.name + ": " + reason.message); });