pdf.js.mirror/src/display/draw_layer.js
Titus Wormer 957e004e38
Make text selection more visible (bug 1879559)
References <https://bugzilla.mozilla.org/show_bug.cgi?id=1879559>
(“In HCM, the text selection is barely visible”).

Continues work from @calixteman who had a partial patch.

This PR improves viewer text-selection highlighting by rendering
selection shapes in the draw layer.

* add selection overlay rendering in the draw layer
  * significant code relates to selections spanning multiple text
    layers/pages, and edges/end-of-content boundaries
* clear selection on rotate/scale/scroll/spread changes

My main question is: how should it appear?
I don’t have access to the Figma file linked on bugzilla.

In the CSS (`draw_layer-builder.css`) there are 3 blocks:

* default
* `@supports` for browsers supporting `backdrop-filter`
* `forced-colors` mode

So it’s possible to design for those (or more).
Personally, the `backdrop-filter: invert(1)` is the most contrast,
so perhaps it’s better to use something else as the default,
and to use `invert(1)` if high contrast mode is used (maybe with a
`prefers-contrast` media query instead)?
2026-05-19 21:10:12 +02:00

814 lines
23 KiB
JavaScript

/* Copyright 2023 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.
*/
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
* be able to use mix-blend-mode for some of them.
*/
class DrawLayer {
#parent = null;
#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;
return;
}
if (this.#parent !== parent) {
if (this.#mapping.size > 0) {
for (const root of this.#mapping.values()) {
root.remove();
parent.append(root);
}
}
this.#parent = parent;
}
}
/**
* 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());
}
static #setBox(element, [x, y, width, height]) {
const { style } = element;
style.top = `${100 * y}%`;
style.left = `${100 * x}%`;
style.width = `${100 * width}%`;
style.height = `${100 * height}%`;
}
#createSVG() {
const svg = DrawLayer._svgFactory.create(1, 1, /* skipDimensions = */ true);
this.#parent.append(svg);
svg.setAttribute("aria-hidden", "true");
return svg;
}
#createClipPath(defs, pathId) {
const clipPath = DrawLayer._svgFactory.createElement("clipPath");
defs.append(clipPath);
const clipPathId = `clip_${pathId}`;
clipPath.setAttribute("id", clipPathId);
clipPath.setAttribute("clipPathUnits", "objectBoundingBox");
const clipPathUse = DrawLayer._svgFactory.createElement("use");
clipPath.append(clipPathUse);
clipPathUse.setAttribute("href", `#${pathId}`);
clipPathUse.classList.add("clip");
return clipPathId;
}
#updateProperties(element, properties) {
for (const [key, value] of Object.entries(properties)) {
if (value === null) {
element.removeAttribute(key);
} else {
element.setAttribute(key, value);
}
}
}
draw(properties, isPathUpdatable = false, hasClip = false) {
const id = DrawLayer.#id++;
const root = this.#createSVG();
const defs = DrawLayer._svgFactory.createElement("defs");
root.append(defs);
const path = DrawLayer._svgFactory.createElement("path");
defs.append(path);
const pathId = `path_${id}`;
path.setAttribute("id", pathId);
path.setAttribute("vector-effect", "non-scaling-stroke");
if (isPathUpdatable) {
this.#toUpdate.set(id, path);
}
// Create the clipping path for the editor div.
const clipPathId = hasClip ? this.#createClipPath(defs, pathId) : null;
const use = DrawLayer._svgFactory.createElement("use");
root.append(use);
use.setAttribute("href", `#${pathId}`);
this.updateProperties(root, properties);
this.#mapping.set(id, root);
return { id, clipPathId: `url(#${clipPathId})` };
}
drawOutline(properties, mustRemoveSelfIntersections) {
// We cannot draw the outline directly in the SVG for highlights because
// it composes with its parent with mix-blend-mode: multiply.
// But the outline has a different mix-blend-mode, so we need to draw it in
// its own SVG.
const id = DrawLayer.#id++;
const root = this.#createSVG();
const defs = DrawLayer._svgFactory.createElement("defs");
root.append(defs);
const path = DrawLayer._svgFactory.createElement("path");
defs.append(path);
const pathId = `path_${id}`;
path.setAttribute("id", pathId);
path.setAttribute("vector-effect", "non-scaling-stroke");
let maskId;
if (mustRemoveSelfIntersections) {
const mask = DrawLayer._svgFactory.createElement("mask");
defs.append(mask);
maskId = `mask_${id}`;
mask.setAttribute("id", maskId);
mask.setAttribute("maskUnits", "objectBoundingBox");
const rect = DrawLayer._svgFactory.createElement("rect");
mask.append(rect);
rect.setAttribute("width", "1");
rect.setAttribute("height", "1");
rect.setAttribute("fill", "white");
const use = DrawLayer._svgFactory.createElement("use");
mask.append(use);
use.setAttribute("href", `#${pathId}`);
use.setAttribute("stroke", "none");
use.setAttribute("fill", "black");
use.setAttribute("fill-rule", "nonzero");
use.classList.add("mask");
}
const use1 = DrawLayer._svgFactory.createElement("use");
root.append(use1);
use1.setAttribute("href", `#${pathId}`);
if (maskId) {
use1.setAttribute("mask", `url(#${maskId})`);
}
const use2 = use1.cloneNode();
root.append(use2);
use1.classList.add("mainOutline");
use2.classList.add("secondaryOutline");
this.updateProperties(root, properties);
this.#mapping.set(id, root);
return id;
}
finalizeDraw(id, properties) {
this.#toUpdate.delete(id);
this.updateProperties(id, properties);
}
updateProperties(elementOrId, properties) {
if (!properties) {
return;
}
const { root, bbox, rootClass, path } = properties;
const element =
typeof elementOrId === "number"
? this.#mapping.get(elementOrId)
: elementOrId;
if (!element) {
return;
}
if (root) {
this.#updateProperties(element, root);
}
if (bbox) {
DrawLayer.#setBox(element, bbox);
}
if (rootClass) {
const { classList } = element;
for (const [className, value] of Object.entries(rootClass)) {
classList.toggle(className, value);
}
}
if (path) {
const defs = element.firstElementChild;
const pathElement = defs.firstElementChild;
this.#updateProperties(pathElement, path);
}
}
updateParent(id, layer) {
if (layer === this) {
return;
}
const root = this.#mapping.get(id);
if (!root) {
return;
}
layer.#parent.append(root);
this.#mapping.delete(id);
layer.#mapping.set(id, root);
}
remove(id) {
this.#toUpdate.delete(id);
if (this.#parent === null) {
return;
}
this.#mapping.get(id).remove();
this.#mapping.delete(id);
}
destroy() {
this.#parent = null;
for (const root of this.#mapping.values()) {
root.remove();
}
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;
}
}
}
export { DrawLayer };