OpenCode commited on
Commit
81f0fe5
·
1 Parent(s): b2a8745

Improve cross-page PDF selection

Browse files
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
- pdfSelectionPointer = event.target.closest?.(".reader-page")
2184
- ? { id: event.pointerId, x: event.clientX, y: event.clientY } : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 || selection.isCollapsed) return;
2729
- const origin = selection.anchorNode?.parentElement?.closest(".reader-page");
2730
- const target = document.elementFromPoint(x, y)?.closest(".reader-page");
 
 
 
 
 
 
 
 
 
 
 
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(selection.anchorNode, selection.anchorOffset, node, offset);
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