From 7e3b3ae14f1098dfd4bc1f31324eb25701d0d134 Mon Sep 17 00:00:00 2001 From: legraina Date: Tue, 2 Sep 2025 21:11:04 -0400 Subject: [PATCH 01/13] Fix previous commit, as some code was removed in the merge. --- src/display/editor/draw.js | 56 +++++++++----------------- src/display/editor/tools.js | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 37 deletions(-) diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index 2ef5640766bfb..2d3f2e3dfc85c 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -16,6 +16,7 @@ import { AnnotationEditorParamsType, unreachable } from "../../shared/util.js"; import { noContextMenu, stopEvent } from "../display_utils.js"; import { AnnotationEditor } from "./editor.js"; +import { CurrentPointers } from "./tools.js"; class DrawingOptions { #svgProperties = Object.create(null); @@ -81,14 +82,6 @@ class DrawingEditor extends AnnotationEditor { static #currentDrawingOptions = null; - static #currentPointerId = NaN; - - static #currentPointerType = null; - - static #currentPointerIds = null; - - static #currentMoveTimestamp = NaN; - static _INNER_MARGIN = 3; constructor(params) { @@ -674,20 +667,15 @@ class DrawingEditor extends AnnotationEditor { } static startDrawing(parent, uiManager, _isLTR, event) { - // The _currentPointerType is set when the user starts an empty drawing - // session. If, in the same drawing session, the user starts using a + // The pointerType of CurrentPointer is set when the user starts an empty + // drawing session. If, in the same drawing session, the user starts using a // different type of pointer (e.g. a pen and then a finger), we just return. // - // The _currentPointerId and _currentPointerIds are used to keep track of - // the pointers with a same type (e.g. two fingers). If the user starts to - // draw with a finger and then uses a second finger, we just stop the - // current drawing and let the user zoom the document. + // If the user starts to draw with a finger and then uses a second finger, + // we just stop the current drawing and let the user zoom the document. const { target, offsetX: x, offsetY: y, pointerId, pointerType } = event; - if ( - DrawingEditor.#currentPointerType && - DrawingEditor.#currentPointerType !== pointerType - ) { + if (CurrentPointers.isInitializedAndDifferentPointerType(pointerType)) { return; } @@ -700,16 +688,13 @@ class DrawingEditor extends AnnotationEditor { const ac = (DrawingEditor.#currentDrawingAC = new AbortController()); const signal = parent.combinedSignal(ac); - DrawingEditor.#currentPointerId ||= pointerId; - DrawingEditor.#currentPointerType ??= pointerType; + CurrentPointers.setPointer(pointerType, pointerId); window.addEventListener( "pointerup", e => { - if (DrawingEditor.#currentPointerId === e.pointerId) { + if (CurrentPointers.isSamePointerIdOrRemove(e.pointerId)) { this._endDraw(e); - } else { - DrawingEditor.#currentPointerIds?.delete(e.pointerId); } }, { signal } @@ -717,10 +702,8 @@ class DrawingEditor extends AnnotationEditor { window.addEventListener( "pointercancel", e => { - if (DrawingEditor.#currentPointerId === e.pointerId) { + if (CurrentPointers.isSamePointerIdOrRemove(e.pointerId)) { this._currentParent.endDrawingSession(); - } else { - DrawingEditor.#currentPointerIds?.delete(e.pointerId); } }, { signal } @@ -728,14 +711,14 @@ class DrawingEditor extends AnnotationEditor { window.addEventListener( "pointerdown", e => { - if (DrawingEditor.#currentPointerType !== e.pointerType) { + if (!CurrentPointers.isSamePointerType(e.pointerType)) { // For example, we started with a pen and the user // is now using a finger. return; } // For example, the user is using a second finger. - (DrawingEditor.#currentPointerIds ||= new Set()).add(e.pointerId); + CurrentPointers.initializeAndAddPointerId(e.pointerId); // The first finger created a first point and a second finger just // started, so we stop the drawing and remove this only point. @@ -761,7 +744,7 @@ class DrawingEditor extends AnnotationEditor { target.addEventListener( "touchmove", e => { - if (e.timeStamp === DrawingEditor.#currentMoveTimestamp) { + if (CurrentPointers.isSameTimeStamp(e.timeStamp)) { // This move event is used to draw so we don't want to scroll. stopEvent(e); } @@ -808,16 +791,16 @@ class DrawingEditor extends AnnotationEditor { } static _drawMove(event) { - DrawingEditor.#currentMoveTimestamp = -1; + CurrentPointers.isSameTimeStamp(event.timeStamp); if (!DrawingEditor.#currentDraw) { return; } const { offsetX, offsetY, pointerId } = event; - if (DrawingEditor.#currentPointerId !== pointerId) { + if (!CurrentPointers.isSamePointerIdOrRemove(pointerId)) { return; } - if (DrawingEditor.#currentPointerIds?.size >= 1) { + if (CurrentPointers.isUsingMultiplePointers()) { // The user is using multiple fingers and the first one is moving. this._endDraw(event); return; @@ -827,7 +810,7 @@ class DrawingEditor extends AnnotationEditor { DrawingEditor.#currentDraw.add(offsetX, offsetY) ); // We track the timestamp to know if the touchmove event is used to draw. - DrawingEditor.#currentMoveTimestamp = event.timeStamp; + CurrentPointers.setTimeStamp(event.timeStamp); stopEvent(event); } @@ -837,15 +820,14 @@ class DrawingEditor extends AnnotationEditor { this._currentParent = null; DrawingEditor.#currentDraw = null; DrawingEditor.#currentDrawingOptions = null; - DrawingEditor.#currentPointerType = null; - DrawingEditor.#currentMoveTimestamp = NaN; + CurrentPointers.clearPointerType(); + CurrentPointers.clearTimeStamp(); } if (DrawingEditor.#currentDrawingAC) { DrawingEditor.#currentDrawingAC.abort(); DrawingEditor.#currentDrawingAC = null; - DrawingEditor.#currentPointerId = NaN; - DrawingEditor.#currentPointerIds = null; + CurrentPointers.clearPointerId(); } } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 6a1239403e351..990dcf4b9a59c 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -42,6 +42,83 @@ function bindEvents(obj, element, names) { } } +/** + * Class to store current pointers used by the editor to be able to handle + * multiple pointers (e.g. two fingers, a pen, a mouse, ...). + */ +class CurrentPointers { + // To manage the pointer events. + + // The pointerId and pointerIds are used to keep track of + // the pointers with a same type (e.g. two fingers). + static #pointerId = NaN; + + static #pointerIds = null; + + // Track the timestamp to know if the touchmove event is used. + static #moveTimestamp = NaN; + + // The pointerType is used to know if we are using a mouse, a pen or a touch. + static #pointerType = null; + + static initializeAndAddPointerId(pointerId) { + // Store pointer ids. For example, the user is using a second finger. + (CurrentPointers.#pointerIds ||= new Set()).add(pointerId); + } + + static setPointer(pointerType, pointerId) { + CurrentPointers.#pointerId ||= pointerId; + CurrentPointers.#pointerType ??= pointerType; + } + + static setTimeStamp(timeStamp) { + CurrentPointers.#moveTimestamp = timeStamp; + } + + // Check if it's the same pointer id, otherwise remove it from the set. + static isSamePointerIdOrRemove(pointerId) { + if (CurrentPointers.#pointerId === pointerId) { + return true; + } + + CurrentPointers.#pointerIds?.delete(pointerId); + return false; + } + + static isSamePointerType(pointerType) { + return CurrentPointers.#pointerType === pointerType; + } + + static isInitializedAndDifferentPointerType(pointerType) { + return ( + CurrentPointers.#pointerType !== null && + !CurrentPointers.isSamePointerType(pointerType) + ); + } + + static isSameTimeStamp(timeStamp) { + return CurrentPointers.#moveTimestamp === timeStamp; + } + + static isUsingMultiplePointers() { + // Check if the user is using multiple fingers + return CurrentPointers.#pointerIds?.size >= 1; + } + + static clearPointerType() { + CurrentPointers.#pointerType = null; + } + + static clearPointerId() { + CurrentPointers.#pointerId = NaN; + CurrentPointers.#pointerIds = null; + } + + static clearTimeStamp() { + CurrentPointers.#moveTimestamp = NaN; + } +} + /** * Class to create some unique ids for the different editors. */ @@ -2740,5 +2817,6 @@ export { bindEvents, ColorManager, CommandManager, + CurrentPointers, KeyboardManager, }; From a98bb89cb4628d467b0c837cb320ec831060006e Mon Sep 17 00:00:00 2001 From: legraina Date: Tue, 2 Sep 2025 21:30:12 -0400 Subject: [PATCH 02/13] Check just the pointer id when sufficient. --- src/display/editor/draw.js | 4 ++-- src/display/editor/tools.js | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index 2d3f2e3dfc85c..49bcc7c178843 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -797,7 +797,7 @@ class DrawingEditor extends AnnotationEditor { } const { offsetX, offsetY, pointerId } = event; - if (!CurrentPointers.isSamePointerIdOrRemove(pointerId)) { + if (!CurrentPointers.isSamePointerId(pointerId)) { return; } if (CurrentPointers.isUsingMultiplePointers()) { @@ -827,7 +827,7 @@ class DrawingEditor extends AnnotationEditor { if (DrawingEditor.#currentDrawingAC) { DrawingEditor.#currentDrawingAC.abort(); DrawingEditor.#currentDrawingAC = null; - CurrentPointers.clearPointerId(); + CurrentPointers.clearPointerIds(); } } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 990dcf4b9a59c..d0437b62d226b 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -75,6 +75,10 @@ class CurrentPointers { CurrentPointers.#moveTimestamp = timeStamp; } + static isSamePointerId(pointerId) { + return CurrentPointers.#pointerId === pointerId; + } + // Check if it's the same pointer id, otherwise remove it from the set. static isSamePointerIdOrRemove(pointerId) { if (CurrentPointers.#pointerId === pointerId) { @@ -109,7 +113,7 @@ class CurrentPointers { CurrentPointers.#pointerType = null; } - static clearPointerId() { + static clearPointerIds() { CurrentPointers.#pointerId = NaN; CurrentPointers.#pointerIds = null; } From c8ac815fc2639c2651fd8b2ba2356273237bd30d Mon Sep 17 00:00:00 2001 From: legraina Date: Tue, 2 Sep 2025 21:34:27 -0400 Subject: [PATCH 03/13] Clear pointer type outside of the DrawingEditor to always keep the same, until the user changes the mode. --- src/display/editor/draw.js | 1 - src/display/editor/tools.js | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index 49bcc7c178843..45d25d4909499 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -820,7 +820,6 @@ class DrawingEditor extends AnnotationEditor { this._currentParent = null; DrawingEditor.#currentDraw = null; DrawingEditor.#currentDrawingOptions = null; - CurrentPointers.clearPointerType(); CurrentPointers.clearTimeStamp(); } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index d0437b62d226b..3e68471314712 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -1877,13 +1877,16 @@ class AnnotationEditorUIManager { * edit mode. * @param {boolean} [editComment] - true if the mode change is due to a * comment edit. + * @param {boolean} [isFromEvent] - true if the mode change is due to an event + * (e.g. toolbar button clicked). */ async updateMode( mode, editId = null, isFromKeyboard = false, mustEnterInEditMode = false, - editComment = false + editComment = false, + isFromEvent = false ) { if (this.#mode === mode) { return; @@ -1921,6 +1924,10 @@ class AnnotationEditorUIManager { if (mode === AnnotationEditorType.SIGNATURE) { await this.#signatureManager?.loadSignatures(); } + if (isFromEvent) { + // reinitialize the pointer type when mode changed by an event + CurrentPointers.clearPointerType(); + } this.setEditingState(true); await this.#enableAll(); From c2ed7255c9b54f162151415d03f1040869f2ddb0 Mon Sep 17 00:00:00 2001 From: legraina Date: Tue, 2 Sep 2025 22:29:19 -0400 Subject: [PATCH 04/13] Add an eraser editor. It can erase annotations that have the property eraseable implemented. It behaves as a rubber when erasing writting. --- src/display/editor/annotation_editor_layer.js | 24 + src/display/editor/draw.js | 15 + src/display/editor/eraser.js | 548 ++++++++++++++++++ src/shared/util.js | 2 + web/annotation_editor_layer_builder.css | 21 + web/annotation_editor_params.js | 8 + web/images/toolbarButton-editorEraser.svg | 5 + web/toolbar.js | 20 + web/viewer.css | 5 + web/viewer.html | 13 + web/viewer.js | 5 + 11 files changed, 666 insertions(+) create mode 100644 src/display/editor/eraser.js create mode 100644 web/images/toolbarButton-editorEraser.svg diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 62401b91b4ae7..d9185025f468a 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -31,6 +31,7 @@ import { FeatureTest, } from "../../shared/util.js"; import { AnnotationEditor } from "./editor.js"; +import { EraserEditor } from "./eraser.js"; import { FreeTextEditor } from "./freetext.js"; import { HighlightEditor } from "./highlight.js"; import { InkEditor } from "./ink.js"; @@ -99,6 +100,7 @@ class AnnotationEditorLayer { static #editorTypes = new Map( [ + EraserEditor, FreeTextEditor, InkEditor, StampEditor, @@ -168,6 +170,7 @@ class AnnotationEditorLayer { */ updateMode(mode = this.#uiManager.getMode()) { this.#cleanup(); + this.#toogleEditorPointerEvents(true); switch (mode) { case AnnotationEditorType.NONE: this.disableTextSelection(); @@ -175,6 +178,13 @@ class AnnotationEditorLayer { this.toggleAnnotationLayerPointerEvents(true); this.disableClick(); return; + case AnnotationEditorType.ERASER: + this.#toogleEditorPointerEvents(false); + this.disableTextSelection(); + this.togglePointerEvents(true); + this.enableClick(); + this.addNewEditor({ /* eraser */}); + break; case AnnotationEditorType.INK: this.disableTextSelection(); this.togglePointerEvents(true); @@ -238,6 +248,20 @@ class AnnotationEditorLayer { this.#annotationLayer?.div.classList.toggle("disabled", !enabled); } + #toogleEditorPointerEvents(enabled = false) { + const value = enabled ? "" : "none"; + for (const editor of this.#editors.values()) { + editor.div.style.pointerEvents = value; + // for highlight editors, we must also set pointer-events + // of the clipped child. + for (const child of editor.div.children) { + if (child.className === "internal") { + child.style.pointerEvents = value; + } + } + } + } + /** * Enable pointer events on the main div in order to enable * editor creation. diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index 45d25d4909499..7a0772bc82aaa 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -133,6 +133,21 @@ class DrawingEditor extends AnnotationEditor { return id; } + replaceOutlines(newOutlines) { + if (!newOutlines || !this.parent || this._drawId === null) { + return; + } + this.#drawOutlines = newOutlines; + + this.parent.drawLayer.finalizeDraw( + this._drawId, + this.#drawOutlines.defaultProperties + ); + + this.#updateBbox(this.#drawOutlines.box); + this.rotate(); + } + static _mergeSVGProperties(p1, p2) { const p1Keys = new Set(Object.keys(p1)); diff --git a/src/display/editor/eraser.js b/src/display/editor/eraser.js new file mode 100644 index 0000000000000..7d5a5b7830d5d --- /dev/null +++ b/src/display/editor/eraser.js @@ -0,0 +1,548 @@ +import { + AnnotationEditorParamsType, + AnnotationEditorType, +} from "../../shared/util.js"; +import { noContextMenu, stopEvent } from "../display_utils.js"; +import { AnnotationEditor } from "./editor.js"; +import { CurrentPointers } from "./tools.js"; +import { InkEditor } from "./ink.js"; + +class EraserEditor extends AnnotationEditor { + static #currentCursorAC = null; + + static #currentEraserAC = null; + + #inkEditors = []; + + static _defaultThickness = 20; + + static _thickness; + + static _type = "eraser"; + + static _editorType = AnnotationEditorType.ERASER; + + #cursor = null; + + #isErasing = false; + + #erased = null; + + constructor(params) { + super({ ...params, name: "eraserEditor" }); + EraserEditor._thickness = + params.thickness || + EraserEditor._thickness || + EraserEditor._defaultThickness; + this.#erased = new Map(); + } + + /** @inheritdoc */ + static initialize(l10n, uiManager) { + AnnotationEditor.initialize(l10n, uiManager); + } + + /** @inheritdoc */ + static updateDefaultParams(type, value) { + switch (type) { + case AnnotationEditorParamsType.ERASER_THICKNESS: + EraserEditor._defaultThickness = value; + EraserEditor._thickness = value; + break; + } + } + + /** @inheritdoc */ + updateParams(type, value) { + switch (type) { + case AnnotationEditorParamsType.ERASER_THICKNESS: + this.updateThickness(value); + break; + } + } + + static get defaultPropertiesToUpdate() { + return [ + [ + AnnotationEditorParamsType.ERASER_THICKNESS, + EraserEditor._defaultThickness, + ], + ]; + } + + /** @inheritdoc */ + get propertiesToUpdate() { + return [ + [ + AnnotationEditorParamsType.ERASER_THICKNESS, + EraserEditor._thickness || EraserEditor._defaultThickness, + ], + ]; + } + + updateThickness(thickness) { + const setThickness = th => { + EraserEditor._thickness = th; + this.#updateCursor(); + }; + + const savedThickness = EraserEditor._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.ERASER_THICKNESS, + overwriteIfSameType: true, + keepUndo: true, + }); + } + + /** Ensures EraserEditor spans the entire AnnotationEditorLayer */ + fixAndSetPosition() { + this.x = 0; + this.y = 0; + this.width = 1; + this.height = 1; + + const [parentWidth, parentHeight] = this.parentDimensions; + this.setDims(parentWidth, parentHeight); + + return super.fixAndSetPosition(0); + } + + /** @inheritdoc */ + render() { + if (this.div) { + return this.div; + } + + const div = super.render(); + this.fixAndSetPosition(); + this.#inkEditors = this.#getInkEditors(); + this.enableEditing(); + return div; + } + + /** @inheritdoc */ + enableEditing() { + super.enableEditing(); + this.div?.classList.toggle("disabled", false); + this.#erased.clear(); + + if (this.#cursor) { + this.#cursor.remove(); + this.#cursor = null; + } + + if (this.div) { + this.div.style.pointerEvents = "auto"; + this.div.style.zIndex = "1000"; + + this.#cursor = document.createElement("div"); + this.#cursor.className = "eraserCursor"; + this.#cursor.style.width = `${EraserEditor._thickness}px`; + this.#cursor.style.height = `${EraserEditor._thickness}px`; + this.#cursor.style.display = "none"; + this.#cursor.style.pointerEvents = "none"; + this.div.append(this.#cursor); + + const ac = (EraserEditor.#currentCursorAC = new AbortController()); + const signal = this.parent.combinedSignal(ac); + + this.div.addEventListener("pointermove", this.#moveCursor.bind(this), { + signal, + }); + this.div.addEventListener( + "pointerenter", + this.#displayCursor.bind(this), + { signal } + ); + this.div.addEventListener("pointerleave", this.#hideCursor.bind(this), { + signal, + }); + this.div.addEventListener( + "pointerdown", + this.#startEraseSession.bind(this), + { signal } + ); + } + } + + /** @inheritdoc */ + disableEditing() { + super.disableEditing(); + this.div?.classList.toggle("disabled", true); + + this.#abortEraseSession(); + this.#abortCursor(); + } + + /** @inheritdoc */ + remove() { + super.remove(); + + this.#abortEraseSession(); + this.#abortCursor(); + } + + #abortCursor() { + if (EraserEditor.#currentCursorAC) { + EraserEditor.#currentCursorAC.abort(); + EraserEditor.#currentCursorAC = null; + } + + if (this.#cursor) { + this.#cursor.remove(); + this.#cursor = null; + } + + if (this.div) { + this.div.style.pointerEvents = ""; + this.div.style.zIndex = ""; + } + } + + #startEraseSession(event) { + if (event.button && event.button !== 0) { + return; + } + + this.#moveCursor(event); + + const { pointerId, pointerType, target } = event; + if (CurrentPointers.isInitializedAndDifferentPointerType(pointerType)) { + return; + } + CurrentPointers.setPointer(pointerType, pointerId); + + const ac = (EraserEditor.#currentEraserAC = new AbortController()); + const signal = this.parent.combinedSignal(ac); + + window.addEventListener( + "pointerup", + e => { + if (CurrentPointers.isSamePointerIdOrRemove(e.pointerId)) { + this.#endErase(e); + } + }, + { signal } + ); + + window.addEventListener( + "pointercancel", + e => { + if (CurrentPointers.isSamePointerIdOrRemove(e.pointerId)) { + this.#endErase(e); + } + }, + { signal } + ); + + window.addEventListener( + "pointerdown", + e => { + if (!CurrentPointers.isSamePointerType(pointerType)) { + return; + } + + // Multi-pointer of same type (e.g., two fingers) -> stop erasing + CurrentPointers.initializeAndAddPointerId(e.pointerId); + if (this.#isErasing) { + this.#endErase(null); + } + }, + { capture: true, passive: false, signal } + ); + + window.addEventListener("contextmenu", noContextMenu, { signal }); + + target.addEventListener("pointermove", this.#onPointerMove.bind(this), { + signal, + }); + + // Prevent touch scroll when the move is used for erasing + target.addEventListener( + "touchmove", + e => { + if (CurrentPointers.isSameTimeStamp(e.timeStamp)) { + stopEvent(e); + } + }, + { signal } + ); + + this.#isErasing = true; + this.#erase(event.clientX, event.clientY); + stopEvent(event); + } + + #onPointerMove(event) { + CurrentPointers.clearTimeStamp(); + + if (!this.#isErasing) { + return; + } + + const { pointerId } = event; + + if (!CurrentPointers.isSamePointerId(pointerId)) { + return; + } + if (CurrentPointers.isUsingMultiplePointers()) { + // The user is using multiple fingers and the first one is moving. + this.#endErase(event); + return; + } + + this.#erase(event.clientX, event.clientY); + + // We track the timestamp to know if the touchmove event is used to draw. + CurrentPointers.setTimeStamp(event.timeStamp); + + stopEvent(event); + } + + #endErase(event) { + if (event) { + this.#erase(event.clientX, event.clientY); + } + this.#commit(); + this.#abortEraseSession(); + } + + #commit() { + if (this.#erased && this.#erased.size > 0) { + for (const [editor, newPaths] of this.#erased) { + this.#commitInkEditorPaths(editor, newPaths); + } + } + this.#erased.clear(); + } + + #abortEraseSession() { + if (EraserEditor.#currentEraserAC) { + EraserEditor.#currentEraserAC.abort(); + EraserEditor.#currentEraserAC = null; + } + CurrentPointers.clearPointerIds(); + CurrentPointers.clearTimeStamp(); + this.#isErasing = false; + this.#erased.clear(); + } + + isEmpty() { + return true; + } + + #updateCursor() { + if (this.#cursor) { + this.#cursor.style.width = `${EraserEditor._thickness}px`; + this.#cursor.style.height = `${EraserEditor._thickness}px`; + } + } + + #displayCursor(event) { + this.#updateCursor(); + this.#moveCursor(event); + } + + #moveCursor(event) { + if (!this.#cursor) { + return; + } + + if ( + CurrentPointers.isInitializedAndDifferentPointerType(event.pointerType) + ) { + this.#hideCursor(); + return; + } + + const rect = this.parent.div.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + this.#cursor.style.left = `${x - EraserEditor._thickness / 2}px`; + this.#cursor.style.top = `${y - EraserEditor._thickness / 2}px`; + + this.#showCursor(); + } + + #showCursor() { + this.#cursor.style.display = "block"; + } + + #hideCursor() { + this.#cursor.style.display = "none"; + } + + #getInkEditors() { + const editors = this._uiManager.getEditors(this.pageIndex) || []; + return editors.filter( + ed => ed.editorType === "ink" && ed?.parent?.div && ed?.div + ); + } + + #erase(clientX, clientY) { + const layerRect = this.parent.div.getBoundingClientRect(); + const x = clientX - layerRect.left; + const y = clientY - layerRect.top; + const radius = EraserEditor._thickness / 2; + const radius2 = radius * radius; + + for (const editor of this.#inkEditors) { + let modified = false; + if (!editor?.parent?.div || !editor?.div) { + continue; + } + + const pdfRect = editor.getRect(0, 0, editor.rotation); + const [, pageHeight] = editor.pageDimensions; + const [pageX, pageY] = editor.pageTranslation; + const [cx, cy, cw, ch] = editor.getRectInCurrentCoords( + pdfRect, + pageHeight + ); + const scale = editor.parentScale; + const left = (cx - pageX) * scale; + const top = (cy + pageY) * scale; + const right = left + cw * scale; + const bottom = top + ch * scale; + if (!this.#hitBBox(x, y, radius, [left, top, right, bottom])) { + continue; + } + + const points = + this.#erased.get(editor) ?? editor.serializeDraw(false).points; + + const newPaths = []; + + for (const path of points) { + if (path.length === 0) { + continue; + } + let newPath = []; + for (let i = 0; i < path.length; i += 2) { + const [lx, ly] = this.#pagePointToLayer(path[i], path[i + 1], editor); + const dx = lx - x; + const dy = ly - y; + const dist = dx * dx + dy * dy; + if (dist >= radius2) { + newPath.push(path[i], path[i + 1]); + } else { + modified = true; + if (newPath.length >= 4) { + newPaths.push(new Float32Array(newPath)); + } + newPath = []; + } + } + if (newPath.length >= 4) { + newPaths.push(new Float32Array(newPath)); + } + } + if (modified) { + this.#erased.set(editor, newPaths); + this.#previewInkEditorPaths(editor, newPaths); + } + } + } + + #deserializeOutline(editor, newPaths) { + const { + viewport: { + rawDims: { pageWidth, pageHeight, pageX, pageY }, + }, + } = editor.parent; + + const thickness = editor._drawingOptions["stroke-width"]; + const rotation = editor.rotation; + + const newOutline = InkEditor.deserializeDraw( + pageX, + pageY, + pageWidth, + pageHeight, + InkEditor._INNER_MARGIN, + { + paths: { points: newPaths }, + rotation, + thickness, + } + ); + + return newOutline; + } + + #previewInkEditorPaths(editor, newPaths) { + if (!newPaths || newPaths.length === 0) { + editor.parent.drawLayer.updateProperties(editor._drawId, { + path: { d: "" }, + }); + return; + } + + const tempOutline = this.#deserializeOutline(editor, newPaths); + editor.parent.drawLayer.updateProperties(editor._drawId, { + path: { d: tempOutline.toSVGPath() }, + }); + } + + #commitInkEditorPaths(editor, newPaths) { + if (!newPaths || newPaths.length === 0) { + editor.remove(); + return; + } + + const newOutlines = this.#deserializeOutline(editor, newPaths); + editor.replaceOutlines(newOutlines); + editor.onScaleChanging(); + } + + #hitBBox(x, y, r, rect) { + const [left, top, right, bottom] = rect; + const cx = Math.max(left, Math.min(x, right)); + const cy = Math.max(top, Math.min(y, bottom)); + const dx = x - cx; + const dy = y - cy; + return dx * dx + dy * dy <= r * r; + } + + #pagePointToLayer(px, py, editor) { + const [pageX, pageY] = editor.pageTranslation; + const [pageW, pageH] = editor.pageDimensions; + const { width: layerW, height: layerH } = + editor.parent.div.getBoundingClientRect(); + + const nx = (px - pageX) / pageW; + const ny = (py - pageY) / pageH; + + let rx, ry; + switch ((editor.rotation || 0) % 360) { + case 90: + rx = ny; + ry = 1 - nx; + break; + case 180: + rx = 1 - nx; + ry = 1 - ny; + break; + case 270: + rx = 1 - ny; + ry = nx; + break; + default: + rx = nx; + ry = ny; + break; + } + + const lx = rx * layerW; + const ly = (1 - ry) * layerH; + return [lx, ly]; + } +} + +export { EraserEditor }; diff --git a/src/shared/util.js b/src/shared/util.js index 729c1e698c809..cbc2ed718ff5c 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -76,6 +76,7 @@ const AnnotationEditorType = { STAMP: 13, INK: 15, POPUP: 16, + ERASER: 20, SIGNATURE: 101, COMMENT: 102, }; @@ -89,6 +90,7 @@ const AnnotationEditorParamsType = { INK_COLOR: 21, INK_THICKNESS: 22, INK_OPACITY: 23, + ERASER_THICKNESS: 25, HIGHLIGHT_COLOR: 31, HIGHLIGHT_THICKNESS: 32, HIGHLIGHT_FREE: 33, diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 99a8179017181..221267c13a14e 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -43,6 +43,7 @@ ); --editorFreeText-editing-cursor: text; --editorInk-editing-cursor: url(images/cursor-editorInk.svg) 0 16, pointer; + --editorEraser-thickness: 20px; --editorHighlight-editing-cursor: url(images/cursor-editorTextHighlight.svg) 24 24, text; --editorFreeHighlight-editing-cursor: @@ -159,6 +160,26 @@ box-sizing: border-box; } +.annotationEditorLayer.eraserEditing { + cursor: none; +} + +.annotationEditorLayer.eraserEditing .editToolbar{ + display: none; +} + +.eraserCursor { + position: absolute; + display: none; + width: var(--editorEraser-thickness); + height: var(--editorEraser-thickness); + background-color: transparent; + border: 1px solid black; + border-radius: 50%; + pointer-events: none; + transition: transform 0.05s ease-out; +} + .annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) { position: absolute; diff --git a/web/annotation_editor_params.js b/web/annotation_editor_params.js index 1e4fc431db954..323a9d67c651b 100644 --- a/web/annotation_editor_params.js +++ b/web/annotation_editor_params.js @@ -24,6 +24,7 @@ import { AnnotationEditorParamsType } from "pdfjs-lib"; * @property {HTMLInputElement} editorInkColor * @property {HTMLInputElement} editorInkThickness * @property {HTMLInputElement} editorInkOpacity + * @property {HTMLInputElement} editorEraserThickness * @property {HTMLButtonElement} editorStampAddImage * @property {HTMLInputElement} editorFreeHighlightThickness * @property {HTMLButtonElement} editorHighlightShowAll @@ -49,6 +50,7 @@ class AnnotationEditorParams { editorInkColor, editorInkThickness, editorInkOpacity, + editorEraserThickness, editorStampAddImage, editorFreeHighlightThickness, editorHighlightShowAll, @@ -78,6 +80,9 @@ class AnnotationEditorParams { editorInkOpacity.addEventListener("input", function () { dispatchEvent("INK_OPACITY", this.valueAsNumber); }); + editorEraserThickness.addEventListener("input", function () { + dispatchEvent("ERASER_THICKNESS", this.valueAsNumber); + }); editorStampAddImage.addEventListener("click", () => { eventBus.dispatch("reporttelemetry", { source: this, @@ -118,6 +123,9 @@ class AnnotationEditorParams { case AnnotationEditorParamsType.INK_OPACITY: editorInkOpacity.value = value; break; + case AnnotationEditorParamsType.ERASER_THICKNESS: + editorEraserThickness.value = value; + break; case AnnotationEditorParamsType.HIGHLIGHT_COLOR: eventBus.dispatch("mainhighlightcolorpickerupdatecolor", { source: this, diff --git a/web/images/toolbarButton-editorEraser.svg b/web/images/toolbarButton-editorEraser.svg new file mode 100644 index 0000000000000..78b0e4112d2e4 --- /dev/null +++ b/web/images/toolbarButton-editorEraser.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/toolbar.js b/web/toolbar.js index 95639af9a4f23..9ceb302ad0887 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -115,6 +115,18 @@ class Toolbar { }, }, }, + { + element: options.editorEraserButton, + eventName: "switchannotationeditormode", + eventDetails: { + get mode() { + const { classList } = options.editorEraserButton; + return classList.contains("toggled") + ? AnnotationEditorType.NONE + : AnnotationEditorType.ERASER; + }, + }, + }, { element: options.editorStampButton, eventName: "switchannotationeditormode", @@ -290,6 +302,8 @@ class Toolbar { #editorModeChanged({ mode }) { const { + editorEraserButton, + editorEraserParamsToolbar, editorCommentButton, editorCommentParamsToolbar, editorFreeTextButton, @@ -309,6 +323,11 @@ class Toolbar { mode === AnnotationEditorType.POPUP, editorCommentParamsToolbar ); + toggleExpandedBtn( + editorEraserButton, + mode === AnnotationEditorType.ERASER, + editorEraserParamsToolbar + ); toggleExpandedBtn( editorFreeTextButton, mode === AnnotationEditorType.FREETEXT, @@ -336,6 +355,7 @@ class Toolbar { ); editorCommentButton.disabled = + editorEraserButton.disabled = editorFreeTextButton.disabled = editorHighlightButton.disabled = editorInkButton.disabled = diff --git a/web/viewer.css b/web/viewer.css index 7822771b5e88e..a88a9d716ba00 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -103,6 +103,7 @@ --toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg); --toolbarButton-editorHighlight-icon: url(images/toolbarButton-editorHighlight.svg); --toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg); + --toolbarButton-editorEraser-icon: url(images/toolbarButton-editorEraser.svg); --toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg); --toolbarButton-editorSignature-icon: url(images/toolbarButton-editorSignature.svg); --toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg); @@ -542,6 +543,10 @@ body { mask-image: var(--toolbarButton-zoomIn-icon); } +#editorEraserButton::before { + mask-image: var(--toolbarButton-editorEraser-icon); +} + #editorCommentButton::before { mask-image: var(--toolbarButton-editorComment-icon); transform: scaleX(var(--dir-factor)); diff --git a/web/viewer.html b/web/viewer.html index b931c7cd5ffaa..cad1eb8c8c63d 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -341,6 +341,19 @@ +
+ + +
-