pdf.js/src/display/editor/highlight.js
Calixte Denizet 8fbfef0c07 [Editor] Add the ability to make a free highlight (i.e. without having to select some text) (bug 1856218)
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.
2024-01-18 16:26:04 +01:00

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 };