Skip to content

Commit

Permalink
Fix flickering on text selection no Firefox&Chrome mobile
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nicolo-ribaudo committed Apr 11, 2024
1 parent 77ee914 commit 92b5cf6
Show file tree
Hide file tree
Showing 2 changed files with 89 additions and 20 deletions.
8 changes: 6 additions & 2 deletions web/text_layer_builder.css
Expand Up @@ -17,7 +17,7 @@
position: absolute;
text-align: initial;
inset: 0;
overflow: hidden;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
Expand Down Expand Up @@ -108,12 +108,16 @@
display: block;
position: absolute;
inset: 100% 0 0;
z-index: -1;
z-index: 0;
cursor: default;
user-select: none;

&.active {
top: 0;
}
}

> * {
z-index: 1;
}
}
101 changes: 83 additions & 18 deletions web/text_layer_builder.js
Expand Up @@ -75,7 +75,7 @@ class TextLayerBuilder {
endOfContent.className = "endOfContent";
this.div.append(endOfContent);

this.#bindMouse();
this.#bindMouse(endOfContent);
}

get numTextDivs() {
Expand Down Expand Up @@ -181,26 +181,25 @@ class TextLayerBuilder {
* clicked. This reduces flickering of the content if the mouse is slowly
* dragged up or down.
*/
#bindMouse() {
#bindMouse(end) {
const { div } = this;

let isFirefox;
let selectingThroughMouse = false;
let prevRange = null;

div.addEventListener("mousedown", evt => {
const end = div.querySelector(".endOfContent");
if (!end) {
return;
}
selectingThroughMouse = true;

if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
isFirefox ??=
(typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) &&
getComputedStyle(end).getPropertyValue("-moz-user-select") === "none";
// 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) {
if (!isFirefox && evt.target !== div) {
const divBounds = div.getBoundingClientRect();
const r = Math.max(0, (evt.pageY - divBounds.top) / divBounds.height);
end.style.top = (r * 100).toFixed(2) + "%";
Expand All @@ -209,15 +208,81 @@ class TextLayerBuilder {
end.classList.add("active");
});

div.addEventListener("mouseup", () => {
const end = div.querySelector(".endOfContent");
if (!end) {
return;
}
const reset = () => {
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
end.style.top = "";
}
end.classList.remove("active");
selectingThroughMouse = false;
prevRange = null;
};
div.addEventListener("pointerup", reset);

// On touchscreen devices, when expaning/reducing a selection through the
// 'bubbles' that mark the selection end/start the browser will not fire
// any pointer event, because the user is interacting with browser-specific
// UI and not with the webpage itself.
// To workaround this, we listen to the 'selectionchange' event and check if
// the selection includes elements from this page. This has two drawbacks:
// - We can only detect changes once they start happening. If the first
// movement that the user does is to move one of the selection bubbles
// to a blank part of the page, the selection will flicker before
// that we can apply the fix.
// - We cannot detect when the user stops interacting with the selection,
// because there is no difference between "the user is still holding
// their finger on the selection 'bubble'" and "the user is lifter their
// finger and then pressed the 'bubble' again". Thus, the .endOfContent
// element will remain active until when the user interacts with the
// page again, triggering a pointerup event.
document.addEventListener("selectionchange", () => {
if (selectingThroughMouse) {
return;
}

const selection = document.getSelection();
if (selection.rangeCount === 0) {
reset();
return;
}

const range = selection.getRangeAt(0);
if (!range.intersectsNode(div)) {
reset();
return;
}

if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
isFirefox ??=
(typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) &&
getComputedStyle(end).getPropertyValue("-moz-user-select") === "none";
// 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.
if (!isFirefox) {
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;
}
// Make sure that we are always only moving .endOfContent to somewhere
// directly in .textLayer, and not inside nested spans.
anchor = anchor.closest(".textLayer > span");
anchor.parentElement.insertBefore(
end,
modifyStart ? anchor : anchor.nextSibling
);
prevRange = range.cloneRange();
}
}

end.classList.add("active");
});

div.addEventListener("copy", event => {
Expand Down

0 comments on commit 92b5cf6

Please sign in to comment.