diff --git a/README.md b/README.md index 62dffa2..e5a3a38 100644 --- a/README.md +++ b/README.md @@ -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=` 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. diff --git a/package.json b/package.json index 814a18e..f604d10 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/viewer.html b/src/viewer.html index eeb4de1..a460b0b 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -7,6 +7,7 @@ + @@ -329,6 +330,7 @@

Scan file URL

+ diff --git a/src/viewer/selection/reading-return-cue.css b/src/viewer/selection/reading-return-cue.css new file mode 100644 index 0000000..229c12a --- /dev/null +++ b/src/viewer/selection/reading-return-cue.css @@ -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; + } +} diff --git a/src/viewer/selection/reading-return-cue.js b/src/viewer/selection/reading-return-cue.js new file mode 100644 index 0000000..d2c6905 --- /dev/null +++ b/src/viewer/selection/reading-return-cue.js @@ -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(); diff --git a/tests/reading-return-cue.test.mjs b/tests/reading-return-cue.test.mjs new file mode 100644 index 0000000..90421cd --- /dev/null +++ b/tests/reading-return-cue.test.mjs @@ -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/); +});