/* 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 "./display_utils.js"; import { shadow } from "../shared/util.js"; /** * 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; #id = 0; #mapping = new Map(); #toUpdate = new Map(); constructor({ pageIndex }) { this.pageIndex = pageIndex; } 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; } } static get _svgFactory() { return shadow(this, "_svgFactory", new DOMSVGFactory()); } static #setBox(element, { x = 0, y = 0, width = 1, height = 1 } = {}) { const { style } = element; style.top = `${100 * y}%`; style.left = `${100 * x}%`; style.width = `${100 * width}%`; style.height = `${100 * height}%`; } #createSVG(box) { const svg = DrawLayer._svgFactory.create(1, 1, /* skipDimensions = */ true); this.#parent.append(svg); DrawLayer.#setBox(svg, box); 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; } highlight(outlines, color, opacity, isPathUpdatable = false) { const id = this.#id++; const root = this.#createSVG(outlines.box); root.classList.add("highlight"); if (outlines.free) { root.classList.add("free"); } const defs = DrawLayer._svgFactory.createElement("defs"); root.append(defs); const path = DrawLayer._svgFactory.createElement("path"); defs.append(path); const pathId = `path_p${this.pageIndex}_${id}`; path.setAttribute("id", pathId); path.setAttribute("d", outlines.toSVGPath()); if (isPathUpdatable) { this.#toUpdate.set(id, path); } // Create the clipping path for the editor div. const clipPathId = this.#createClipPath(defs, pathId); const use = DrawLayer._svgFactory.createElement("use"); root.append(use); root.setAttribute("fill", color); root.setAttribute("fill-opacity", opacity); use.setAttribute("href", `#${pathId}`); this.#mapping.set(id, root); return { id, clipPathId: `url(#${clipPathId})` }; } highlightOutline(outlines) { // 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 = this.#id++; const root = this.#createSVG(outlines.box); root.classList.add("highlightOutline"); const defs = DrawLayer._svgFactory.createElement("defs"); root.append(defs); const path = DrawLayer._svgFactory.createElement("path"); defs.append(path); const pathId = `path_p${this.pageIndex}_${id}`; path.setAttribute("id", pathId); path.setAttribute("d", outlines.toSVGPath()); path.setAttribute("vector-effect", "non-scaling-stroke"); let maskId; if (outlines.free) { root.classList.add("free"); const mask = DrawLayer._svgFactory.createElement("mask"); defs.append(mask); maskId = `mask_p${this.pageIndex}_${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.#mapping.set(id, root); return id; } finalizeLine(id, line) { const path = this.#toUpdate.get(id); this.#toUpdate.delete(id); this.updateBox(id, line.box); path.setAttribute("d", line.toSVGPath()); } updateLine(id, line) { const root = this.#mapping.get(id); const defs = root.firstChild; const path = defs.firstChild; path.setAttribute("d", line.toSVGPath()); } removeFreeHighlight(id) { this.remove(id); this.#toUpdate.delete(id); } updatePath(id, line) { this.#toUpdate.get(id).setAttribute("d", line.toSVGPath()); } updateBox(id, box) { DrawLayer.#setBox(this.#mapping.get(id), box); } show(id, visible) { this.#mapping.get(id).classList.toggle("hidden", !visible); } rotate(id, angle) { this.#mapping.get(id).setAttribute("data-main-rotation", angle); } changeColor(id, color) { this.#mapping.get(id).setAttribute("fill", color); } changeOpacity(id, opacity) { this.#mapping.get(id).setAttribute("fill-opacity", opacity); } addClass(id, className) { this.#mapping.get(id).classList.add(className); } removeClass(id, className) { this.#mapping.get(id).classList.remove(className); } remove(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(); } } export { DrawLayer };