pdf.js.mirror/web/text_layer_builder.js
Nicolò Ribaudo ea43bb43fb
Avoid text selection workaround in modern Chromium
Chromium 148+ improved their selection behavior when it comes to absolutely
positioned elements, thus making text selectino in PDF.js much better.

Unfortunately this does not only mean that the workaround we currently have
for Chromium is unnecessary, but it actually become harmful. It conflicts
with Chromium's new behavior, making text selection *worse* on mobile.

As the change has been released in Chrome only a month ago, this patch keeps
the workaround for older Chromium versions. There is no easy way to
feture-detect is, so unfortunately we need to do user agent version detection.
2026-07-01 13:14:34 +02:00

381 lines
12 KiB
JavaScript

/* Copyright 2012 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/text_layer_images.js").TextLayerImages} TextLayerImages */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { normalizeUnicode, stopEvent, TextLayer } from "pdfjs-lib";
import { removeNullCharacters } from "./ui_utils.js";
/**
* @typedef {Object} TextLayerBuilderOptions
* @property {PDFPageProxy} pdfPage
* @property {TextHighlighter} [highlighter] - Optional object that will handle
* highlighting text from the find controller.
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {boolean} [enablePermissions]
* @property {function} [onAppend]
* @property {AbortSignal} [abortSignal]
*/
/**
* @typedef {Object} TextLayerBuilderRenderOptions
* @property {PageViewport} viewport
* @property {TextLayerImages} images
* @property {Object} [textContentParams]
*/
/**
* The text layer builder provides text selection functionality for the PDF.
* It does this by creating overlay divs over the PDF's text. These divs
* contain text that matches the PDF text they are overlaying.
*/
class TextLayerBuilder {
#abortSignal = null;
#enablePermissions = false;
#onAppend = null;
#renderingDone = false;
#textLayer = null;
static #textLayers = new Map();
static #selectionChangeAC = null;
/**
* @param {TextLayerBuilderOptions} options
*/
constructor({
pdfPage,
highlighter = null,
accessibilityManager = null,
enablePermissions = false,
onAppend = null,
abortSignal = null,
}) {
this.pdfPage = pdfPage;
this.highlighter = highlighter;
this.accessibilityManager = accessibilityManager;
this.#enablePermissions = enablePermissions === true;
this.#onAppend = onAppend;
this.#abortSignal = abortSignal;
this.div = document.createElement("div");
this.div.tabIndex = 0;
this.div.className = "textLayer";
}
/**
* Renders the text layer.
* @param {TextLayerBuilderRenderOptions} options
* @returns {Promise<void>}
*/
async render({ viewport, images, textContentParams = null }) {
if (this.#renderingDone && this.#textLayer) {
this.#textLayer.update({
viewport,
onBefore: this.hide.bind(this),
});
this.show();
return;
}
this.cancel();
this.#textLayer = new TextLayer({
textContentSource: this.pdfPage.streamTextContent(
textContentParams || {
includeMarkedContent: true,
disableNormalization: true,
}
),
images,
container: this.div,
viewport,
});
const { textDivs, textContentItemsStr } = this.#textLayer;
this.highlighter?.setTextMapping(textDivs, textContentItemsStr);
this.accessibilityManager?.setTextMapping(textDivs);
await this.#textLayer.render();
this.#renderingDone = true;
const endOfContent = document.createElement("div");
endOfContent.className = "endOfContent";
this.div.append(endOfContent);
this.#bindMouse(endOfContent);
// Ensure that the textLayer is appended to the DOM *before* handling
// e.g. a pending search operation.
this.#onAppend?.(this.div);
this.highlighter?.enable();
this.accessibilityManager?.enable();
}
hide() {
if (!this.div.hidden && this.#renderingDone) {
// We turn off the highlighter in order to avoid to scroll into view an
// element of the text layer which could be hidden.
this.highlighter?.disable();
this.div.hidden = true;
}
}
show() {
if (this.div.hidden && this.#renderingDone) {
this.div.hidden = false;
this.highlighter?.enable();
}
}
/**
* Cancel rendering of the text layer.
*/
cancel() {
this.#textLayer?.cancel();
this.#textLayer = null;
this.#renderingDone = false;
this.div.replaceChildren();
this.highlighter?.disable();
this.accessibilityManager?.disable();
TextLayerBuilder.#removeGlobalSelectionListener(this.div);
}
/**
* Improves text selection by adding an additional div where the mouse was
* clicked. This reduces flickering of the content if the mouse is slowly
* dragged up or down.
*/
#bindMouse(end) {
const { div } = this;
const abortSignal = this.#abortSignal;
const opts = abortSignal ? { signal: abortSignal } : null;
div.addEventListener(
"mousedown",
() => {
div.classList.add("selecting");
},
opts
);
div.addEventListener(
"copy",
event => {
if (!this.#enablePermissions) {
const selection = document.getSelection();
event.clipboardData.setData(
"text/plain",
removeNullCharacters(normalizeUnicode(selection.toString()))
);
}
stopEvent(event);
},
opts
);
TextLayerBuilder.#textLayers.set(div, end);
TextLayerBuilder.#enableGlobalSelectionListener(abortSignal);
}
static #removeGlobalSelectionListener(textLayerDiv) {
this.#textLayers.delete(textLayerDiv);
if (this.#textLayers.size === 0) {
this.#selectionChangeAC?.abort();
this.#selectionChangeAC = null;
}
}
static #enableGlobalSelectionListener(globalAbortSignal) {
if (this.#selectionChangeAC) {
// document-level event listeners already installed
return;
}
this.#selectionChangeAC = new AbortController();
const signal = globalAbortSignal
? AbortSignal.any([this.#selectionChangeAC.signal, globalAbortSignal])
: this.#selectionChangeAC.signal;
const reset = (end, textLayer) => {
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
textLayer.append(end);
end.style.width = "";
end.style.height = "";
}
textLayer.classList.remove("selecting");
};
let isPointerDown = false;
document.addEventListener(
"pointerdown",
() => {
isPointerDown = true;
},
{ signal }
);
document.addEventListener(
"pointerup",
() => {
isPointerDown = false;
this.#textLayers.forEach(reset);
},
{ signal }
);
window.addEventListener(
"blur",
() => {
isPointerDown = false;
this.#textLayers.forEach(reset);
},
{ signal }
);
document.addEventListener(
"keyup",
() => {
if (!isPointerDown) {
this.#textLayers.forEach(reset);
}
},
{ signal }
);
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
// eslint-disable-next-line no-var
var isFirefoxOrModernChromium, prevRange;
}
document.addEventListener(
"selectionchange",
() => {
const selection = document.getSelection();
if (selection.rangeCount === 0) {
this.#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 this.#textLayers.keys()) {
if (
!activeTextLayers.has(textLayerDiv) &&
range.intersectsNode(textLayerDiv)
) {
activeTextLayers.add(textLayerDiv);
}
}
}
for (const [textLayerDiv, endDiv] of this.#textLayers) {
if (activeTextLayers.has(textLayerDiv)) {
textLayerDiv.classList.add("selecting");
} else {
reset(endDiv, textLayerDiv);
}
}
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
return;
}
if (isFirefoxOrModernChromium === undefined) {
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) {
isFirefoxOrModernChromium =
getComputedStyle(
this.#textLayers.values().next().value
).getPropertyValue("-moz-user-select") === "none";
}
if (
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("CHROME")) ||
!isFirefoxOrModernChromium
) {
// navigator.userAgentData is only available in secure contexts
const chromiumVersion = navigator.userAgentData
? navigator.userAgentData.brands.find(
({ brand }) => brand === "Chromium"
)?.version
: /\bChrome\/(\d+)\b/.exec(navigator.userAgent)?.[1];
isFirefoxOrModernChromium =
!!chromiumVersion && parseInt(chromiumVersion, 10) >= 148;
}
}
if (isFirefoxOrModernChromium) {
return;
}
// In browsers other than Firefox or Chromium 148+, 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 entirety 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;
}
if (anchor.classList?.contains("highlight")) {
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);
if (endDiv) {
endDiv.style.width = parentTextLayer.style.width;
endDiv.style.height = parentTextLayer.style.height;
endDiv.style.userSelect = "text";
anchor.parentElement.insertBefore(
endDiv,
modifyStart ? anchor : anchor.nextSibling
);
}
prevRange = range.cloneRange();
},
{ signal }
);
}
}
export { TextLayerBuilder };