mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-05-31 07:11:00 +02:00
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)?
814 lines
23 KiB
JavaScript
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 };
|