/* Copyright 2022 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 { AnnotationEditorParamsType, AnnotationEditorType, Util, } from "../../shared/util.js"; import { FreeOutliner, Outliner } from "./outliner.js"; import { AnnotationEditor } from "./editor.js"; import { bindEvents } from "./tools.js"; import { ColorPicker } from "./color_picker.js"; import { noContextMenu } from "../display_utils.js"; /** * Basic draw editor in order to generate an Highlight annotation. */ class HighlightEditor extends AnnotationEditor { #boxes; #clipPathId = null; #colorPicker = null; #focusOutlines = null; #highlightDiv = null; #highlightOutlines = null; #id = null; #isFreeHighlight = false; #lastPoint = null; #opacity; #outlineId = null; static _defaultColor = null; static _defaultOpacity = 1; static _defaultThickness = 10; static _l10nPromise; static _type = "highlight"; static _editorType = AnnotationEditorType.HIGHLIGHT; static _freeHighlightId = -1; static _freeHighlight = null; static _freeHighlightClipId = ""; constructor(params) { super({ ...params, name: "highlightEditor" }); this.color = params.color || HighlightEditor._defaultColor; this.#opacity = params.opacity || HighlightEditor._defaultOpacity; this.#boxes = params.boxes || null; this._isDraggable = false; if (params.highlightId > -1) { this.#isFreeHighlight = true; this.#createFreeOutlines(params); this.#addToDrawLayer(); } else { this.#createOutlines(); this.#addToDrawLayer(); this.rotate(this.rotation); } } #createOutlines() { const outliner = new Outliner(this.#boxes, /* borderWidth = */ 0.001); this.#highlightOutlines = outliner.getOutlines(); ({ x: this.x, y: this.y, width: this.width, height: this.height, } = this.#highlightOutlines.box); const outlinerForOutline = new Outliner( this.#boxes, /* borderWidth = */ 0.0025, /* innerMargin = */ 0.001, this._uiManager.direction === "ltr" ); this.#focusOutlines = outlinerForOutline.getOutlines(); // The last point is in the pages coordinate system. const { lastPoint } = this.#focusOutlines.box; this.#lastPoint = [ (lastPoint[0] - this.x) / this.width, (lastPoint[1] - this.y) / this.height, ]; } #createFreeOutlines({ highlight, highlightId, clipPathId }) { this.#highlightOutlines = highlight.getOutlines( this._uiManager.direction === "ltr" ); this.#id = highlightId; this.#clipPathId = clipPathId; const { x, y, width, height, lastPoint } = this.#highlightOutlines.box; // We need to redraw the highlight because we change the coordinates to be // in the box coordinate system. this.parent.drawLayer.finalizeLine(this.#id, this.#highlightOutlines); switch (this.rotation) { case 0: this.x = x; this.y = y; this.width = width; this.height = height; break; case 90: { const [pageWidth, pageHeight] = this.parentDimensions; this.x = y; this.y = 1 - x; this.width = (width * pageHeight) / pageWidth; this.height = (height * pageWidth) / pageHeight; break; } case 180: this.x = 1 - x; this.y = 1 - y; this.width = width; this.height = height; break; case 270: { const [pageWidth, pageHeight] = this.parentDimensions; this.x = 1 - y; this.y = x; this.width = (width * pageHeight) / pageWidth; this.height = (height * pageWidth) / pageHeight; break; } } const innerMargin = 1.5; this.#focusOutlines = highlight.getFocusOutline( /* Slightly bigger than the highlight in order to have a little space between the highlight and the outline. */ HighlightEditor._defaultThickness + innerMargin ); this.#outlineId = this.parent.drawLayer.highlightOutline( this.#focusOutlines ); this.#lastPoint = lastPoint; } static initialize(l10n, uiManager) { AnnotationEditor.initialize(l10n, uiManager); HighlightEditor._defaultColor ||= uiManager.highlightColors?.values().next().value || "#fff066"; } static updateDefaultParams(type, value) { switch (type) { case AnnotationEditorParamsType.HIGHLIGHT_DEFAULT_COLOR: HighlightEditor._defaultColor = value; break; } } /** @inheritdoc */ translateInPage(x, y) {} /** @inheritdoc */ get toolbarPosition() { return this.#lastPoint; } /** @inheritdoc */ updateParams(type, value) { switch (type) { case AnnotationEditorParamsType.HIGHLIGHT_COLOR: this.#updateColor(value); break; } } static get defaultPropertiesToUpdate() { return [ [ AnnotationEditorParamsType.HIGHLIGHT_DEFAULT_COLOR, HighlightEditor._defaultColor, ], ]; } /** @inheritdoc */ get propertiesToUpdate() { return [ [ AnnotationEditorParamsType.HIGHLIGHT_COLOR, this.color || HighlightEditor._defaultColor, ], ]; } /** * Update the color and make this action undoable. * @param {string} color */ #updateColor(color) { const savedColor = this.color; this.addCommands({ cmd: () => { this.color = color; this.parent?.drawLayer.changeColor(this.#id, color); this.#colorPicker?.updateColor(color); }, undo: () => { this.color = savedColor; this.parent?.drawLayer.changeColor(this.#id, savedColor); this.#colorPicker?.updateColor(savedColor); }, mustExec: true, type: AnnotationEditorParamsType.HIGHLIGHT_COLOR, overwriteIfSameType: true, keepUndo: true, }); } /** @inheritdoc */ async addEditToolbar() { const toolbar = await super.addEditToolbar(); if (!toolbar) { return null; } if (this._uiManager.highlightColors) { this.#colorPicker = new ColorPicker({ editor: this }); toolbar.addColorPicker(this.#colorPicker); } return toolbar; } /** @inheritdoc */ disableEditing() { super.disableEditing(); this.div.classList.toggle("disabled", true); } /** @inheritdoc */ enableEditing() { super.enableEditing(); this.div.classList.toggle("disabled", false); } /** @inheritdoc */ fixAndSetPosition() { return super.fixAndSetPosition(this.#getRotation()); } /** @inheritdoc */ getRect(tx, ty) { return super.getRect(tx, ty, this.#getRotation()); } /** @inheritdoc */ onceAdded() { this.parent.addUndoableEditor(this); this.div.focus(); } /** @inheritdoc */ remove() { super.remove(); this.#cleanDrawLayer(); } /** @inheritdoc */ rebuild() { if (!this.parent) { return; } super.rebuild(); if (this.div === null) { return; } this.#addToDrawLayer(); if (!this.isAttachedToDOM) { // At some point this editor was removed and we're rebuilding it, // hence we must add it to its parent. this.parent.add(this); } } setParent(parent) { let mustBeSelected = false; if (this.parent && !parent) { this.#cleanDrawLayer(); } else if (parent) { this.#addToDrawLayer(parent); // If mustBeSelected is true it means that this editor was selected // when its parent has been destroyed, hence we must select it again. mustBeSelected = !this.parent && this.div?.classList.contains("selectedEditor"); } super.setParent(parent); if (mustBeSelected) { // We select it after the parent has been set. this.select(); } } #cleanDrawLayer() { if (this.#id === null || !this.parent) { return; } this.parent.drawLayer.remove(this.#id); this.#id = null; this.parent.drawLayer.remove(this.#outlineId); this.#outlineId = null; } #addToDrawLayer(parent = this.parent) { if (this.#id !== null) { return; } ({ id: this.#id, clipPathId: this.#clipPathId } = parent.drawLayer.highlight( this.#highlightOutlines, this.color, this.#opacity )); this.#outlineId = parent.drawLayer.highlightOutline(this.#focusOutlines); if (this.#highlightDiv) { this.#highlightDiv.style.clipPath = this.#clipPathId; } } static #rotateBbox({ x, y, width, height }, angle) { switch (angle) { case 90: return { x: 1 - y - height, y: x, width: height, height: width, }; case 180: return { x: 1 - x - width, y: 1 - y - height, width, height, }; case 270: return { x: y, y: 1 - x - width, width: height, height: width, }; } return { x, y, width, height, }; } /** @inheritdoc */ rotate(angle) { // We need to rotate the svgs because of the coordinates system. const { drawLayer } = this.parent; let box; if (this.#isFreeHighlight) { angle = (angle - this.rotation + 360) % 360; box = HighlightEditor.#rotateBbox(this.#highlightOutlines.box, angle); } else { // An highlight annotation is always drawn horizontally. box = HighlightEditor.#rotateBbox(this, angle); } drawLayer.rotate(this.#id, angle); drawLayer.rotate(this.#outlineId, angle); drawLayer.updateBox(this.#id, box); drawLayer.updateBox( this.#outlineId, HighlightEditor.#rotateBbox(this.#focusOutlines.box, angle) ); } /** @inheritdoc */ render() { if (this.div) { return this.div; } const div = super.render(); if (this.#isFreeHighlight) { div.classList.add("free"); } const highlightDiv = (this.#highlightDiv = document.createElement("div")); div.append(highlightDiv); highlightDiv.className = "internal"; highlightDiv.style.clipPath = this.#clipPathId; const [parentWidth, parentHeight] = this.parentDimensions; this.setDims(this.width * parentWidth, this.height * parentHeight); bindEvents(this, this.#highlightDiv, ["pointerover", "pointerleave"]); this.enableEditing(); return div; } pointerover() { this.parent.drawLayer.addClass(this.#outlineId, "hovered"); } pointerleave() { this.parent.drawLayer.removeClass(this.#outlineId, "hovered"); } /** @inheritdoc */ select() { super.select(); this.parent?.drawLayer.removeClass(this.#outlineId, "hovered"); this.parent?.drawLayer.addClass(this.#outlineId, "selected"); } /** @inheritdoc */ unselect() { super.unselect(); this.parent?.drawLayer.removeClass(this.#outlineId, "selected"); } #getRotation() { // Highlight annotations are always drawn horizontally but if // a free highlight annotation can be rotated. return this.#isFreeHighlight ? this.rotation : 0; } #serializeBoxes(rect) { if (this.#isFreeHighlight) { return null; } const [pageWidth, pageHeight] = this.pageDimensions; const boxes = this.#boxes; const quadPoints = new Array(boxes.length * 8); const [tx, ty] = rect; let i = 0; for (const { x, y, width, height } of boxes) { const sx = tx + x * pageWidth; const sy = ty + (1 - y - height) * pageHeight; // The specifications say that the rectangle should start from the bottom // left corner and go counter-clockwise. // But when opening the file in Adobe Acrobat it appears that this isn't // correct hence the 4th and 6th numbers are just swapped. quadPoints[i] = quadPoints[i + 4] = sx; quadPoints[i + 1] = quadPoints[i + 3] = sy; quadPoints[i + 2] = quadPoints[i + 6] = sx + width * pageWidth; quadPoints[i + 5] = quadPoints[i + 7] = sy + height * pageHeight; i += 8; } return quadPoints; } #serializeOutlines(rect) { return this.#highlightOutlines.serialize(rect, this.#getRotation()); } static startHighlighting(parent, { target: textLayer, x, y }) { const { x: layerX, y: layerY, width: parentWidth, height: parentHeight, } = textLayer.getBoundingClientRect(); const pointerMove = e => { this.#highlightMove(parent, e); }; const pointerDownOptions = { capture: true, passive: false }; const pointerDown = e => { // Avoid to have undesired clicks during the drawing. e.preventDefault(); e.stopPropagation(); }; const pointerUpCallback = e => { textLayer.removeEventListener("pointermove", pointerMove); window.removeEventListener("blur", pointerUpCallback); window.removeEventListener("pointerup", pointerUpCallback); window.removeEventListener( "pointerdown", pointerDown, pointerDownOptions ); window.removeEventListener("contextmenu", noContextMenu); this.#endHighlight(parent, e); }; window.addEventListener("blur", pointerUpCallback); window.addEventListener("pointerup", pointerUpCallback); window.addEventListener("pointerdown", pointerDown, pointerDownOptions); window.addEventListener("contextmenu", noContextMenu); textLayer.addEventListener("pointermove", pointerMove); this._freeHighlight = new FreeOutliner( { x, y }, [layerX, layerY, parentWidth, parentHeight], parent.scale, this._defaultThickness, /* innerMargin = */ 0.001 ); ({ id: this._freeHighlightId, clipPathId: this._freeHighlightClipId } = parent.drawLayer.highlight( this._freeHighlight, this._defaultColor, this._defaultOpacity, /* isPathUpdatable = */ true )); } static #highlightMove(parent, event) { if (this._freeHighlight.add(event)) { // Redraw only if the point has been added. parent.drawLayer.updatePath(this._freeHighlightId, this._freeHighlight); } } static #endHighlight(parent, event) { if (!this._freeHighlight.isEmpty()) { parent.createAndAddNewEditor(event, false, { highlightId: this._freeHighlightId, highlight: this._freeHighlight, clipPathId: this._freeHighlightClipId, }); } else { parent.drawLayer.removeFreeHighlight(this._freeHighlightId); } this._freeHighlightId = -1; this._freeHighlight = null; this._freeHighlightClipId = ""; } /** @inheritdoc */ static deserialize(data, parent, uiManager) { const editor = super.deserialize(data, parent, uiManager); const { rect: [blX, blY, trX, trY], color, quadPoints, } = data; editor.color = Util.makeHexColor(...color); editor.#opacity = data.opacity; const [pageWidth, pageHeight] = editor.pageDimensions; editor.width = (trX - blX) / pageWidth; editor.height = (trY - blY) / pageHeight; const boxes = (editor.#boxes = []); for (let i = 0; i < quadPoints.length; i += 8) { boxes.push({ x: (quadPoints[4] - trX) / pageWidth, y: (trY - (1 - quadPoints[i + 5])) / pageHeight, width: (quadPoints[i + 2] - quadPoints[i]) / pageWidth, height: (quadPoints[i + 5] - quadPoints[i + 1]) / pageHeight, }); } editor.#createOutlines(); return editor; } /** @inheritdoc */ serialize(isForCopying = false) { // It doesn't make sense to copy/paste a highlight annotation. if (this.isEmpty() || isForCopying) { return null; } const rect = this.getRect(0, 0); const color = AnnotationEditor._colorManager.convert(this.color); return { annotationType: AnnotationEditorType.HIGHLIGHT, color, opacity: this.#opacity, quadPoints: this.#serializeBoxes(rect), outlines: this.#serializeOutlines(rect), pageIndex: this.pageIndex, rect, rotation: this.#getRotation(), structTreeParentId: this._structTreeParentId, }; } static canCreateNewEmptyEditor() { return false; } } export { HighlightEditor };