From 1ade94c2b14d35763cdb6d0c8314cfc8d5fd7416 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:21:01 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A9=20Enable=20double-click=20paragrap?= =?UTF-8?q?h=20selection=20and=20clipboard=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect semantic or visually wrapped PDF paragraphs, select all their text when double-clicked, and automatically copy them with the existing Markdown setting. Respect column boundaries, preserve ordinary selection, and add focused tests. Co-authored-by: Codex Web --- package.json | 2 +- .../selection/paragraph-double-click.js | 166 ++++++++++++++++++ src/viewer/viewer.js | 2 + tests/paragraph-double-click.test.mjs | 81 +++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/viewer/selection/paragraph-double-click.js create mode 100644 tests/paragraph-double-click.test.mjs diff --git a/package.json b/package.json index 814a18e..b2a9e1f 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/paragraph-double-click.test.mjs" }, "dependencies": { "pdfjs-dist": "6.3.289", diff --git a/src/viewer/selection/paragraph-double-click.js b/src/viewer/selection/paragraph-double-click.js new file mode 100644 index 0000000..57faf0f --- /dev/null +++ b/src/viewer/selection/paragraph-double-click.js @@ -0,0 +1,166 @@ +import { markdownFromRuns } from './markdown-copy.js'; + +// PDFs position font runs rather than expose HTML paragraphs. Prefer the PDF +// semantic blocks; otherwise identify nearby lines in one visual column. +function spanItems(spans) { + return spans.flatMap((span, index) => { + const text = span.textContent || ''; + const rect = span.getBoundingClientRect(); + return text.trim() && rect.width > 0 && rect.height > 0 + ? [{ span, index, text, left: rect.left, right: rect.right, + top: rect.top, bottom: rect.bottom, height: rect.height }] : []; + }); +} + +function sameLine(line, item) { + const overlap = Math.min(line.bottom, item.bottom) - Math.max(line.top, item.top); + const verticalMatch = overlap > Math.min(line.height, item.height) * 0.5; + const gap = Math.max(line.left - item.right, item.left - line.right, 0); + return verticalMatch && gap <= Math.max(12, Math.min(line.height, item.height) * 2.2); +} + +function visualLines(items) { + const lines = []; + for (const item of [...items].sort((a, b) => a.top - b.top || a.left - b.left)) { + let line = lines.find(candidate => sameLine(candidate, item)); + if (!line) { + line = { items: [], left: item.left, right: item.right, top: item.top, + bottom: item.bottom, height: item.height }; + lines.push(line); + } + line.items.push(item); + line.left = Math.min(line.left, item.left); + line.right = Math.max(line.right, item.right); + line.top = Math.min(line.top, item.top); + line.bottom = Math.max(line.bottom, item.bottom); + line.height = line.bottom - line.top; + } + for (const line of lines) line.items.sort((a, b) => a.left - b.left || a.index - b.index); + return lines.sort((a, b) => a.top - b.top || a.left - b.left); +} + +function continuesParagraph(previous, next) { + const height = Math.max(previous.height, next.height); + const separation = next.top - previous.top; + if (separation < height * 0.55 || separation > height * 1.95) return false; + if (next.items.some(item => item.span.dataset.markdownParagraphStart === 'true')) return false; + if ([...previous.items, ...next.items].some(item => item.span.dataset.copyBlock !== undefined)) return false; + // Exclude other columns, marginalia, and differently sized headings. + if (Math.min(previous.right, next.right) - Math.max(previous.left, next.left) <= 0) return false; + if (Math.abs(next.left - previous.left) > height * 5) return false; + if (Math.max(previous.height, next.height) / Math.min(previous.height, next.height) > 1.55) return false; + // First-line indentation marks a new paragraph; dedenting a wrapped + // second line is expected. + if (next.left - previous.left > height * 1.7) return false; + // A short sentence-ending line followed by a full line at the same margin + // is often an unindented paragraph boundary. + const previousText = previous.items.map(item => item.text).join(''); + if (/[.!?][”"')\]]?\s*$/.test(previousText) && + previous.right - previous.left > height * 5 && + Math.abs(next.left - previous.left) < height * 1.2 && + previous.right < next.right - height * 3 && + previous.right - previous.left < (next.right - next.left) * 0.75) return false; + return true; +} + +export function paragraphAtSpan(spans, clickedSpan) { + const items = spanItems(spans); + const clicked = items.find(item => item.span === clickedSpan); + if (!clicked) return null; + const block = clickedSpan.dataset.copyBlock; + if (block !== undefined) { + const tagged = items.filter(item => item.span.dataset.copyBlock === block); + if (tagged.length) return { lines: visualLines(tagged), spans: tagged.map(item => item.span) }; + } + + const lines = visualLines(items); + const anchor = lines.find(line => line.items.some(item => item.span === clickedSpan)); + if (!anchor) return null; + // Do not let a parallel column interrupt this paragraph's line sequence. + const columnLines = lines.filter(line => + Math.abs(line.left - anchor.left) < Math.max(line.height, anchor.height) * 6 && + Math.min(line.right, anchor.right) > Math.max(line.left, anchor.left)); + const index = columnLines.indexOf(anchor); + let start = index, end = index; + while (start > 0 && continuesParagraph(columnLines[start - 1], columnLines[start])) start--; + while (end < columnLines.length - 1 && continuesParagraph(columnLines[end], columnLines[end + 1])) end++; + const paragraphLines = columnLines.slice(start, end + 1); + return { lines: paragraphLines, spans: paragraphLines.flatMap(line => line.items.map(item => item.span)) }; +} + +export function paragraphCopyText(lines, markdown = false) { + const runs = []; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const items = lines[lineIndex].items; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const prior = items[index - 1]; + const gap = prior ? item.left - prior.right : 0; + runs.push({ + text: item.text, + bold: item.span.dataset.markdownBold === 'true', + italic: item.span.dataset.markdownItalic === 'true', + newLine: lineIndex > 0 && index === 0, + // The bounds are already one paragraph: reflow visual lines into + // spaces even when the source PDF is not tagged. + role: 'P', block: 'clicked-paragraph', + spaceBefore: item.span.dataset.markdownSpaceBefore === 'true' || + (prior && gap > Math.max(1, Math.min(prior.height, item.height) * 0.12)), + }); + } + } + return markdownFromRuns(runs, { markdown, reflow: true }).trim(); +} + +function selectParagraph(doc, layer, spans) { + const ordered = [...layer.querySelectorAll('[data-markdown-item]')]; + const indices = spans.map(span => ordered.indexOf(span)).filter(index => index >= 0); + if (!indices.length) return false; + const range = doc.createRange(); + range.setStartBefore(ordered[Math.min(...indices)]); + range.setEndAfter(ordered[Math.max(...indices)]); + const selection = doc.getSelection(); + if (!selection) return false; + selection.removeAllRanges(); + selection.addRange(range); + return true; +} + +export function installParagraphDoubleClick(root, markdownToggle, doc = document, view = window) { + if (!root) return; + let pendingCopy = null; + // Installed after the regular copy formatter. Override just this gesture's + // copy with visual paragraph order and optional Markdown formatting. + doc.addEventListener('copy', event => { + if (!pendingCopy || !event.clipboardData) return; + event.clipboardData.setData('text/plain', pendingCopy.text); + if (pendingCopy.markdown) event.clipboardData.setData('text/markdown', pendingCopy.text); + event.preventDefault(); + pendingCopy.handled = true; + }); + doc.addEventListener('dblclick', async event => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || + !event.target?.closest || event.target.closest('a, button, input, textarea, [contenteditable]')) return; + const span = event.target.closest('.text-layer [data-markdown-item]'); + if (!span || !root.contains(span)) return; + const layer = span.closest('.text-layer'); + if (!layer) return; + const paragraph = paragraphAtSpan([...layer.querySelectorAll('[data-markdown-item]')], span); + if (!paragraph || !paragraph.spans.length) return; + const markdown = markdownToggle?.checked === true; + const text = paragraphCopyText(paragraph.lines, markdown); + if (!text || !selectParagraph(doc, layer, paragraph.spans)) return; + event.preventDefault(); + pendingCopy = { text, markdown, handled: false }; + try { + let copied = false; + try { copied = doc.execCommand?.('copy') === true && pendingCopy.handled; } catch { /* blocked */ } + if (!copied) await view.navigator.clipboard.writeText(text); + view.dispatchEvent(new view.CustomEvent('pdf-viewer-toast', { detail: { message: 'Copied paragraph' } })); + } catch { + view.dispatchEvent(new view.CustomEvent('pdf-viewer-toast', { detail: { message: 'Could not copy paragraph' } })); + } finally { + pendingCopy = null; + } + }); +} diff --git a/src/viewer/viewer.js b/src/viewer/viewer.js index 75c09d0..9fe055d 100644 --- a/src/viewer/viewer.js +++ b/src/viewer/viewer.js @@ -8,6 +8,7 @@ import { resolvePdfSource } from "./pdf-source.js"; import { readResumedPage, saveResumedPage } from "./page-resume.js"; import { findSearchMatches } from "./search/search-matches.js"; import { installMarkdownCopy, markPdfTextStyles, markPdfTextStructure } from "./selection/markdown-copy.js"; +import { installParagraphDoubleClick } from "./selection/paragraph-double-click.js"; import { highlightTextLayer, registerSearchText } from "./search/search-highlight.js"; import { normalizeSearchText } from "./search/search-text.js"; import { PagePreviews } from "./page-previews.js"; @@ -1336,6 +1337,7 @@ async function downloadPdf() { function bindControls() { installMarkdownCopy(viewer, document.querySelector("#copy-as-markdown"), document, document.querySelector("#copy-tagged-paragraphs")); + installParagraphDoubleClick(viewer, document.querySelector("#copy-as-markdown")); window.addEventListener("pdf-viewer-toast", (event) => { if (typeof event.detail?.message === "string") showToast(event.detail.message); }); diff --git a/tests/paragraph-double-click.test.mjs b/tests/paragraph-double-click.test.mjs new file mode 100644 index 0000000..a6af4a1 --- /dev/null +++ b/tests/paragraph-double-click.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { paragraphAtSpan, paragraphCopyText, installParagraphDoubleClick } from '../src/viewer/selection/paragraph-double-click.js'; + +function span(text, x, y, width = text.length * 6, dataset = {}, height = 12) { + return { textContent: text, dataset, getBoundingClientRect: () => ({ + left: x, right: x + width, top: y, bottom: y + height, width, height, + }) }; +} + +test('selects all wrapped lines but stops at a new paragraph indent', () => { + const a = span('This is a paragraph', 20, 20, 160); + const b = span('continued on line two', 20, 35, 150); + const c = span('with a third line.', 20, 50, 140); + const d = span('A separate paragraph', 48, 65, 180); + const e = span('continues here', 20, 80, 130); + assert.deepEqual(paragraphAtSpan([a,b,c,d,e],b).spans, [a,b,c]); + assert.equal(paragraphCopyText(paragraphAtSpan([a,b,c,d,e],b).lines), + 'This is a paragraph continued on line two with a third line.'); + assert.deepEqual(paragraphAtSpan([a,b,c,d,e],e).spans, [d,e]); +}); + +test('uses semantic tags instead of visual guesses when available', () => { + const a = span('Tagged paragraph line one',20,20,175,{copyBlock:'5',copyRole:'P'}); + const b = span('line two',20,35,80,{copyBlock:'5',copyRole:'P'}); + const c = span('Different block',20,50,150,{copyBlock:'6',copyRole:'P'}); + assert.deepEqual(paragraphAtSpan([a,b,c],b).spans,[a,b]); +}); + +test('excludes other columns and detached headings', () => { + const left1 = span('Left column line one',20,20,155); + const left2 = span('Left column line two',20,35,150); + const right1 = span('Right column line one',220,20,155); + const right2 = span('Right column line two',220,35,150); + const heading = span('Heading',20,0,55,{},20); + const items=[heading,right2,left1,right1,left2]; + assert.deepEqual(paragraphAtSpan(items,left1).spans,[left1,left2]); + assert.deepEqual(paragraphAtSpan(items,right1).spans,[right1,right2]); +}); + +test('reflows split font runs using the existing Markdown formatter', () => { + const a = span('Hello',20,20,35,{markdownBold:'true'}); + const b = span('world.',60,20,40,{markdownItalic:'true'}); + const c = span('Again.',20,35,60); + const p=paragraphAtSpan([a,b,c],b); + assert.deepEqual(p.spans,[a,b,c]); + assert.equal(paragraphCopyText(p.lines,true),'**Hello** _world._ Again.'); +}); + +test('double-click highlights and copies; unrelated clicks are untouched', async () => { + const first=span('Hello world',20,20,100); + const second=span('from the PDF.',20,35,100); + const spans=[first,second]; + const layer={querySelectorAll:()=>spans}; + for (const s of spans) s.closest=selector=>selector==='.text-layer'?layer:null; + const selection={removeAllRanges(){this.ranges=[];},addRange(r){this.ranges.push(r);}}; + const handlers={}, clipboard={}; + const doc={ + addEventListener(type,handler){handlers[type]=handler;}, + createRange(){return {setStartBefore(s){this.start=s;},setEndAfter(s){this.end=s;}};}, + getSelection(){return selection;}, + execCommand(command){ + assert.equal(command,'copy'); + handlers.copy({clipboardData:{setData(type,value){clipboard[type]=value;}},preventDefault(){}}); + return true; + }, + }; + const events=[]; + const view={dispatchEvent(e){events.push(e);},CustomEvent:class{constructor(type,{detail}){this.type=type;this.detail=detail;}}}; + installParagraphDoubleClick({contains:()=>true},{checked:false},doc,view); + const target={closest(selector){ + if(selector.startsWith('a,'))return null; + if(selector.startsWith('.text-layer'))return second; + return null; + }}; + await handlers.dblclick({button:0,target,preventDefault(){}}); + assert.equal(clipboard['text/plain'],'Hello world from the PDF.'); + assert.equal(selection.ranges[0].start,first); + assert.equal(selection.ranges[0].end,second); + assert.equal(events[0].detail.message,'Copied paragraph'); +});