1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-18 14:18:23 +02:00

[chrome] Fix text selection with .markedContent

The current text layer approach based on absolutely positioned
`<span>` elements by default causes flickering with text selection,
and we have browser-specific workarounds to solve that.

In Chrome, the workaround involves moving the `.endOfContent` element to
right after the last element that contains some selected content. This
works well in simple PDFs, but breaks when we have `span.markedContent`
elements. Given a text layer structure like the following, rendered
as four consecutive lines:
```html
<span class="markedContent">
  <br>
  <span>development enter the construction phase (estimated at around</span>
</span>
<span class="markedContent">
  <br>
  <span>300 MEUR).</span>
</span>
<span class="markedContent">
  <br>
  <span>Kreate's EBITA increased to 2.8 MEUR (Q4'23: 2.7 MEUR) and the</span>
</span>
<span class="markedContent">
  <br>
  <span>margin rose to 3.7% (Q4'23: 3.4%). However, profitability was</span>
</span>
```
when starting to select from inside the first line and dragging down
to the empty space after the second line, Chrome will anchor the
selection at the beginning of either the `<br>` or the `<span>` inside
the last `.markedContent`, depending on whether the selection is in
"per-character mode" (i.e. click and drag) or "per-word mode" (i.e.
double click and drag). This causes us to insert the `.endOfContent`
element in the wrong place (one element too far), which causes one
more line to be selected, which triggers another `"selecctionchange"`
event, which causes us to move `.endOfContent` again, and so on, looping
until when the whole page is selected.

This commit fixes the issue by making sure that when the end of the
selection range points to the _begining_ of an element, we walk back
the dom finding the first non-empty element, and attatch `.endOfContent`
to the end of that.
This commit is contained in:
Nicolò Ribaudo 2025-04-07 15:59:45 +02:00
parent 72feb4c256
commit da5b681b16
No known key found for this signature in database
GPG key ID: AAFDA9101C58F338
4 changed files with 107 additions and 0 deletions

View file

@ -217,6 +217,104 @@ describe("Text layer", () => {
});
});
describe("doesn't jump when hovering on an empty area, with .markedContent", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait(
"chrome-text-selection-markedContent.pdf",
`.page[data-page-number = "1"] .endOfContent`
);
});
afterAll(async () => {
await closePages(pages);
});
it("in per-character selection mode", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [positionStart, positionEnd] = await Promise.all([
getSpanRectFromText(
page,
1,
"strengthen in the coming quarters as the railway projects under"
).then(middlePosition),
getSpanRectFromText(
page,
1,
"development enter the construction phase (estimated at around"
).then(belowEndPosition),
]);
await page.mouse.move(positionStart.x, positionStart.y);
await page.mouse.down();
await moveInSteps(page, positionStart, positionEnd, 20);
await page.mouse.up();
await expectAsync(page)
.withContext(`In ${browserName}`)
.toHaveRoughlySelected(
"rs as the railway projects under\n" +
"development enter the construction phase (estimated at "
);
})
);
});
it("in per-word selection mode", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [positionStart, positionEnd] = await Promise.all([
getSpanRectFromText(
page,
1,
"strengthen in the coming quarters as the railway projects under"
).then(middlePosition),
getSpanRectFromText(
page,
1,
"development enter the construction phase (estimated at around"
).then(belowEndPosition),
]);
if (browserName !== "firefox") {
await page.mouse.move(positionStart.x, positionStart.y);
await page.mouse.down({ clickCount: 1 });
await page.mouse.up({ clickCount: 1 });
await page.mouse.down({ clickCount: 2 });
} else {
// When running tests with Firefox we use WebDriver BiDi, for
// which puppeteer doesn't support emulating "double click and
// hold". We need to manually dispatch an action through the
// protocol.
// See https://github.com/puppeteer/puppeteer/issues/13745.
await page.mainFrame().browsingContext.performActions([
{
type: "pointer",
id: "__puppeteer_mouse",
actions: [
{ type: "pointerMove", ...positionStart },
{ type: "pointerDown", button: 0 },
{ type: "pointerUp", button: 0 },
{ type: "pointerDown", button: 0 },
],
},
]);
}
await moveInSteps(page, positionStart, positionEnd, 20);
await page.mouse.up();
await expectAsync(page)
.withContext(`In ${browserName}`)
.toHaveRoughlySelected(
"quarters as the railway projects under\n" +
"development enter the construction phase (estimated at around"
);
})
);
});
});
describe("when selecting over a link", () => {
let pages;

View file

@ -718,3 +718,4 @@
!issue19424.pdf
!issue18529.pdf
!issue16742.pdf
!chrome-text-selection-markedContent.pdf

Binary file not shown.

View file

@ -307,6 +307,14 @@ class TextLayerBuilder {
if (anchor.nodeType === Node.TEXT_NODE) {
anchor = anchor.parentNode;
}
if (!modifyStart && range.endOffset === 0) {
do {
while (!anchor.previousSibling) {
anchor = anchor.parentNode;
}
anchor = anchor.previousSibling;
} while (!anchor.childNodes.length);
}
const parentTextLayer = anchor.parentElement?.closest(".textLayer");
const endDiv = this.#textLayers.get(parentTextLayer);