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

Fix flickering on text selection

When seleciting on a touch screen device, whenever the finger moves to a
blank area (so over `div.textLayer` directly rather than on a `<span>`),
the selection jumps to include all the text between the beginning of the
.textLayer and the selection side that is not being moved.

The existing selection flickering fix when using the mouse cannot be
trivially re-used on mobile, because when modifying a selection on
a touchscreen device Firefox will not emit any pointer event (and
Chrome will emit them inconsistently). Instead, we have to listen to the
'selectionchange' event.

The fix is different in Firefox and Chrome:
- on Firefox, we have to make sure that, when modifying the selection,
  hovering on blank areas will hover on the .endOfContent element
  rather than on the .textLayer element. This is done by adjusting the
  z-indexes so that .endOfContent is above .textLayer.
- on Chrome, hovering on blank areas needs to trigger hovering on an
  element that is either immediately after (or immediately before,
  depending on which side of the selection the user is moving) the
  currently selected text. This is done by moving the .endOfContent
  element around between the correct `<span>`s in the text layer.

The new anti-flickering code is also used when selecting using a mouse:
the improvement in Firefox is only observable on multi-page selection,
while in Chrome it also affects selection within a single page.

After this commit, the `z-index`es inside .textLayer are as follows:
- .endOfContent has `z-index: 0`
- everything else has `z-index: 1`
  - except for .markedContent, which have `z-index: 0`
    and their contents have `z-index: 1`.

`.textLayer` has an explicit `z-index: 0` to introduce a new stacking context,
so that its contents are not drawn on top of `.annotationLayer`.
This commit is contained in:
Nicolò Ribaudo 2024-04-11 12:50:57 +02:00
parent 7290faf840
commit 6f2e4d0d94
No known key found for this signature in database
GPG key ID: AAFDA9101C58F338
7 changed files with 467 additions and 59 deletions

View file

@ -17,13 +17,14 @@
position: absolute;
text-align: initial;
inset: 0;
overflow: hidden;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 0;
&.highlighting {
touch-action: none;
@ -37,6 +38,11 @@
transform-origin: 0% 0%;
}
> :not(.markedContent),
.markedContent span:not(.markedContent) {
z-index: 1;
}
/* Only necessary in Google Chrome, see issue 14205, and most unfortunately
* the problem doesn't show up in "text" reference tests. */
/*#if !MOZCENTRAL*/
@ -108,7 +114,7 @@
display: block;
position: absolute;
inset: 100% 0 0;
z-index: -1;
z-index: 0;
cursor: default;
user-select: none;

View file

