Merge pull request #20981 from wooorm/wooorm/hcm

Make text selection more visible (bug 1879559)
This commit is contained in:
calixteman 2026-05-20 15:49:01 +02:00 committed by GitHub
commit 5a4d93a238
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1313 additions and 22 deletions

View File

@ -16,6 +16,174 @@
import { DOMSVGFactory } from "./svg_factory.js";
import { shadow } from "../shared/util.js";
/**
* @typedef DrawLayerOptions
* Configuration for {@linkcode DrawLayer}.
* @property {Object | null} [filterFactory]
* Filter factory used to style selections (optional).
* @property {Object | null} [pageColors]
* Page foreground/background colors for HCM (optional).
* @property {number} pageIndex
* Zero-based page index.
* @property {Element | null} [textLayer]
* Text layer element (optional).
*/
/**
* @typedef EdgeBoundaryResult
* Result of {@linkcode normalizeEdgeBoundary}.
* @property {Node} container
* Normalized container.
* @property {number} offset
* Normalized offset.
*/
/**
* @typedef SelectionRotatorResult
* Result of {@linkcode SelectionRotator}.
* @property {number} x
* Rotated X coordinate.
* @property {number} y
* Rotated Y coordinate.
* @property {number} width
* Rotated width.
* @property {number} height
* Rotated height.
*/
/**
* @callback SelectionRotator
* Rotate the coordinates of a rectangle according to the position of the
* text layer in the viewport.
* @param {number} x
* X coordinate.
* @param {number} y
* Y coordinate.
* @param {number} width
* Width.
* @param {number} height
* Height.
* @returns {SelectionRotatorResult}
* Rotated coordinates.
*/
/**
* @typedef TextLayerSelectionData
* Data related to the selection for a text layer.
* @property {DrawLayer} drawLayer
* Draw layer associated with the text layer.
* @property {SVGPathElement | null} [path]
* Node (SVG path element) used to draw the selection.
* @property {HTMLDivElement | null} [selectionDiv]
* Node (div element) used to display the selection.
*/
/**
* Compare the document position of two text layers.
*
* @param {Element} a
* Text layer.
* @param {Element} b
* Other text layer.
* @returns {-1 | 0 | 1}
* `-1` if the `a` is before `b`, `1` if after, or `0`.
*/
function compareTextLayers(a, b) {
if (a === b) {
return 0;
}
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
}
/**
* Find the closest text layer upwards.
*
* @param {Node | null} node
* Node.
* @returns {Element | null}
* Closest ancestral text layer or `null`.
*/
function getTextLayer(node) {
if (!node) {
return null;
}
if (node.nodeType === Node.ELEMENT_NODE) {
return node.closest(".textLayer");
}
return node.parentElement?.closest(".textLayer") || null;
}
/**
* Compare the position of two points in the document order.
*
* @param {Node} nodeA
* Node.
* @param {number} offsetA
* Offset.
* @param {Node} nodeB
* Other node.
* @param {number} offsetB
* Other offset.
* @returns {boolean | null}
* Whether the first point is before the second one, or `null` if they are
* not comparable.
*/
function isPointBefore(nodeA, offsetA, nodeB, offsetB) {
if (nodeA === nodeB) {
return offsetA <= offsetB;
}
const relation = nodeA.compareDocumentPosition(nodeB);
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) {
return true;
}
if (relation & Node.DOCUMENT_POSITION_PRECEDING) {
return false;
}
return null;
}
/**
* Normalize the position of a boundary point when it's at the end of a text
* layer.
* In that case, we want to move it to the last valid position within
* the text layer, which can be either the end of the last text node or the end
* of the last text node before the endOfContent element if it exists.
*
* @param {Node} container
* Container.
* @param {number} offset
* Offset.
* @param {Element} textLayer
* Text layer.
* @returns {EdgeBoundaryResult | null}
* Normalized position or `null` if the position is not valid.
*/
function normalizeEdgeBoundary(container, offset, textLayer) {
if (
container.nodeType !== Node.ELEMENT_NODE ||
!container.classList.contains("textLayer") ||
offset !== container.childNodes.length
) {
return { container, offset };
}
let lastNode = container.lastChild;
if (
lastNode?.nodeType === Node.ELEMENT_NODE &&
lastNode.classList.contains("endOfContent")
) {
lastNode = lastNode.previousSibling;
}
if (!lastNode || !textLayer.contains(lastNode)) {
return null;
}
if (lastNode.nodeType === Node.TEXT_NODE) {
return { container: lastNode, offset: lastNode.textContent.length };
}
return { container: lastNode, offset: lastNode.childNodes.length };
}
/**
* Manage the SVGs drawn on top of the page canvas.
* It's important to have them directly on top of the canvas because we want to
@ -26,10 +194,97 @@ class DrawLayer {
#mapping = new Map();
/** @type {Element | null} */
#textLayer = null;
/** @type {Object | null} */
#filterFactory = null;
/** @type {Object | null} */
#pageColors = null;
#toUpdate = new Map();
static #id = 0;
static #selectionId = 0;
/** @type {AbortController | null} */
static #selectionChangeAC = null;
/** @type {Set<HTMLDivElement>} */
static #selections = new Set();
/** @type {boolean} */
static #isSelecting = false;
/** @type {Set<Element>} */
static #textLayerSet = new Set();
/** @type {WeakMap<Element, TextLayerSelectionData>} */
static #textLayers = new WeakMap();
/**
* @param {DrawLayerOptions} options
* Configuration.
* @returns
* Instance.
*/
constructor({
filterFactory = null,
pageColors = null,
pageIndex,
textLayer = null,
}) {
this.pageIndex = pageIndex;
this.#filterFactory = filterFactory;
this.#pageColors = pageColors;
if (textLayer) {
const previousData = DrawLayer.#textLayers.get(textLayer);
if (previousData?.selectionDiv) {
previousData.selectionDiv.remove();
DrawLayer.#selections.delete(previousData.selectionDiv);
}
DrawLayer.#textLayers.set(textLayer, { drawLayer: this });
DrawLayer.#textLayerSet.add(textLayer);
this.#textLayer = textLayer;
if (DrawLayer.#selectionChangeAC === null) {
DrawLayer.#selectionChangeAC = new AbortController();
const { signal } = DrawLayer.#selectionChangeAC;
document.addEventListener(
"selectionchange",
DrawLayer.#selectionChange.bind(DrawLayer),
{ signal }
);
// Track pointer selection state to preserve selections during
// cross-boundary drags.
document.addEventListener(
"pointerdown",
() => {
DrawLayer.#isSelecting = true;
},
{ signal }
);
document.addEventListener(
"pointerup",
() => {
DrawLayer.#isSelecting = false;
},
{ signal }
);
// If the pointer is released outside the window, we may not get a
// corresponding `pointerup` event.
window.addEventListener(
"blur",
() => {
DrawLayer.#isSelecting = false;
},
{ signal }
);
}
}
}
setParent(parent) {
if (!this.#parent) {
this.#parent = parent;
@ -47,6 +302,305 @@ class DrawLayer {
}
}
/**
* Clean up the selection for a text layer.
*
* @param {Element} textLayer
* Text layer.
* @returns {undefined}
* Nothing.
*/
static #cleanupTextLayerSelection(textLayer) {
const textLayerData = this.#textLayers.get(textLayer);
if (!textLayerData?.selectionDiv) {
return;
}
textLayerData.selectionDiv.remove();
this.#selections.delete(textLayerData.selectionDiv);
textLayerData.selectionDiv = null;
textLayerData.path = null;
}
/**
* Handle `selectionchange` to update the selection display for text layers.
* We want to display the selection in a separate layer on top of the text
* layer because the text layer has `mix-blend-mode: multiply` and we want
* the selection to have a different blend mode.
*
* @returns {undefined}
* Nothing.
*/
static #selectionChange() {
const selection = document.getSelection();
if (!selection || selection.isCollapsed) {
for (const root of this.#selections) {
root.remove();
}
this.#selections.clear();
return;
}
/** @type {WeakMap<Node, SelectionRotator>} */
const rotators = new WeakMap();
/** @type {Array<[Range, Element]>} */
const ranges = [];
for (let i = 0, ii = selection.rangeCount; i < ii; i++) {
const range = selection.getRangeAt(i);
if (range.collapsed) {
continue;
}
let { startContainer, startOffset, endContainer, endOffset } = range;
let startTextLayer = getTextLayer(startContainer);
let endTextLayer = getTextLayer(endContainer);
const startMissing = startTextLayer === null;
const endMissing = endTextLayer === null;
// XOR case: exactly one boundary is outside tracked text layers.
// In Firefox/Safari this can happen transiently while dragging outside
// the page. Preserve the current overlay and exit early.
if (this.#isSelecting && startMissing !== endMissing) {
return;
}
if (selection.rangeCount === 1) {
const { anchorNode, anchorOffset, focusNode, focusOffset } = selection;
const anchorLayer = getTextLayer(anchorNode);
const focusLayer = getTextLayer(focusNode);
const anchorBeforeFocus = isPointBefore(
anchorNode,
anchorOffset,
focusNode,
focusOffset
);
if (anchorLayer && focusLayer && anchorBeforeFocus !== null) {
if (anchorBeforeFocus) {
startContainer = anchorNode;
startOffset = anchorOffset;
startTextLayer = anchorLayer;
endContainer = focusNode;
endOffset = focusOffset;
endTextLayer = focusLayer;
} else {
startContainer = focusNode;
startOffset = focusOffset;
startTextLayer = focusLayer;
endContainer = anchorNode;
endOffset = anchorOffset;
endTextLayer = anchorLayer;
}
}
}
if (!startTextLayer || !endTextLayer) {
// Any remaining partial/outside range can be ignored.
continue;
}
if (endContainer.nodeType === Node.ELEMENT_NODE) {
if (endContainer.classList.contains("endOfContent")) {
const previousNode = endContainer.previousSibling;
if (!previousNode) {
continue;
}
endContainer = previousNode;
endOffset =
previousNode.nodeType === Node.TEXT_NODE
? previousNode.textContent.length
: previousNode.childNodes.length;
} else if (
endContainer.classList.contains("textLayer") &&
endContainer.childNodes.length === endOffset
) {
const normalizedEnd = normalizeEdgeBoundary(
endContainer,
endOffset,
endTextLayer
);
if (!normalizedEnd) {
continue;
}
endContainer = normalizedEnd.container;
endOffset = normalizedEnd.offset;
}
}
if (startContainer.nodeType === Node.ELEMENT_NODE) {
const normalizedStart = normalizeEdgeBoundary(
startContainer,
startOffset,
startTextLayer
);
if (!normalizedStart) {
continue;
}
startContainer = normalizedStart.container;
startOffset = normalizedStart.offset;
}
if (startTextLayer === endTextLayer) {
ranges.push([range, startTextLayer]);
continue;
}
/** @type {Array<Element>} */
let activeTextLayers = [];
const orderedTextLayers = [...this.#textLayerSet].sort(compareTextLayers);
const startIndex = orderedTextLayers.indexOf(startTextLayer);
const endIndex = orderedTextLayers.indexOf(endTextLayer);
if (startIndex !== -1 && endIndex !== -1) {
const [minIndex, maxIndex] =
startIndex < endIndex
? [startIndex, endIndex]
: [endIndex, startIndex];
activeTextLayers = orderedTextLayers.slice(minIndex, maxIndex + 1);
} else {
// Fallback if a layer is no longer tracked for any reason.
for (const textLayer of this.#textLayerSet) {
if (range.intersectsNode(textLayer)) {
activeTextLayers.push(textLayer);
}
}
if (!activeTextLayers.includes(startTextLayer)) {
activeTextLayers.push(startTextLayer);
}
if (!activeTextLayers.includes(endTextLayer)) {
activeTextLayers.push(endTextLayer);
}
activeTextLayers.sort(compareTextLayers);
}
for (const textLayer of activeTextLayers) {
const firstNode = textLayer.firstChild;
if (!firstNode) {
continue;
}
const subRange = document.createRange();
if (textLayer === startTextLayer) {
subRange.setStart(startContainer, startOffset);
} else {
subRange.setStartBefore(firstNode);
}
if (textLayer === endTextLayer) {
subRange.setEnd(endContainer, endOffset);
} else {
const lastNode = textLayer.lastChild;
if (!lastNode) {
continue;
}
if (
lastNode.nodeType === Node.ELEMENT_NODE &&
lastNode.classList.contains("endOfContent")
) {
const lastTextNode = lastNode.previousSibling;
if (!lastTextNode) {
continue;
}
subRange.setEndAfter(lastTextNode);
} else {
subRange.setEndAfter(lastNode);
}
}
if (!subRange.collapsed) {
ranges.push([subRange, textLayer]);
}
}
}
/** @type {Set<Element>} */
const selectedTextLayers = new Set(ranges.map(range => range[1]));
for (const textLayer of this.#textLayerSet) {
if (!selectedTextLayers.has(textLayer)) {
this.#cleanupTextLayerSelection(textLayer);
}
}
for (const [range, textLayer] of ranges) {
const textLayerData = DrawLayer.#textLayers.get(textLayer);
if (!textLayerData) {
continue;
}
let rotator = rotators.get(textLayer);
if (!rotator) {
const clientRect = textLayer.getBoundingClientRect();
rotator = (x, y, w, h) => ({
x: (x - clientRect.x) / clientRect.width,
y: (y - clientRect.y) / clientRect.height,
width: w / clientRect.width,
height: h / clientRect.height,
});
rotators.set(textLayer, rotator);
}
/** @type {Array<string>} */
const boxes = [];
for (let { x, y, width, height } of range.getClientRects()) {
if (width === 0 || height === 0) {
continue;
}
({ x, y, width, height } = rotator(x, y, width, height));
if (width === 1 && height === 1) {
// The entire page is selected.
continue;
}
boxes.push(`M${x} ${y} h${width} v${height} h-${width} Z`);
}
if (boxes.length === 0) {
continue;
}
const drawLayer = textLayerData.drawLayer;
let div = textLayerData.selectionDiv;
let path = textLayerData.path;
if (!div) {
const clipPathId = `clip_selection_${DrawLayer.#selectionId++}`;
div = document.createElement("div");
div.className = "selection";
div.style.clipPath = `url(#${clipPathId})`;
const selectionStyle = drawLayer.#filterFactory?.createSelectionStyle(
drawLayer.#pageColors
);
if (selectionStyle) {
for (const [name, value] of Object.entries(selectionStyle)) {
div.style.setProperty(name, value);
}
}
const svg = DrawLayer._svgFactory.create(
1,
1,
/* skipDimensions = */ true
);
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
const clipPath = DrawLayer._svgFactory.createElement("clipPath");
clipPath.setAttribute("id", clipPathId);
clipPath.setAttribute("clipPathUnits", "objectBoundingBox");
path = DrawLayer._svgFactory.createElement("path");
clipPath.append(path);
svg.append(clipPath);
div.append(svg);
textLayerData.path = path;
textLayerData.selectionDiv = div;
}
if (!div.parentNode && drawLayer.#parent) {
drawLayer.#parent.append(div);
this.#selections.add(div);
}
path.setAttribute("d", boxes.join(" "));
}
}
static get _svgFactory() {
return shadow(this, "_svgFactory", new DOMSVGFactory());
}
@ -62,7 +616,7 @@ class DrawLayer {
#createSVG() {
const svg = DrawLayer._svgFactory.create(1, 1, /* skipDimensions = */ true);
this.#parent.append(svg);
svg.setAttribute("aria-hidden", true);
svg.setAttribute("aria-hidden", "true");
return svg;
}
@ -239,6 +793,20 @@ class DrawLayer {
}
this.#mapping.clear();
this.#toUpdate.clear();
if (this.#textLayer) {
const data = DrawLayer.#textLayers.get(this.#textLayer);
if (data?.drawLayer === this) {
DrawLayer.#cleanupTextLayerSelection(this.#textLayer);
DrawLayer.#textLayers.delete(this.#textLayer);
DrawLayer.#textLayerSet.delete(this.#textLayer);
if (DrawLayer.#textLayerSet.size === 0) {
DrawLayer.#selectionChangeAC?.abort();
DrawLayer.#selectionChangeAC = null;
DrawLayer.#isSelecting = false;
}
}
this.#textLayer = null;
}
}
}

