Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check-extension.mjs",
"test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs"
"test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs tests/two-page-layout.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
Expand Down
9 changes: 9 additions & 0 deletions src/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@
<input id="custom-loading-image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
<button id="section-cross-reference-notes" class="tool-button" type="button" role="menuitem">§ <span>Section cross-references</span></button>
<button id="cross-reference-notes" class="tool-button" type="button" role="menuitem">↗ <span>Footnote cross-references</span></button>
<label class="tool-toggle" title="Show two PDF pages side by side">
<span>Two-page view</span>
<input id="two-page-view" class="checkbox-input" type="checkbox" />
</label>
<label class="tool-toggle" title="Show page 1 alone on the right, then pair pages 2–3, 4–5, and so on">
<span>Offset cover</span>
<input id="offset-cover" class="checkbox-input" type="checkbox" />
</label>
<div class="menu-separator" role="separator"></div>
<label class="tool-toggle" title="Show a separate printed-page search field in the toolbar (off by default; you can always type 181* in the regular page field)">
<span>Show printed-page search</span>
<input id="printed-page-jump-opt-in" class="checkbox-input" type="checkbox" />
Expand Down
48 changes: 46 additions & 2 deletions src/viewer/viewer-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const CLICKABLE_REFERENCES_STORAGE_KEY = "pdf-viewer-clickable-references";
const PRINTED_PAGE_JUMP_STORAGE_KEY = "pdf-viewer-printed-page-jump";
const MARKDOWN_COPY_STORAGE_KEY = "pdf-viewer-copy-as-markdown";
const TAGGED_PARAGRAPH_COPY_STORAGE_KEY = "pdf-viewer-copy-tagged-paragraphs";
const TWO_PAGE_STORAGE_KEY = "pdf-viewer-two-page-view";
const OFFSET_COVER_STORAGE_KEY = "pdf-viewer-offset-cover";
const STUDIO_GREEN = "#43af49";

const toolsMenu = document.querySelector("#tools-menu");
Expand Down Expand Up @@ -385,6 +387,9 @@ function createZoomControls() {
}

