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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A Chrome Manifest V3 extension that replaces the normal PDF tab with a local PDF
- Renders PDFs locally with bundled PDF.js assets; no remotely hosted executable code is used.
- Supports dark and light viewing modes, remembers the selected theme, and shows Celestia in dark mode and Luna in light mode.
- Tracks the page currently centered in the viewport.
- Remembers the last text clicked or selected on a PDF page; switching away shows a blue text highlight and a left-pointing marker on the right page edge, retained on return until the next page click. The cue follows zoom and page rerenders without saving document text.
- Copies the original document URL as `#page=<current page>` silently when the mark is clicked.
- Honors an existing `#page=N` fragment when opening a document.
- Places Download beside the page-link icon and keeps rotate left/right and print in the **More tools** menu.
Expand Down
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/reading-return-cue.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
Expand Down
2 changes: 2 additions & 0 deletions src/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<link rel="icon" type="image/png" href="assets/luna-mark.png" />
<link rel="stylesheet" href="viewer/viewer.css" />
<link rel="stylesheet" href="viewer/selection/text-selection.css" />
<link rel="stylesheet" href="viewer/selection/reading-return-cue.css" />
<link rel="stylesheet" href="viewer/search/search-highlight-cues.css" />
<link rel="stylesheet" href="viewer/navigation/toolbar-layout.css" />
<link rel="stylesheet" href="viewer/navigation/minimap.css" />
Expand Down Expand Up @@ -329,6 +330,7 @@ <h2 id="qr-file-url-title">Scan file URL</h2>
<script type="module" src="viewer/viewer.js"></script>
<script type="module" src="viewer/navigation/rapid-page-navigation.js"></script>
<script type="module" src="viewer/selection/text-selection.js"></script>
<script type="module" src="viewer/selection/reading-return-cue.js"></script>
<script type="module" src="viewer/search/search-highlight-cues.js"></script>
<script type="module" src="viewer/navigation/minimap.js"></script>
<script type="module" src="viewer/navigation/page-tabs.js"></script>
Expand Down
31 changes: 31 additions & 0 deletions src/viewer/selection/reading-return-cue.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* Distinct from the amber arrow that marks the active search result. */
.reading-return-highlight {
position: absolute;
z-index: 3;
border-radius: 3px;
outline: 1px solid rgb(77 154 255 / 85%);
background: rgb(77 154 255 / 24%);
pointer-events: none;
user-select: none;
}

.reading-return-arrow {
position: absolute;
right: 7px;
z-index: 4;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 9px solid rgb(77 154 255 / 95%);
filter: drop-shadow(0 0 2px rgb(0 0 0 / 55%));
pointer-events: none;
transform: translateY(-50%);
}

@media print {
.reading-return-highlight,
.reading-return-arrow {
display: none !important;
}
}
193 changes: 193 additions & 0 deletions src/viewer/selection/reading-return-cue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Keep a visual reminder of the last PDF text the reader clicked when this
// window loses focus. The reminder is transient and never stores PDF text.
const viewer = typeof document === "undefined" ? null : document.querySelector("#viewer");
const HIGHLIGHT_CLASS = "reading-return-highlight";
const ARROW_CLASS = "reading-return-arrow";

export function closestReadingRectangle(rectangles, x, y) {
let nearest = null;
let nearestDistance = Infinity;
for (const rect of rectangles) {
if (!(rect.width > 0 && rect.height > 0)) continue;
const dx = Math.max(rect.left - x, x - rect.right, 0);
const dy = Math.max(rect.top - y, y - rect.bottom, 0);
const distance = dx * dx + dy * dy;
if (distance < nearestDistance) {
nearest = rect;
nearestDistance = distance;
}
}
return nearest;
}

export function relativeReadingRectangle(page, text) {
if (!(page.width > 0 && page.height > 0 && text.width > 0 && text.height > 0)) {
return null;
}
const left = Math.max(page.left, text.left);
const right = Math.min(page.right, text.right);
const top = Math.max(page.top, text.top);
const bottom = Math.min(page.bottom, text.bottom);
if (right <= left || bottom <= top) return null;
return {
left: 100 * (left - page.left) / page.width,
top: 100 * (top - page.top) / page.height,
width: 100 * (right - left) / page.width,
height: 100 * (bottom - top) / page.height,
};
}