View File

@ -13,14 +13,15 @@
* limitations under the License.
*/
import { getRGB, isDataScheme } from "./display_utils.js";
import {
FeatureTest,
SVG_NS,
unreachable,
updateUrlHash,
Util,
warn,
} from "../shared/util.js";
import { getRGB, getRGBA, isDataScheme } from "./display_utils.js";
class BaseFilterFactory {
constructor() {
@ -56,6 +57,36 @@ class BaseFilterFactory {
return "none";
}
/**
* Create a filter for the selection of text, given colors.
*
* @param {string} fgColor
* @param {string} bgColor
* @returns {string}
*/
addSelectionHCMFilter(fgColor, bgColor) {
return "none";
}
/**
* Create a filter for the selection of text.
*
* @returns {string}
*/
addSelectionFilter() {
return "none";
}
/**
* @param {Object} [pageColors]
* @param {string} [pageColors.background]
* @param {string} [pageColors.foreground]
* @returns {Record<string, string> | null}
*/
createSelectionStyle(pageColors = null) {
return null;
}
destroy(keepHCM = false) {}
}
@ -275,6 +306,66 @@ class DOMFilterFactory extends BaseFilterFactory {
return info.url;
}
/**
* Create a filter for the selection of text, given colors.
*
* @param {string} fgColor
* @param {string} bgColor
* @returns {string}
*/
addSelectionHCMFilter(fgColor, bgColor) {
return this.addHighlightHCMFilter(
"selection",
fgColor,
bgColor,
// Background becomes foreground so these are flipped.
"HighlightText",
"Highlight"
);
}
/**
* Create a filter for the selection of text.
*
* @param {string} fgColor
* @param {string} bgColor
* @returns {string}
*/
addSelectionFilter() {
return this.addHighlightHCMFilter(
"selection_default",
"black",
"white",
"HighlightText",
"Highlight"
);
}
/**
* @param {Object} [pageColors]
* @param {string} [pageColors.background]
* @param {string} [pageColors.foreground]
* @returns {Record<string, string> | null}
*/
createSelectionStyle(pageColors = null) {
const filter = pageColors
? this.addSelectionHCMFilter(pageColors.foreground, pageColors.background)
: this.addSelectionFilter();
// Safari does not supported SVG filters in `backdrop-filter`:
// <https://bugs.webkit.org/show_bug.cgi?id=245510>.
// Chrome *and* Safari do not use the users preferred text selection color.
// So this is Firefox-specific for now.
if (filter === "none" || !FeatureTest.platform.isFirefox) {
return null;
}
return {
"backdrop-filter": filter,
"background-color": "transparent",
};
}
addAlphaFilter(map) {
// When a page is zoomed the page is re-drawn but the maps are likely
// the same.
@ -403,7 +494,7 @@ class DOMFilterFactory extends BaseFilterFactory {
0.2126 * bgRGB[0] + 0.7152 * bgRGB[1] + 0.0722 * bgRGB[2]
);
let [newFgRGB, newBgRGB] = [newFgColor, newBgColor].map(
this.#getRGB.bind(this)
this.#getOpaqueTextColor.bind(this)
);
if (bgGray < fgGray) {
[fgGray, bgGray, newFgRGB, newBgRGB] = [
@ -545,6 +636,62 @@ class DOMFilterFactory extends BaseFilterFactory {
this.#defs.style.color = color;
return getRGB(getComputedStyle(this.#defs).getPropertyValue("color"));
}
/**
* Get the RGBA channels of a color.
*
* @param {string} color
* Color in any valid CSS format (such as `x` in `color: x`).
* @returns {[number, number, number, number]}
* RGBA values of the color;
* the RGB channels are in the range `[0, 255]`;
* the alpha channel is in the range `[0, 1]`.
*/
#getRGBA(color) {
this.#defs.style.color = color;
return getRGBA(getComputedStyle(this.#defs).getPropertyValue("color"));
}
/**
* Get the opaque text color by, if it has an alpha layer, blending it with
* the `Canvas` background.
*
* @param {string} color
* Color in any valid CSS format (such as `x` in `color: x`).
* @returns {[number, number, number]}
* RGB values of the opaque color.
*/
#getOpaqueTextColor(color) {
const [r, g, b, alpha] = this.#getRGBA(color);
if (alpha === 1) {
return [r, g, b];
}
const [canvasR, canvasG, canvasB] = this.#getRGB("Canvas");
return [
blend(r, canvasR, alpha),
blend(g, canvasG, alpha),
blend(b, canvasB, alpha),
];
}
}
/**
* Blend a foreground color with a background color using the alpha value.
*
* @param {number} fg
* Foreground color channel value in the range `[0, 255]`.
* @param {number} bg
* Background color channel value in the range `[0, 255]`.
* @param {number} alpha
* Alpha value in the range `[0, 1]`.
* @returns {number}
* Blended color channel value in the range `[0, 255]`.
*/
function blend(fg, bg, alpha) {
return Math.round(alpha * fg + (1 - alpha) * bg);
}
export { BaseFilterFactory, DOMFilterFactory };

View File

@ -13,6 +13,10 @@
* limitations under the License.
*/
/**
* @import { Page } from "puppeteer"
*/
import {
closePages,
closeSinglePage,
@ -20,8 +24,29 @@ import {
loadAndWait,
waitForEvent,
} from "./test_utils.mjs";
import { MathClamp } from "../../src/shared/math_clamp.js";
import { startBrowser } from "../test.mjs";
/**
* @typedef Point
* @property {number} x
* @property {number} y
*/
/**
* @typedef Rect
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
*/
/**
* @typedef SpanInfo
* @property {Rect} rect
* @property {string} text
*/
describe("Text layer", () => {
describe("Text selection", () => {
// page.mouse.move(x, y, { steps: ... }) doesn't work in Firefox, because
@ -59,6 +84,144 @@ describe("Text layer", () => {
};
}
/**
* Pick a point outside the page while remaining inside the viewer.
*
* @param {Rect} page
* Page rectangle.
* @param {Rect} viewer
* Viewer rectangle.
* @param {number} preferredY
* Preferred Y coordinate for the pointer target, to avoid unnecessarily
* moving the pointer too far.
* @returns {Point}
* Point outside the page bounds but inside the viewer.
*/
function getOutsidePagePosition(page, viewer, preferredY) {
// The pointer target must remain inside the visible viewer area;
// otherwise Firefox can fail with an out-of-bounds move.
const minX = Math.ceil(viewer.x) + 5;
const maxX = Math.floor(viewer.x + viewer.width) - 5;
const minY = Math.ceil(viewer.y) + 5;
const maxY = Math.floor(viewer.y + viewer.height) - 5;
const y = MathClamp(minY, Math.round(preferredY), maxY);
const candidates = [
{ x: Math.round(page.x + page.width + 20), y },
// Prefer below over left: going left retraces through existing text
// and shrinks the selection before exiting the page boundary.
{
x: Math.round(page.x + page.width / 2),
y: Math.round(page.y + page.height + 20),
},
{ x: Math.round(page.x - 20), y },
{
x: Math.round(page.x + page.width / 2),
y: Math.round(page.y - 20),
},
];
for (const candidate of candidates) {
if (
candidate.x >= minX &&
candidate.x <= maxX &&
candidate.y >= minY &&
candidate.y <= maxY
) {
return candidate;
}
}
// Fallback: still return a safe in-view point if preferred directions
// are clipped by the viewport at this scroll position.
return { x: maxX, y };
}
/**
* Get current selection.
*
* @param {Page} page
* @returns {Promise<string>}
*/
async function getSelectionText(page) {
return page.evaluate(
() => window.getSelection()?.toString().replaceAll("\r\n", "\n") || ""
);
}
/**
* Check if the draw layer contains a non-empty selection.
*
* @param {Page} page
* @returns {Promise<boolean>}
*/
async function hasDrawnSelection(page) {
return page.evaluate(() => {
// If there is no selection, the `div.selection` is removed.
for (const path of document.querySelectorAll(
".canvasWrapper .selection svg path"
)) {
if (path.getAttribute("d")?.trim()) {
return true;
}
}
return false;
});
}
/**
* Get the first non-empty text span on a page.
*
* @param {Page} page
* @param {number} pageNumber
* @returns {Promise<SpanInfo | null>}
*/
async function getFirstSpanInfo(page, pageNumber) {
await page.waitForSelector(
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
);
return page.evaluate(number => {
for (const el of document.querySelectorAll(
`.page[data-page-number="${number}"] > .textLayer span:not(:has(> span))`
)) {
const text = el.textContent?.trim();
if (!text) {
continue;
}
const { x, y, width, height } = el.getBoundingClientRect();
return { rect: { x, y, width, height }, text };
}
return null;
}, pageNumber);
}
/**
* Get the last non-empty text span on a page.
*
* @param {Page} page
* @param {number} pageNumber
* @returns {Promise<SpanInfo | null>}
*/
async function getLastSpanInfo(page, pageNumber) {
await page.waitForSelector(
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
);
return page.evaluate(number => {
let last = null;
for (const el of document.querySelectorAll(
`.page[data-page-number="${number}"] > .textLayer span:not(:has(> span))`
)) {
const text = el.textContent?.trim();
if (!text) {
continue;
}
const { x, y, width, height } = el.getBoundingClientRect();
last = { rect: { x, y, width, height }, text };
}
return last;
}, pageNumber);
}
beforeEach(() => {
jasmine.addAsyncMatchers({
// Check that a page has a selection containing the given text, with
@ -67,11 +230,7 @@ describe("Text layer", () => {
return {
async compare(page, expected) {
const TOLERANCE = 10;
const actual = await page.evaluate(() =>
// We need to normalize EOL for Windows
window.getSelection().toString().replaceAll("\r\n", "\n")
);
const actual = await getSelectionText(page);
let start, end;
if (expected instanceof RegExp) {
@ -108,6 +267,275 @@ describe("Text layer", () => {
});
describe("using mouse", () => {
describe("selection is preserved when dragging outside page bounds", () => {
/** @type {Array<[string, Page]>} */
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"tracemonkey.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
undefined,
(_page, browserName) => ({
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
afterEach(async () => {
await closePages(pages);
});
it("keeps selection when dragging to another page and then outside", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const scrollTarget = await getSpanRectFromText(
page,
1,
"Unlike method-based dynamic compilers, our dynamic com-"
);
await page.evaluate(top => {
document.getElementById("viewerContainer").scrollTop = top;
}, scrollTarget.y - 50);
const [
positionStartPage1,
positionStartPage2,
positionEndPage2,
page2Rect,
viewerRect,
] = await Promise.all([
getSpanRectFromText(
page,
1,
"Each compiled trace covers one path through the program with"
).then(middlePosition),
getSpanRectFromText(
page,
2,
"Hence, recording and compiling a trace"
).then(middlePosition),
getSpanRectFromText(
page,
2,
"cache. Alternatively, the VM could simply stop tracing, and give up"
).then(belowEndPosition),
page.$eval('.page[data-page-number="2"]', div => {
const { x, y, width, height } = div.getBoundingClientRect();
return { x, y, width, height };
}),
page.$eval("#viewerContainer", div => {
const { x, y, width, height } = div.getBoundingClientRect();
return { x, y, width, height };
}),
]);
const outsidePage2 = getOutsidePagePosition(
page2Rect,
viewerRect,
positionEndPage2.y
);
await page.mouse.move(positionStartPage1.x, positionStartPage1.y);
await page.mouse.down();
// First cross into page 2 while still in-bounds so we can verify
// the multi-page selection is established before exiting page 2.
await moveInSteps(
page,
positionStartPage1,
positionStartPage2,
20
);
const selectionBeforeOutside = await getSelectionText(page);
expect(selectionBeforeOutside)
.withContext(`In ${browserName}, before leaving page 2`)
.toMatch(/path through.*Hence, recording/s);
await moveInSteps(page, positionStartPage2, positionEndPage2, 20);
const selectionInsidePage2 = await getSelectionText(page);
expect(selectionInsidePage2)
.withContext(`In ${browserName}, while still on page 2`)
.toMatch(/path through.*Hence, recording and .* tracing/s);
await moveInSteps(page, positionEndPage2, outsidePage2, 20);
await page.mouse.up();
expect(await hasDrawnSelection(page))
.withContext(
`In ${browserName}, selection drawn while outside page`
)
.toBeTrue();
const selectedText = await getSelectionText(page);
expect(selectedText.length)
.withContext(
`In ${browserName}, selection not lost after mouseup`
)
.toBeGreaterThan(10);
})
);
});
it("keeps selection when dragging outside the current page", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [positionStart, page1Rect, viewerRect] = await Promise.all([
getSpanRectFromText(
page,
1,
"(frequently executed) bytecode sequences, records"
).then(middlePosition),
page.$eval('.page[data-page-number="1"]', div => {
const { x, y, width, height } = div.getBoundingClientRect();
return { x, y, width, height };
}),
page.$eval("#viewerContainer", div => {
const { x, y, width, height } = div.getBoundingClientRect();
return { x, y, width, height };
}),
]);
const outsidePage1 = getOutsidePagePosition(
page1Rect,
viewerRect,
positionStart.y
);
await page.mouse.move(positionStart.x, positionStart.y);
await page.mouse.down();
await moveInSteps(page, positionStart, outsidePage1, 20);
await page.mouse.up();
expect(await hasDrawnSelection(page))
.withContext(
`In ${browserName}, selection drawn while outside page`
)
.toBeTrue();
const selectedText = await getSelectionText(page);
expect(selectedText.length)
.withContext(`In ${browserName}`)
.toBeGreaterThan(5);
})
);
});
});
describe("selection with tagged PDFs", () => {
/** @type {Array<[string, Page]>} */
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"structure_simple.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
undefined,
(_page, browserName) => ({
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
afterEach(async () => {
await closePages(pages);
});
it("keeps selection when dragging outside the page", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [firstSpanInfo, pageRect, viewerRect] = await Promise.all([
getFirstSpanInfo(page, 1),
page.$eval('.page[data-page-number="1"]', div => {
const { x, y, width, height } = div.getBoundingClientRect();
return { x, y, width, height };
}),
page.$eval("#viewerContainer", div => {
const { x, y, width, height } = div.getBoundingClientRect();
return { x, y, width, height };
}),
]);
expect(firstSpanInfo)
.withContext(`In ${browserName}`)
.not.toBeNull();
const positionStart = middlePosition(firstSpanInfo.rect);
const outsidePage = getOutsidePagePosition(
pageRect,
viewerRect,
positionStart.y
);
await page.mouse.move(positionStart.x, positionStart.y);
await page.mouse.down();
await moveInSteps(page, positionStart, outsidePage, 20);
await page.mouse.up();
expect(await hasDrawnSelection(page))
.withContext(
`In ${browserName}, selection drawn while outside page`
)
.toBeTrue();
const selectedText = await getSelectionText(page);
expect(selectedText.length)
.withContext(`In ${browserName}`)
.toBeGreaterThan(0);
expect(selectedText)
.withContext(`In ${browserName}`)
.toContain(firstSpanInfo.text.slice(0, 1));
})
);
});
it("doesn't jump when hovering on an empty area", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [firstSpanInfo, lastSpanInfo] = await Promise.all([
getFirstSpanInfo(page, 1),
getLastSpanInfo(page, 1),
]);
expect(firstSpanInfo)
.withContext(`In ${browserName}, first span`)
.not.toBeNull();
expect(lastSpanInfo)
.withContext(`In ${browserName}, last span`)
.not.toBeNull();
const positionStart = middlePosition(firstSpanInfo.rect);
const positionEnd = belowEndPosition(lastSpanInfo.rect);
await page.mouse.move(positionStart.x, positionStart.y);
await page.mouse.down();
// Drag from the first to the last text run to pass through the
// tagged content and end in the empty area below the text.
await moveInSteps(page, positionStart, positionEnd, 20);
await page.mouse.up();
expect(await hasDrawnSelection(page))
.withContext(`In ${browserName}, selection drawn in tagged PDF`)
.toBeTrue();
await expectAsync(page)
.withContext(`In ${browserName}`)
// Selection starts mid-word in Heading 1, so assert the stable
// trailing content rather than exact full-line boundaries.
.toHaveRoughlySelected(
/ing 1\s+This paragraph 1\.\s+Heading 2\s+This paragraph 2/s
);
})
);
});
});
describe("doesn't jump when hovering on an empty area", () => {
let pages;
@ -273,7 +701,7 @@ describe("Text layer", () => {
.withContext(`In ${browserName}`)
.toHaveRoughlySelected(
"rs as the railway projects under\n" +
"development enter the construction phase (estimated at "
"development enter the construction phase (estimated at"
);
})
);
@ -519,9 +947,7 @@ describe("Text layer", () => {
);
await page.mouse.up();
const selection = await page.evaluate(() =>
window.getSelection().toString()
);
const selection = await getSelectionText(page);
expect(selection).withContext(`In ${browserName}`).toEqual("AB");
// The selectionchange handler in TextLayerBuilder walks up
@ -541,6 +967,65 @@ describe("Text layer", () => {
);
});
});
describe("with `enableSelectionRendering` disabled", () => {
/** @type {Array<[string, Page]>} */
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"tracemonkey.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
undefined,
(_page, browserName) => ({
enableSelectionRendering: false,
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
afterEach(async () => {
await closePages(pages);
});
it("does not render a selection overlay in the draw layer", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [positionStart, positionEnd] = await Promise.all([
getSpanRectFromText(
page,
1,
"(frequently executed) bytecode sequences, records"
).then(middlePosition),
getSpanRectFromText(
page,
1,
"them, and compiles them to fast native code. We call such a se-"
).then(belowEndPosition),
]);
await page.mouse.move(positionStart.x, positionStart.y);
await page.mouse.down();
await moveInSteps(page, positionStart, positionEnd, 20);
await page.mouse.up();
// Text should still be selectable.
const selectedText = await getSelectionText(page);
expect(selectedText.length)
.withContext(`In ${browserName}, text is still selectable`)
.toBeGreaterThan(0);
// But no selection overlay should appear in the draw layer.
expect(await hasDrawnSelection(page))
.withContext(
`In ${browserName}, no selection drawn when disabled`
)
.toBeFalse();
})
);
});
});
});
describe("using selection carets", () => {

View File

@ -285,6 +285,7 @@
pointer-events: auto;
box-sizing: content-box;
padding: var(--editor-toolbar-padding);
user-select: none;
position: absolute;
inset-inline-end: 0;

View File

@ -376,6 +376,7 @@ const PDFViewerApplication = {
enableNewBadge: x => x === "true",
enablePermissions: x => x === "true",
enableMerge: x => x === "true",
enableSelectionRendering: x => x === "true",
enableSplitMerge: x => x === "true",
enableUpdatedAddImage: x => x === "true",
highlightEditorColors: x => x,
@ -578,6 +579,7 @@ const PDFViewerApplication = {
enableOptimizedPartialRendering: AppOptions.get(
"enableOptimizedPartialRendering"
),
enableSelectionRendering: AppOptions.get("enableSelectionRendering"),
imagesRightClickMinSize: AppOptions.get("imagesRightClickMinSize"),
pageColors,
mlManager,

View File

@ -320,6 +320,11 @@ const defaultOptions = {
: "./images/",
kind: OptionKind.VIEWER,
},
enableSelectionRendering: {
/** @type {boolean} */
value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
imagesRightClickMinSize: {
/** @type {number} */
value:

View File

@ -36,6 +36,8 @@ class BasePDFPageView extends RenderableView {
enableOptimizedPartialRendering = false;
enableSelectionRendering = true;
imagesRightClickMinSize = -1;
eventBus = null;
@ -58,6 +60,7 @@ class BasePDFPageView extends RenderableView {
this.renderingQueue = options.renderingQueue;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.enableSelectionRendering = options.enableSelectionRendering !== false;
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
this.minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
}

View File

@ -14,6 +14,16 @@
*/
.canvasWrapper {
.selection {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: rgb(0 90 255 / 0.22);
}
svg {
transform: none;

View File

@ -15,6 +15,19 @@
import { DrawLayer } from "pdfjs-lib";
/**
* @typedef DrawLayerBuilderOptions
* Configuration for {@linkcode DrawLayerBuilder}.
* @property {number} pageIndex
* Zero-based page index.
* @property {Element | null} [textLayer]
* Text layer element (optional).
* @property {Object | null} [filterFactory]
* Filter factory used to style selections (optional).
* @property {Object | null} [pageColors]
* Page foreground/background colors for HCM (optional).
*/
/**
* @typedef {Object} DrawLayerBuilderRenderOptions
* @property {string} [intent] - The default value is "display".
@ -23,6 +36,19 @@ import { DrawLayer } from "pdfjs-lib";
class DrawLayerBuilder {
#drawLayer = null;
/**
* @param {DrawLayerBuilderOptions} options
* Configuration.
* @returns
* Instance.
*/
constructor(options) {
this.pageIndex = options.pageIndex;
this.textLayer = options.textLayer || null;
this.filterFactory = options.filterFactory || null;
this.pageColors = options.pageColors || null;
}
/**
* @param {DrawLayerBuilderRenderOptions} options
* @returns {Promise<void>}
@ -31,7 +57,12 @@ class DrawLayerBuilder {
if (intent !== "display" || this.#drawLayer || this._cancelled) {
return;
}
this.#drawLayer = new DrawLayer();
this.#drawLayer = new DrawLayer({
pageIndex: this.pageIndex,
textLayer: this.textLayer,
filterFactory: this.filterFactory,
pageColors: this.pageColors,
});
}
cancel() {

View File

@ -93,6 +93,9 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js";
* @property {number} [imagesRightClickMinSize] - All images whose width and
* height are at least this value (in pixels) will be lazily inserted in the
* dom to allow right-clicking and saving them. Use `-1` to disable this.
* @property {boolean} [enableSelectionRendering] - When enabled, renders text
* selections in the draw layer.
* The default value is `true`.
* @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
* rendering will keep track of which areas of the page each PDF operation
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is
@ -1191,14 +1194,20 @@ class PDFPageView extends BasePDFPageView {
}
}
this.drawLayer ||= new DrawLayerBuilder({
pageIndex: this.id,
textLayer: this.enableSelectionRendering ? this.textLayer?.div : null,
filterFactory: this.pdfPage?.filterFactory,
pageColors: this.pageColors,
});
await this.#renderDrawLayer();
this.drawLayer.setParent(canvasWrapper);
const { annotationEditorUIManager } = this.#layerProperties;
if (!annotationEditorUIManager) {
return;
}
this.drawLayer ||= new DrawLayerBuilder();
await this.#renderDrawLayer();
this.drawLayer.setParent(canvasWrapper);
if (
this.annotationLayer ||

View File

@ -132,6 +132,9 @@ function isValidAnnotationEditorMode(mode) {
* `maxCanvasDim`, it will draw a second canvas on top of the CSS-zoomed one,
* that only renders the part of the page that is close to the viewport.
* The default value is `true`.
* @property {boolean} [enableSelectionRendering] - Enables rendering of text
* selections in the draw layer.
* The default value is `true`.
* @property {number} [imagesRightClickMinSize] - All images whose width and
* height are at least this value (in pixels) will be lazily inserted in the
* dom to allow right-clicking and saving them. Use `-1` to disable this.
@ -362,6 +365,7 @@ class PDFViewer {
this.enableDetailCanvas = options.enableDetailCanvas ?? true;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.enableSelectionRendering = options.enableSelectionRendering !== false;
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
@ -457,6 +461,25 @@ class PDFViewer {
return this._pages.every(pageView => pageView?.pdfPage);
}
/**
* Clear text selections within the viewer.
*/
clearSelection() {
const selection = document.getSelection();
if (!selection || selection.isCollapsed) {
return;
}
for (let i = 0, ii = selection.rangeCount; i < ii; i++) {
if (selection.getRangeAt(i).intersectsNode(this.viewer)) {
// `empty()` is non-standard; `removeAllRanges()` is the standard API.
selection.removeAllRanges?.();
selection.empty?.();
return;
}
}
}
/**
* @type {boolean}
*/
@ -488,6 +511,9 @@ class PDFViewer {
if (!this.pdfDocument) {
return;
}
if (this._currentPageNumber !== val) {
this.clearSelection();
}
// The intent can be to just reset a scroll position and/or scale.
if (!this._setCurrentPageNumber(val, /* resetCurrentPageView = */ true)) {
console.error(`currentPageNumber: "${val}" is not a valid page.`);
@ -547,6 +573,9 @@ class PDFViewer {
page = i + 1;
}
}
if (this._currentPageNumber !== page) {
this.clearSelection();
}
// The intent can be to just reset a scroll position and/or scale.
if (!this._setCurrentPageNumber(page, /* resetCurrentPageView = */ true)) {
console.error(`currentPageLabel: "${val}" is not a valid page.`);
@ -617,6 +646,7 @@ class PDFViewer {
if (this._pagesRotation === rotation) {
return; // The rotation didn't change.
}
this.clearSelection();
this._pagesRotation = rotation;
const pageNumber = this._currentPageNumber;
@ -1066,6 +1096,7 @@ class PDFViewer {
enableDetailCanvas: this.enableDetailCanvas,
enableOptimizedPartialRendering:
this.enableOptimizedPartialRendering,
enableSelectionRendering: this.enableSelectionRendering,
imagesRightClickMinSize: this.imagesRightClickMinSize,
pageColors,
l10n: this.l10n,
@ -1488,6 +1519,7 @@ class PDFViewer {
newValue,
{ noScroll = false, preset = false, drawingDelay = -1, origin = null }
) {
this.clearSelection();
this._currentScaleValue = newValue.toString();
if (this.#isSameScale(newScale)) {
@ -2210,6 +2242,7 @@ class PDFViewer {
}
this._previousScrollMode = this._scrollMode;
this.clearSelection();
this._scrollMode = mode;
this.eventBus.dispatch("scrollmodechanged", { source: this, mode });
@ -2275,6 +2308,7 @@ class PDFViewer {
if (!isValidSpreadMode(mode)) {
throw new Error(`Invalid spread mode: ${mode}`);
}
this.clearSelection();
this._spreadMode = mode;
this.eventBus.dispatch("spreadmodechanged", { source: this, mode });

View File

@ -38,6 +38,7 @@
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
user-select: text;
}
/* We multiply the font size by --min-font-size, and then scale the text
@ -115,12 +116,7 @@
}
::selection {
/* stylelint-disable declaration-block-no-duplicate-properties */
/*#if !MOZCENTRAL*/
background: rgba(0 0 255 / 0.25);
/*#endif*/
/* stylelint-enable declaration-block-no-duplicate-properties */
background: color-mix(in srgb, AccentColor, transparent 75%);
background: transparent;
}
/* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */