From 1ea62939238ddffb664979afb0074dbd6667e523 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 22 Nov 2023 19:02:42 +0100 Subject: [PATCH] [Editor] Add a new editor to highlight some text in a pdf (bug 1866119) This patch is first big step for the new highlight feature. Few patches will follow in order to conform to the specs UX/UI gave us. --- extensions/chromium/preferences_schema.json | 4 + src/display/editor/annotation_editor_layer.js | 173 ++++++- src/display/editor/editor.js | 64 ++- src/display/editor/highlight.js | 454 ++++++++++++++++++ src/display/editor/toolbar.js | 13 + src/display/editor/tools.js | 4 +- src/shared/util.js | 2 + web/annotation_editor_layer_builder.css | 80 ++- web/annotation_editor_layer_builder.js | 10 + web/annotation_editor_params.js | 14 + web/app.js | 5 + web/app_options.js | 8 + web/draw_layer_builder.js | 12 +- web/images/toolbarButton-editorHighlight.svg | 6 + web/pdf_page_view.js | 36 +- web/toolbar.js | 20 + web/viewer.css | 9 + web/viewer.html | 33 +- web/viewer.js | 6 + 19 files changed, 897 insertions(+), 56 deletions(-) create mode 100644 src/display/editor/highlight.js create mode 100644 web/images/toolbarButton-editorHighlight.svg diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 6e32677ac..fb8637528 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -81,6 +81,10 @@ "description": "Whether to allow execution of active content (JavaScript) by PDF files.", "default": false }, + "enableHighlightEditor": { + "type": "boolean", + "default": false + }, "disableRange": { "title": "Disable range requests", "description": "Whether to disable range requests (not recommended).", diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index ccb10cbdc..358d6bb48 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -21,10 +21,12 @@ /** @typedef {import("../../../web/interfaces").IL10n} IL10n */ // eslint-disable-next-line max-len /** @typedef {import("../annotation_layer.js").AnnotationLayer} AnnotationLayer */ +/** @typedef {import("../draw_layer.js").DrawLayer} DrawLayer */ import { AnnotationEditorType, FeatureTest } from "../../shared/util.js"; import { AnnotationEditor } from "./editor.js"; import { FreeTextEditor } from "./freetext.js"; +import { HighlightEditor } from "./highlight.js"; import { InkEditor } from "./ink.js"; import { setLayerDimensions } from "../display_utils.js"; import { StampEditor } from "./stamp.js"; @@ -39,6 +41,8 @@ import { StampEditor } from "./stamp.js"; * @property {number} pageIndex * @property {IL10n} l10n * @property {AnnotationLayer} [annotationLayer] + * @property {HTMLDivElement} [textLayer] + * @property {DrawLayer} drawLayer * @property {PageViewport} viewport */ @@ -59,10 +63,14 @@ class AnnotationEditorLayer { #boundPointerup = this.pointerup.bind(this); + #boundPointerUpAfterSelection = this.pointerUpAfterSelection.bind(this); + #boundPointerdown = this.pointerdown.bind(this); #editorFocusTimeoutId = null; + #boundSelectionStart = this.selectionStart.bind(this); + #editors = new Map(); #hadPointerDown = false; @@ -71,12 +79,14 @@ class AnnotationEditorLayer { #isDisabling = false; + #textLayer = null; + #uiManager; static _initialized = false; static #editorTypes = new Map( - [FreeTextEditor, InkEditor, StampEditor].map(type => [ + [FreeTextEditor, InkEditor, StampEditor, HighlightEditor].map(type => [ type._editorType, type, ]) @@ -91,6 +101,8 @@ class AnnotationEditorLayer { div, accessibilityManager, annotationLayer, + drawLayer, + textLayer, viewport, l10n, }) { @@ -109,6 +121,8 @@ class AnnotationEditorLayer { this.#accessibilityManager = accessibilityManager; this.#annotationLayer = annotationLayer; this.viewport = viewport; + this.#textLayer = textLayer; + this.drawLayer = drawLayer; this.#uiManager.addLayer(this); } @@ -131,12 +145,24 @@ class AnnotationEditorLayer { */ updateMode(mode = this.#uiManager.getMode()) { this.#cleanup(); - if (mode === AnnotationEditorType.INK) { - // We always want to an ink editor ready to draw in. - this.addInkEditorIfNeeded(false); - this.disableClick(); - } else { - this.enableClick(); + switch (mode) { + case AnnotationEditorType.INK: + // We always want to have an ink editor ready to draw in. + this.addInkEditorIfNeeded(false); + + this.disableTextSelection(); + this.togglePointerEvents(true); + this.disableClick(); + break; + case AnnotationEditorType.HIGHLIGHT: + this.enableTextSelection(); + this.togglePointerEvents(false); + this.disableClick(); + break; + default: + this.disableTextSelection(); + this.togglePointerEvents(true); + this.enableClick(); } if (mode !== AnnotationEditorType.NONE) { @@ -272,6 +298,7 @@ class AnnotationEditorLayer { for (const editorType of AnnotationEditorLayer.#editorTypes.values()) { classList.remove(`${editorType._type}Editing`); } + this.disableTextSelection(); this.#isDisabling = false; } @@ -293,6 +320,18 @@ class AnnotationEditorLayer { this.#uiManager.setActiveEditor(editor); } + enableTextSelection() { + if (this.#textLayer?.div) { + document.addEventListener("selectstart", this.#boundSelectionStart); + } + } + + disableTextSelection() { + if (this.#textLayer?.div) { + document.removeEventListener("selectstart", this.#boundSelectionStart); + } + } + enableClick() { this.div.addEventListener("pointerdown", this.#boundPointerdown); this.div.addEventListener("pointerup", this.#boundPointerup); @@ -458,18 +497,24 @@ class AnnotationEditorLayer { return this.#uiManager.getId(); } + get #currentEditorType() { + return AnnotationEditorLayer.#editorTypes.get(this.#uiManager.getMode()); + } + /** * Create a new editor * @param {Object} params * @returns {AnnotationEditor} */ #createNewEditor(params) { - const editorType = AnnotationEditorLayer.#editorTypes.get( - this.#uiManager.getMode() - ); + const editorType = this.#currentEditorType; return editorType ? new editorType.prototype.constructor(params) : null; } + canCreateNewEmptyEditor() { + return this.#currentEditorType?.canCreateNewEmptyEditor(); + } + /** * Paste some content into a new editor. * @param {number} mode @@ -512,9 +557,10 @@ class AnnotationEditorLayer { * Create and add a new editor. * @param {PointerEvent} event * @param {boolean} isCentered + * @param [Object] data * @returns {AnnotationEditor} */ - #createAndAddNewEditor(event, isCentered) { + #createAndAddNewEditor(event, isCentered, data = {}) { const id = this.getNextId(); const editor = this.#createNewEditor({ parent: this, @@ -523,6 +569,7 @@ class AnnotationEditorLayer { y: event.offsetY, uiManager: this.#uiManager, isCentered, + ...data, }); if (editor) { this.add(editor); @@ -589,6 +636,98 @@ class AnnotationEditorLayer { this.#uiManager.unselect(editor); } + /** + * SelectionChange callback. + * @param {Event} _event + */ + selectionStart(_event) { + this.#textLayer?.div.addEventListener( + "pointerup", + this.#boundPointerUpAfterSelection, + { once: true } + ); + } + + /** + * Called when the user releases the mouse button after having selected + * some text. + * @param {PointerEvent} event + */ + pointerUpAfterSelection(event) { + const selection = document.getSelection(); + if (selection.rangeCount === 0) { + return; + } + const range = selection.getRangeAt(0); + if (range.collapsed) { + return; + } + + if (!this.#textLayer?.div.contains(range.commonAncestorContainer)) { + return; + } + + const { + x: layerX, + y: layerY, + width: parentWidth, + height: parentHeight, + } = this.#textLayer.div.getBoundingClientRect(); + const bboxes = range.getClientRects(); + + // We must rotate the boxes because we want to have them in the non-rotated + // page coordinates. + let rotator; + switch (this.viewport.rotation) { + case 90: + rotator = (x, y, w, h) => ({ + x: (y - layerY) / parentHeight, + y: 1 - (x + w - layerX) / parentWidth, + width: h / parentHeight, + height: w / parentWidth, + }); + break; + case 180: + rotator = (x, y, w, h) => ({ + x: 1 - (x + w - layerX) / parentWidth, + y: 1 - (y + h - layerY) / parentHeight, + width: w / parentWidth, + height: h / parentHeight, + }); + break; + case 270: + rotator = (x, y, w, h) => ({ + x: 1 - (y + h - layerY) / parentHeight, + y: (x - layerX) / parentWidth, + width: h / parentHeight, + height: w / parentWidth, + }); + break; + default: + rotator = (x, y, w, h) => ({ + x: (x - layerX) / parentWidth, + y: (y - layerY) / parentHeight, + width: w / parentWidth, + height: h / parentHeight, + }); + break; + } + + const boxes = []; + for (const { x, y, width, height } of bboxes) { + if (width === 0 || height === 0) { + continue; + } + boxes.push(rotator(x, y, width, height)); + } + if (boxes.length !== 0) { + this.#createAndAddNewEditor(event, false, { + boxes, + }); + } + selection.empty(); + } + /** * Pointerup callback. * @param {PointerEvent} event @@ -631,6 +770,9 @@ class AnnotationEditorLayer { * @param {PointerEvent} event */ pointerdown(event) { + if (this.#uiManager.getMode() === AnnotationEditorType.HIGHLIGHT) { + this.enableTextSelection(); + } if (this.#hadPointerDown) { // It's possible to have a second pointerdown event before a pointerup one // when the user puts a finger on a touchscreen and then add a second one @@ -734,8 +876,15 @@ class AnnotationEditorLayer { // the viewport. this.#uiManager.commitOrRemove(); + const oldRotation = this.viewport.rotation; + const rotation = viewport.rotation; this.viewport = viewport; - setLayerDimensions(this.div, { rotation: viewport.rotation }); + setLayerDimensions(this.div, { rotation }); + if (oldRotation !== rotation) { + for (const editor of this.#editors.values()) { + editor.rotate(rotation); + } + } this.updateMode(); } diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index ae1f01920..5808ee811 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -507,7 +507,11 @@ class AnnotationEditor { } } - fixAndSetPosition() { + /** + * Fix the position of the editor in order to keep it inside its parent page. + * @param {number} [rotation] - the rotation of the page. + */ + fixAndSetPosition(rotation = this.rotation) { const [pageWidth, pageHeight] = this.pageDimensions; let { x, y, width, height } = this; width *= pageWidth; @@ -515,7 +519,7 @@ class AnnotationEditor { x *= pageWidth; y *= pageHeight; - switch (this.rotation) { + switch (rotation) { case 0: x = Math.max(0, Math.min(pageWidth - width, x)); y = Math.max(0, Math.min(pageHeight - height, y)); @@ -1125,14 +1129,28 @@ class AnnotationEditor { this.#hasBeenClicked = true; - this.#setUpDragSession(event); - } - - #setUpDragSession(event) { - if (!this._isDraggable) { + if (this._isDraggable) { + this.#setUpDragSession(event); return; } + this.#selectOnPointerEvent(event); + } + + #selectOnPointerEvent(event) { + const { isMac } = FeatureTest.platform; + if ( + (event.ctrlKey && !isMac) || + event.shiftKey || + (event.metaKey && isMac) + ) { + this.parent.toggleSelected(this); + } else { + this.parent.setSelected(this); + } + } + + #setUpDragSession(event) { const isSelected = this._uiManager.isSelected(this); this._uiManager.setUpDragSession(); @@ -1163,16 +1181,7 @@ class AnnotationEditor { this.#hasBeenClicked = false; if (!this._uiManager.endDragSession()) { - const { isMac } = FeatureTest.platform; - if ( - (event.ctrlKey && !isMac) || - event.shiftKey || - (event.metaKey && isMac) - ) { - this.parent.toggleSelected(this); - } else { - this.parent.setSelected(this); - } + this.#selectOnPointerEvent(event); } }; window.addEventListener("pointerup", pointerUpCallback); @@ -1204,8 +1213,11 @@ class AnnotationEditor { /** * Convert the current rect into a page one. + * @param {number} tx - x-translation in screen coordinates. + * @param {number} ty - y-translation in screen coordinates. + * @param {number} [rotation] - the rotation of the page. */ - getRect(tx, ty) { + getRect(tx, ty, rotation = this.rotation) { const scale = this.parentScale; const [pageWidth, pageHeight] = this.pageDimensions; const [pageX, pageY] = this.pageTranslation; @@ -1216,7 +1228,7 @@ class AnnotationEditor { const width = this.width * pageWidth; const height = this.height * pageHeight; - switch (this.rotation) { + switch (rotation) { case 0: return [ x + shiftX + pageX, @@ -1332,6 +1344,12 @@ class AnnotationEditor { this.div?.addEventListener("focusout", this.#boundFocusout); } + /** + * Rotate the editor. + * @param {number} angle + */ + rotate(_angle) {} + /** * Serialize the editor. * The result of the serialization will be used to construct a @@ -1426,6 +1444,10 @@ class AnnotationEditor { } } + get toolbarPosition() { + return null; + } + /** * onkeydown callback. * @param {KeyboardEvent} event @@ -1669,6 +1691,10 @@ class AnnotationEditor { static get MIN_SIZE() { return 16; } + + static canCreateNewEmptyEditor() { + return true; + } } // This class is used to fake an editor which has been deleted. diff --git a/src/display/editor/highlight.js b/src/display/editor/highlight.js new file mode 100644 index 000000000..c9b85a9e3 --- /dev/null +++ b/src/display/editor/highlight.js @@ -0,0 +1,454 @@ +/* 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 { Outliner } from "./outliner.js"; + +/** + * Basic draw editor in order to generate an Highlight annotation. + */ +class HighlightEditor extends AnnotationEditor { + #boxes; + + #clipPathId = null; + + #color; + + #focusOutlines = null; + + #highlightDiv = null; + + #highlightOutlines = null; + + #id = null; + + #lastPoint = null; + + #opacity; + + #outlineId = null; + + static _defaultColor = "#FFF066"; + + static _defaultOpacity = 0.4; + + 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) { + AnnotationEditor.initialize(l10n); + } + + static updateDefaultParams(type, value) { + switch (type) { + case AnnotationEditorParamsType.HIGHLIGHT_COLOR: + HighlightEditor._defaultColor = value; + break; + case AnnotationEditorParamsType.HIGHLIGHT_OPACITY: + HighlightEditor._defaultOpacity = value / 100; + break; + } + } + + /** @inheritdoc */ + get toolbarPosition() { + return this.#lastPoint; + } + + /** @inheritdoc */ + updateParams(type, value) { + switch (type) { + case AnnotationEditorParamsType.HIGHLIGHT_COLOR: + this.#updateColor(value); + break; + case AnnotationEditorParamsType.HIGHLIGHT_OPACITY: + this.#updateOpacity(value); + break; + } + } + + static get defaultPropertiesToUpdate() { + return [ + [ + AnnotationEditorParamsType.HIGHLIGHT_COLOR, + HighlightEditor._defaultColor, + ], + [ + AnnotationEditorParamsType.HIGHLIGHT_OPACITY, + Math.round(HighlightEditor._defaultOpacity * 100), + ], + ]; + } + + /** @inheritdoc */ + get propertiesToUpdate() { + return [ + [ + AnnotationEditorParamsType.HIGHLIGHT_COLOR, + this.#color || HighlightEditor._defaultColor, + ], + [ + AnnotationEditorParamsType.HIGHLIGHT_OPACITY, + Math.round(100 * (this.#opacity ?? HighlightEditor._defaultOpacity)), + ], + ]; + } + + /** + * 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); + }, + undo: () => { + this.#color = savedColor; + this.parent.drawLayer.changeColor(this.#id, savedColor); + }, + mustExec: true, + type: AnnotationEditorParamsType.HIGHLIGHT_COLOR, + overwriteIfSameType: true, + keepUndo: true, + }); + } + + /** + * Update the opacity and make this action undoable. + * @param {number} opacity + */ + #updateOpacity(opacity) { + opacity /= 100; + const savedOpacity = this.#opacity; + this.addCommands({ + cmd: () => { + this.#opacity = opacity; + this.parent.drawLayer.changeOpacity(this.#id, opacity); + }, + undo: () => { + this.#opacity = savedOpacity; + this.parent.drawLayer.changeOpacity(this.#id, savedOpacity); + }, + mustExec: true, + type: AnnotationEditorParamsType.HIGHLIGHT_OPACITY, + overwriteIfSameType: true, + keepUndo: true, + }); + } + + /** @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) { + if (this.parent && !parent) { + this.#cleanDrawLayer(); + } else if (parent) { + this.#addToDrawLayer(parent); + } + super.setParent(parent); + } + + #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() { + 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() { + const [pageWidth, pageHeight] = this.pageDimensions; + const width = this.width * pageWidth; + const height = this.height * pageHeight; + const tx = this.x * pageWidth; + const ty = (1 - this.y - this.height) * pageHeight; + 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, color, quadPoints } = data; + editor.#color = Util.makeHexColor(...color); + editor.#opacity = data.opacity; + + const [pageWidth, pageHeight] = editor.pageDimensions; + editor.width = (rect[2] - rect[0]) / pageWidth; + editor.height = (rect[3] - rect[1]) / pageHeight; + const boxes = (editor.#boxes = []); + for (let i = 0; i < quadPoints.length; i += 8) { + boxes.push({ + x: quadPoints[4] / pageWidth, + y: 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(), + outlines: this.#serializeOutlines(), + pageIndex: this.pageIndex, + rect, + rotation: 0, + structTreeParentId: this._structTreeParentId, + }; + } + + static canCreateNewEmptyEditor() { + return false; + } +} + +export { HighlightEditor }; diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js index 19e18e231..d78cf7083 100644 --- a/src/display/editor/toolbar.js +++ b/src/display/editor/toolbar.js @@ -36,6 +36,19 @@ class EditorToolbar { buttons.className = "buttons"; editToolbar.append(buttons); + const position = this.#editor.toolbarPosition; + if (position) { + const { style } = editToolbar; + const x = + this.#editor._uiManager.direction === "ltr" + ? 1 - position[0] + : position[0]; + style.insetInlineEnd = `${100 * x}%`; + style.top = `calc(${ + 100 * position[1] + }% + var(--editor-toolbar-vert-offset))`; + } + this.#addDeleteButton(); return editToolbar; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index a3db74a0d..f4b490c60 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -1217,7 +1217,9 @@ class AnnotationEditorUIManager { } addNewEditorFromKeyboard() { - this.currentLayer.addNewEditor(); + if (this.currentLayer.canCreateNewEmptyEditor()) { + this.currentLayer.addNewEditor(); + } } /** diff --git a/src/shared/util.js b/src/shared/util.js index f2cd2446f..068874cb9 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -86,6 +86,8 @@ const AnnotationEditorParamsType = { INK_COLOR: 21, INK_THICKNESS: 22, INK_OPACITY: 23, + HIGHLIGHT_COLOR: 31, + HIGHLIGHT_OPACITY: 32, }; // Permission flags from Table 22, Section 7.6.3.2 of the PDF specification. diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 9a29fc0ff..4d39dfe4f 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -13,6 +13,8 @@ * limitations under the License. */ +@import url(draw_layer_builder.css); + :root { --outline-width: 2px; --outline-color: #0060df; @@ -188,7 +190,10 @@ border: var(--focus-outline-around); } } +} +.annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .highlightEditor) { .editToolbar { --editor-toolbar-delete-image: url(images/editor-toolbar-delete.svg); --editor-toolbar-bg-color: #f0f0f4; @@ -198,6 +203,7 @@ --editor-toolbar-active-bg-color: #cfcfd8; --editor-toolbar-focus-outline-color: #0060df; --editor-toolbar-shadow: 0 2px 6px 0 rgb(58 57 68 / 0.2); + --editor-toolbar-vert-offset: 6px; @media (prefers-color-scheme: dark) { --editor-toolbar-bg-color: #2b2a33; @@ -225,10 +231,11 @@ justify-content: center; align-items: center; cursor: default; + pointer-events: auto; position: absolute; inset-inline-end: 0; - inset-block-start: calc(100% + 6px); + inset-block-start: calc(100% + var(--editor-toolbar-vert-offset)); border-radius: 4px; background-color: var(--editor-toolbar-bg-color); @@ -316,7 +323,7 @@ height: 100%; } -.annotationEditorLayer .freeTextEditor .overlay.enabled { +.annotationEditorLayer freeTextEditor .overlay.enabled { display: block; } @@ -513,12 +520,12 @@ rotate: 270deg; &:dir(ltr) { - inset-inline-start: calc(100% + 6px); + inset-inline-start: calc(100% + var(--editor-toolbar-vert-offset)); inset-block-start: 0; } &:dir(rtl) { - inset-inline-end: calc(100% + 6px); + inset-inline-end: calc(100% + var(--editor-toolbar-vert-offset)); inset-block-end: 0; inset-block-start: unset; } @@ -547,7 +554,7 @@ .editToolbar { rotate: 180deg; inset-inline-start: 0; - inset-block-end: calc(100% + 6px); + inset-block-end: calc(100% + var(--editor-toolbar-vert-offset)); inset-block-start: unset; } } @@ -585,13 +592,13 @@ rotate: 90deg; &:dir(ltr) { - inset-inline-end: calc(100% + 6px); + inset-inline-end: calc(100% + var(--editor-toolbar-vert-offset)); inset-block-end: 0; inset-block-start: unset; } &:dir(rtl) { - inset-inline-start: calc(100% + 6px); + inset-inline-start: calc(100% + var(--editor-toolbar-vert-offset)); inset-block-start: 0; } } @@ -1000,3 +1007,62 @@ } } } + +.annotationEditorLayer { + &[data-main-rotation="0"] { + .highlightEditor > .editToolbar { + rotate: 0deg; + } + } + + &[data-main-rotation="90"] { + .highlightEditor > .editToolbar { + rotate: 270deg; + } + } + + &[data-main-rotation="180"] { + .highlightEditor > .editToolbar { + rotate: 180deg; + } + } + + &[data-main-rotation="270"] { + .highlightEditor > .editToolbar { + rotate: 90deg; + } + } + + .highlightEditor { + position: absolute; + background: transparent; + z-index: 1; + transform-origin: 0 0; + cursor: auto; + max-width: 100%; + max-height: 100%; + border: none; + outline: none; + pointer-events: none; + transform: none; + + .internal { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: auto; + } + + &.selectedEditor { + .internal { + cursor: pointer; + } + } + + .editToolbar { + transform-origin: center; + } + } +} diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js index 3bc368cff..d4a2aa9fd 100644 --- a/web/annotation_editor_layer_builder.js +++ b/web/annotation_editor_layer_builder.js @@ -35,11 +35,17 @@ import { NullL10n } from "web-l10n_utils"; * @property {IL10n} [l10n] * @property {TextAccessibilityManager} [accessibilityManager] * @property {AnnotationLayer} [annotationLayer] + * @property {TextLayer} [textLayer] + * @property {DrawLayer} [drawLayer] */ class AnnotationEditorLayerBuilder { #annotationLayer = null; + #drawLayer = null; + + #textLayer = null; + #uiManager; /** @@ -55,6 +61,8 @@ class AnnotationEditorLayerBuilder { this._cancelled = false; this.#uiManager = options.uiManager; this.#annotationLayer = options.annotationLayer || null; + this.#textLayer = options.textLayer || null; + this.#drawLayer = options.drawLayer || null; } /** @@ -93,6 +101,8 @@ class AnnotationEditorLayerBuilder { l10n: this.l10n, viewport: clonedViewport, annotationLayer: this.#annotationLayer, + textLayer: this.#textLayer, + drawLayer: this.#drawLayer, }); const parameters = { diff --git a/web/annotation_editor_params.js b/web/annotation_editor_params.js index ddfc17d1a..ee84fe4a7 100644 --- a/web/annotation_editor_params.js +++ b/web/annotation_editor_params.js @@ -28,6 +28,8 @@ class AnnotationEditorParams { #bindListeners({ editorFreeTextFontSize, editorFreeTextColor, + editorHighlightColor, + editorHighlightOpacity, editorInkColor, editorInkThickness, editorInkOpacity, @@ -46,6 +48,12 @@ class AnnotationEditorParams { editorFreeTextColor.addEventListener("input", function () { dispatchEvent("FREETEXT_COLOR", this.value); }); + editorHighlightColor.addEventListener("input", function () { + dispatchEvent("HIGHLIGHT_COLOR", this.value); + }); + editorHighlightOpacity.addEventListener("input", function () { + dispatchEvent("HIGHLIGHT_OPACITY", this.valueAsNumber); + }); editorInkColor.addEventListener("input", function () { dispatchEvent("INK_COLOR", this.value); }); @@ -68,6 +76,12 @@ class AnnotationEditorParams { case AnnotationEditorParamsType.FREETEXT_COLOR: editorFreeTextColor.value = value; break; + case AnnotationEditorParamsType.HIGHLIGHT_COLOR: + editorHighlightColor.value = value; + break; + case AnnotationEditorParamsType.HIGHLIGHT_OPACITY: + editorHighlightOpacity.value = value; + break; case AnnotationEditorParamsType.INK_COLOR: editorInkColor.value = value; break; diff --git a/web/app.js b/web/app.js index 552566d6c..253a6d78f 100644 --- a/web/app.js +++ b/web/app.js @@ -486,6 +486,11 @@ const PDFViewerApplication = { appConfig.toolbar?.editorStampButton?.classList.add("hidden"); } + const editorHighlightButton = appConfig.toolbar?.editorHighlightButton; + if (editorHighlightButton && AppOptions.get("enableHighlightEditor")) { + editorHighlightButton.hidden = false; + } + this.annotationEditorParams = new AnnotationEditorParams( appConfig.annotationEditorParams, eventBus diff --git a/web/app_options.js b/web/app_options.js index 216da7ff1..caf4dcd76 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -125,6 +125,14 @@ const defaultOptions = { value: false, kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableHighlightEditor: { + // We'll probably want to make some experiments before enabling this + // in Firefox release, but it has to be temporary. + // TODO: remove it when unnecessary. + /** @type {boolean} */ + value: typeof PDFJSDev === "undefined", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enablePermissions: { /** @type {boolean} */ value: false, diff --git a/web/draw_layer_builder.js b/web/draw_layer_builder.js index 693f15a3b..6f349b6cf 100644 --- a/web/draw_layer_builder.js +++ b/web/draw_layer_builder.js @@ -13,13 +13,11 @@ * limitations under the License. */ -/** @typedef {import("../src/display/draw_layer.js").DrawLayer} DrawLayer */ - import { DrawLayer } from "pdfjs-lib"; /** * @typedef {Object} DrawLayerBuilderOptions - * @property {DrawLayer} [drawLayer] + * @property {number} pageIndex */ class DrawLayerBuilder { @@ -53,6 +51,14 @@ class DrawLayerBuilder { this.#drawLayer.destroy(); this.#drawLayer = null; } + + setParent(parent) { + this.#drawLayer?.setParent(parent); + } + + getDrawLayer() { + return this.#drawLayer; + } } export { DrawLayerBuilder }; diff --git a/web/images/toolbarButton-editorHighlight.svg b/web/images/toolbarButton-editorHighlight.svg new file mode 100644 index 000000000..b3cd7fda9 --- /dev/null +++ b/web/images/toolbarButton-editorHighlight.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index b678c4223..d31f50b39 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -42,6 +42,7 @@ import { import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js"; import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { compatibilityParams } from "./app_options.js"; +import { DrawLayerBuilder } from "./draw_layer_builder.js"; import { NullL10n } from "web-l10n_utils"; import { SimpleLinkService } from "./pdf_link_service.js"; import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js"; @@ -177,6 +178,7 @@ class PDFPageView { this.zoomLayer = null; this.xfaLayer = null; this.structTreeLayer = null; + this.drawLayer = null; const div = document.createElement("div"); div.className = "page"; @@ -354,6 +356,14 @@ class PDFPageView { } } + async #renderDrawLayer() { + try { + await this.drawLayer.render("display"); + } catch (ex) { + console.error(`#renderDrawLayer: "${ex}".`); + } + } + async #renderXfaLayer() { let error = null; try { @@ -718,6 +728,10 @@ class PDFPageView { this.annotationEditorLayer && (!keepAnnotationEditorLayer || !this.annotationEditorLayer.div) ) { + if (this.drawLayer) { + this.drawLayer.cancel(); + this.drawLayer = null; + } this.annotationEditorLayer.cancel(); this.annotationEditorLayer = null; } @@ -770,6 +784,9 @@ class PDFPageView { this.#renderAnnotationLayer(); } if (redrawAnnotationEditorLayer && this.annotationEditorLayer) { + if (this.drawLayer) { + this.#renderDrawLayer(); + } this.#renderAnnotationEditorLayer(); } if (redrawXfaLayer && this.xfaLayer) { @@ -1001,12 +1018,19 @@ class PDFPageView { await this.#renderAnnotationLayer(); } - if (!this.annotationEditorLayer) { - const { annotationEditorUIManager } = this.#layerProperties; + const { annotationEditorUIManager } = this.#layerProperties; - if (!annotationEditorUIManager) { - return; - } + if (!annotationEditorUIManager) { + return; + } + + this.drawLayer ||= new DrawLayerBuilder({ + pageIndex: this.id, + }); + await this.#renderDrawLayer(); + this.drawLayer.setParent(canvasWrapper); + + if (!this.annotationEditorLayer) { this.annotationEditorLayer = new AnnotationEditorLayerBuilder({ uiManager: annotationEditorUIManager, pageDiv: div, @@ -1014,6 +1038,8 @@ class PDFPageView { l10n, accessibilityManager: this._accessibilityManager, annotationLayer: this.annotationLayer?.annotationLayer, + textLayer: this.textLayer, + drawLayer: this.drawLayer.getDrawLayer(), }); } this.#renderAnnotationEditorLayer(); diff --git a/web/toolbar.js b/web/toolbar.js index 30b80e688..1f31a6423 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -69,6 +69,18 @@ class Toolbar { }, }, }, + { + element: options.editorHighlightButton, + eventName: "switchannotationeditormode", + eventDetails: { + get mode() { + const { classList } = options.editorHighlightButton; + return classList.contains("toggled") + ? AnnotationEditorType.NONE + : AnnotationEditorType.HIGHLIGHT; + }, + }, + }, { element: options.editorInkButton, eventName: "switchannotationeditormode", @@ -202,6 +214,8 @@ class Toolbar { #bindEditorToolsListener({ editorFreeTextButton, editorFreeTextParamsToolbar, + editorHighlightButton, + editorHighlightParamsToolbar, editorInkButton, editorInkParamsToolbar, editorStampButton, @@ -213,6 +227,11 @@ class Toolbar { mode === AnnotationEditorType.FREETEXT, editorFreeTextParamsToolbar ); + toggleCheckedBtn( + editorHighlightButton, + mode === AnnotationEditorType.HIGHLIGHT, + editorHighlightParamsToolbar + ); toggleCheckedBtn( editorInkButton, mode === AnnotationEditorType.INK, @@ -226,6 +245,7 @@ class Toolbar { const isDisable = mode === AnnotationEditorType.DISABLE; editorFreeTextButton.disabled = isDisable; + editorHighlightButton.disabled = isDisable; editorInkButton.disabled = isDisable; editorStampButton.disabled = isDisable; }; diff --git a/web/viewer.css b/web/viewer.css index 8fbaae955..bad8fad70 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -81,6 +81,7 @@ --treeitem-expanded-icon: url(images/treeitem-expanded.svg); --treeitem-collapsed-icon: url(images/treeitem-collapsed.svg); --toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg); + --toolbarButton-editorHighlight-icon: url(images/toolbarButton-editorHighlight.svg); --toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg); --toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg); --toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg); @@ -584,6 +585,10 @@ body { inset-inline-end: calc(var(--editor-toolbar-base-offset) + 56px); } +#editorHighlightParamsToolbar { + inset-inline-end: calc(var(--editor-toolbar-base-offset) + 84px); +} + #editorStampAddImage::before { mask-image: var(--editorParams-stampAddImage-icon); } @@ -903,6 +908,10 @@ body { mask-image: var(--toolbarButton-editorFreeText-icon); } +#editorHighlight::before { + mask-image: var(--toolbarButton-editorHighlight-icon); +} + #editorInk::before { mask-image: var(--toolbarButton-editorInk-icon); } diff --git a/web/viewer.html b/web/viewer.html index a10b570cf..4aab7094b 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -171,15 +171,28 @@ See https://github.com/adobe-type-tools/cmap-resources + + @@ -188,22 +201,22 @@ See https://github.com/adobe-type-tools/cmap-resources
- +
- +
- +
- - -
diff --git a/web/viewer.js b/web/viewer.js index 0f9c31b42..5a2e2e7ce 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -57,6 +57,10 @@ function getViewerConfiguration() { editorFreeTextParamsToolbar: document.getElementById( "editorFreeTextParamsToolbar" ), + editorHighlightButton: document.getElementById("editorHighlight"), + editorHighlightParamsToolbar: document.getElementById( + "editorHighlightParamsToolbar" + ), editorInkButton: document.getElementById("editorInk"), editorInkParamsToolbar: document.getElementById("editorInkParamsToolbar"), editorStampButton: document.getElementById("editorStamp"), @@ -164,6 +168,8 @@ function getViewerConfiguration() { annotationEditorParams: { editorFreeTextFontSize: document.getElementById("editorFreeTextFontSize"), editorFreeTextColor: document.getElementById("editorFreeTextColor"), + editorHighlightColor: document.getElementById("editorHighlightColor"), + editorHighlightOpacity: document.getElementById("editorHighlightOpacity"), editorInkColor: document.getElementById("editorInkColor"), editorInkThickness: document.getElementById("editorInkThickness"), editorInkOpacity: document.getElementById("editorInkOpacity"),