OpenCode commited on
Commit ·
81f0fe5
1
Parent(s): b2a8745
Improve cross-page PDF selection
Browse files- static/reader.css +4 -0
- static/reader.js +46 -8
- tests/test_reader_refactor.py +38 -0
static/reader.css
CHANGED
|
@@ -907,6 +907,8 @@ body {
|
|
| 907 |
width: 100%;
|
| 908 |
max-width: 100%;
|
| 909 |
height: auto;
|
|
|
|
|
|
|
| 910 |
}
|
| 911 |
.reader-content[data-mode="pdf-pages"] .reader-page {
|
| 912 |
content-visibility: auto;
|
|
@@ -917,6 +919,8 @@ body {
|
|
| 917 |
position: relative;
|
| 918 |
width: 100%;
|
| 919 |
height: auto;
|
|
|
|
|
|
|
| 920 |
opacity: 0;
|
| 921 |
transition: opacity 120ms ease;
|
| 922 |
}
|
|
|
|
| 907 |
width: 100%;
|
| 908 |
max-width: 100%;
|
| 909 |
height: auto;
|
| 910 |
+
user-select: none;
|
| 911 |
+
-webkit-user-select: none;
|
| 912 |
}
|
| 913 |
.reader-content[data-mode="pdf-pages"] .reader-page {
|
| 914 |
content-visibility: auto;
|
|
|
|
| 919 |
position: relative;
|
| 920 |
width: 100%;
|
| 921 |
height: auto;
|
| 922 |
+
user-select: none;
|
| 923 |
+
-webkit-user-select: none;
|
| 924 |
opacity: 0;
|
| 925 |
transition: opacity 120ms ease;
|
| 926 |
}
|
static/reader.js
CHANGED
|
@@ -2180,8 +2180,35 @@ for (const type of ["wheel", "touchstart", "pointerdown"])
|
|
| 2180 |
viewport.addEventListener(type, notePdfScrollIntent, { passive: true });
|
| 2181 |
let pdfSelectionPointer = null;
|
| 2182 |
viewport.addEventListener("pointerdown", (event) => {
|
| 2183 |
-
|
| 2184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2185 |
}, { passive: true });
|
| 2186 |
document.addEventListener("pointerup", (event) => {
|
| 2187 |
const start = pdfSelectionPointer;
|
|
@@ -2189,7 +2216,7 @@ document.addEventListener("pointerup", (event) => {
|
|
| 2189 |
if (!["pdf", "pdf-pages"].includes(capability.mode) || !start || start.id !== event.pointerId ||
|
| 2190 |
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
| 2191 |
const x = event.clientX, y = event.clientY;
|
| 2192 |
-
requestAnimationFrame(() => correctPdfCrossPageSelection(x, y));
|
| 2193 |
}, { passive: true });
|
| 2194 |
document.addEventListener("pointercancel", () => { pdfSelectionPointer = null; }, { passive: true });
|
| 2195 |
window.addEventListener("pagehide", (event) => {
|
|
@@ -2723,11 +2750,22 @@ function pdfSelectionIntersects(element) {
|
|
| 2723 |
if (element.contains(selection.anchorNode) || element.contains(selection.focusNode)) return true;
|
| 2724 |
return !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(element);
|
| 2725 |
}
|
| 2726 |
-
function correctPdfCrossPageSelection(x, y) {
|
| 2727 |
const selection = document.getSelection();
|
| 2728 |
-
if (!selection?.rangeCount
|
| 2729 |
-
const
|
| 2730 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2731 |
if (!origin || !target || origin === target || !content.contains(origin) || !content.contains(target)) return;
|
| 2732 |
const runs = [...target.querySelectorAll(".reader-pdf-text-run")].filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE);
|
| 2733 |
if (!runs.length) return;
|
|
@@ -2757,7 +2795,7 @@ function correctPdfCrossPageSelection(x, y) {
|
|
| 2757 |
}
|
| 2758 |
offset = low;
|
| 2759 |
}
|
| 2760 |
-
selection.setBaseAndExtent(
|
| 2761 |
}
|
| 2762 |
function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) {
|
| 2763 |
if (total <= 256) return null;
|
|
|
|
| 2180 |
viewport.addEventListener(type, notePdfScrollIntent, { passive: true });
|
| 2181 |
let pdfSelectionPointer = null;
|
| 2182 |
viewport.addEventListener("pointerdown", (event) => {
|
| 2183 |
+
const run = event.target.closest?.(".reader-pdf-text-run");
|
| 2184 |
+
const selection = document.getSelection();
|
| 2185 |
+
const selectedRun = !selection?.isCollapsed && event.target.closest?.(".reader-page")
|
| 2186 |
+
? selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run") : null;
|
| 2187 |
+
const origin = run || selectedRun;
|
| 2188 |
+
pdfSelectionPointer = origin
|
| 2189 |
+
? { id: event.pointerId, x: event.clientX, y: event.clientY, frame: 0,
|
| 2190 |
+
page: origin.closest(".reader-page"), anchorNode: run ? null : selection.anchorNode,
|
| 2191 |
+
anchorOffset: run ? 0 : selection.anchorOffset } : null;
|
| 2192 |
+
}, { passive: true });
|
| 2193 |
+
document.addEventListener("pointermove", (event) => {
|
| 2194 |
+
const start = pdfSelectionPointer;
|
| 2195 |
+
if (!start || start.id !== event.pointerId ||
|
| 2196 |
+
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
| 2197 |
+
const selection = document.getSelection();
|
| 2198 |
+
if (!start.anchorNode && selection?.rangeCount && start.page.contains(selection.anchorNode) &&
|
| 2199 |
+
selection.anchorNode.parentElement?.closest(".reader-pdf-text-run")) {
|
| 2200 |
+
start.anchorNode = selection.anchorNode;
|
| 2201 |
+
start.anchorOffset = selection.anchorOffset;
|
| 2202 |
+
}
|
| 2203 |
+
if (start.anchorNode) {
|
| 2204 |
+
start.endX = event.clientX;
|
| 2205 |
+
start.endY = event.clientY;
|
| 2206 |
+
if (!start.frame) start.frame = requestAnimationFrame(() => {
|
| 2207 |
+
start.frame = 0;
|
| 2208 |
+
if (pdfSelectionPointer === start)
|
| 2209 |
+
correctPdfCrossPageSelection(start.endX, start.endY, start);
|
| 2210 |
+
});
|
| 2211 |
+
}
|
| 2212 |
}, { passive: true });
|
| 2213 |
document.addEventListener("pointerup", (event) => {
|
| 2214 |
const start = pdfSelectionPointer;
|
|
|
|
| 2216 |
if (!["pdf", "pdf-pages"].includes(capability.mode) || !start || start.id !== event.pointerId ||
|
| 2217 |
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
| 2218 |
const x = event.clientX, y = event.clientY;
|
| 2219 |
+
requestAnimationFrame(() => correctPdfCrossPageSelection(x, y, start));
|
| 2220 |
}, { passive: true });
|
| 2221 |
document.addEventListener("pointercancel", () => { pdfSelectionPointer = null; }, { passive: true });
|
| 2222 |
window.addEventListener("pagehide", (event) => {
|
|
|
|
| 2750 |
if (element.contains(selection.anchorNode) || element.contains(selection.focusNode)) return true;
|
| 2751 |
return !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(element);
|
| 2752 |
}
|
| 2753 |
+
function correctPdfCrossPageSelection(x, y, start = null) {
|
| 2754 |
const selection = document.getSelection();
|
| 2755 |
+
if (!selection?.rangeCount) return;
|
| 2756 |
+
const anchorNode = start?.anchorNode || selection.anchorNode;
|
| 2757 |
+
const anchorOffset = start?.anchorNode ? start.anchorOffset : selection.anchorOffset;
|
| 2758 |
+
if (!start?.anchorNode && selection.isCollapsed) return;
|
| 2759 |
+
const origin = anchorNode?.parentElement?.closest(".reader-page");
|
| 2760 |
+
let target = document.elementFromPoint(x, y)?.closest(".reader-page");
|
| 2761 |
+
if (!target) {
|
| 2762 |
+
let distance = Infinity;
|
| 2763 |
+
for (const shell of content.querySelectorAll(".reader-page")) {
|
| 2764 |
+
const rect = shell.getBoundingClientRect();
|
| 2765 |
+
const gap = Math.max(rect.top - y, y - rect.bottom, 0);
|
| 2766 |
+
if (gap < distance) { distance = gap; target = shell; }
|
| 2767 |
+
}
|
| 2768 |
+
}
|
| 2769 |
if (!origin || !target || origin === target || !content.contains(origin) || !content.contains(target)) return;
|
| 2770 |
const runs = [...target.querySelectorAll(".reader-pdf-text-run")].filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE);
|
| 2771 |
if (!runs.length) return;
|
|
|
|
| 2795 |
}
|
| 2796 |
offset = low;
|
| 2797 |
}
|
| 2798 |
+
selection.setBaseAndExtent(anchorNode, anchorOffset, node, offset);
|
| 2799 |
}
|
| 2800 |
function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) {
|
| 2801 |
if (total <= 256) return null;
|
tests/test_reader_refactor.py
CHANGED
|
@@ -254,6 +254,13 @@ class ReaderRefactorTest(unittest.TestCase):
|
|
| 254 |
text: document.elementFromPoint(text.left + text.width / 2, text.top + text.height / 2) === run };
|
| 255 |
}""")
|
| 256 |
self.assertEqual(hit, {"image": True, "text": True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
gap = self.page.evaluate("""() => {
|
| 258 |
const runs = document.querySelectorAll('.reader-page[data-page="2"] .reader-pdf-text-run');
|
| 259 |
const first = runs[0].getBoundingClientRect(), second = runs[1].getBoundingClientRect();
|
|
@@ -262,6 +269,16 @@ class ReaderRefactorTest(unittest.TestCase):
|
|
| 262 |
self.page.mouse.move(points[0]["x"], points[0]["y"])
|
| 263 |
self.page.mouse.down()
|
| 264 |
self.page.mouse.move(gap["x"], gap["y"], steps=24)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
self.page.mouse.up()
|
| 266 |
self.page.wait_for_function("() => !getSelection().toString().includes('Page 2 end')")
|
| 267 |
selection = self.page.evaluate("getSelection().toString()")
|
|
@@ -269,6 +286,27 @@ class ReaderRefactorTest(unittest.TestCase):
|
|
| 269 |
self.assertIn("Page 2 text", selection)
|
| 270 |
self.assertNotIn("Page 1 text", selection)
|
| 271 |
self.assertNotIn("Page 2 end", selection)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
self.assertEqual(requested, [ocr_path, text_root + "/ocr/page-000001.json.gz",
|
| 273 |
text_root + "/ocr/page-000002.json.gz"])
|
| 274 |
|
|
|
|
| 254 |
text: document.elementFromPoint(text.left + text.width / 2, text.top + text.height / 2) === run };
|
| 255 |
}""")
|
| 256 |
self.assertEqual(hit, {"image": True, "text": True})
|
| 257 |
+
self.page.evaluate("""() => {
|
| 258 |
+
window.__pdfImageMenu = false;
|
| 259 |
+
document.querySelector('.reader-page[data-page="2"] img').addEventListener('contextmenu',
|
| 260 |
+
() => { window.__pdfImageMenu = true; });
|
| 261 |
+
}""")
|
| 262 |
+
self.page.locator('.reader-page[data-page="2"] img').click(button="right", position={"x": 10, "y": 10})
|
| 263 |
+
self.assertTrue(self.page.evaluate("window.__pdfImageMenu"))
|
| 264 |
gap = self.page.evaluate("""() => {
|
| 265 |
const runs = document.querySelectorAll('.reader-page[data-page="2"] .reader-pdf-text-run');
|
| 266 |
const first = runs[0].getBoundingClientRect(), second = runs[1].getBoundingClientRect();
|
|
|
|
| 269 |
self.page.mouse.move(points[0]["x"], points[0]["y"])
|
| 270 |
self.page.mouse.down()
|
| 271 |
self.page.mouse.move(gap["x"], gap["y"], steps=24)
|
| 272 |
+
during = self.page.evaluate("""() => ({
|
| 273 |
+
imageSelect: getComputedStyle(document.querySelector('.reader-page[data-page="1"] img')).userSelect,
|
| 274 |
+
anchor: getSelection().anchorNode?.parentElement?.closest('.reader-page')?.dataset.page,
|
| 275 |
+
focus: getSelection().focusNode?.parentElement?.closest('.reader-page')?.dataset.page,
|
| 276 |
+
text: getSelection().toString()
|
| 277 |
+
})""")
|
| 278 |
+
self.assertEqual(during["imageSelect"], "none")
|
| 279 |
+
self.assertEqual(during["anchor"], "1")
|
| 280 |
+
self.assertEqual(during["focus"], "2")
|
| 281 |
+
self.assertNotIn("Page 2 end", during["text"])
|
| 282 |
self.page.mouse.up()
|
| 283 |
self.page.wait_for_function("() => !getSelection().toString().includes('Page 2 end')")
|
| 284 |
selection = self.page.evaluate("getSelection().toString()")
|
|
|
|
| 286 |
self.assertIn("Page 2 text", selection)
|
| 287 |
self.assertNotIn("Page 1 text", selection)
|
| 288 |
self.assertNotIn("Page 2 end", selection)
|
| 289 |
+
self.page.set_viewport_size({"width": 390, "height": 1500})
|
| 290 |
+
self.page.evaluate("() => { getSelection().removeAllRanges(); document.querySelector('#viewport').scrollTop = 0; }")
|
| 291 |
+
bottom = self.page.evaluate("""() => {
|
| 292 |
+
const start = document.querySelectorAll('.reader-page[data-page="1"] .reader-pdf-text-run')[2].getBoundingClientRect();
|
| 293 |
+
const end = document.querySelector('.reader-page[data-page="2"]').getBoundingClientRect();
|
| 294 |
+
return { x: start.left + 2, y: start.top + start.height / 2,
|
| 295 |
+
endX: end.left + end.width / 2, endY: end.bottom + 8 };
|
| 296 |
+
}""")
|
| 297 |
+
self.page.mouse.move(bottom["x"], bottom["y"])
|
| 298 |
+
self.page.mouse.down()
|
| 299 |
+
self.page.mouse.move(bottom["endX"], bottom["endY"], steps=36)
|
| 300 |
+
self.page.wait_for_function("() => getSelection().focusNode?.parentElement?.closest('.reader-page')?.dataset.page === '2'")
|
| 301 |
+
moving = self.page.evaluate("""() => ({
|
| 302 |
+
anchor: getSelection().anchorNode.parentElement.closest('.reader-page').dataset.page,
|
| 303 |
+
text: getSelection().toString()
|
| 304 |
+
})""")
|
| 305 |
+
self.assertEqual(moving["anchor"], "1")
|
| 306 |
+
self.assertIn("Page 1 end", moving["text"])
|
| 307 |
+
self.assertIn("Page 2 end", moving["text"])
|
| 308 |
+
self.assertNotIn("Page 1 text", moving["text"])
|
| 309 |
+
self.page.mouse.up()
|
| 310 |
self.assertEqual(requested, [ocr_path, text_root + "/ocr/page-000001.json.gz",
|
| 311 |
text_root + "/ocr/page-000002.json.gz"])
|
| 312 |
|