fix(annotation): preserve line breaks in selected text across <br> elements, closes #3981 (#3986)

In multi-line PDF selections, pdf.js renders each text run as its own <span>
and inserts <br role="presentation"> at line endings. getTextFromRange only
walked text nodes, so <br>s were dropped and adjacent line-final/line-initial
words glued together (e.g. "lastfirst") in highlights, notes, and AI inputs.

Walk elements alongside text nodes and emit "\n" for <br>, mirroring how
Selection.toString() handles line breaks.
This commit is contained in:
Huang Xin
2026-04-29 00:56:55 +08:00
committed by GitHub
parent 920627ae59
commit 34f19fd148
2 changed files with 61 additions and 12 deletions
@@ -340,6 +340,43 @@ describe('sel utilities', () => {
document.body.removeChild(container);
});
it('should insert a newline for <br> 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 <span>, and line endings are <br role="presentation">.
const container = document.createElement('div');
container.className = 'textLayer';
container.innerHTML =
'<span role="presentation">last word of line 1</span>' +
'<br role="presentation">' +
'<span role="presentation">first word of line 2</span>';
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 <br> in HTML content', async () => {
const { getTextFromRange } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = 'first<br>second<br>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', () => {
+24 -12
View File
@@ -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 <br role="presentation"> 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 <br> 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;