function fitWidthBase() {
if (document.querySelector("#viewer")?.dataset.pageLayout === "two-up") {
return viewportPageWidth();
}
return Math.max(
160,
Math.min(DEFAULT_MAX_PAGE_WIDTH, window.innerWidth - PAGE_HORIZONTAL_GUTTER),
Expand All @@ -405,7 +410,11 @@ function viewportPageWidth() {
? root.classList.contains("minimap-collapsed") ? 2 : PAGE_HORIZONTAL_GUTTER / 2 + 1
: PAGE_HORIZONTAL_GUTTER;
const availableWidth = root.clientWidth || window.innerWidth;
return Math.max(160, availableWidth - minimapWidth - horizontalGutter);
const usableWidth = availableWidth - minimapWidth - horizontalGutter;
if (document.querySelector("#viewer")?.dataset.pageLayout === "two-up") {
return Math.max(100, (usableWidth - 16) / 2);
}
return Math.max(160, usableWidth);
}

function viewportPageTop() {
Expand Down Expand Up @@ -443,7 +452,7 @@ function applyZoomLayout() {
const pageWidth = zoomMode === "custom"
? fitWidthBase() * zoomScale
: viewportPageWidth();
viewer.style.setProperty("--zoom-page-width", `${Math.max(160, pageWidth)}px`);
viewer.style.setProperty("--zoom-page-width", `${Math.max(viewer.dataset.pageLayout === "two-up" ? 100 : 160, pageWidth)}px`);
}

function captureZoomAnchor() {
Expand Down Expand Up @@ -668,3 +677,38 @@ if (zoomControls) {
applyZoomLayout();
syncZoomControls();
}

function installPageLayoutSettings() {
const viewer = document.querySelector("#viewer");
const twoPageToggle = document.querySelector("#two-page-view");
const offsetCoverToggle = document.querySelector("#offset-cover");
if (!viewer || !twoPageToggle || !offsetCoverToggle) return;

styleSettingCheckbox(twoPageToggle);
styleSettingCheckbox(offsetCoverToggle);
twoPageToggle.checked = localStorage.getItem(TWO_PAGE_STORAGE_KEY) === "true";
offsetCoverToggle.checked = localStorage.getItem(OFFSET_COVER_STORAGE_KEY) === "true";

const apply = (persist = false) => {
const anchor = persist ? captureZoomAnchor() : null;
viewer.dataset.pageLayout = twoPageToggle.checked ? "two-up" : "single";
viewer.dataset.offsetCover = String(offsetCoverToggle.checked);
offsetCoverToggle.disabled = !twoPageToggle.checked;
offsetCoverToggle.closest(".tool-toggle").style.opacity = twoPageToggle.checked ? "1" : "0.5";

applyZoomLayout();
restoreZoomAnchor(anchor);
syncZoomControls();
if (persist) {
localStorage.setItem(TWO_PAGE_STORAGE_KEY, String(twoPageToggle.checked));
localStorage.setItem(OFFSET_COVER_STORAGE_KEY, String(offsetCoverToggle.checked));
scheduleViewerRerender();
}
};

twoPageToggle.addEventListener("change", () => apply(true));
offsetCoverToggle.addEventListener("change", () => apply(true));
apply();
}

installPageLayoutSettings();
23 changes: 23 additions & 0 deletions src/viewer/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,25 @@ input {
padding: calc(52px + var(--page-top-gap)) 16px calc(20px + var(--document-scrubber-height));
}

.viewer[data-page-layout="two-up"] {
display: grid;
grid-template-columns: repeat(2, var(--zoom-page-width, min(550px, calc((100vw - 48px) / 2))));
column-gap: 16px;
row-gap: 20px;
justify-content: center;
align-content: start;
align-items: start;
}

/* Offset the cover into the right column before forming facing-page spreads. */
.viewer[data-page-layout="two-up"][data-offset-cover="true"] .page[data-page="1"] {
grid-column: 2;
}

.viewer[data-page-layout="two-up"] .status {
grid-column: 1 / -1;
}

.page {
position: relative;
isolation: isolate;
Expand Down Expand Up @@ -1144,6 +1163,10 @@ input {
padding: 0;
}

.viewer[data-page-layout="two-up"] {
display: block;
}

.page {
width: 100%;
margin: 0;
Expand Down
97 changes: 97 additions & 0 deletions tests/two-page-layout.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";

const source = readFileSync(new URL("../src/viewer/viewer-settings.js", import.meta.url), "utf8");
const html = readFileSync(new URL("../src/viewer.html", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/viewer/viewer.css", import.meta.url), "utf8");

function loadFunction(name, context) {
const definition = source.match(new RegExp(`function ${name}\\([^]*?\\n\\}`))?.[0];
assert.ok(definition, `Missing ${name}`);
vm.runInContext(definition, context);
}

test("two-page preferences persist and reading position survives toggling", () => {
const stored = new Map();
const events = new Map();
const calls = [];
const viewer = { dataset: {} };
const toggle = name => ({
checked: false,
disabled: false,
closest: () => ({ style: {} }),
addEventListener(event, handler) { events.set(name, handler); },
});
const twoPage = toggle("two-page");
const offset = toggle("offset");
const selectors = { "#viewer": viewer, "#two-page-view": twoPage, "#offset-cover": offset };
const context = vm.createContext({
TWO_PAGE_STORAGE_KEY: "two",
OFFSET_COVER_STORAGE_KEY: "offset",
document: { querySelector(selector) { return selectors[selector]; } },
localStorage: {
getItem(key) { return stored.get(key) ?? null; },
setItem(key, value) { stored.set(key, value); },
},
styleSettingCheckbox() {},
captureZoomAnchor() { calls.push("capture"); return { page: 7 }; },
restoreZoomAnchor(anchor) { calls.push(["restore", anchor]); },
applyZoomLayout() { calls.push("layout"); },
syncZoomControls() { calls.push("sync"); },
scheduleViewerRerender() { calls.push("rerender"); },
});
loadFunction("installPageLayoutSettings", context);
context.installPageLayoutSettings();
assert.equal(viewer.dataset.pageLayout, "single");
assert.equal(offset.disabled, true);
assert.equal(calls.includes("rerender"), false);

twoPage.checked = true;
events.get("two-page")();
assert.equal(viewer.dataset.pageLayout, "two-up");
assert.equal(offset.disabled, false);
assert.equal(stored.get("two"), "true");
assert.equal(calls.at(-1), "rerender");
assert.deepEqual(calls.at(-3), ["restore", { page: 7 }]);

offset.checked = true;
events.get("offset")();
assert.equal(viewer.dataset.offsetCover, "true");
assert.equal(stored.get("offset"), "true");

twoPage.checked = false;
events.get("two-page")();
assert.equal(viewer.dataset.pageLayout, "single");
assert.equal(offset.disabled, true);
assert.equal(offset.checked, true, "Cover preference is retained in single-page mode");
});

test("grid pairs pages, offsets the first page on request, and resets for print", () => {
assert.match(html, /id="two-page-view"[^>]*type="checkbox"/);
assert.match(html, /id="offset-cover"[^>]*type="checkbox"/);
assert.match(css, /\.viewer\[data-page-layout="two-up"\]\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(2,/);
assert.match(css, /\[data-offset-cover="true"\] \.page\[data-page="1"\]\s*\{\s*grid-column:\s*2;/);
assert.match(css, /@media print\s*\{[\s\S]*?\.viewer\[data-page-layout="two-up"\]\s*\{\s*display:\s*block;/);
});

test("fit-to-width sizes two facing pages within the available width", () => {
const viewer = { dataset: { pageLayout: "two-up" } };
const context = vm.createContext({
PAGE_HORIZONTAL_GUTTER: 32,
DEFAULT_MAX_PAGE_WIDTH: 1100,
document: {
documentElement: { clientWidth: 1200, classList: { contains: () => true } },
querySelector(selector) { return selector === "#viewer" ? viewer : null; },
},
window: { innerWidth: 1200 },
});
loadFunction("viewportPageWidth", context);
loadFunction("fitWidthBase", context);
assert.equal(context.viewportPageWidth(), 576);
assert.equal(context.fitWidthBase(), 576);
viewer.dataset.pageLayout = "single";
assert.equal(context.viewportPageWidth(), 1168);
assert.equal(context.fitWidthBase(), 1100);
});
Loading