function readingRectangleAtClick(page, event) {
const textLayer = page.querySelector(".text-layer");
if (!textLayer) return null;

// A drag may end outside the originating text span. Follow the selected
// endpoint when possible, including selections spanning multiple PDF spans.
const selection = document.getSelection();
if (selection && !selection.isCollapsed && textLayer.contains(selection.focusNode)) {
const bounds = page.getBoundingClientRect();
const rects = [];
for (let index = 0; index < selection.rangeCount; index += 1) {
try {
rects.push(...selection.getRangeAt(index).getClientRects());
} catch {
// PDF.js can replace selection nodes during a render.
}
}
const withinPage = rects.filter(rect => relativeReadingRectangle(bounds, rect));
const selected = closestReadingRectangle(withinPage, event.clientX, event.clientY);
if (selected) return selected;
}

const span = event.target instanceof Element
? event.target.closest(".text-layer span") : null;
if (!span || !textLayer.contains(span)) return null;

// Highlight the word nearest the click rather than an entire PDF.js text
// span, which may contain most of a paragraph or visual line.
const caret = document.caretRangeFromPoint?.(event.clientX, event.clientY);
const node = caret?.startContainer;
if (node?.nodeType === Node.TEXT_NODE && span.contains(node)) {
const text = node.textContent;
let start = Math.min(caret.startOffset, text.length);
let end = start;
while (start > 0 && !/\s/u.test(text[start - 1])) start -= 1;
while (end < text.length && !/\s/u.test(text[end])) end += 1;
if (end > start) {
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const word = closestReadingRectangle(range.getClientRects(), event.clientX, event.clientY);
if (word) return word;
}
}
return closestReadingRectangle(span.getClientRects(), event.clientX, event.clientY);
}

export function installReadingReturnCue() {
if (!viewer) return;
let lastLocation = null;
let isShown = false;
let pressedPage = null;

function removeCue() {
for (const cue of viewer.querySelectorAll("." + HIGHLIGHT_CLASS + ", ." + ARROW_CLASS)) {
cue.remove();
}
}

function drawCue() {
removeCue();
if (!isShown || !lastLocation) return;
const page = [...viewer.querySelectorAll(".page")].find(
element => element.dataset.page === lastLocation.page,
);
if (!page?.querySelector(".text-layer")) return;

const highlight = document.createElement("span");
highlight.className = HIGHLIGHT_CLASS;
highlight.setAttribute("aria-hidden", "true");
highlight.style.left = lastLocation.left + "%";
highlight.style.top = lastLocation.top + "%";
highlight.style.width = lastLocation.width + "%";
highlight.style.height = lastLocation.height + "%";

const arrow = document.createElement("span");
arrow.className = ARROW_CLASS;
arrow.setAttribute("aria-hidden", "true");
arrow.style.top = (lastLocation.top + lastLocation.height / 2) + "%";
page.append(highlight, arrow);
}

function resetLocation() {
lastLocation = null;
isShown = false;
removeCue();
}

function revealLocation() {
if (!lastLocation) return;
isShown = true;
drawCue();
}

viewer.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || event.pointerType === "touch") return;
pressedPage = null;
const page = event.target instanceof Element ? event.target.closest(".page") : null;
if (!page || !viewer.contains(page)) return;
// A new document click takes over the reading position and dismisses the
// previous reminder, even if the click was in a blank area of the page.
resetLocation();
pressedPage = event.target.closest(".text-layer") ? page : null;
});

viewer.addEventListener("pointerup", (event) => {
const page = pressedPage;
pressedPage = null;
if (!page || !page.isConnected || event.button !== 0) return;
const rect = readingRectangleAtClick(page, event);
if (!rect) return;
const position = relativeReadingRectangle(page.getBoundingClientRect(), rect);
if (position) lastLocation = { page: page.dataset.page, ...position };
});

viewer.addEventListener("pointercancel", () => {
pressedPage = null;
});

