From bd5fa0f7ab74f7a89e8c5f2007275d492d37ae03 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:12:42 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=96=8C=EF=B8=8F=20Mark=20clicked=20PD?= =?UTF-8?q?F=20text=20after=20leaving=20the=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remember the last mouse-clicked word or selected text location and reveal a temporary blue highlight with a right-edge pointer when the viewer loses focus. Keep the cue visible on return, restore it after page rerenders, and clear stale coordinates after rotation. Add positioning and wiring regression tests. Co-authored-by: ChatGPT 6 --- README.md | 1 + src/viewer.html | 2 + src/viewer/selection/reading-return-cue.css | 31 ++++ src/viewer/selection/reading-return-cue.js | 193 ++++++++++++++++++++ tests/reading-return-cue.test.mjs | 55 ++++++ 5 files changed, 282 insertions(+) create mode 100644 src/viewer/selection/reading-return-cue.css create mode 100644 src/viewer/selection/reading-return-cue.js create mode 100644 tests/reading-return-cue.test.mjs 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/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/); +}); From 5a915f782f5ba4970d6ce5eb6cc29a7f10b3ed76 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:14:01 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20Run=20reading-return=20cue?= =?UTF-8?q?=20tests=20in=20extension=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the new nearest-text and focus-marker regression suite in the repository's explicit npm test command so pull-request builds execute it. Co-authored-by: ChatGPT 6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",