diff --git a/apps/readest-app/src/__tests__/utils/sel.test.ts b/apps/readest-app/src/__tests__/utils/sel.test.ts index e40fac2e..511f4a06 100644 --- a/apps/readest-app/src/__tests__/utils/sel.test.ts +++ b/apps/readest-app/src/__tests__/utils/sel.test.ts @@ -340,6 +340,43 @@ describe('sel utilities', () => { document.body.removeChild(container); }); + + it('should insert a newline for
between adjacent text spans (PDF line wrap)', async () => { + const { getTextFromRange } = await import('@/utils/sel'); + // Mirrors how pdf.js renders the text layer: each text run is its + // own , and line endings are
. + const container = document.createElement('div'); + container.className = 'textLayer'; + container.innerHTML = + 'last word of line 1' + + '
' + + 'first word of line 2'; + document.body.appendChild(container); + + const range = document.createRange(); + range.selectNodeContents(container); + + const text = getTextFromRange(range); + // Without separating the spans the text becomes + // "last word of line 1first word of line 2" — words are glued. + expect(text).toBe('last word of line 1\nfirst word of line 2'); + + document.body.removeChild(container); + }); + + it('should insert a newline for explicit
in HTML content', async () => { + const { getTextFromRange } = await import('@/utils/sel'); + const container = document.createElement('div'); + container.innerHTML = 'first
second
third'; + document.body.appendChild(container); + + const range = document.createRange(); + range.selectNodeContents(container); + + expect(getTextFromRange(range)).toBe('first\nsecond\nthird'); + + document.body.removeChild(container); + }); }); describe('snapRangeToWords', () => { diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index 75827f81..76fdf4a8 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -271,21 +271,33 @@ export const snapRangeToWords = (range: Range): void => { export const getTextFromRange = (range: Range, rejectTags: string[] = []): string => { const clonedRange = range.cloneRange(); const fragment = clonedRange.cloneContents(); - const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT, { - acceptNode: (node) => { - const parent = node.parentElement; - if (rejectTags.includes(parent?.tagName.toLowerCase() || '')) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; + const walker = document.createTreeWalker( + fragment, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + { + acceptNode: (node) => { + const parent = node.parentElement; + if (rejectTags.includes(parent?.tagName.toLowerCase() || '')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, }, - }); + ); + // pdf.js inserts
between text spans at line endings + // (see TextLayer#appendText in pdfjs). Without this, multi-line PDF + // selections collapse adjacent line-final and line-initial words into a + // single token (e.g. "lastfirst"). Treat
as a newline, matching how + // Selection.toString() handles line breaks in the browser. let text = ''; - let node: Text | null; - - while ((node = walker.nextNode() as Text | null)) { - text += node.nodeValue ?? ''; + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + text += (node as Text).nodeValue ?? ''; + } else if ((node as Element).tagName === 'BR') { + text += '\n'; + } } return text;