// Browser blur covers switching to another application or Chrome window;
// visibilitychange covers a tab switch where focus reporting differs.
window.addEventListener("blur", revealLocation);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") revealLocation();
});
// Do not dismiss on refocus: the cue is specifically for finding your place.
window.addEventListener("focus", () => {
if (isShown) drawCue();
});

// A quarter-turn moves text to a different page-relative position. Forget
// the old coordinates rather than pointing at unrelated text after rotation.
document.querySelector("#rotate-left")?.addEventListener("click", resetLocation);
document.querySelector("#rotate-right")?.addEventListener("click", resetLocation);

// Render-window recycling uses page.replaceChildren(). Reattach the marker
// once the anchored page is rendered again without observing our own spans.
const observer = new MutationObserver((mutations) => {
if (!isShown || !lastLocation) return;
const pageChanged = mutations.some(mutation =>
mutation.type === "childList" &&
mutation.target instanceof Element &&
mutation.target.matches(".page") &&
[...mutation.addedNodes, ...mutation.removedNodes].some(node =>
node instanceof Element &&
!node.matches("." + HIGHLIGHT_CLASS + ", ." + ARROW_CLASS),
),
);
if (pageChanged) drawCue();
});
observer.observe(viewer, { childList: true, subtree: true });
}

if (viewer) installReadingReturnCue();
55 changes: 55 additions & 0 deletions tests/reading-return-cue.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
closestReadingRectangle,
relativeReadingRectangle,
} from "../src/viewer/selection/reading-return-cue.js";

const source = readFileSync(
new URL("../src/viewer/selection/reading-return-cue.js", import.meta.url), "utf8",
);
const styles = readFileSync(
new URL("../src/viewer/selection/reading-return-cue.css", import.meta.url), "utf8",
);
const html = readFileSync(new URL("../src/viewer.html", import.meta.url), "utf8");

test("the text rectangle closest to the pointer determines the reading position", () => {
const upper = { left: 20, right: 80, top: 10, bottom: 22, width: 60, height: 12 };
const lower = { left: 20, right: 90, top: 35, bottom: 48, width: 70, height: 13 };
assert.equal(closestReadingRectangle([upper, lower], 42, 40), lower);
assert.equal(closestReadingRectangle([upper, lower], 42, 14), upper);
assert.equal(closestReadingRectangle([], 42, 14), null);
assert.equal(closestReadingRectangle([{ left: 0, right: 0, top: 0, bottom: 1, width: 0, height: 1 }], 0, 0), null);
});

test("click coordinates stay proportional to the page through zoom and clipping", () => {
const page = { left: 100, top: 50, right: 500, bottom: 850, width: 400, height: 800 };
const word = { left: 200, top: 250, right: 280, bottom: 270, width: 80, height: 20 };
assert.deepEqual(relativeReadingRectangle(page, word), {
left: 25, top: 25, width: 20, height: 2.5,
});
assert.deepEqual(relativeReadingRectangle(page, {
left: 90, top: 45, right: 140, bottom: 65, width: 50, height: 20,
}), { left: 0, top: 0, width: 10, height: 1.875 });
assert.equal(relativeReadingRectangle(page, {
left: 600, right: 620, top: 50, bottom: 70, width: 20, height: 20,
}), null);
});

test("the reminder appears only after leaving the viewer and survives refocus", () => {
assert.match(source, /window\.addEventListener\("blur", revealLocation\)/);
assert.match(source, /document\.addEventListener\("visibilitychange"/);
assert.match(source, /document\.visibilityState === "hidden"/);
assert.match(source, /window\.addEventListener\("focus"[\s\S]*?if \(isShown\) drawCue\(\)/);
assert.match(source, /viewer\.addEventListener\("pointerdown"/);
assert.match(source, /viewer\.addEventListener\("pointerup"/);
assert.match(source, /if \(pageChanged\) drawCue\(\)/);
});

test("the right-side pointer is separate from the existing search arrow", () => {
assert.match(styles, /border-right: 9px solid/);
assert.match(styles, /right: 7px/);
assert.match(html, /viewer\/selection\/reading-return-cue\.css/);
assert.match(html, /viewer\/selection\/reading-return-cue\.js/);
});
Loading