The free highlighting is enabled when the mouse pointer isn't on some text. Then we draw a shape with smoothed borders corresponding to the movement of the mouse. Printing/saving and changing the thickness will come later.
615 lines
16 KiB
JavaScript
615 lines
16 KiB
JavaScript
/* 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 };
|