@ -47,6 +47,10 @@ class TextLayerBuilder {
#textContentSource = null;
static #textLayers = new Map();
static #selectionChangeAbortController = null;
constructor({
highlighter = null,
accessibilityManager = null,
@ -75,7 +79,7 @@ class TextLayerBuilder {
endOfContent.className = "endOfContent";
this.div.append(endOfContent);
this.#bindMouse();
this.#bindMouse(endOfContent);
}
get numTextDivs() {
@ -166,6 +170,7 @@ class TextLayerBuilder {
this.textContentItemsStr.length = 0;
this.textDivs.length = 0;
this.textDivProperties = new WeakMap();
TextLayerBuilder.#removeGlobalSelectionListener(this.div);
}
/**
@ -181,45 +186,13 @@ class TextLayerBuilder {
* clicked. This reduces flickering of the content if the mouse is slowly
* dragged up or down.
*/
#bindMouse() {
#bindMouse(end) {
const { div } = this;
div.addEventListener("mousedown", evt => {
const end = div.querySelector(".endOfContent");
if (!end) {
return;
}
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
// On non-Firefox browsers, the selection will feel better if the height
// of the `endOfContent` div is adjusted to start at mouse click
// location. This avoids flickering when the selection moves up.
// However it does not work when selection is started on empty space.
let adjustTop = evt.target !== div;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
adjustTop &&=
getComputedStyle(end).getPropertyValue("-moz-user-select") !==
"none";
}
if (adjustTop) {
const divBounds = div.getBoundingClientRect();
const r = Math.max(0, (evt.pageY - divBounds.top) / divBounds.height);
end.style.top = (r * 100).toFixed(2) + "%";
}
}
end.classList.add("active");
});
div.addEventListener("mouseup", () => {
const end = div.querySelector(".endOfContent");
if (!end) {
return;
}
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
end.style.top = "";
}
end.classList.remove("active");
});
div.addEventListener("copy", event => {
if (!this.#enablePermissions) {
const selection = document.getSelection();
@ -231,6 +204,131 @@ class TextLayerBuilder {
event.preventDefault();
event.stopPropagation();
});
TextLayerBuilder.#textLayers.set(div, end);
TextLayerBuilder.#enableGlobalSelectionListener();
}
static #removeGlobalSelectionListener(textLayerDiv) {
this.#textLayers.delete(textLayerDiv);
if (this.#textLayers.size === 0) {
this.#selectionChangeAbortController?.abort();
this.#selectionChangeAbortController = null;
}
}
static #enableGlobalSelectionListener() {
if (TextLayerBuilder.#selectionChangeAbortController) {
// document-level event listeners already installed
return;
}
TextLayerBuilder.#selectionChangeAbortController = new AbortController();
const reset = (end, textLayer) => {
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
textLayer.append(end);
end.style.width = "";
end.style.height = "";
}
end.classList.remove("active");
};
document.addEventListener(
"pointerup",
() => {
TextLayerBuilder.#textLayers.forEach(reset);
},
{ signal: TextLayerBuilder.#selectionChangeAbortController.signal }
);
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
// eslint-disable-next-line no-var
var isFirefox, prevRange;
}
document.addEventListener(
"selectionchange",
() => {
const selection = document.getSelection();
if (selection.rangeCount === 0) {
TextLayerBuilder.#textLayers.forEach(reset);
return;
}
// Even though the spec says that .rangeCount should be 0 or 1, Firefox
// creates multiple ranges when selecting across multiple pages.
// Make sure to collect all the .textLayer elements where the selection
// is happening.
const activeTextLayers = new Set();
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i);
for (const textLayerDiv of TextLayerBuilder.#textLayers.keys()) {
if (
!activeTextLayers.has(textLayerDiv) &&
range.intersectsNode(textLayerDiv)
) {
activeTextLayers.add(textLayerDiv);
}
}
}
for (const [textLayerDiv, endDiv] of TextLayerBuilder.#textLayers) {
if (activeTextLayers.has(textLayerDiv)) {
endDiv.classList.add("active");
} else {
reset(endDiv, textLayerDiv);
}
}
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("CHROME")) {
isFirefox = false;
} else {
isFirefox ??=
getComputedStyle(
TextLayerBuilder.#textLayers.values().next().value
).getPropertyValue("-moz-user-select") === "none";
}
if (!isFirefox) {
// In non-Firefox browsers, when hovering over an empty space (thus,
// on .endOfContent), the selection will expand to cover all the
// text between the current selection and .endOfContent. By moving
// .endOfContent to right after (or before, depending on which side
// of the selection the user is moving), we limit the selection jump
// to at most cover the enteirety of the <span> where the selection
// is being modified.
const range = selection.getRangeAt(0);
const modifyStart =
prevRange &&
(range.compareBoundaryPoints(Range.END_TO_END, prevRange) === 0 ||
range.compareBoundaryPoints(Range.START_TO_END, prevRange) ===
0);
let anchor = modifyStart
? range.startContainer
: range.endContainer;
if (anchor.nodeType === Node.TEXT_NODE) {
anchor = anchor.parentNode;
}
const parentTextLayer = anchor.parentElement.closest(".textLayer");
const endDiv = TextLayerBuilder.#textLayers.get(parentTextLayer);
if (endDiv) {
endDiv.style.width = parentTextLayer.style.width;
endDiv.style.height = parentTextLayer.style.height;
anchor.parentElement.insertBefore(
endDiv,
modifyStart ? anchor : anchor.nextSibling
);
}
prevRange = range.cloneRange();
}
}
},
{ signal: TextLayerBuilder.#selectionChangeAbortController.signal }
);
}
}