From cee65fcd4e2702b2b5ed3516fd9a33e20e3b6a93 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 20 Nov 2024 22:26:38 +0100 Subject: [PATCH] [Editor] Add a new base class to allow to add a drawing in the SVG layer. This patch makes a clear separation between the way to draw and the editing stuff. It adds a class DrawEditor which should be extended in order to create new drawing tools. As an example, the ink tool has been rewritten in order to use it. --- src/core/annotation.js | 52 +- src/display/draw_layer.js | 22 +- src/display/editor/annotation_editor_layer.js | 106 +- src/display/editor/draw.js | 852 +++++++++++ src/display/editor/drawers/inkdraw.js | 853 +++++++++++ src/display/editor/drawers/outline.js | 45 + src/display/editor/editor.js | 134 +- src/display/editor/ink.js | 1274 ++--------------- src/display/editor/tools.js | 29 + src/shared/util.js | 1 + test/driver.js | 32 +- test/integration/ink_editor_spec.mjs | 118 +- test/test_manifest.json | 212 ++- test/unit/annotation_spec.js | 135 +- web/annotation_editor_layer_builder.css | 25 +- web/draw_layer_builder.css | 21 + web/viewer.html | 2 +- 17 files changed, 2548 insertions(+), 1365 deletions(-) create mode 100644 src/display/editor/draw.js create mode 100644 src/display/editor/drawers/inkdraw.js diff --git a/src/core/annotation.js b/src/core/annotation.js index e970dc34cddd4..0e3d816877a52 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -4466,7 +4466,7 @@ class InkAnnotation extends MarkupAnnotation { ink.set("Subtype", Name.get("Ink")); ink.set("CreationDate", `D:${getModificationDate()}`); ink.set("Rect", rect); - ink.set("InkList", outlines?.points || paths.map(p => p.points)); + ink.set("InkList", outlines?.points || paths.points); ink.set("F", 4); ink.set("Rotate", rotation); @@ -4523,28 +4523,29 @@ class InkAnnotation extends MarkupAnnotation { appearanceBuffer.push("/R0 gs"); } - const buffer = []; - for (const { bezier } of paths) { - buffer.length = 0; - buffer.push( - `${numberToString(bezier[0])} ${numberToString(bezier[1])} m` - ); - if (bezier.length === 2) { - buffer.push( - `${numberToString(bezier[0])} ${numberToString(bezier[1])} l S` - ); - } else { - for (let i = 2, ii = bezier.length; i < ii; i += 6) { - const curve = bezier - .slice(i, i + 6) - .map(numberToString) - .join(" "); - buffer.push(`${curve} c`); + for (const outline of paths.lines) { + for (let i = 0, ii = outline.length; i < ii; i += 6) { + if (isNaN(outline[i])) { + appearanceBuffer.push( + `${numberToString(outline[i + 4])} ${numberToString( + outline[i + 5] + )} m` + ); + } else { + const [c1x, c1y, c2x, c2y, x, y] = outline.slice(i, i + 6); + appearanceBuffer.push( + [c1x, c1y, c2x, c2y, x, y].map(numberToString).join(" ") + " c" + ); } - buffer.push("S"); } - appearanceBuffer.push(buffer.join("\n")); + if (outline.length === 6) { + appearanceBuffer.push( + `${numberToString(outline[4])} ${numberToString(outline[5])} l` + ); + } } + appearanceBuffer.push("S"); + const appearance = appearanceBuffer.join("\n"); const appearanceStreamDict = new Dict(xref); @@ -4587,18 +4588,17 @@ class InkAnnotation extends MarkupAnnotation { `${numberToString(outline[4])} ${numberToString(outline[5])} m` ); for (let i = 6, ii = outline.length; i < ii; i += 6) { - if (isNaN(outline[i]) || outline[i] === null) { + if (isNaN(outline[i])) { appearanceBuffer.push( `${numberToString(outline[i + 4])} ${numberToString( outline[i + 5] )} l` ); } else { - const curve = outline - .slice(i, i + 6) - .map(numberToString) - .join(" "); - appearanceBuffer.push(`${curve} c`); + const [c1x, c1y, c2x, c2y, x, y] = outline.slice(i, i + 6); + appearanceBuffer.push( + [c1x, c1y, c2x, c2y, x, y].map(numberToString).join(" ") + " c" + ); } } appearanceBuffer.push("h f"); diff --git a/src/display/draw_layer.js b/src/display/draw_layer.js index d7592069814d8..23ba3e350b748 100644 --- a/src/display/draw_layer.js +++ b/src/display/draw_layer.js @@ -183,11 +183,18 @@ class DrawLayer { this.updateProperties(id, properties); } - updateProperties(elementOrId, { root, bbox, rootClass, path }) { + updateProperties(elementOrId, properties) { + if (!properties) { + return; + } + const { root, bbox, rootClass, path } = properties; const element = typeof elementOrId === "number" ? this.#mapping.get(elementOrId) : elementOrId; + if (!element) { + return; + } if (root) { this.#updateProperties(element, root); } @@ -207,6 +214,19 @@ class DrawLayer { } } + updateParent(id, layer) { + if (layer === this) { + return; + } + const root = this.#mapping.get(id); + if (!root) { + return; + } + layer.#parent.append(root); + this.#mapping.delete(id); + layer.#mapping.set(id, root); + } + remove(id) { this.#toUpdate.delete(id); if (this.#parent === null) { diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 1cce512288524..72ee6c57a5579 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -72,10 +72,10 @@ class AnnotationEditorLayer { #hadPointerDown = false; - #isCleaningUp = false; - #isDisabling = false; + #drawingAC = null; + #textLayer = null; #textSelectionAC = null; @@ -160,12 +160,9 @@ class AnnotationEditorLayer { this.disableClick(); return; 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(); + this.enableClick(); break; case AnnotationEditorType.HIGHLIGHT: this.enableTextSelection(); @@ -193,30 +190,6 @@ class AnnotationEditorLayer { return textLayer === this.#textLayer?.div; } - addInkEditorIfNeeded(isCommitting) { - if (this.#uiManager.getMode() !== AnnotationEditorType.INK) { - // We don't want to add an ink editor if we're not in ink mode! - return; - } - - if (!isCommitting) { - // We're removing an editor but an empty one can already exist so in this - // case we don't need to create a new one. - for (const editor of this.#editors.values()) { - if (editor.isEmpty()) { - editor.setInBackground(); - return; - } - } - } - - const editor = this.createAndAddNewEditor( - { offsetX: 0, offsetY: 0 }, - /* isCentered = */ false - ); - editor.setInBackground(); - } - /** * Set the editing state. * @param {boolean} isEditing @@ -233,6 +206,10 @@ class AnnotationEditorLayer { this.#uiManager.addCommands(params); } + cleanUndoStack(type) { + this.#uiManager.cleanUndoStack(type); + } + toggleDrawing(enabled = false) { this.div.classList.toggle("drawing", !enabled); } @@ -482,10 +459,6 @@ class AnnotationEditorLayer { this.#uiManager.removeEditor(editor); editor.div.remove(); editor.isAttachedToDOM = false; - - if (!this.#isCleaningUp) { - this.addInkEditorIfNeeded(/* isCommitting = */ false); - } } /** @@ -766,6 +739,13 @@ class AnnotationEditorLayer { } this.#hadPointerDown = false; + if ( + this.#currentEditorType?.isDrawer && + this.#currentEditorType.supportMultipleDrawings + ) { + return; + } + if (!this.#allowClick) { this.#allowClick = true; return; @@ -808,10 +788,48 @@ class AnnotationEditorLayer { this.#hadPointerDown = true; + if (this.#currentEditorType?.isDrawer) { + this.startDrawingSession(event); + return; + } + const editor = this.#uiManager.getActive(); this.#allowClick = !editor || editor.isEmpty(); } + startDrawingSession(event) { + this.div.focus(); + if (this.#drawingAC) { + this.#currentEditorType.startDrawing(this, this.#uiManager, false, event); + return; + } + + this.#uiManager.unselectAll(); + this.#drawingAC = new AbortController(); + const signal = this.#uiManager.combinedSignal(this.#drawingAC); + this.div.addEventListener( + "blur", + ({ relatedTarget }) => { + if (relatedTarget && !this.div.contains(relatedTarget)) { + this.commitOrRemove(); + } + }, + { signal } + ); + this.#uiManager.disableUserSelect(true); + this.#currentEditorType.startDrawing(this, this.#uiManager, false, event); + } + + endDrawingSession() { + if (!this.#drawingAC) { + return; + } + this.#drawingAC.abort(); + this.#drawingAC = null; + this.#uiManager.disableUserSelect(false); + this.#currentEditorType.endDrawing(); + } + /** * * @param {AnnotationEditor} editor @@ -828,10 +846,26 @@ class AnnotationEditorLayer { return true; } + commitOrRemove() { + if (this.#drawingAC) { + this.endDrawingSession(); + return true; + } + return false; + } + + onScaleChanging() { + if (!this.#drawingAC) { + return; + } + this.#currentEditorType.onScaleChangingWhenDrawing(this); + } + /** * Destroy the main editor. */ destroy() { + this.commitOrRemove(); if (this.#uiManager.getActive()?.parent === this) { // We need to commit the current editor before destroying the layer. this.#uiManager.commitOrRemove(); @@ -858,13 +892,11 @@ class AnnotationEditorLayer { // When we're cleaning up, some editors are removed but we don't want // to add a new one which will induce an addition in this.#editors, hence // an infinite loop. - this.#isCleaningUp = true; for (const editor of this.#editors.values()) { if (editor.isEmpty()) { editor.remove(); } } - this.#isCleaningUp = false; } /** @@ -896,6 +928,7 @@ class AnnotationEditorLayer { const oldRotation = this.viewport.rotation; const rotation = viewport.rotation; + this.viewport = viewport; setLayerDimensions(this.div, { rotation }); if (oldRotation !== rotation) { @@ -903,7 +936,6 @@ class AnnotationEditorLayer { editor.rotate(rotation); } } - this.addInkEditorIfNeeded(/* isCommitting = */ false); } /** diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js new file mode 100644 index 0000000000000..f9cf60fa95005 --- /dev/null +++ b/src/display/editor/draw.js @@ -0,0 +1,852 @@ +/* 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, unreachable } from "../../shared/util.js"; +import { noContextMenu, stopEvent } from "../display_utils.js"; +import { AnnotationEditor } from "./editor.js"; + +class DrawingOptions { + #svgProperties = Object.create(null); + + updateProperty(name, value) { + this[name] = value; + this.updateSVGProperty(name, value); + } + + updateProperties(properties) { + if (!properties) { + return; + } + for (const [name, value] of Object.entries(properties)) { + this.updateProperty(name, value); + } + } + + updateSVGProperty(name, value) { + this.#svgProperties[name] = value; + } + + toSVGProperties() { + const root = this.#svgProperties; + this.#svgProperties = Object.create(null); + return { root }; + } + + reset() { + this.#svgProperties = Object.create(null); + } + + updateAll(options = this) { + this.updateProperties(options); + } + + clone() { + unreachable("Not implemented"); + } +} + +/** + * Basic draw editor. + */ +class DrawingEditor extends AnnotationEditor { + #drawOutlines = null; + + #mustBeCommitted; + + _drawId = null; + + static _currentDrawId = -1; + + static _currentDraw = null; + + static _currentDrawingOptions = null; + + static _currentParent = null; + + static _INNER_MARGIN = 3; + + constructor(params) { + super(params); + this.#mustBeCommitted = params.mustBeCommitted || false; + + if (params.drawOutlines) { + this.#createDrawOutlines(params); + this.#addToDrawLayer(); + } + } + + #createDrawOutlines({ drawOutlines, drawId, drawingOptions }) { + this.#drawOutlines = drawOutlines; + this._drawingOptions ||= drawingOptions; + + if (drawId >= 0) { + this._drawId = drawId; + // We need to redraw the drawing because we changed the coordinates to be + // in the box coordinate system. + this.parent.drawLayer.finalizeDraw( + drawId, + drawOutlines.defaultProperties + ); + } else { + // We create a new drawing. + this._drawId = this.#createDrawing(drawOutlines, this.parent); + } + + this.#updateBbox(drawOutlines.box); + } + + #createDrawing(drawOutlines, parent) { + const { id } = parent.drawLayer.draw( + DrawingEditor._mergeSVGProperties( + this._drawingOptions.toSVGProperties(), + drawOutlines.defaultSVGProperties + ), + /* isPathUpdatable = */ false, + /* hasClip = */ false + ); + return id; + } + + static _mergeSVGProperties(p1, p2) { + const p1Keys = new Set(Object.keys(p1)); + + for (const [key, value] of Object.entries(p2)) { + if (p1Keys.has(key)) { + Object.assign(p1[key], value); + } else { + p1[key] = value; + } + } + return p1; + } + + /** + * @param {Object} options + * @return {DrawingOptions} the default options to use for a new editor. + */ + static getDefaultDrawingOptions(_options) { + unreachable("Not implemented"); + } + + /** + * @return {Map} a map between the + * parameter types and the name of the options. + */ + // eslint-disable-next-line getter-return + static get typesMap() { + unreachable("Not implemented"); + } + + static get isDrawer() { + return true; + } + + /** + * @returns {boolean} `true` if several drawings can be added to the + * annotation. + */ + static get supportMultipleDrawings() { + return false; + } + + /** @inheritdoc */ + static updateDefaultParams(type, value) { + const propertyName = this.typesMap.get(type); + if (propertyName) { + this._defaultDrawingOptions.updateProperty(propertyName, value); + } + if (this._currentParent) { + this._currentDraw.updateProperty(propertyName, value); + this._currentParent.drawLayer.updateProperties( + this._currentDrawId, + this._defaultDrawingOptions.toSVGProperties() + ); + } + } + + /** @inheritdoc */ + updateParams(type, value) { + const propertyName = this.constructor.typesMap.get(type); + if (propertyName) { + this._updateProperty(type, propertyName, value); + } + } + + /** @inheritdoc */ + static get defaultPropertiesToUpdate() { + const properties = []; + const options = this._defaultDrawingOptions; + for (const [type, name] of this.typesMap) { + properties.push([type, options[name]]); + } + return properties; + } + + /** @inheritdoc */ + get propertiesToUpdate() { + const properties = []; + const { _drawingOptions } = this; + for (const [type, name] of this.constructor.typesMap) { + properties.push([type, _drawingOptions[name]]); + } + return properties; + } + + /** + * Update a property and make this action undoable. + * @param {string} color + */ + _updateProperty(type, name, value) { + const options = this._drawingOptions; + const savedValue = options[name]; + const setter = val => { + options.updateProperty(name, val); + const bbox = this.#drawOutlines.updateProperty(name, val); + if (bbox) { + this.#updateBbox(bbox); + } + this.parent?.drawLayer.updateProperties( + this._drawId, + options.toSVGProperties() + ); + }; + this.addCommands({ + cmd: setter.bind(this, value), + undo: setter.bind(this, savedValue), + post: this._uiManager.updateUI.bind(this._uiManager, this), + mustExec: true, + type, + overwriteIfSameType: true, + keepUndo: true, + }); + } + + /** @inheritdoc */ + _onResizing() { + this.parent?.drawLayer.updateProperties( + this._drawId, + DrawingEditor._mergeSVGProperties( + this.#drawOutlines.getPathResizingSVGProperties( + this.#convertToDrawSpace() + ), + { + bbox: this.#rotateBox(), + } + ) + ); + } + + /** @inheritdoc */ + _onResized() { + this.parent?.drawLayer.updateProperties( + this._drawId, + DrawingEditor._mergeSVGProperties( + this.#drawOutlines.getPathResizedSVGProperties( + this.#convertToDrawSpace() + ), + { + bbox: this.#rotateBox(), + } + ) + ); + } + + /** @inheritdoc */ + _onTranslating(x, y) { + this.parent?.drawLayer.updateProperties(this._drawId, { + bbox: this.#rotateBox(x, y), + }); + } + + /** @inheritdoc */ + _onTranslated() { + this.parent?.drawLayer.updateProperties( + this._drawId, + DrawingEditor._mergeSVGProperties( + this.#drawOutlines.getPathTranslatedSVGProperties( + this.#convertToDrawSpace(), + this.parentDimensions + ), + { + bbox: this.#rotateBox(), + } + ) + ); + } + + _onStartDragging() { + this.parent?.drawLayer.updateProperties(this._drawId, { + rootClass: { + moving: true, + }, + }); + } + + _onStopDragging() { + this.parent?.drawLayer.updateProperties(this._drawId, { + rootClass: { + moving: false, + }, + }); + } + + /** @inheritdoc */ + commit() { + super.commit(); + + this.disableEditMode(); + this.disableEditing(); + } + + /** @inheritdoc */ + disableEditing() { + super.disableEditing(); + this.div.classList.toggle("disabled", true); + } + + /** @inheritdoc */ + enableEditing() { + super.enableEditing(); + this.div.classList.toggle("disabled", false); + } + + /** @inheritdoc */ + getBaseTranslation() { + // The editor itself doesn't have any CSS border (we're drawing one + // ourselves in using SVG). + return [0, 0]; + } + + /** @inheritdoc */ + get isResizable() { + return true; + } + + /** @inheritdoc */ + onceAdded() { + if (!this.annotationElementId) { + this.parent.addUndoableEditor(this); + } + this._isDraggable = true; + if (this.#mustBeCommitted) { + this.#mustBeCommitted = false; + this.commit(); + this.parent.setSelected(this); + this.div.focus(); + } + } + + /** @inheritdoc */ + remove() { + this.#cleanDrawLayer(); + super.remove(); + } + + /** @inheritdoc */ + rebuild() { + if (!this.parent) { + return; + } + super.rebuild(); + if (this.div === null) { + return; + } + + this.#addToDrawLayer(); + this.#updateBbox(this.#drawOutlines.box); + + 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._uiManager.removeShouldRescale(this); + this.#cleanDrawLayer(); + } else if (parent) { + this._uiManager.addShouldRescale(this); + 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._drawId === null || !this.parent) { + return; + } + this.parent.drawLayer.remove(this._drawId); + this._drawId = null; + + // All the SVG properties must be reset in order to make it possible to + // undo. + this._drawingOptions.reset(); + } + + #addToDrawLayer(parent = this.parent) { + if (this._drawId !== null && this.parent === parent) { + return; + } + if (this._drawId !== null) { + // The parent has changed, we need to move the drawing to the new parent. + this.parent.drawLayer.updateParent(this._drawId, parent.drawLayer); + return; + } + this._drawingOptions.updateAll(); + this._drawId = this.#createDrawing(this.#drawOutlines, parent); + } + + #convertToParentSpace([x, y, width, height]) { + const { + parentDimensions: [pW, pH], + rotation, + } = this; + switch (rotation) { + case 90: + return [y, 1 - x, width * (pH / pW), height * (pW / pH)]; + case 180: + return [1 - x, 1 - y, width, height]; + case 270: + return [1 - y, x, width * (pH / pW), height * (pW / pH)]; + default: + return [x, y, width, height]; + } + } + + #convertToDrawSpace() { + const { + x, + y, + width, + height, + parentDimensions: [pW, pH], + rotation, + } = this; + switch (rotation) { + case 90: + return [1 - y, x, width * (pW / pH), height * (pH / pW)]; + case 180: + return [1 - x, 1 - y, width, height]; + case 270: + return [y, 1 - x, width * (pW / pH), height * (pH / pW)]; + default: + return [x, y, width, height]; + } + } + + #updateBbox(bbox) { + [this.x, this.y, this.width, this.height] = + this.#convertToParentSpace(bbox); + if (this.div) { + this.fixAndSetPosition(); + const [parentWidth, parentHeight] = this.parentDimensions; + this.setDims(this.width * parentWidth, this.height * parentHeight); + } + this._onResized(); + } + + #rotateBox() { + // We've to deal with two rotations: the rotation of the annotation and the + // rotation of the parent page. + // When the page is rotated, all the layers are just rotated thanks to CSS + // but there is a notable exception: the canvas wrapper. + // The canvas wrapper is not rotated but the dimensions are (or not) swapped + // and the page is redrawn with the rotation applied to the canvas. + // The drawn layer is under the canvas wrapper and is not rotated so we have + // to "manually" rotate the coordinates. + // + // The coordinates (this.x, this.y) correspond to the top-left corner of + // the editor after it has been rotated in the page coordinate system. + + const { + x, + y, + width, + height, + rotation, + parentRotation, + parentDimensions: [pW, pH], + } = this; + switch ((rotation * 4 + parentRotation) / 90) { + case 1: + // 0 -> 90 + return [1 - y - height, x, height, width]; + case 2: + // 0 -> 180 + return [1 - x - width, 1 - y - height, width, height]; + case 3: + // 0 -> 270 + return [y, 1 - x - width, height, width]; + case 4: + // 90 -> 0 + return [ + x, + y - width * (pW / pH), + height * (pH / pW), + width * (pW / pH), + ]; + case 5: + // 90 -> 90 + return [1 - y, x, width * (pW / pH), height * (pH / pW)]; + case 6: + // 90 -> 180 + return [ + 1 - x - height * (pH / pW), + 1 - y, + height * (pH / pW), + width * (pW / pH), + ]; + case 7: + // 90 -> 270 + return [ + y - width * (pW / pH), + 1 - x - height * (pH / pW), + width * (pW / pH), + height * (pH / pW), + ]; + case 8: + // 180 -> 0 + return [x - width, y - height, width, height]; + case 9: + // 180 -> 90 + return [1 - y, x - width, height, width]; + case 10: + // 180 -> 180 + return [1 - x, 1 - y, width, height]; + case 11: + // 180 -> 270 + return [y - height, 1 - x, height, width]; + case 12: + // 270 -> 0 + return [ + x - height * (pH / pW), + y, + height * (pH / pW), + width * (pW / pH), + ]; + case 13: + // 270 -> 90 + return [ + 1 - y - width * (pW / pH), + x - height * (pH / pW), + width * (pW / pH), + height * (pH / pW), + ]; + case 14: + // 270 -> 180 + return [ + 1 - x, + 1 - y - width * (pW / pH), + height * (pH / pW), + width * (pW / pH), + ]; + case 15: + // 270 -> 270 + return [y, 1 - x, width * (pW / pH), height * (pH / pW)]; + default: + // 0 -> 0 + return [x, y, width, height]; + } + } + + /** @inheritdoc */ + rotate() { + if (!this.parent) { + return; + } + this.parent.drawLayer.updateProperties( + this._drawId, + DrawingEditor._mergeSVGProperties( + { + bbox: this.#rotateBox(), + }, + this.#drawOutlines.updateRotation( + (this.parentRotation - this.rotation + 360) % 360 + ) + ) + ); + } + + onScaleChanging() { + if (!this.parent) { + return; + } + this.#updateBbox( + this.#drawOutlines.updateParentDimensions( + this.parentDimensions, + this.parent.scale + ) + ); + } + + static onScaleChangingWhenDrawing() {} + + /** @inheritdoc */ + render() { + if (this.div) { + return this.div; + } + + const div = super.render(); + div.classList.add("draw"); + + const drawDiv = document.createElement("div"); + div.append(drawDiv); + drawDiv.setAttribute("aria-hidden", "true"); + drawDiv.className = "internal"; + const [parentWidth, parentHeight] = this.parentDimensions; + this.setDims(this.width * parentWidth, this.height * parentHeight); + this._uiManager.addShouldRescale(this); + this.disableEditing(); + + return div; + } + + /** + * Create a new drawer instance. + * @param {number} x - The x coordinate of the event. + * @param {number} y - The y coordinate of the event. + * @param {number} parentWidth - The parent width. + * @param {number} parentHeight - The parent height. + * @param {number} rotation - The parent rotation. + */ + static createDrawerInstance(_x, _y, _parentWidth, _parentHeight, _rotation) { + unreachable("Not implemented"); + } + + static startDrawing( + parent, + uiManager, + _isLTR, + { target, offsetX: x, offsetY: y } + ) { + const { + viewport: { rotation }, + } = parent; + const { width: parentWidth, height: parentHeight } = + target.getBoundingClientRect(); + const ac = new AbortController(); + const signal = parent.combinedSignal(ac); + + window.addEventListener( + "pointerup", + e => { + ac.abort(); + parent.toggleDrawing(true); + this._endDraw(e); + }, + { signal } + ); + window.addEventListener( + "pointerdown", + stopEvent /* Avoid to have undesired clicks during drawing. */, + { + capture: true, + passive: false, + signal, + } + ); + window.addEventListener("contextmenu", noContextMenu, { signal }); + target.addEventListener("pointermove", this._drawMove.bind(this), { + signal, + }); + parent.toggleDrawing(); + + if (this._currentDraw) { + parent.drawLayer.updateProperties( + this._currentDrawId, + this._currentDraw.startNew(x, y, parentWidth, parentHeight, rotation) + ); + return; + } + + uiManager.updateUIForDefaultProperties(this); + + this._currentDraw = this.createDrawerInstance( + x, + y, + parentWidth, + parentHeight, + rotation + ); + this._currentDrawingOptions = this.getDefaultDrawingOptions(); + this._currentParent = parent; + + ({ id: this._currentDrawId } = parent.drawLayer.draw( + this._mergeSVGProperties( + this._currentDrawingOptions.toSVGProperties(), + this._currentDraw.defaultSVGProperties + ), + /* isPathUpdatable = */ true, + /* hasClip = */ false + )); + } + + static _drawMove({ offsetX, offsetY }) { + this._currentParent.drawLayer.updateProperties( + this._currentDrawId, + this._currentDraw.add(offsetX, offsetY) + ); + } + + static _endDraw({ offsetX, offsetY }) { + const parent = this._currentParent; + parent.drawLayer.updateProperties( + this._currentDrawId, + this._currentDraw.end(offsetX, offsetY) + ); + if (this.supportMultipleDrawings) { + const draw = this._currentDraw; + const drawId = this._currentDrawId; + const lastElement = draw.getLastElement(); + parent.addCommands({ + cmd: () => { + parent.drawLayer.updateProperties( + drawId, + draw.setLastElement(lastElement) + ); + }, + undo: () => { + parent.drawLayer.updateProperties(drawId, draw.removeLastElement()); + }, + mustExec: false, + type: AnnotationEditorParamsType.DRAW_STEP, + }); + + return; + } + + this.endDrawing(); + } + + static endDrawing() { + const parent = this._currentParent; + if (!parent) { + return; + } + parent.toggleDrawing(true); + parent.cleanUndoStack(AnnotationEditorParamsType.DRAW_STEP); + + if (!this._currentDraw.isEmpty()) { + const { + pageDimensions: [pageWidth, pageHeight], + scale, + } = parent; + + parent.createAndAddNewEditor({ offsetX: 0, offsetY: 0 }, false, { + drawId: this._currentDrawId, + drawOutlines: this._currentDraw.getOutlines( + pageWidth * scale, + pageHeight * scale, + scale, + this._INNER_MARGIN + ), + drawingOptions: this._currentDrawingOptions, + mustBeCommitted: true, + }); + } else { + parent.drawLayer.remove(this._currentDrawId); + } + this._currentDrawId = -1; + this._currentDraw = null; + this._currentDrawingOptions = null; + this._currentParent = null; + } + + /** + * Create the drawing options. + * @param {Object} _data + */ + createDrawingOptions(_data) {} + + /** + * Deserialize the drawing outlines. + * @param {number} pageX - The x coordinate of the page. + * @param {number} pageY - The y coordinate of the page. + * @param {number} pageWidth - The width of the page. + * @param {number} pageHeight - The height of the page. + * @param {number} innerWidth - The inner width. + * @param {Object} data - The data to deserialize. + * @returns {Object} The deserialized outlines. + */ + static deserializeDraw( + _pageX, + _pageY, + _pageWidth, + _pageHeight, + _innerWidth, + _data + ) { + unreachable("Not implemented"); + } + + /** @inheritdoc */ + static async deserialize(data, parent, uiManager) { + const { + rawDims: { pageWidth, pageHeight, pageX, pageY }, + } = parent.viewport; + const drawOutlines = this.deserializeDraw( + pageX, + pageY, + pageWidth, + pageHeight, + this._INNER_MARGIN, + data + ); + const editor = await super.deserialize(data, parent, uiManager); + editor.createDrawingOptions(data); + editor.#createDrawOutlines({ drawOutlines }); + editor.#addToDrawLayer(); + editor.onScaleChanging(); + editor.rotate(); + + return editor; + } + + serializeDraw(isForCopying) { + const [pageX, pageY] = this.pageTranslation; + const [pageWidth, pageHeight] = this.pageDimensions; + return this.#drawOutlines.serialize( + [pageX, pageY, pageWidth, pageHeight], + isForCopying + ); + } + + /** @inheritdoc */ + renderAnnotationElement(annotation) { + annotation.updateEdited({ + rect: this.getRect(0, 0), + }); + + return null; + } + + static canCreateNewEmptyEditor() { + return false; + } +} + +export { DrawingEditor, DrawingOptions }; diff --git a/src/display/editor/drawers/inkdraw.js b/src/display/editor/drawers/inkdraw.js new file mode 100644 index 0000000000000..bcc7f2b574595 --- /dev/null +++ b/src/display/editor/drawers/inkdraw.js @@ -0,0 +1,853 @@ +/* Copyright 2024 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 { Outline } from "./outline.js"; +import { Util } from "../../../shared/util.js"; + +class InkDrawOutliner { + // The last 3 points of the line. + #last = new Float64Array(6); + + #line; + + #lines; + + #rotation; + + #thickness; + + #points; + + #lastSVGPath = ""; + + #lastIndex = 0; + + #outlines = new InkDrawOutline(); + + #parentWidth; + + #parentHeight; + + constructor(x, y, parentWidth, parentHeight, rotation, thickness) { + this.#parentWidth = parentWidth; + this.#parentHeight = parentHeight; + this.#rotation = rotation; + this.#thickness = thickness; + + [x, y] = this.#normalizePoint(x, y); + + const line = (this.#line = [NaN, NaN, NaN, NaN, x, y]); + this.#points = [x, y]; + this.#lines = [{ line, points: this.#points }]; + this.#last.set(line, 0); + } + + updateProperty(name, value) { + if (name === "stroke-width") { + this.#thickness = value; + } + } + + #normalizePoint(x, y) { + return Outline._normalizePoint( + x, + y, + this.#parentWidth, + this.#parentHeight, + this.#rotation + ); + } + + isEmpty() { + return !this.#lines || this.#lines.length === 0; + } + + add(x, y) { + // The point is in canvas coordinates which means that there is no rotation. + // It's the same as parent coordinates. + [x, y] = this.#normalizePoint(x, y); + const [x1, y1, x2, y2] = this.#last.subarray(2, 6); + const diffX = x - x2; + const diffY = y - y2; + const d = Math.hypot(this.#parentWidth * diffX, this.#parentHeight * diffY); + if (d <= 2) { + // The idea is to avoid garbage points around the last point. + // When the points are too close, it just leads to bad normal vectors and + // control points. + return null; + } + + this.#points.push(x, y); + + if (isNaN(x1)) { + // We've only one point. + this.#last.set([x2, y2, x, y], 2); + this.#line.push(NaN, NaN, NaN, NaN, x, y); + return { + path: { + d: this.toSVGPath(), + }, + }; + } + + if (isNaN(this.#last[0])) { + // We've only two points. + this.#line.splice(6, 6); + } + + this.#last.set([x1, y1, x2, y2, x, y], 0); + this.#line.push( + (x1 + 5 * x2) / 6, + (y1 + 5 * y2) / 6, + (5 * x2 + x) / 6, + (5 * y2 + y) / 6, + (x2 + x) / 2, + (y2 + y) / 2 + ); + + return { + path: { + d: this.toSVGPath(), + }, + }; + } + + end(x, y) { + const change = this.add(x, y); + if (change) { + return change; + } + if (this.#points.length === 2) { + // We've only one point. + return { + path: { + d: this.toSVGPath(), + }, + }; + } + return null; + } + + startNew(x, y, parentWidth, parentHeight, rotation) { + this.#parentWidth = parentWidth; + this.#parentHeight = parentHeight; + this.#rotation = rotation; + + [x, y] = this.#normalizePoint(x, y); + + const line = (this.#line = [NaN, NaN, NaN, NaN, x, y]); + this.#points = [x, y]; + const last = this.#lines.at(-1); + if (last) { + last.line = new Float32Array(last.line); + last.points = new Float32Array(last.points); + } + this.#lines.push({ line, points: this.#points }); + this.#last.set(line, 0); + this.#lastIndex = 0; + this.toSVGPath(); + + return null; + } + + getLastElement() { + return this.#lines.at(-1); + } + + setLastElement(element) { + if (!this.#lines) { + return this.#outlines.setLastElement(element); + } + this.#lines.push(element); + this.#line = element.line; + this.#points = element.points; + this.#lastIndex = 0; + return { + path: { + d: this.toSVGPath(), + }, + }; + } + + removeLastElement() { + if (!this.#lines) { + return this.#outlines.removeLastElement(); + } + this.#lines.pop(); + this.#lastSVGPath = ""; + for (let i = 0, ii = this.#lines.length; i < ii; i++) { + const { line, points } = this.#lines[i]; + this.#line = line; + this.#points = points; + this.#lastIndex = 0; + this.toSVGPath(); + } + + return { + path: { + d: this.#lastSVGPath, + }, + }; + } + + toSVGPath() { + const firstX = Outline.svgRound(this.#line[4]); + const firstY = Outline.svgRound(this.#line[5]); + if (this.#points.length === 2) { + this.#lastSVGPath = `${this.#lastSVGPath} M ${firstX} ${firstY} Z`; + return this.#lastSVGPath; + } + + if (this.#points.length <= 6) { + // We've 2 or 3 points. + const i = this.#lastSVGPath.lastIndexOf("M"); + this.#lastSVGPath = `${this.#lastSVGPath.slice(0, i)} M ${firstX} ${firstY}`; + this.#lastIndex = 6; + } + + if (this.#points.length === 4) { + const secondX = Outline.svgRound(this.#line[10]); + const secondY = Outline.svgRound(this.#line[11]); + this.#lastSVGPath = `${this.#lastSVGPath} L ${secondX} ${secondY}`; + this.#lastIndex = 12; + return this.#lastSVGPath; + } + + const buffer = []; + if (this.#lastIndex === 0) { + buffer.push(`M ${firstX} ${firstY}`); + this.#lastIndex = 6; + } + + for (let i = this.#lastIndex, ii = this.#line.length; i < ii; i += 6) { + const [c1x, c1y, c2x, c2y, x, y] = this.#line + .slice(i, i + 6) + .map(Outline.svgRound); + buffer.push(`C${c1x} ${c1y} ${c2x} ${c2y} ${x} ${y}`); + } + this.#lastSVGPath += buffer.join(" "); + this.#lastIndex = this.#line.length; + + return this.#lastSVGPath; + } + + getOutlines(parentWidth, parentHeight, scale, innerMargin) { + const last = this.#lines.at(-1); + last.line = new Float32Array(last.line); + last.points = new Float32Array(last.points); + + this.#outlines.build( + this.#lines, + parentWidth, + parentHeight, + scale, + this.#rotation, + this.#thickness, + innerMargin + ); + + // We reset everything: the drawing is done. + this.#last = null; + this.#line = null; + this.#lines = null; + this.#lastSVGPath = null; + + return this.#outlines; + } + + get defaultSVGProperties() { + return { + root: { + viewBox: "0 0 10000 10000", + }, + rootClass: { + draw: true, + }, + bbox: [0, 0, 1, 1], + }; + } +} + +class InkDrawOutline extends Outline { + #bbox; + + #currentRotation = 0; + + #innerMargin; + + #lines; + + #parentWidth; + + #parentHeight; + + #parentScale; + + #rotation; + + #thickness; + + build( + lines, + parentWidth, + parentHeight, + parentScale, + rotation, + thickness, + innerMargin + ) { + this.#parentWidth = parentWidth; + this.#parentHeight = parentHeight; + this.#parentScale = parentScale; + this.#rotation = rotation; + this.#thickness = thickness; + this.#innerMargin = innerMargin ?? 0; + this.#lines = lines; + + this.#computeBbox(); + } + + setLastElement(element) { + this.#lines.push(element); + return { + path: { + d: this.toSVGPath(), + }, + }; + } + + removeLastElement() { + this.#lines.pop(); + return { + path: { + d: this.toSVGPath(), + }, + }; + } + + toSVGPath() { + const buffer = []; + for (const { line } of this.#lines) { + buffer.push(`M${Outline.svgRound(line[4])} ${Outline.svgRound(line[5])}`); + if (line.length === 6) { + buffer.push("Z"); + continue; + } + if (line.length === 12) { + buffer.push( + `L${Outline.svgRound(line[10])} ${Outline.svgRound(line[11])}` + ); + continue; + } + for (let i = 6, ii = line.length; i < ii; i += 6) { + const [c1x, c1y, c2x, c2y, x, y] = line + .subarray(i, i + 6) + .map(Outline.svgRound); + buffer.push(`C${c1x} ${c1y} ${c2x} ${c2y} ${x} ${y}`); + } + } + return buffer.join(""); + } + + serialize([pageX, pageY, pageWidth, pageHeight], isForCopying) { + const serializedLines = []; + const serializedPoints = []; + const [x, y, width, height] = this.#getBBoxWithNoMargin(); + let tx, ty, sx, sy, x1, y1, x2, y2, rescaleFn; + + switch (this.#rotation) { + case 0: + rescaleFn = Outline._rescale; + tx = pageX; + ty = pageY + pageHeight; + sx = pageWidth; + sy = -pageHeight; + x1 = pageX + x * pageWidth; + y1 = pageY + (1 - y - height) * pageHeight; + x2 = pageX + (x + width) * pageWidth; + y2 = pageY + (1 - y) * pageHeight; + break; + case 90: + rescaleFn = Outline._rescaleAndSwap; + tx = pageX; + ty = pageY; + sx = pageWidth; + sy = pageHeight; + x1 = pageX + y * pageWidth; + y1 = pageY + x * pageHeight; + x2 = pageX + (y + height) * pageWidth; + y2 = pageY + (x + width) * pageHeight; + break; + case 180: + rescaleFn = Outline._rescale; + tx = pageX + pageWidth; + ty = pageY; + sx = -pageWidth; + sy = pageHeight; + x1 = pageX + (1 - x - width) * pageWidth; + y1 = pageY + y * pageHeight; + x2 = pageX + (1 - x) * pageWidth; + y2 = pageY + (y + height) * pageHeight; + break; + case 270: + rescaleFn = Outline._rescaleAndSwap; + tx = pageX + pageWidth; + ty = pageY + pageHeight; + sx = -pageWidth; + sy = -pageHeight; + x1 = pageX + (1 - y - height) * pageWidth; + y1 = pageY + (1 - x - width) * pageHeight; + x2 = pageX + (1 - y) * pageWidth; + y2 = pageY + (1 - x) * pageHeight; + break; + } + + for (const { line, points } of this.#lines) { + serializedLines.push( + rescaleFn( + line, + tx, + ty, + sx, + sy, + isForCopying ? new Array(line.length) : null + ) + ); + serializedPoints.push( + rescaleFn( + points, + tx, + ty, + sx, + sy, + isForCopying ? new Array(points.length) : null + ) + ); + } + + return { + lines: serializedLines, + points: serializedPoints, + rect: [x1, y1, x2, y2], + }; + } + + static deserialize( + pageX, + pageY, + pageWidth, + pageHeight, + innerMargin, + { paths: { lines, points }, rotation, thickness } + ) { + const newLines = []; + let tx, ty, sx, sy, rescaleFn; + switch (rotation) { + case 0: + rescaleFn = Outline._rescale; + tx = -pageX / pageWidth; + ty = pageY / pageHeight + 1; + sx = 1 / pageWidth; + sy = -1 / pageHeight; + break; + case 90: + rescaleFn = Outline._rescaleAndSwap; + tx = -pageY / pageHeight; + ty = -pageX / pageWidth; + sx = 1 / pageHeight; + sy = 1 / pageWidth; + break; + case 180: + rescaleFn = Outline._rescale; + tx = pageX / pageWidth + 1; + ty = -pageY / pageHeight; + sx = -1 / pageWidth; + sy = 1 / pageHeight; + break; + case 270: + rescaleFn = Outline._rescaleAndSwap; + tx = pageY / pageHeight + 1; + ty = pageX / pageWidth + 1; + sx = -1 / pageHeight; + sy = -1 / pageWidth; + break; + } + + for (let i = 0, ii = lines.length; i < ii; i++) { + newLines.push({ + line: rescaleFn( + lines[i].map(x => x ?? NaN), + tx, + ty, + sx, + sy + ), + points: rescaleFn( + points[i].map(x => x ?? NaN), + tx, + ty, + sx, + sy + ), + }); + } + + const outlines = new InkDrawOutline(); + outlines.build( + newLines, + pageWidth, + pageHeight, + 1, + rotation, + thickness, + innerMargin + ); + + return outlines; + } + + #getMarginComponents(thickness = this.#thickness) { + const margin = this.#innerMargin + (thickness / 2) * this.#parentScale; + return this.#rotation % 180 === 0 + ? [margin / this.#parentWidth, margin / this.#parentHeight] + : [margin / this.#parentHeight, margin / this.#parentWidth]; + } + + #getBBoxWithNoMargin() { + const [x, y, width, height] = this.#bbox; + const [marginX, marginY] = this.#getMarginComponents(0); + + return [ + x + marginX, + y + marginY, + width - 2 * marginX, + height - 2 * marginY, + ]; + } + + #computeBbox() { + const bbox = (this.#bbox = new Float32Array([ + Infinity, + Infinity, + -Infinity, + -Infinity, + ])); + + for (const { line } of this.#lines) { + if (line.length <= 12) { + // We've only one or two points => no bezier curve. + for (let i = 4, ii = line.length; i < ii; i += 6) { + const [x, y] = line.subarray(i, i + 2); + bbox[0] = Math.min(bbox[0], x); + bbox[1] = Math.min(bbox[1], y); + bbox[2] = Math.max(bbox[2], x); + bbox[3] = Math.max(bbox[3], y); + } + continue; + } + let lastX = line[4], + lastY = line[5]; + for (let i = 6, ii = line.length; i < ii; i += 6) { + const [c1x, c1y, c2x, c2y, x, y] = line.subarray(i, i + 6); + Util.bezierBoundingBox(lastX, lastY, c1x, c1y, c2x, c2y, x, y, bbox); + lastX = x; + lastY = y; + } + } + + const [marginX, marginY] = this.#getMarginComponents(); + bbox[0] = Math.min(1, Math.max(0, bbox[0] - marginX)); + bbox[1] = Math.min(1, Math.max(0, bbox[1] - marginY)); + bbox[2] = Math.min(1, Math.max(0, bbox[2] + marginX)); + bbox[3] = Math.min(1, Math.max(0, bbox[3] + marginY)); + + bbox[2] -= bbox[0]; + bbox[3] -= bbox[1]; + } + + get box() { + return this.#bbox; + } + + updateProperty(name, value) { + if (name === "stroke-width") { + return this.#updateThickness(value); + } + return null; + } + + #updateThickness(thickness) { + const [oldMarginX, oldMarginY] = this.#getMarginComponents(); + this.#thickness = thickness; + const [newMarginX, newMarginY] = this.#getMarginComponents(); + const [diffMarginX, diffMarginY] = [ + newMarginX - oldMarginX, + newMarginY - oldMarginY, + ]; + const bbox = this.#bbox; + bbox[0] -= diffMarginX; + bbox[1] -= diffMarginY; + bbox[2] += 2 * diffMarginX; + bbox[3] += 2 * diffMarginY; + + return bbox; + } + + updateParentDimensions([width, height], scale) { + const [oldMarginX, oldMarginY] = this.#getMarginComponents(); + this.#parentWidth = width; + this.#parentHeight = height; + this.#parentScale = scale; + const [newMarginX, newMarginY] = this.#getMarginComponents(); + const diffMarginX = newMarginX - oldMarginX; + const diffMarginY = newMarginY - oldMarginY; + + const bbox = this.#bbox; + bbox[0] -= diffMarginX; + bbox[1] -= diffMarginY; + bbox[2] += 2 * diffMarginX; + bbox[3] += 2 * diffMarginY; + + return bbox; + } + + updateRotation(rotation) { + this.#currentRotation = rotation; + return { + path: { + transform: this.rotationTransform, + }, + }; + } + + get viewBox() { + return this.#bbox.map(Outline.svgRound).join(" "); + } + + get defaultProperties() { + const [x, y] = this.#bbox; + return { + root: { + viewBox: this.viewBox, + }, + path: { + "transform-origin": `${Outline.svgRound(x)} ${Outline.svgRound(y)}`, + }, + }; + } + + get rotationTransform() { + const [, , width, height] = this.#bbox; + let a = 0, + b = 0, + c = 0, + d = 0, + e = 0, + f = 0; + switch (this.#currentRotation) { + case 90: + b = height / width; + c = -width / height; + e = width; + break; + case 180: + a = -1; + d = -1; + e = width; + f = height; + break; + case 270: + b = -height / width; + c = width / height; + f = height; + break; + default: + return ""; + } + return `matrix(${a} ${b} ${c} ${d} ${Outline.svgRound(e)} ${Outline.svgRound(f)})`; + } + + getPathResizingSVGProperties([newX, newY, newWidth, newHeight]) { + const [marginX, marginY] = this.#getMarginComponents(); + const [x, y, width, height] = this.#bbox; + + if ( + Math.abs(width - marginX) <= Outline.PRECISION || + Math.abs(height - marginY) <= Outline.PRECISION + ) { + // Center the path in the new bounding box. + const tx = newX + newWidth / 2 - (x + width / 2); + const ty = newY + newHeight / 2 - (y + height / 2); + return { + path: { + "transform-origin": `${Outline.svgRound(newX)} ${Outline.svgRound(newY)}`, + transform: `${this.rotationTransform} translate(${tx} ${ty})`, + }, + }; + } + + // We compute the following transform: + // 1. Translate the path to the origin (-marginX, -marginY). + // 2. Scale the path to the new size: + // ((newWidth - 2*marginX) / (bbox.width - 2*marginX), + // (newHeight - 2*marginY) / (bbox.height - 2*marginY)). + // 3. Translate the path back to its original position + // (marginX, marginY). + // 4. Scale the inverse of bbox scaling: + // (bbox.width / newWidth, bbox.height / newHeight). + + const s1x = (newWidth - 2 * marginX) / (width - 2 * marginX); + const s1y = (newHeight - 2 * marginY) / (height - 2 * marginY); + const s2x = width / newWidth; + const s2y = height / newHeight; + + return { + path: { + "transform-origin": `${Outline.svgRound(x)} ${Outline.svgRound(y)}`, + transform: + `${this.rotationTransform} scale(${s2x} ${s2y}) ` + + `translate(${Outline.svgRound(marginX)} ${Outline.svgRound(marginY)}) scale(${s1x} ${s1y}) ` + + `translate(${Outline.svgRound(-marginX)} ${Outline.svgRound(-marginY)})`, + }, + }; + } + + getPathResizedSVGProperties([newX, newY, newWidth, newHeight]) { + const [marginX, marginY] = this.#getMarginComponents(); + const bbox = this.#bbox; + const [x, y, width, height] = bbox; + + bbox[0] = newX; + bbox[1] = newY; + bbox[2] = newWidth; + bbox[3] = newHeight; + + if ( + Math.abs(width - marginX) <= Outline.PRECISION || + Math.abs(height - marginY) <= Outline.PRECISION + ) { + // Center the path in the new bounding box. + const tx = newX + newWidth / 2 - (x + width / 2); + const ty = newY + newHeight / 2 - (y + height / 2); + for (const { line, points } of this.#lines) { + Outline._translate(line, tx, ty, line); + Outline._translate(points, tx, ty, points); + } + return { + root: { + viewBox: this.viewBox, + }, + path: { + "transform-origin": `${Outline.svgRound(newX)} ${Outline.svgRound(newY)}`, + transform: this.rotationTransform || null, + d: this.toSVGPath(), + }, + }; + } + + // We compute the following transform: + // 1. Translate the path to the origin (-(x + marginX), -(y + marginY)). + // 2. Scale the path to the new size: + // ((newWidth - 2*marginX) / (bbox.width - 2*marginX), + // (newHeight - 2*marginY) / (bbox.height - 2*marginY)). + // 3. Translate the path back to its new position + // (newX + marginX,y newY + marginY). + + const s1x = (newWidth - 2 * marginX) / (width - 2 * marginX); + const s1y = (newHeight - 2 * marginY) / (height - 2 * marginY); + const tx = -s1x * (x + marginX) + newX + marginX; + const ty = -s1y * (y + marginY) + newY + marginY; + + if (s1x !== 1 || s1y !== 1 || tx !== 0 || ty !== 0) { + for (const { line, points } of this.#lines) { + Outline._rescale(line, tx, ty, s1x, s1y, line); + Outline._rescale(points, tx, ty, s1x, s1y, points); + } + } + + return { + root: { + viewBox: this.viewBox, + }, + path: { + "transform-origin": `${Outline.svgRound(newX)} ${Outline.svgRound(newY)}`, + transform: this.rotationTransform || null, + d: this.toSVGPath(), + }, + }; + } + + getPathTranslatedSVGProperties([newX, newY], parentDimensions) { + const [newParentWidth, newParentHeight] = parentDimensions; + const bbox = this.#bbox; + const tx = newX - bbox[0]; + const ty = newY - bbox[1]; + + if ( + this.#parentWidth === newParentWidth && + this.#parentHeight === newParentHeight + ) { + // We don't change the parent dimensions so it's a simple translation. + for (const { line, points } of this.#lines) { + Outline._translate(line, tx, ty, line); + Outline._translate(points, tx, ty, points); + } + } else { + const sx = this.#parentWidth / newParentWidth; + const sy = this.#parentHeight / newParentHeight; + this.#parentWidth = newParentWidth; + this.#parentHeight = newParentHeight; + + for (const { line, points } of this.#lines) { + Outline._rescale(line, tx, ty, sx, sy, line); + Outline._rescale(points, tx, ty, sx, sy, points); + } + bbox[2] *= sx; + bbox[3] *= sy; + } + bbox[0] = newX; + bbox[1] = newY; + + return { + root: { + viewBox: this.viewBox, + }, + path: { + d: this.toSVGPath(), + "transform-origin": `${Outline.svgRound(newX)} ${Outline.svgRound(newY)}`, + }, + }; + } + + get defaultSVGProperties() { + const bbox = this.#bbox; + return { + root: { + viewBox: this.viewBox, + }, + rootClass: { + draw: true, + }, + path: { + d: this.toSVGPath(), + "transform-origin": `${Outline.svgRound(bbox[0])} ${Outline.svgRound(bbox[1])}`, + transform: this.rotationTransform || null, + }, + bbox, + }; + } +} + +export { InkDrawOutline, InkDrawOutliner }; diff --git a/src/display/editor/drawers/outline.js b/src/display/editor/drawers/outline.js index 2e05e61f7efd0..27fceed76d567 100644 --- a/src/display/editor/drawers/outline.js +++ b/src/display/editor/drawers/outline.js @@ -16,6 +16,8 @@ import { unreachable } from "../../../shared/util.js"; class Outline { + static PRECISION = 1e-4; + /** * @returns {string} The SVG path of the outline. */ @@ -52,6 +54,49 @@ class Outline { } return dest; } + + static _translate(src, tx, ty, dest) { + dest ||= new Float32Array(src.length); + for (let i = 0, ii = src.length; i < ii; i += 2) { + dest[i] = tx + src[i]; + dest[i + 1] = ty + src[i + 1]; + } + return dest; + } + + static svgRound(x) { + // 0.1234 will be 1234 and this way we economize 2 bytes per number. + // Of course, it makes sense only when the viewBox is [0 0 10000 10000]. + // And it helps to avoid bugs like: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1929340 + return Math.round(x * 10000); + } + + static _normalizePoint(x, y, parentWidth, parentHeight, rotation) { + switch (rotation) { + case 90: + return [1 - y / parentWidth, x / parentHeight]; + case 180: + return [1 - x / parentWidth, 1 - y / parentHeight]; + case 270: + return [y / parentWidth, 1 - x / parentHeight]; + default: + return [x / parentWidth, y / parentHeight]; + } + } + + static _normalizePagePoint(x, y, rotation) { + switch (rotation) { + case 90: + return [1 - y, x]; + case 180: + return [1 - x, 1 - y]; + case 270: + return [y, 1 - x]; + default: + return [x, y]; + } + } } export { Outline }; diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 86dcdd78fdaf1..095afec3f9f6c 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -192,6 +192,10 @@ class AnnotationEditor { return Object.getPrototypeOf(this).constructor._type; } + static get isDrawer() { + return false; + } + static get _defaultLineColor() { return shadow( this, @@ -441,6 +445,8 @@ class AnnotationEditor { this.x += x / width; this.y += y / height; + this._onTranslating(this.x, this.y); + this.fixAndSetPosition(); } @@ -469,7 +475,10 @@ class AnnotationEditor { drag(tx, ty) { this.#initialPosition ||= [this.x, this.y]; - const [parentWidth, parentHeight] = this.parentDimensions; + const { + div, + parentDimensions: [parentWidth, parentHeight], + } = this; this.x += tx / parentWidth; this.y += ty / parentHeight; if (this.parent && (this.x < 0 || this.x > 1 || this.y < 0 || this.y > 1)) { @@ -496,11 +505,29 @@ class AnnotationEditor { x += bx; y += by; - this.div.style.left = `${(100 * x).toFixed(2)}%`; - this.div.style.top = `${(100 * y).toFixed(2)}%`; - this.div.scrollIntoView({ block: "nearest" }); + const { style } = div; + style.left = `${(100 * x).toFixed(2)}%`; + style.top = `${(100 * y).toFixed(2)}%`; + + this._onTranslating(x, y); + + div.scrollIntoView({ block: "nearest" }); } + /** + * Called when the editor is being translated. + * @param {number} x - in page coordinates. + * @param {number} y - in page coordinates. + */ + _onTranslating(x, y) {} + + /** + * Called when the editor has been translated. + * @param {number} x - in page coordinates. + * @param {number} y - in page coordinates. + */ + _onTranslated(x, y) {} + get _hasBeenMoved() { return ( !!this.#initialPosition && @@ -546,7 +573,10 @@ class AnnotationEditor { * @param {number} [rotation] - the rotation of the page. */ fixAndSetPosition(rotation = this.rotation) { - const [pageWidth, pageHeight] = this.pageDimensions; + const { + div: { style }, + pageDimensions: [pageWidth, pageHeight], + } = this; let { x, y, width, height } = this; width *= pageWidth; height *= pageHeight; @@ -581,7 +611,6 @@ class AnnotationEditor { x += bx; y += by; - const { style } = this.div; style.left = `${(100 * x).toFixed(2)}%`; style.top = `${(100 * y).toFixed(2)}%`; @@ -659,9 +688,10 @@ class AnnotationEditor { */ setDims(width, height) { const [parentWidth, parentHeight] = this.parentDimensions; - this.div.style.width = `${((100 * width) / parentWidth).toFixed(2)}%`; + const { style } = this.div; + style.width = `${((100 * width) / parentWidth).toFixed(2)}%`; if (!this.#keepAspectRatio) { - this.div.style.height = `${((100 * height) / parentHeight).toFixed(2)}%`; + style.height = `${((100 * height) / parentHeight).toFixed(2)}%`; } } @@ -679,9 +709,7 @@ class AnnotationEditor { style.width = `${((100 * parseFloat(width)) / parentWidth).toFixed(2)}%`; } if (!this.#keepAspectRatio && !heightPercent) { - style.height = `${((100 * parseFloat(height)) / parentHeight).toFixed( - 2 - )}%`; + style.height = `${((100 * parseFloat(height)) / parentHeight).toFixed(2)}%`; } } @@ -759,10 +787,12 @@ class AnnotationEditor { { passive: false, signal } ); window.addEventListener("contextmenu", noContextMenu, { signal }); - const savedX = this.x; - const savedY = this.y; - const savedWidth = this.width; - const savedHeight = this.height; + this.#savedDimensions = { + savedX: this.x, + savedY: this.y, + savedWidth: this.width, + savedHeight: this.height, + }; const savedParentCursor = this.parent.div.style.cursor; const savedCursor = this.div.style.cursor; this.div.style.cursor = this.parent.div.style.cursor = @@ -776,7 +806,7 @@ class AnnotationEditor { this.parent.div.style.cursor = savedParentCursor; this.div.style.cursor = savedCursor; - this.#addResizeToUndoStack(savedX, savedY, savedWidth, savedHeight); + this.#addResizeToUndoStack(); }; window.addEventListener("pointerup", pointerUpCallback, { signal }); // If the user switches to another window (with alt+tab), then we end the @@ -784,7 +814,29 @@ class AnnotationEditor { window.addEventListener("blur", pointerUpCallback, { signal }); } - #addResizeToUndoStack(savedX, savedY, savedWidth, savedHeight) { + #resize(x, y, width, height) { + this.width = width; + this.height = height; + this.x = x; + this.y = y; + const [parentWidth, parentHeight] = this.parentDimensions; + this.setDims(parentWidth * width, parentHeight * height); + this.fixAndSetPosition(); + this._onResized(); + } + + /** + * Called when the editor has been resized. + */ + _onResized() {} + + #addResizeToUndoStack() { + if (!this.#savedDimensions) { + return; + } + const { savedX, savedY, savedWidth, savedHeight } = this.#savedDimensions; + this.#savedDimensions = null; + const newX = this.x; const newY = this.y; const newWidth = this.width; @@ -799,24 +851,8 @@ class AnnotationEditor { } this.addCommands({ - cmd: () => { - this.width = newWidth; - this.height = newHeight; - this.x = newX; - this.y = newY; - const [parentWidth, parentHeight] = this.parentDimensions; - this.setDims(parentWidth * newWidth, parentHeight * newHeight); - this.fixAndSetPosition(); - }, - undo: () => { - this.width = savedWidth; - this.height = savedHeight; - this.x = savedX; - this.y = savedY; - const [parentWidth, parentHeight] = this.parentDimensions; - this.setDims(parentWidth * savedWidth, parentHeight * savedHeight); - this.fixAndSetPosition(); - }, + cmd: this.#resize.bind(this, newX, newY, newWidth, newHeight), + undo: this.#resize.bind(this, savedX, savedY, savedWidth, savedHeight), mustExec: true, }); } @@ -960,8 +996,15 @@ class AnnotationEditor { this.setDims(parentWidth * newWidth, parentHeight * newHeight); this.fixAndSetPosition(); + + this._onResizing(); } + /** + * Called when the editor is being resized. + */ + _onResizing() {} + /** * Called when the alt text dialog is closed. */ @@ -1194,9 +1237,12 @@ class AnnotationEditor { ); } + this._onStartDragging(); + const pointerUpCallback = e => { if (!this.#dragPointerId || this.#dragPointerId === e.pointerId) { cancelDrag(e); + this._onStopDragging(); return; } stopEvent(e); @@ -1208,6 +1254,10 @@ class AnnotationEditor { window.addEventListener("blur", pointerUpCallback, { signal }); } + _onStartDragging() {} + + _onStopDragging() {} + moveInDOM() { // Moving the editor in the DOM can be expensive, so we wait a bit before. // It's important to not block the UI (for example when changing the font @@ -1226,6 +1276,7 @@ class AnnotationEditor { this.x = x; this.y = y; this.fixAndSetPosition(); + this._onTranslated(); } /** @@ -1372,11 +1423,16 @@ class AnnotationEditor { } /** - * Rotate the editor. + * Rotate the editor when the page is rotated. * @param {number} angle */ rotate(_angle) {} + /** + * Resize the editor when the page is resized. + */ + resize() {} + /** * Serialize the editor when it has been deleted. * @returns {Object} @@ -1622,11 +1678,7 @@ class AnnotationEditor { #stopResizing() { this.#isResizerEnabledForKeyboard = false; this.#setResizerTabIndex(-1); - if (this.#savedDimensions) { - const { savedX, savedY, savedWidth, savedHeight } = this.#savedDimensions; - this.#addResizeToUndoStack(savedX, savedY, savedWidth, savedHeight); - this.#savedDimensions = null; - } + this.#addResizeToUndoStack(); } _stopResizingWithKeyboard() { diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index c5d0def6c51bb..e1a10f0c1739a 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -16,1220 +16,206 @@ import { AnnotationEditorParamsType, AnnotationEditorType, - assert, + shadow, Util, } from "../../shared/util.js"; +import { DrawingEditor, DrawingOptions } from "./draw.js"; +import { InkDrawOutline, InkDrawOutliner } from "./drawers/inkdraw.js"; import { AnnotationEditor } from "./editor.js"; import { InkAnnotationElement } from "../annotation_layer.js"; -import { noContextMenu } from "../display_utils.js"; -import { opacityToHex } from "./tools.js"; -/** - * Basic draw editor in order to generate an Ink annotation. - */ -class InkEditor extends AnnotationEditor { - #baseHeight = 0; - - #baseWidth = 0; - - #canvasContextMenuTimeoutId = null; - - #currentPath2D = new Path2D(); - - #disableEditing = false; - - #drawingAC = null; - - #hasSomethingToDraw = false; - - #isCanvasInitialized = false; - - #observer = null; - - #pointerdownAC = null; - - #realWidth = 0; - - #realHeight = 0; - - #requestFrameCallback = null; - - static _defaultColor = null; +class InkDrawingOptions extends DrawingOptions { + #viewParameters; + + constructor(viewerParameters) { + super(); + this.#viewParameters = viewerParameters; + + super.updateProperties({ + fill: "none", + stroke: AnnotationEditor._defaultLineColor, + "stroke-opacity": 1, + "stroke-width": 1, + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-miterlimit": 10, + }); + } - static _defaultOpacity = 1; + updateSVGProperty(name, value) { + if (name === "stroke-width") { + value ??= this["stroke-width"]; + value *= this.#viewParameters.realScale; + } + super.updateSVGProperty(name, value); + } - static _defaultThickness = 1; + clone() { + const clone = new InkDrawingOptions(this.#viewParameters); + clone.updateAll(this); + return clone; + } +} +/** + * Basic draw editor in order to generate an Ink annotation. + */ +class InkEditor extends DrawingEditor { static _type = "ink"; static _editorType = AnnotationEditorType.INK; + static _defaultDrawingOptions = null; + constructor(params) { super({ ...params, name: "inkEditor" }); - this.color = params.color || null; - this.thickness = params.thickness || null; - this.opacity = params.opacity || null; - this.paths = []; - this.bezierPath2D = []; - this.allRawPaths = []; - this.currentPath = []; - this.scaleFactor = 1; - this.translationX = this.translationY = 0; - this.x = 0; - this.y = 0; this._willKeepAspectRatio = true; } /** @inheritdoc */ static initialize(l10n, uiManager) { AnnotationEditor.initialize(l10n, uiManager); + this._defaultDrawingOptions = new InkDrawingOptions( + uiManager.viewParameters + ); } /** @inheritdoc */ - static updateDefaultParams(type, value) { - switch (type) { - case AnnotationEditorParamsType.INK_THICKNESS: - InkEditor._defaultThickness = value; - break; - case AnnotationEditorParamsType.INK_COLOR: - InkEditor._defaultColor = value; - break; - case AnnotationEditorParamsType.INK_OPACITY: - InkEditor._defaultOpacity = value / 100; - break; - } - } - - /** @inheritdoc */ - updateParams(type, value) { - switch (type) { - case AnnotationEditorParamsType.INK_THICKNESS: - this.#updateThickness(value); - break; - case AnnotationEditorParamsType.INK_COLOR: - this.#updateColor(value); - break; - case AnnotationEditorParamsType.INK_OPACITY: - this.#updateOpacity(value); - break; - } - } - - /** @inheritdoc */ - static get defaultPropertiesToUpdate() { - return [ - [AnnotationEditorParamsType.INK_THICKNESS, InkEditor._defaultThickness], - [ - AnnotationEditorParamsType.INK_COLOR, - InkEditor._defaultColor || AnnotationEditor._defaultLineColor, - ], - [ - AnnotationEditorParamsType.INK_OPACITY, - Math.round(InkEditor._defaultOpacity * 100), - ], - ]; - } - - /** @inheritdoc */ - get propertiesToUpdate() { - return [ - [ - AnnotationEditorParamsType.INK_THICKNESS, - this.thickness || InkEditor._defaultThickness, - ], - [ - AnnotationEditorParamsType.INK_COLOR, - this.color || - InkEditor._defaultColor || - AnnotationEditor._defaultLineColor, - ], - [ - AnnotationEditorParamsType.INK_OPACITY, - Math.round(100 * (this.opacity ?? InkEditor._defaultOpacity)), - ], - ]; - } - - /** - * Update the thickness and make this action undoable. - * @param {number} thickness - */ - #updateThickness(thickness) { - const setThickness = th => { - this.thickness = th; - this.#fitToContent(); - }; - const savedThickness = this.thickness; - 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, - }); - } - - /** - * Update the color and make this action undoable. - * @param {string} color - */ - #updateColor(color) { - const setColor = col => { - this.color = col; - this.#redraw(); - }; - 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.INK_COLOR, - overwriteIfSameType: true, - keepUndo: true, - }); - } - - /** - * Update the opacity and make this action undoable. - * @param {number} opacity - */ - #updateOpacity(opacity) { - const setOpacity = op => { - this.opacity = op; - this.#redraw(); - }; - opacity /= 100; - const savedOpacity = this.opacity; - this.addCommands({ - cmd: setOpacity.bind(this, opacity), - undo: setOpacity.bind(this, savedOpacity), - post: this._uiManager.updateUI.bind(this._uiManager, this), - mustExec: true, - type: AnnotationEditorParamsType.INK_OPACITY, - overwriteIfSameType: true, - keepUndo: true, - }); - } - - /** @inheritdoc */ - rebuild() { - if (!this.parent) { - return; - } - super.rebuild(); - if (this.div === null) { - return; - } - - if (!this.canvas) { - this.#createCanvas(); - this.#createObserver(); - } - - 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); - this.#setCanvasDims(); - } - this.#fitToContent(); - } - - /** @inheritdoc */ - remove() { - if (this.canvas === null) { - return; - } - - if (!this.isEmpty()) { - this.commit(); - } - - // Destroy the canvas. - this.canvas.width = this.canvas.height = 0; - this.canvas.remove(); - this.canvas = null; - - if (this.#canvasContextMenuTimeoutId) { - clearTimeout(this.#canvasContextMenuTimeoutId); - this.#canvasContextMenuTimeoutId = null; - } - - this.#observer?.disconnect(); - this.#observer = null; - - super.remove(); - } - - setParent(parent) { - if (!this.parent && parent) { - // We've a parent hence the rescale will be handled thanks to the - // ResizeObserver. - this._uiManager.removeShouldRescale(this); - } else if (this.parent && parent === null) { - // The editor is removed from the DOM, hence we handle the rescale thanks - // to the onScaleChanging callback. - // This way, it'll be saved/printed correctly. - this._uiManager.addShouldRescale(this); - } - super.setParent(parent); - } - - onScaleChanging() { - const [parentWidth, parentHeight] = this.parentDimensions; - const width = this.width * parentWidth; - const height = this.height * parentHeight; - this.setDimensions(width, height); - } - - /** @inheritdoc */ - enableEditMode() { - if (this.#disableEditing || this.canvas === null) { - return; - } - - super.enableEditMode(); - this._isDraggable = false; - this.#addPointerdownListener(); - } - - /** @inheritdoc */ - disableEditMode() { - if (!this.isInEditMode() || this.canvas === null) { - return; - } - - super.disableEditMode(); - this._isDraggable = !this.isEmpty(); - this.div.classList.remove("editing"); - this.#removePointerdownListener(); + static getDefaultDrawingOptions(options) { + const clone = this._defaultDrawingOptions.clone(); + clone.updateProperties(options); + return clone; } /** @inheritdoc */ - onceAdded() { - this._isDraggable = !this.isEmpty(); + static get supportMultipleDrawings() { + return true; } /** @inheritdoc */ - isEmpty() { - return ( - this.paths.length === 0 || - (this.paths.length === 1 && this.paths[0].length === 0) + static get typesMap() { + return shadow( + this, + "typesMap", + new Map([ + [AnnotationEditorParamsType.INK_THICKNESS, "stroke-width"], + [AnnotationEditorParamsType.INK_COLOR, "stroke"], + [AnnotationEditorParamsType.INK_OPACITY, "stroke-opacity"], + ]) ); } - #getInitialBBox() { - const { - parentRotation, - parentDimensions: [width, height], - } = this; - switch (parentRotation) { - case 90: - return [0, height, height, width]; - case 180: - return [width, height, width, height]; - case 270: - return [width, 0, height, width]; - default: - return [0, 0, width, height]; - } - } - - /** - * Set line styles. - */ - #setStroke() { - const { ctx, color, opacity, thickness, parentScale, scaleFactor } = this; - ctx.lineWidth = (thickness * parentScale) / scaleFactor; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - ctx.miterLimit = 10; - ctx.strokeStyle = `${color}${opacityToHex(opacity)}`; - } - - /** - * Start to draw on the canvas. - * @param {number} x - * @param {number} y - */ - #startDrawing(x, y) { - this.canvas.addEventListener("contextmenu", noContextMenu, { - signal: this._uiManager._signal, - }); - this.#removePointerdownListener(); - - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { - assert( - !this.#drawingAC, - "No `this.#drawingAC` AbortController should exist." - ); - } - this.#drawingAC = new AbortController(); - const signal = this._uiManager.combinedSignal(this.#drawingAC); - - this.canvas.addEventListener( - "pointerleave", - this.canvasPointerleave.bind(this), - { signal } - ); - this.canvas.addEventListener( - "pointermove", - this.canvasPointermove.bind(this), - { signal } - ); - this.canvas.addEventListener("pointerup", this.canvasPointerup.bind(this), { - signal, - }); - - this.isEditing = true; - if (!this.#isCanvasInitialized) { - this.#isCanvasInitialized = true; - this.#setCanvasDims(); - this.thickness ||= InkEditor._defaultThickness; - this.color ||= - InkEditor._defaultColor || AnnotationEditor._defaultLineColor; - this.opacity ??= InkEditor._defaultOpacity; - } - this.currentPath.push([x, y]); - this.#hasSomethingToDraw = false; - this.#setStroke(); - - this.#requestFrameCallback = () => { - this.#drawPoints(); - if (this.#requestFrameCallback) { - window.requestAnimationFrame(this.#requestFrameCallback); - } - }; - window.requestAnimationFrame(this.#requestFrameCallback); - } - - /** - * Draw on the canvas. - * @param {number} x - * @param {number} y - */ - #draw(x, y) { - const [lastX, lastY] = this.currentPath.at(-1); - if (this.currentPath.length > 1 && x === lastX && y === lastY) { - return; - } - const currentPath = this.currentPath; - let path2D = this.#currentPath2D; - currentPath.push([x, y]); - this.#hasSomethingToDraw = true; - - if (currentPath.length <= 2) { - path2D.moveTo(...currentPath[0]); - path2D.lineTo(x, y); - return; - } - - if (currentPath.length === 3) { - this.#currentPath2D = path2D = new Path2D(); - path2D.moveTo(...currentPath[0]); - } - - this.#makeBezierCurve( - path2D, - ...currentPath.at(-3), - ...currentPath.at(-2), + /** @inheritdoc */ + static createDrawerInstance(x, y, parentWidth, parentHeight, rotation) { + return new InkDrawOutliner( x, - y - ); - } - - #endPath() { - if (this.currentPath.length === 0) { - return; - } - const lastPoint = this.currentPath.at(-1); - this.#currentPath2D.lineTo(...lastPoint); - } - - /** - * Stop to draw on the canvas. - * @param {number} x - * @param {number} y - */ - #stopDrawing(x, y) { - this.#requestFrameCallback = null; - - x = Math.min(Math.max(x, 0), this.canvas.width); - y = Math.min(Math.max(y, 0), this.canvas.height); - - this.#draw(x, y); - this.#endPath(); - - // Interpolate the path entered by the user with some - // Bezier's curves in order to have a smoother path and - // to reduce the data size used to draw it in the PDF. - let bezier; - if (this.currentPath.length !== 1) { - bezier = this.#generateBezierPoints(); - } else { - // We have only one point finally. - const xy = [x, y]; - bezier = [[xy, xy.slice(), xy.slice(), xy]]; - } - const path2D = this.#currentPath2D; - const currentPath = this.currentPath; - this.currentPath = []; - this.#currentPath2D = new Path2D(); - - const cmd = () => { - this.allRawPaths.push(currentPath); - this.paths.push(bezier); - this.bezierPath2D.push(path2D); - this._uiManager.rebuild(this); - }; - - const undo = () => { - this.allRawPaths.pop(); - this.paths.pop(); - this.bezierPath2D.pop(); - if (this.paths.length === 0) { - this.remove(); - } else { - if (!this.canvas) { - this.#createCanvas(); - this.#createObserver(); - } - this.#fitToContent(); - } - }; - - this.addCommands({ cmd, undo, mustExec: true }); - } - - #drawPoints() { - if (!this.#hasSomethingToDraw) { - return; - } - this.#hasSomethingToDraw = false; - - const thickness = Math.ceil(this.thickness * this.parentScale); - const lastPoints = this.currentPath.slice(-3); - const x = lastPoints.map(xy => xy[0]); - const y = lastPoints.map(xy => xy[1]); - const xMin = Math.min(...x) - thickness; - const xMax = Math.max(...x) + thickness; - const yMin = Math.min(...y) - thickness; - const yMax = Math.max(...y) + thickness; - - const { ctx } = this; - ctx.save(); - - if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) { - // In Chrome, the clip() method doesn't work as expected. - ctx.clearRect(xMin, yMin, xMax - xMin, yMax - yMin); - ctx.beginPath(); - ctx.rect(xMin, yMin, xMax - xMin, yMax - yMin); - ctx.clip(); - } else { - ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - } - - for (const path of this.bezierPath2D) { - ctx.stroke(path); - } - ctx.stroke(this.#currentPath2D); - - ctx.restore(); - } - - #makeBezierCurve(path2D, x0, y0, x1, y1, x2, y2) { - const prevX = (x0 + x1) / 2; - const prevY = (y0 + y1) / 2; - const x3 = (x1 + x2) / 2; - const y3 = (y1 + y2) / 2; - - path2D.bezierCurveTo( - prevX + (2 * (x1 - prevX)) / 3, - prevY + (2 * (y1 - prevY)) / 3, - x3 + (2 * (x1 - x3)) / 3, - y3 + (2 * (y1 - y3)) / 3, - x3, - y3 + y, + parentWidth, + parentHeight, + rotation, + this._defaultDrawingOptions["stroke-width"] ); } - #generateBezierPoints() { - const path = this.currentPath; - if (path.length <= 2) { - return [[path[0], path[0], path.at(-1), path.at(-1)]]; - } - - const bezierPoints = []; - let i; - let [x0, y0] = path[0]; - for (i = 1; i < path.length - 2; i++) { - const [x1, y1] = path[i]; - const [x2, y2] = path[i + 1]; - const x3 = (x1 + x2) / 2; - const y3 = (y1 + y2) / 2; - - // The quadratic is: [[x0, y0], [x1, y1], [x3, y3]]. - // Convert the quadratic to a cubic - // (see https://fontforge.org/docs/techref/bezier.html#converting-truetype-to-postscript) - const control1 = [x0 + (2 * (x1 - x0)) / 3, y0 + (2 * (y1 - y0)) / 3]; - const control2 = [x3 + (2 * (x1 - x3)) / 3, y3 + (2 * (y1 - y3)) / 3]; - - bezierPoints.push([[x0, y0], control1, control2, [x3, y3]]); - - [x0, y0] = [x3, y3]; - } - - const [x1, y1] = path[i]; - const [x2, y2] = path[i + 1]; - - // The quadratic is: [[x0, y0], [x1, y1], [x2, y2]]. - const control1 = [x0 + (2 * (x1 - x0)) / 3, y0 + (2 * (y1 - y0)) / 3]; - const control2 = [x2 + (2 * (x1 - x2)) / 3, y2 + (2 * (y1 - y2)) / 3]; - - bezierPoints.push([[x0, y0], control1, control2, [x2, y2]]); - return bezierPoints; - } - - /** - * Redraw all the paths. - */ - #redraw() { - if (this.isEmpty()) { - this.#updateTransform(); - return; - } - this.#setStroke(); - - const { canvas, ctx } = this; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, canvas.width, canvas.height); - this.#updateTransform(); - - for (const path of this.bezierPath2D) { - ctx.stroke(path); - } - } - - /** - * Commit the curves we have in this editor. - */ - commit() { - if (this.#disableEditing) { - return; - } - - super.commit(); - - this.isEditing = false; - this.disableEditMode(); - - // This editor must be on top of the main ink editor. - this.setInForeground(); - - this.#disableEditing = true; - this.div.classList.add("disabled"); - - this.#fitToContent(/* firstTime = */ true); - this.select(); - - this.parent.addInkEditorIfNeeded(/* isCommitting = */ true); - - // When committing, the position of this editor is changed, hence we must - // move it to the right position in the DOM. - this.moveInDOM(); - this.div.focus({ - preventScroll: true /* See issue #15744 */, - }); - } - /** @inheritdoc */ - focusin(event) { - if (!this._focusEventsAllowed) { - return; - } - super.focusin(event); - this.enableEditMode(); - } - - #addPointerdownListener() { - if (this.#pointerdownAC) { - return; - } - this.#pointerdownAC = new AbortController(); - const signal = this._uiManager.combinedSignal(this.#pointerdownAC); - - this.canvas.addEventListener( - "pointerdown", - this.canvasPointerdown.bind(this), - { signal } - ); - } - - #removePointerdownListener() { - this.pointerdownAC?.abort(); - this.pointerdownAC = null; - } - - /** - * onpointerdown callback for the canvas we're drawing on. - * @param {PointerEvent} event - */ - canvasPointerdown(event) { - if (event.button !== 0 || !this.isInEditMode() || this.#disableEditing) { - return; - } - - // We want to draw on top of any other editors. - // Since it's the last child, there's no need to give it a higher z-index. - this.setInForeground(); - - event.preventDefault(); - - if (!this.div.contains(document.activeElement)) { - this.div.focus({ - preventScroll: true /* See issue #17327 */, - }); - } - - this.#startDrawing(event.offsetX, event.offsetY); - } - - /** - * onpointermove callback for the canvas we're drawing on. - * @param {PointerEvent} event - */ - canvasPointermove(event) { - event.preventDefault(); - this.#draw(event.offsetX, event.offsetY); - } - - /** - * onpointerup callback for the canvas we're drawing on. - * @param {PointerEvent} event - */ - canvasPointerup(event) { - event.preventDefault(); - this.#endDrawing(event); - } - - /** - * onpointerleave callback for the canvas we're drawing on. - * @param {PointerEvent} event - */ - canvasPointerleave(event) { - this.#endDrawing(event); - } - - /** - * End the drawing. - * @param {PointerEvent} event - */ - #endDrawing(event) { - this.#drawingAC?.abort(); - this.#drawingAC = null; - - this.#addPointerdownListener(); - // Slight delay to avoid the context menu to appear (it can happen on a long - // tap with a pen). - if (this.#canvasContextMenuTimeoutId) { - clearTimeout(this.#canvasContextMenuTimeoutId); - } - this.#canvasContextMenuTimeoutId = setTimeout(() => { - this.#canvasContextMenuTimeoutId = null; - this.canvas.removeEventListener("contextmenu", noContextMenu); - }, 10); - - this.#stopDrawing(event.offsetX, event.offsetY); - - this.addToAnnotationStorage(); - - // Since the ink editor covers all of the page and we want to be able - // to select another editor, we just put this one in the background. - this.setInBackground(); - } - - /** - * Create the canvas element. - */ - #createCanvas() { - this.canvas = document.createElement("canvas"); - this.canvas.width = this.canvas.height = 0; - this.canvas.className = "inkEditorCanvas"; - this.canvas.setAttribute("data-l10n-id", "pdfjs-ink-canvas"); - - this.div.append(this.canvas); - this.ctx = this.canvas.getContext("2d"); - } - - /** - * Create the resize observer. - */ - #createObserver() { - this.#observer = new ResizeObserver(entries => { - const rect = entries[0].contentRect; - if (rect.width && rect.height) { - this.setDimensions(rect.width, rect.height); - } - }); - this.#observer.observe(this.div); - this._uiManager._signal.addEventListener( - "abort", - () => { - this.#observer?.disconnect(); - this.#observer = null; - }, - { once: true } + static deserializeDraw( + pageX, + pageY, + pageWidth, + pageHeight, + innerMargin, + data + ) { + return InkDrawOutline.deserialize( + pageX, + pageY, + pageWidth, + pageHeight, + innerMargin, + data ); } /** @inheritdoc */ - get isResizable() { - return !this.isEmpty() && this.#disableEditing; - } - - /** @inheritdoc */ - render() { - if (this.div) { - return this.div; - } - - let baseX, baseY; - if (this.width) { - baseX = this.x; - baseY = this.y; - } - - super.render(); - - this.div.setAttribute("data-l10n-id", "pdfjs-ink"); - - const [x, y, w, h] = this.#getInitialBBox(); - this.setAt(x, y, 0, 0); - this.setDims(w, h); - - this.#createCanvas(); - - if (this.width) { - // This editor was created in using copy (ctrl+c). - const [parentWidth, parentHeight] = this.parentDimensions; - this.setAspectRatio(this.width * parentWidth, this.height * parentHeight); - this.setAt( - baseX * parentWidth, - baseY * parentHeight, - this.width * parentWidth, - this.height * parentHeight - ); - this.#isCanvasInitialized = true; - this.#setCanvasDims(); - this.setDims(this.width * parentWidth, this.height * parentHeight); - this.#redraw(); - this.div.classList.add("disabled"); - } else { - this.div.classList.add("editing"); - this.enableEditMode(); + static async deserialize(data, parent, uiManager) { + if (data instanceof InkAnnotationElement) { + return null; } - this.#createObserver(); - - return this.div; - } - - #setCanvasDims() { - if (!this.#isCanvasInitialized) { - return; - } - const [parentWidth, parentHeight] = this.parentDimensions; - this.canvas.width = Math.ceil(this.width * parentWidth); - this.canvas.height = Math.ceil(this.height * parentHeight); - this.#updateTransform(); + return super.deserialize(data, parent, uiManager); } - /** - * When the dimensions of the div change the inner canvas must - * renew its dimensions, hence it must redraw its own contents. - * @param {number} width - the new width of the div - * @param {number} height - the new height of the div - * @returns - */ - setDimensions(width, height) { - const roundedWidth = Math.round(width); - const roundedHeight = Math.round(height); - if ( - this.#realWidth === roundedWidth && - this.#realHeight === roundedHeight - ) { + /** @inheritdoc */ + onScaleChanging() { + if (!this.parent) { return; } - - this.#realWidth = roundedWidth; - this.#realHeight = roundedHeight; - - this.canvas.style.visibility = "hidden"; - - const [parentWidth, parentHeight] = this.parentDimensions; - this.width = width / parentWidth; - this.height = height / parentHeight; - this.fixAndSetPosition(); - - if (this.#disableEditing) { - this.#setScaleFactor(width, height); - } - - this.#setCanvasDims(); - this.#redraw(); - - this.canvas.style.visibility = "visible"; - - // For any reason the dimensions couldn't be in percent but in pixels, hence - // we must fix them. - this.fixDims(); - } - - #setScaleFactor(width, height) { - const padding = this.#getPadding(); - const scaleFactorW = (width - padding) / this.#baseWidth; - const scaleFactorH = (height - padding) / this.#baseHeight; - this.scaleFactor = Math.min(scaleFactorW, scaleFactorH); - } - - /** - * Update the canvas transform. - */ - #updateTransform() { - const padding = this.#getPadding() / 2; - this.ctx.setTransform( - this.scaleFactor, - 0, - 0, - this.scaleFactor, - this.translationX * this.scaleFactor + padding, - this.translationY * this.scaleFactor + padding + super.onScaleChanging(); + const { _drawId, _drawingOptions, parent } = this; + _drawingOptions.updateSVGProperty("stroke-width"); + parent.drawLayer.updateProperties( + _drawId, + _drawingOptions.toSVGProperties() ); } - /** - * Convert into a Path2D. - * @param {Array>} bezier - * @returns {Path2D} - */ - static #buildPath2D(bezier) { - const path2D = new Path2D(); - for (let i = 0, ii = bezier.length; i < ii; i++) { - const [first, control1, control2, second] = bezier[i]; - if (i === 0) { - path2D.moveTo(...first); - } - path2D.bezierCurveTo( - control1[0], - control1[1], - control2[0], - control2[1], - second[0], - second[1] - ); - } - return path2D; - } - - static #toPDFCoordinates(points, rect, rotation) { - const [blX, blY, trX, trY] = rect; - - switch (rotation) { - case 0: - for (let i = 0, ii = points.length; i < ii; i += 2) { - points[i] += blX; - points[i + 1] = trY - points[i + 1]; - } - break; - case 90: - for (let i = 0, ii = points.length; i < ii; i += 2) { - const x = points[i]; - points[i] = points[i + 1] + blX; - points[i + 1] = x + blY; - } - break; - case 180: - for (let i = 0, ii = points.length; i < ii; i += 2) { - points[i] = trX - points[i]; - points[i + 1] += blY; - } - break; - case 270: - for (let i = 0, ii = points.length; i < ii; i += 2) { - const x = points[i]; - points[i] = trX - points[i + 1]; - points[i + 1] = trY - x; - } - break; - default: - throw new Error("Invalid rotation"); - } - return points; - } - - static #fromPDFCoordinates(points, rect, rotation) { - const [blX, blY, trX, trY] = rect; - - switch (rotation) { - case 0: - for (let i = 0, ii = points.length; i < ii; i += 2) { - points[i] -= blX; - points[i + 1] = trY - points[i + 1]; - } - break; - case 90: - for (let i = 0, ii = points.length; i < ii; i += 2) { - const x = points[i]; - points[i] = points[i + 1] - blY; - points[i + 1] = x - blX; - } - break; - case 180: - for (let i = 0, ii = points.length; i < ii; i += 2) { - points[i] = trX - points[i]; - points[i + 1] -= blY; - } - break; - case 270: - for (let i = 0, ii = points.length; i < ii; i += 2) { - const x = points[i]; - points[i] = trY - points[i + 1]; - points[i + 1] = trX - x; - } - break; - default: - throw new Error("Invalid rotation"); - } - return points; - } - - /** - * Transform and serialize the paths. - * @param {number} s - scale factor - * @param {number} tx - abscissa of the translation - * @param {number} ty - ordinate of the translation - * @param {Array} rect - the bounding box of the annotation - */ - #serializePaths(s, tx, ty, rect) { - const paths = []; - const padding = this.thickness / 2; - const shiftX = s * tx + padding; - const shiftY = s * ty + padding; - for (const bezier of this.paths) { - const buffer = []; - const points = []; - for (let j = 0, jj = bezier.length; j < jj; j++) { - const [first, control1, control2, second] = bezier[j]; - if (first[0] === second[0] && first[1] === second[1] && jj === 1) { - // We have only one point. - const p0 = s * first[0] + shiftX; - const p1 = s * first[1] + shiftY; - buffer.push(p0, p1); - points.push(p0, p1); - break; - } - const p10 = s * first[0] + shiftX; - const p11 = s * first[1] + shiftY; - const p20 = s * control1[0] + shiftX; - const p21 = s * control1[1] + shiftY; - const p30 = s * control2[0] + shiftX; - const p31 = s * control2[1] + shiftY; - const p40 = s * second[0] + shiftX; - const p41 = s * second[1] + shiftY; - - if (j === 0) { - buffer.push(p10, p11); - points.push(p10, p11); - } - buffer.push(p20, p21, p30, p31, p40, p41); - points.push(p20, p21); - if (j === jj - 1) { - points.push(p40, p41); - } - } - paths.push({ - bezier: InkEditor.#toPDFCoordinates(buffer, rect, this.rotation), - points: InkEditor.#toPDFCoordinates(points, rect, this.rotation), - }); - } - - return paths; - } - - /** - * Get the bounding box containing all the paths. - * @returns {Array} - */ - #getBbox() { - let xMin = Infinity; - let xMax = -Infinity; - let yMin = Infinity; - let yMax = -Infinity; - - for (const path of this.paths) { - for (const [first, control1, control2, second] of path) { - const bbox = Util.bezierBoundingBox( - ...first, - ...control1, - ...control2, - ...second - ); - xMin = Math.min(xMin, bbox[0]); - yMin = Math.min(yMin, bbox[1]); - xMax = Math.max(xMax, bbox[2]); - yMax = Math.max(yMax, bbox[3]); - } - } - - return [xMin, yMin, xMax, yMax]; - } - - /** - * The bounding box is computed with null thickness, so we must take - * it into account for the display. - * It corresponds to the total padding, hence it should be divided by 2 - * in order to have left/right paddings. - * @returns {number} - */ - #getPadding() { - return this.#disableEditing - ? Math.ceil(this.thickness * this.parentScale) - : 0; - } - - /** - * Set the div position and dimensions in order to fit to - * the bounding box of the contents. - * @returns {undefined} - */ - #fitToContent(firstTime = false) { - if (this.isEmpty()) { + static onScaleChangingWhenDrawing() { + const parent = this._currentParent; + if (!parent) { return; } - - if (!this.#disableEditing) { - this.#redraw(); - return; - } - - const bbox = this.#getBbox(); - const padding = this.#getPadding(); - this.#baseWidth = Math.max(AnnotationEditor.MIN_SIZE, bbox[2] - bbox[0]); - this.#baseHeight = Math.max(AnnotationEditor.MIN_SIZE, bbox[3] - bbox[1]); - - const width = Math.ceil(padding + this.#baseWidth * this.scaleFactor); - const height = Math.ceil(padding + this.#baseHeight * this.scaleFactor); - - const [parentWidth, parentHeight] = this.parentDimensions; - this.width = width / parentWidth; - this.height = height / parentHeight; - - this.setAspectRatio(width, height); - - const prevTranslationX = this.translationX; - const prevTranslationY = this.translationY; - - this.translationX = -bbox[0]; - this.translationY = -bbox[1]; - this.#setCanvasDims(); - this.#redraw(); - - this.#realWidth = width; - this.#realHeight = height; - - this.setDims(width, height); - const unscaledPadding = firstTime ? padding / this.scaleFactor / 2 : 0; - this.translate( - prevTranslationX - this.translationX - unscaledPadding, - prevTranslationY - this.translationY - unscaledPadding + super.onScaleChangingWhenDrawing(); + this._defaultDrawingOptions.updateSVGProperty("stroke-width"); + parent.drawLayer.updateProperties( + this._currentDrawId, + this._defaultDrawingOptions.toSVGProperties() ); } /** @inheritdoc */ - static async deserialize(data, parent, uiManager) { - if (data instanceof InkAnnotationElement) { - return null; - } - const editor = await super.deserialize(data, parent, uiManager); - - editor.thickness = data.thickness; - editor.color = Util.makeHexColor(...data.color); - editor.opacity = data.opacity; - - const [pageWidth, pageHeight] = editor.pageDimensions; - const width = editor.width * pageWidth; - const height = editor.height * pageHeight; - const scaleFactor = editor.parentScale; - const padding = data.thickness / 2; - - editor.#disableEditing = true; - editor.#realWidth = Math.round(width); - editor.#realHeight = Math.round(height); - - const { paths, rect, rotation } = data; - - for (let { bezier } of paths) { - bezier = InkEditor.#fromPDFCoordinates(bezier, rect, rotation); - const path = []; - editor.paths.push(path); - let p0 = scaleFactor * (bezier[0] - padding); - let p1 = scaleFactor * (bezier[1] - padding); - for (let i = 2, ii = bezier.length; i < ii; i += 6) { - const p10 = scaleFactor * (bezier[i] - padding); - const p11 = scaleFactor * (bezier[i + 1] - padding); - const p20 = scaleFactor * (bezier[i + 2] - padding); - const p21 = scaleFactor * (bezier[i + 3] - padding); - const p30 = scaleFactor * (bezier[i + 4] - padding); - const p31 = scaleFactor * (bezier[i + 5] - padding); - path.push([ - [p0, p1], - [p10, p11], - [p20, p21], - [p30, p31], - ]); - p0 = p30; - p1 = p31; - } - const path2D = this.#buildPath2D(path); - editor.bezierPath2D.push(path2D); - } - - const bbox = editor.#getBbox(); - editor.#baseWidth = Math.max(AnnotationEditor.MIN_SIZE, bbox[2] - bbox[0]); - editor.#baseHeight = Math.max(AnnotationEditor.MIN_SIZE, bbox[3] - bbox[1]); - editor.#setScaleFactor(width, height); - - return editor; + createDrawingOptions({ color, thickness, opacity }) { + this._drawingOptions = InkEditor.getDefaultDrawingOptions({ + stroke: Util.makeHexColor(...color), + "stroke-width": thickness, + "stroke-opacity": opacity, + }); } /** @inheritdoc */ - serialize() { + serialize(isForCopying = false) { if (this.isEmpty()) { return null; } - const rect = this.getRect(0, 0); - const color = AnnotationEditor._colorManager.convert(this.ctx.strokeStyle); + if (this.deleted) { + return this.serializeDeleted(); + } - return { + const { lines, points, rect } = this.serializeDraw(isForCopying); + const { + _drawingOptions: { + stroke, + "stroke-opacity": opacity, + "stroke-width": thickness, + }, + } = this; + const serialized = { annotationType: AnnotationEditorType.INK, - color, - thickness: this.thickness, - opacity: this.opacity, - paths: this.#serializePaths( - this.scaleFactor / this.parentScale, - this.translationX, - this.translationY, - rect - ), + color: AnnotationEditor._colorManager.convert(stroke), + opacity, + thickness, + paths: { + lines, + points, + }, pageIndex: this.pageIndex, rect, rotation: this.rotation, structTreeParentId: this._structTreeParentId, }; + + serialized.id = this.annotationElementId; + return serialized; } } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 40b96897babba..0f65235b59722 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -411,6 +411,21 @@ class CommandManager { return this.#position < this.#commands.length - 1; } + cleanType(type) { + if (this.#position === -1) { + return; + } + for (let i = this.#position; i >= 0; i--) { + if (this.#commands[i].type !== type) { + this.#commands.splice(i + 1, this.#position - i); + this.#position = i; + return; + } + } + this.#commands.length = 0; + this.#position = -1; + } + destroy() { this.#commands = null; } @@ -1034,6 +1049,7 @@ class AnnotationEditorUIManager { for (const editor of this.#editorsToRescale) { editor.onScaleChanging(); } + this.currentLayer?.onScaleChanging(); } onRotationChanging({ pagesRotation }) { @@ -1931,6 +1947,10 @@ class AnnotationEditorUIManager { } } + updateUIForDefaultProperties(editorType) { + this.#dispatchUpdateUI(editorType.defaultPropertiesToUpdate); + } + /** * Add or remove an editor the current selection. * @param {AnnotationEditor} editor @@ -1957,6 +1977,7 @@ class AnnotationEditorUIManager { * @param {AnnotationEditor} editor */ setSelected(editor) { + this.currentLayer?.commitOrRemove(); for (const ed of this.#selectedEditors) { if (ed !== editor) { ed.unselect(); @@ -2044,6 +2065,10 @@ class AnnotationEditorUIManager { }); } + cleanUndoStack(type) { + this.#commandManager.cleanType(type); + } + #isEmpty() { if (this.#allEditors.size === 0) { return true; @@ -2134,6 +2159,10 @@ class AnnotationEditorUIManager { } } + if (this.currentLayer?.commitOrRemove()) { + return; + } + if (!this.hasSelection) { return; } diff --git a/src/shared/util.js b/src/shared/util.js index 006c416b0db49..b6dfb4002a304 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -94,6 +94,7 @@ const AnnotationEditorParamsType = { HIGHLIGHT_THICKNESS: 33, HIGHLIGHT_FREE: 34, HIGHLIGHT_SHOW_ALL: 35, + DRAW_STEP: 41, }; // Permission flags from Table 22, Section 7.6.3.2 of the PDF specification. diff --git a/test/driver.js b/test/driver.js index 8991e50ab6771..897504edde6eb 100644 --- a/test/driver.js +++ b/test/driver.js @@ -649,7 +649,7 @@ class Driver { if (task.annotationStorage) { for (const annotation of Object.values(task.annotationStorage)) { - const { bitmapName, quadPoints } = annotation; + const { bitmapName, quadPoints, paths, outlines } = annotation; if (bitmapName) { promise = promise.then(async doc => { const response = await fetch( @@ -687,6 +687,36 @@ class Driver { // like IRL (in order to avoid bugs like bug 1907958). annotation.quadPoints = new Float32Array(quadPoints); } + if (paths) { + for (let i = 0, ii = paths.lines.length; i < ii; i++) { + paths.lines[i] = Float32Array.from( + paths.lines[i], + x => x ?? NaN + ); + } + for (let i = 0, ii = paths.points.length; i < ii; i++) { + paths.points[i] = Float32Array.from( + paths.points[i], + x => x ?? NaN + ); + } + } + if (outlines) { + if (Array.isArray(outlines)) { + for (let i = 0, ii = outlines.length; i < ii; i++) { + outlines[i] = Float32Array.from(outlines[i], x => x ?? NaN); + } + } else { + outlines.outline = Float32Array.from( + outlines.outline, + x => x ?? NaN + ); + outlines.points = Float32Array.from( + outlines.points, + x => x ?? NaN + ); + } + } } } diff --git a/test/integration/ink_editor_spec.mjs b/test/integration/ink_editor_spec.mjs index 8cf5059dfb2ae..67fe9fccfbdfc 100644 --- a/test/integration/ink_editor_spec.mjs +++ b/test/integration/ink_editor_spec.mjs @@ -117,7 +117,7 @@ describe("Ink Editor", () => { await commit(page); - const rectBefore = await getRect(page, ".inkEditor canvas"); + const rectBefore = await getRect(page, ".canvasWrapper .draw"); for (let i = 0; i < 30; i++) { await kbUndo(page); @@ -126,7 +126,7 @@ describe("Ink Editor", () => { await waitForStorageEntries(page, 1); } - const rectAfter = await getRect(page, ".inkEditor canvas"); + const rectAfter = await getRect(page, ".canvasWrapper .draw"); expect(Math.round(rectBefore.x)) .withContext(`In ${browserName}`) @@ -453,4 +453,118 @@ describe("Ink Editor", () => { ); }); }); + + describe("Drawing must unselect all", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that when we start to draw then the editors are unselected", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToInk(page); + const rect = await getRect(page, ".annotationEditorLayer"); + + let xStart = rect.x + 10; + const yStart = rect.y + 10; + for (let i = 0; i < 2; i++) { + const clickHandle = await waitForPointerUp(page); + await page.mouse.move(xStart, yStart); + await page.mouse.down(); + if (i === 1) { + expect(await getSelectedEditors(page)) + .withContext(`In ${browserName}`) + .toEqual([]); + } + await page.mouse.move(xStart + 50, yStart + 50); + await page.mouse.up(); + await awaitPromise(clickHandle); + await commit(page); + xStart += 70; + } + }) + ); + }); + }); + + describe("Selected editor must be updated even if the page has been destroyed", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("tracemonkey.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that the color has been changed", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToInk(page); + + const rect = await getRect(page, ".annotationEditorLayer"); + + const x = rect.x + 20; + const y = rect.y + 20; + const clickHandle = await waitForPointerUp(page); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 50, y + 50); + await page.mouse.up(); + await awaitPromise(clickHandle); + + await commit(page); + + const drawSelector = `.page[data-page-number = "1"] .canvasWrapper .draw`; + await page.waitForSelector(drawSelector, { visible: true }); + let color = await page.evaluate(sel => { + const el = document.querySelector(sel); + return el.getAttribute("stroke"); + }, drawSelector); + expect(color).toEqual("#000000"); + + const oneToFourteen = Array.from(new Array(13).keys(), n => n + 2); + for (const pageNumber of oneToFourteen) { + await scrollIntoView( + page, + `.page[data-page-number = "${pageNumber}"]` + ); + } + + const red = "#ff0000"; + page.evaluate(value => { + window.PDFViewerApplication.eventBus.dispatch( + "switchannotationeditorparams", + { + source: null, + type: window.pdfjsLib.AnnotationEditorParamsType.INK_COLOR, + value, + } + ); + }, red); + + const fourteenToOne = Array.from(new Array(13).keys(), n => 13 - n); + for (const pageNumber of fourteenToOne) { + await scrollIntoView( + page, + `.page[data-page-number = "${pageNumber}"]` + ); + } + await page.waitForSelector(drawSelector, { visible: true }); + color = await page.evaluate(sel => { + const el = document.querySelector(sel); + return el.getAttribute("stroke"); + }, drawSelector); + expect(color).toEqual(red); + }) + ); + }); + }); }); diff --git a/test/test_manifest.json b/test/test_manifest.json index 0b6dcc6cdc004..c5d7f48b95883 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -8244,24 +8244,44 @@ "color": [255, 0, 0], "thickness": 3, "opacity": 1, - "paths": [ - { - "bezier": [ - 73, 560.2277710847244, 74.30408044851005, 561.5318515332344, - 76.89681158113368, 557.7555609512324, 77.5, 557.2277710847244, - 81.95407020558315, 553.3304596548392, 87.4811839685984, - 550.8645311043504, 92.5, 547.7277710847244, 97.38795894206055, - 544.6727967459365, 109.48854351637208, 540.2392275683522, 113.5, + "paths": { + "lines": [ + [ + null, + null, + null, + null, + 73, + 560.2277710847244, + 74.30408044851005, + 561.5318515332344, + 76.89681158113368, + 557.7555609512324, + 77.5, + 557.2277710847244, + 81.95407020558315, + 553.3304596548392, + 87.4811839685984, + 550.8645311043504, + 92.5, + 547.7277710847244, + 97.38795894206055, + 544.6727967459365, + 109.48854351637208, + 540.2392275683522, + 113.5, 536.2277710847244 - ], - "points": [ + ] + ], + "points": [ + [ 73, 560.2277710847244, 76.7257911988625, 558.1025687477292, 75.5128345111164, 559.4147224528562, 77.5, 557.2277710847244, 92.5, 547.7277710847244, 109.21378602219673, 539.2873735223628, 103.32868842191223, 542.3364518890394, 113.5, 536.2277710847244 ] - } - ], + ] + }, "pageIndex": 0, "rect": [71.5, 534.5, 115, 562], "rotation": 0 @@ -8330,22 +8350,37 @@ "color": [255, 0, 0], "thickness": 1, "opacity": 1, - "paths": [ - { - "bezier": [ - 417.61538461538464, 520.3461538461538, 419.15384615384613, - 520.3461538461538, 421.0769230769231, 520.3461538461538, - 423.38461538461536, 520.3461538461538, 425.6923076923077, - 520.3461538461538, 429.15384615384613, 519.9615384615385, - 433.7692307692308, 519.1923076923076 - ], - "points": [ + "paths": { + "lines": [ + [ + null, + null, + null, + null, + 417.61538461538464, + 520.3461538461538, + 419.15384615384613, + 520.3461538461538, + 421.0769230769231, + 520.3461538461538, + 423.38461538461536, + 520.3461538461538, + 425.6923076923077, + 520.3461538461538, + 429.15384615384613, + 519.9615384615385, + 433.7692307692308, + 519.1923076923076 + ] + ], + "points": [ + [ 417.61538461538464, 520.3461538461538, 419.15384615384613, 520.3461538461538, 425.6923076923077, 520.3461538461538, 433.7692307692308, 519.1923076923076 ] - } - ], + ] + }, "pageIndex": 0, "rect": [ 417.11538461538464, 510.46153846153845, 434.42307692307696, @@ -8358,22 +8393,37 @@ "color": [0, 255, 0], "thickness": 1, "opacity": 1, - "paths": [ - { - "bezier": [ - 449.92307692307696, 526.6538461538462, 449.92307692307696, - 527.423076923077, 449.6346153846154, 528.8653846153846, - 449.0576923076924, 530.9807692307693, 448.4807692307693, - 533.0961538461539, 447.8076923076924, 536.6538461538462, - 447.0384615384616, 541.6538461538462 - ], - "points": [ + "paths": { + "lines": [ + [ + null, + null, + null, + null, + 449.92307692307696, + 526.6538461538462, + 449.92307692307696, + 527.423076923077, + 449.6346153846154, + 528.8653846153846, + 449.0576923076924, + 530.9807692307693, + 448.4807692307693, + 533.0961538461539, + 447.8076923076924, + 536.6538461538462, + 447.0384615384616, + 541.6538461538462 + ] + ], + "points": [ + [ 449.92307692307696, 526.6538461538462, 449.92307692307696, 527.423076923077, 448.4807692307693, 533.0961538461539, 447.0384615384616, 541.6538461538462 ] - } - ], + ] + }, "pageIndex": 0, "rect": [ 446.5384615384616, 526.1538461538462, 456.92307692307696, @@ -8386,22 +8436,37 @@ "color": [0, 0, 255], "thickness": 1, "opacity": 1, - "paths": [ - { - "bezier": [ - 482.8461538461538, 511.6538461538462, 482.07692307692304, - 511.6538461538462, 480.53846153846155, 511.6538461538462, - 478.23076923076917, 511.6538461538462, 475.9230769230769, - 511.6538461538462, 472.46153846153845, 511.6538461538462, - 467.8461538461538, 511.6538461538462 - ], - "points": [ + "paths": { + "lines": [ + [ + null, + null, + null, + null, + 482.8461538461538, + 511.6538461538462, + 482.07692307692304, + 511.6538461538462, + 480.53846153846155, + 511.6538461538462, + 478.23076923076917, + 511.6538461538462, + 475.9230769230769, + 511.6538461538462, + 472.46153846153845, + 511.6538461538462, + 467.8461538461538, + 511.6538461538462 + ] + ], + "points": [ + [ 482.8461538461538, 511.6538461538462, 482.07692307692304, 511.6538461538462, 475.9230769230769, 511.6538461538462, 467.8461538461538, 511.6538461538462 ] - } - ], + ] + }, "pageIndex": 0, "rect": [ 467.1923076923077, 511.1538461538462, 483.3461538461538, @@ -8414,22 +8479,37 @@ "color": [0, 255, 255], "thickness": 1, "opacity": 1, - "paths": [ - { - "bezier": [ - 445.9230769230769, 509.3846153846154, 445.5384615384615, - 509.3846153846154, 445.15384615384613, 508.1346153846154, - 444.7692307692307, 505.6346153846154, 444.38461538461536, - 503.1346153846154, 443.23076923076917, 499.00000000000006, - 441.30769230769226, 493.2307692307693 - ], - "points": [ + "paths": { + "lines": [ + [ + null, + null, + null, + null, + 445.9230769230769, + 509.3846153846154, + 445.5384615384615, + 509.3846153846154, + 445.15384615384613, + 508.1346153846154, + 444.7692307692307, + 505.6346153846154, + 444.38461538461536, + 503.1346153846154, + 443.23076923076917, + 499.00000000000006, + 441.30769230769226, + 493.2307692307693 + ] + ], + "points": [ + [ 445.9230769230769, 509.3846153846154, 445.5384615384615, 509.3846153846154, 444.38461538461536, 503.1346153846154, 441.30769230769226, 493.2307692307693 ] - } - ], + ] + }, "pageIndex": 0, "rect": [ 436.03846153846155, 492.5769230769231, 446.4230769230769, @@ -9599,12 +9679,12 @@ "color": [53, 228, 47], "thickness": 20, "opacity": 1, - "paths": [ - { - "bezier": [279.9183673469388, 477.0105263157895], - "points": [279.9183673469388, 477.0105263157895] - } - ], + "paths": { + "lines": [ + [null, null, null, null, 279.9183673469388, 477.0105263157895] + ], + "points": [[279.9183673469388, 477.0105263157895]] + }, "pageIndex": 0, "rect": [ 269.9183673469388, 443.93684210526317, 312.9387755102041, diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 7c840704d859d..bc50065c2df1c 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -4448,21 +4448,54 @@ describe("annotation", function () { thickness: 1, opacity: 1, color: [0, 0, 0], - paths: [ - { - bezier: [ - 10, 11, 12, 13, 14, 15, 16, 17, 22, 23, 24, 25, 26, 27, + paths: { + lines: [ + [ + NaN, + NaN, + NaN, + NaN, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 22, + 23, + 24, + 25, + 26, + 27, ], - points: [1, 2, 3, 4, 5, 6, 7, 8], - }, - { - bezier: [ - 910, 911, 912, 913, 914, 915, 916, 917, 922, 923, 924, 925, - 926, 927, + [ + NaN, + NaN, + NaN, + NaN, + 910, + 911, + 912, + 913, + 914, + 915, + 916, + 917, + 922, + 923, + 924, + 925, + 926, + 927, ], - points: [91, 92, 93, 94, 95, 96, 97, 98], - }, - ], + ], + points: [ + [1, 2, 3, 4, 5, 6, 7, 8], + [91, 92, 93, 94, 95, 96, 97, 98], + ], + }, }, ], null, @@ -4482,13 +4515,12 @@ describe("annotation", function () { const appearance = data[1].data; expect(appearance).toEqual( "2 0 obj\n" + - "<< /FormType 1 /Subtype /Form /Type /XObject /BBox [12 34 56 78] /Length 129>> stream\n" + + "<< /FormType 1 /Subtype /Form /Type /XObject /BBox [12 34 56 78] /Length 127>> stream\n" + "1 w 1 J 1 j\n" + "0 G\n" + "10 11 m\n" + "12 13 14 15 16 17 c\n" + "22 23 24 25 26 27 c\n" + - "S\n" + "910 911 m\n" + "912 913 914 915 916 917 c\n" + "922 923 924 925 926 927 c\n" + @@ -4513,21 +4545,54 @@ describe("annotation", function () { thickness: 1, opacity: 0.12, color: [0, 0, 0], - paths: [ - { - bezier: [ - 10, 11, 12, 13, 14, 15, 16, 17, 22, 23, 24, 25, 26, 27, + paths: { + lines: [ + [ + NaN, + NaN, + NaN, + NaN, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 22, + 23, + 24, + 25, + 26, + 27, ], - points: [1, 2, 3, 4, 5, 6, 7, 8], - }, - { - bezier: [ - 910, 911, 912, 913, 914, 915, 916, 917, 922, 923, 924, 925, - 926, 927, + [ + NaN, + NaN, + NaN, + NaN, + 910, + 911, + 912, + 913, + 914, + 915, + 916, + 917, + 922, + 923, + 924, + 925, + 926, + 927, ], - points: [91, 92, 93, 94, 95, 96, 97, 98], - }, - ], + ], + points: [ + [1, 2, 3, 4, 5, 6, 7, 8], + [91, 92, 93, 94, 95, 96, 97, 98], + ], + }, }, ], null, @@ -4547,7 +4612,7 @@ describe("annotation", function () { const appearance = data[1].data; expect(appearance).toEqual( "2 0 obj\n" + - "<< /FormType 1 /Subtype /Form /Type /XObject /BBox [12 34 56 78] /Length 136 /Resources " + + "<< /FormType 1 /Subtype /Form /Type /XObject /BBox [12 34 56 78] /Length 134 /Resources " + "<< /ExtGState << /R0 << /CA 0.12 /Type /ExtGState>>>>>>>> stream\n" + "1 w 1 J 1 j\n" + "0 G\n" + @@ -4555,7 +4620,6 @@ describe("annotation", function () { "10 11 m\n" + "12 13 14 15 16 17 c\n" + "22 23 24 25 26 27 c\n" + - "S\n" + "910 911 m\n" + "912 913 914 915 916 917 c\n" + "922 923 924 925 926 927 c\n" + @@ -4581,13 +4645,10 @@ describe("annotation", function () { thickness: 3, opacity: 1, color: [0, 255, 0], - paths: [ - { - bezier: [1, 2, 3, 4, 5, 6, 7, 8], - // Useless in the printing case. - points: [1, 2, 3, 4, 5, 6, 7, 8], - }, - ], + paths: { + lines: [[NaN, NaN, NaN, NaN, 1, 2, 3, 4, 5, 6, 7, 8]], + points: [[1, 2, 3, 4, 5, 6, 7, 8]], + }, }, ] ) diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 21671995bddae..5edf1639b43ad 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -66,19 +66,21 @@ font-size: 0; } -.textLayer.highlighting { - cursor: var(--editorFreeHighlight-editing-cursor); +.textLayer { + &.highlighting { + cursor: var(--editorFreeHighlight-editing-cursor); - &:not(.free) span { - cursor: var(--editorHighlight-editing-cursor); + &:not(.free) span { + cursor: var(--editorHighlight-editing-cursor); - &[role="img"] { - cursor: var(--editorFreeHighlight-editing-cursor); + &[role="img"] { + cursor: var(--editorFreeHighlight-editing-cursor); + } } - } - &.free span { - cursor: var(--editorFreeHighlight-editing-cursor); + &.free span { + cursor: var(--editorFreeHighlight-editing-cursor); + } } } @@ -154,6 +156,11 @@ .annotationEditorLayer.inkEditing { cursor: var(--editorInk-editing-cursor); + touch-action: none; +} + +.annotationEditorLayer .draw { + box-sizing: border-box; } .annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) { diff --git a/web/draw_layer_builder.css b/web/draw_layer_builder.css index e4b6ae385adfb..5ea206ced7657 100644 --- a/web/draw_layer_builder.css +++ b/web/draw_layer_builder.css @@ -17,6 +17,10 @@ svg { transform: none; + &.moving { + z-index: 100000; + } + &.highlight, &.highlightOutline { &[data-main-rotation="90"] { @@ -41,6 +45,23 @@ } } + &.draw { + position: absolute; + mix-blend-mode: normal; + + &[data-draw-rotation="90"] { + transform: rotate(90deg); + } + + &[data-draw-rotation="180"] { + transform: rotate(180deg); + } + + &[data-draw-rotation="270"] { + transform: rotate(270deg); + } + } + &.highlight { --blend-mode: multiply; diff --git a/web/viewer.html b/web/viewer.html index 1a8eb7f10dc13..dc8020d320af6 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -300,7 +300,7 @@
- +