/* 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, shadow, Util, } from "../../shared/util.js"; import { bindEvents, KeyboardManager } from "./tools.js"; import { FreeOutliner, Outliner } from "./outliner.js"; import { AnnotationEditor } from "./editor.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 { #anchorNode = null; #anchorOffset = 0; #boxes; #clipPathId = null; #colorPicker = null; #focusOutlines = null; #focusNode = null; #focusOffset = 0; #highlightDiv = null; #highlightOutlines = null; #id = null; #isFreeHighlight = false; #boundKeydown = this.#keydown.bind(this); #lastPoint = null; #opacity; #outlineId = null; #thickness; #methodOfCreation = ""; static _defaultColor = null; static _defaultOpacity = 1; static _defaultThickness = 12; static _l10nPromise; static _type = "highlight"; static _editorType = AnnotationEditorType.HIGHLIGHT; static _freeHighlightId = -1; static _freeHighlight = null; static _freeHighlightClipId = ""; static get _keyboardManager() { const proto = HighlightEditor.prototype; return shadow( this, "_keyboardManager", new KeyboardManager([ [["ArrowLeft", "mac+ArrowLeft"], proto._moveCaret, { args: [0] }], [["ArrowRight", "mac+ArrowRight"], proto._moveCaret, { args: [1] }], [["ArrowUp", "mac+ArrowUp"], proto._moveCaret, { args: [2] }], [["ArrowDown", "mac+ArrowDown"], proto._moveCaret, { args: [3] }], ]) ); } constructor(params) { super({ ...params, name: "highlightEditor" }); this.color = params.color || HighlightEditor._defaultColor; this.#thickness = params.thickness || HighlightEditor._defaultThickness; this.#opacity = params.opacity || HighlightEditor._defaultOpacity; this.#boxes = params.boxes || null; this.#methodOfCreation = params.methodOfCreation || ""; this._isDraggable = false; if (params.highlightId > -1) { this.#isFreeHighlight = true; this.#createFreeOutlines(params); this.#addToDrawLayer(); } else { this.#anchorNode = params.anchorNode; this.#anchorOffset = params.anchorOffset; this.#focusNode = params.focusNode; this.#focusOffset = params.focusOffset; this.#createOutlines(); this.#addToDrawLayer(); this.rotate(this.rotation); } } /** @inheritdoc */ get telemetryInitialData() { return { action: "added", type: this.#isFreeHighlight ? "free_highlight" : "highlight", color: this._uiManager.highlightColorNames.get(this.color), thickness: this.#thickness, methodOfCreation: this.#methodOfCreation, }; } /** @inheritdoc */ get telemetryFinalData() { return { type: "highlight", color: this._uiManager.highlightColorNames.get(this.color), }; } static computeTelemetryFinalData(data) { // We want to know how many colors have been used. return { numberOfColors: data.get("color").size }; } #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({ highlightOutlines, highlightId, clipPathId }) { this.#highlightOutlines = highlightOutlines; const extraThickness = 1.5; this.#focusOutlines = highlightOutlines.getNewOutline( /* Slightly bigger than the highlight in order to have a little space between the highlight and the outline. */ this.#thickness / 2 + extraThickness, /* innerMargin = */ 0.0025 ); if (highlightId >= 0) { this.#id = highlightId; this.#clipPathId = clipPathId; // We need to redraw the highlight because we change the coordinates to be // in the box coordinate system. this.parent.drawLayer.finalizeLine(highlightId, highlightOutlines); this.#outlineId = this.parent.drawLayer.highlightOutline( this.#focusOutlines ); } else if (this.parent) { const angle = this.parent.viewport.rotation; this.parent.drawLayer.updateLine(this.#id, highlightOutlines); this.parent.drawLayer.updateBox( this.#id, HighlightEditor.#rotateBbox( this.#highlightOutlines.box, (angle - this.rotation + 360) % 360 ) ); this.parent.drawLayer.updateLine(this.#outlineId, this.#focusOutlines); this.parent.drawLayer.updateBox( this.#outlineId, HighlightEditor.#rotateBbox(this.#focusOutlines.box, angle) ); } const { x, y, width, height } = highlightOutlines.box; 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 { lastPoint } = this.#focusOutlines.box; this.#lastPoint = [(lastPoint[0] - x) / width, (lastPoint[1] - y) / height]; } /** @inheritdoc */ static initialize(l10n, uiManager) { AnnotationEditor.initialize(l10n, uiManager); HighlightEditor._defaultColor ||= uiManager.highlightColors?.values().next().value || "#fff066"; } /** @inheritdoc */ static updateDefaultParams(type, value) { switch (type) { case AnnotationEditorParamsType.HIGHLIGHT_DEFAULT_COLOR: HighlightEditor._defaultColor = value; break; case AnnotationEditorParamsType.HIGHLIGHT_THICKNESS: HighlightEditor._defaultThickness = 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; case AnnotationEditorParamsType.HIGHLIGHT_THICKNESS: this.#updateThickness(value); break; } } static get defaultPropertiesToUpdate() { return [ [ AnnotationEditorParamsType.HIGHLIGHT_DEFAULT_COLOR, HighlightEditor._defaultColor, ], [ AnnotationEditorParamsType.HIGHLIGHT_THICKNESS, HighlightEditor._defaultThickness, ], ]; } /** @inheritdoc */ get propertiesToUpdate() { return [ [ AnnotationEditorParamsType.HIGHLIGHT_COLOR, this.color || HighlightEditor._defaultColor, ], [ AnnotationEditorParamsType.HIGHLIGHT_THICKNESS, this.#thickness || HighlightEditor._defaultThickness, ], [AnnotationEditorParamsType.HIGHLIGHT_FREE, this.#isFreeHighlight], ]; } /** * Update the color and make this action undoable. * @param {string} color */ #updateColor(color) { const setColor = col => { this.color = col; this.parent?.drawLayer.changeColor(this.#id, col); this.#colorPicker?.updateColor(col); }; const savedColor = this.color; this.addCommands({ cmd: setColor.bind(this, color), undo: setColor.bind(this, savedColor), post: this._uiManager.updateUI.bind(this._uiManager, this), mustExec: true, type: AnnotationEditorParamsType.HIGHLIGHT_COLOR, overwriteIfSameType: true, keepUndo: true, }); this._reportTelemetry( { action: "color_changed", color: this._uiManager.highlightColorNames.get(color), }, /* mustWait = */ true ); } /** * Update the thickness and make this action undoable. * @param {number} thickness */ #updateThickness(thickness) { const savedThickness = this.#thickness; const setThickness = th => { this.#thickness = th; this.#changeThickness(th); }; this.addCommands({ cmd: setThickness.bind(this, thickness), undo: setThickness.bind(this, savedThickness), post: this._uiManager.updateUI.bind(this._uiManager, this), mustExec: true, type: AnnotationEditorParamsType.INK_THICKNESS, overwriteIfSameType: true, keepUndo: true, }); this._reportTelemetry( { action: "thickness_changed", thickness }, /* mustWait = */ 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 */ getBaseTranslation() { // The editor itself doesn't have any CSS border (we're drawing one // ourselves in using SVG). return [0, 0]; } /** @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(); this._reportTelemetry({ action: "deleted", }); } /** @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(); } } #changeThickness(thickness) { if (!this.#isFreeHighlight) { return; } this.#createFreeOutlines({ highlightOutlines: this.#highlightOutlines.getNewOutline(thickness / 2), }); this.fixAndSetPosition(); const [parentWidth, parentHeight] = this.parentDimensions; this.setDims(this.width * parentWidth, this.height * parentHeight); } #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"); } else { this.div.addEventListener("keydown", this.#boundKeydown); } 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"); } #keydown(event) { HighlightEditor._keyboardManager.exec(this, event); } _moveCaret(direction) { this.parent.unselect(this); switch (direction) { case 0 /* left */: case 2 /* up */: this.#setCaret(/* start = */ true); break; case 1 /* right */: case 3 /* down */: this.#setCaret(/* start = */ false); break; } } #setCaret(start) { if (!this.#anchorNode) { return; } const selection = window.getSelection(); if (start) { selection.setPosition(this.#anchorNode, this.#anchorOffset); } else { selection.setPosition(this.#focusNode, this.#focusOffset); } } /** @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"); if (!this.#isFreeHighlight) { this.#setCaret(/* start = */ false); } } /** @inheritdoc */ get _mustFixPosition() { return !this.#isFreeHighlight; } #getRotation() { // Highlight annotations are always drawn horizontally but if // a free highlight annotation can be rotated. return this.#isFreeHighlight ? this.rotation : 0; } #serializeBoxes() { if (this.#isFreeHighlight) { return null; } const [pageWidth, pageHeight] = this.pageDimensions; const boxes = this.#boxes; const quadPoints = new Array(boxes.length * 8); let i = 0; for (const { x, y, width, height } of boxes) { const sx = x * pageWidth; const sy = (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, isLTR, { 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 / 2, isLTR, /* 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, highlightOutlines: this._freeHighlight.getOutlines(), clipPathId: this._freeHighlightClipId, methodOfCreation: "main_toolbar", }); } 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, thickness: this.#thickness, quadPoints: this.#serializeBoxes(), outlines: this.#serializeOutlines(rect), pageIndex: this.pageIndex, rect, rotation: this.#getRotation(), structTreeParentId: this._structTreeParentId, }; } static canCreateNewEmptyEditor() { return false; } } export { HighlightEditor };