/* 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 { AnnotationEditor } from "./editor.js"; import { bindEvents } from "./tools.js"; import { ColorPicker } from "./color_picker.js"; import { Outliner } from "./outliner.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; #lastPoint = null; #opacity; #outlineId = null; static _defaultColor = null; static _defaultOpacity = 1; static _l10nPromise; static _type = "highlight"; static _editorType = AnnotationEditorType.HIGHLIGHT; 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; 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, ]; } 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 */ 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(0); } /** @inheritdoc */ getRect(tx, ty) { return super.getRect(tx, ty, 0); } /** @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 rebuilting 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 )); if (this.#highlightDiv) { this.#highlightDiv.style.clipPath = this.#clipPathId; } this.#outlineId = parent.drawLayer.highlightOutline(this.#focusOutlines); } 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) { const { drawLayer } = this.parent; drawLayer.rotate(this.#id, angle); drawLayer.rotate(this.#outlineId, angle); drawLayer.updateBox(this.#id, HighlightEditor.#rotateBbox(this, angle)); drawLayer.updateBox( this.#outlineId, HighlightEditor.#rotateBbox(this.#focusOutlines.box, angle) ); } /** @inheritdoc */ render() { if (this.div) { return this.div; } const div = super.render(); 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"); } #serializeBoxes(rect) { 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) { const [pageWidth, pageHeight] = this.pageDimensions; const width = this.width * pageWidth; const height = this.height * pageHeight; const [tx, ty] = rect; const outlines = []; for (const outline of this.#highlightOutlines.outlines) { const points = new Array(outline.length); for (let i = 0; i < outline.length; i += 2) { points[i] = tx + outline[i] * width; points[i + 1] = ty + (1 - outline[i + 1]) * height; } outlines.push(points); } return outlines; } /** @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: 0, structTreeParentId: this._structTreeParentId, }; } static canCreateNewEmptyEditor() { return false; } } export { HighlightEditor };