diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 974b0073034c7..3ef3a622f352f 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -239,6 +239,11 @@ "type": "boolean", "default": false }, + "enableEraser": { + "description": "Enable the eraser to erase annotations like Ink.", + "type": "boolean", + "default": false + }, "enableOptimizedPartialRendering": { "description": "Enable tracking of PDF operations to optimize partial rendering.", "type": "boolean", 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 2ef5640766bfb..1789c1fb122f5 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) { @@ -98,6 +91,10 @@ class DrawingEditor extends AnnotationEditor { this._addOutlines(params); } + get _drawOutlines() { + return this.#drawOutlines; + } + _addOutlines(params) { if (params.drawOutlines) { this.#createDrawOutlines(params); @@ -674,20 +671,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 +692,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 +706,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 +715,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 +748,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 +795,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.isSamePointerId(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 +814,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 +824,13 @@ class DrawingEditor extends AnnotationEditor { this._currentParent = null; DrawingEditor.#currentDraw = null; DrawingEditor.#currentDrawingOptions = null; - DrawingEditor.#currentPointerType = null; - DrawingEditor.#currentMoveTimestamp = NaN; + CurrentPointers.clearTimeStamp(); } if (DrawingEditor.#currentDrawingAC) { DrawingEditor.#currentDrawingAC.abort(); DrawingEditor.#currentDrawingAC = null; - DrawingEditor.#currentPointerId = NaN; - DrawingEditor.#currentPointerIds = null; + CurrentPointers.clearPointerIds(); } } diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index ee895a905b13d..5c7dbdd6a22aa 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -101,6 +101,8 @@ class AnnotationEditor { _editToolbar = null; + _erasable = false; + _initialOptions = Object.create(null); _initialData = null; @@ -215,6 +217,10 @@ class AnnotationEditor { return Object.getPrototypeOf(this).constructor._editorType; } + get erasable() { + return this._erasable; + } + static get isDrawer() { return false; } @@ -502,6 +508,21 @@ class AnnotationEditor { this.#translate(this.parentDimensions, x, y); } + /** + * Erase everything in a radius of (x,y) position. + * @param {number} x + * @param {number} y + * @param {number} radius + */ + erase(x, y, radius) { + unreachable("Not implemented"); + } + + /** call once the erasing operation is done */ + endErase() { + unreachable("Not implemented"); + } + /** * Translate the editor position within its page and adjust the scroll * in order to have the editor in the view. diff --git a/src/display/editor/eraser.js b/src/display/editor/eraser.js new file mode 100644 index 0000000000000..edee18a2f3b84 --- /dev/null +++ b/src/display/editor/eraser.js @@ -0,0 +1,432 @@ +import { + AnnotationEditorParamsType, + AnnotationEditorType, +} from "../../shared/util.js"; +import { noContextMenu, stopEvent } from "../display_utils.js"; +import { AnnotationEditor } from "./editor.js"; +import { CurrentPointers } from "./tools.js"; + + +class EraserEditor extends AnnotationEditor { + static #currentCursorAC = null; + + static #currentEraserAC = null; + + #erasableEditors = []; + + static _defaultThickness = 20; + + static _thickness; + + static _type = "eraser"; + + static _editorType = AnnotationEditorType.ERASER; + + #cursor = null; + + #isErasing = false; + + constructor(params) { + super({ ...params, name: "eraserEditor" }); + this.defaultL10nId = "pdfjs-editor-eraser-editor"; + EraserEditor._thickness = + params.thickness || + EraserEditor._thickness || + EraserEditor._defaultThickness; + } + + /** @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, + ], + ]; + } + + /** @inheritdoc */ + render() { + if (this.div) { + return this.div; + } + + const div = super.render(); + this.fixAndSetPosition(); + this.#erasableEditors = this.#getErasableEditors(); + this.enableEditing(); + return div; + } + + /** 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 */ + enableEditing() { + super.enableEditing(); + this.div?.classList.toggle("disabled", false); + + 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.#updateCursor(); + 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(); + } + + 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, + }); + } + + isEmpty() { + return true; + } + + #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() { + const cmds = [], + undos = []; + for (const editor of this.#erasableEditors) { + const { cmd, undo } = editor.endErase(); + if (cmd && undo) { + cmds.push(cmd); + undos.push(undo); + } + } + + this.parent.addCommands({ + cmd: () => cmds.forEach(f => f()), + undo: () => undos.forEach(f => f()), + mustExec: false, + type: AnnotationEditorParamsType.ERASER_STEP, + }); + } + + #abortEraseSession() { + if (EraserEditor.#currentEraserAC) { + EraserEditor.#currentEraserAC.abort(); + EraserEditor.#currentEraserAC = null; + } + CurrentPointers.clearPointerIds(); + CurrentPointers.clearTimeStamp(); + this.#isErasing = false; + } + + #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 = ""; + } + } + + #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"; + } + + #getErasableEditors() { + const editors = this._uiManager.getEditors(this.pageIndex) || []; + return editors.filter( + ed => ed.erasable && 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; + + for (const editor of this.#erasableEditors) { + 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])) { + editor.erase(x, y, radius); + } + } + } + + #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; + } +} + +export { EraserEditor }; diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 58afba9988347..a4c3d54c2dc5e 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -60,6 +60,10 @@ class InkDrawingOptions extends DrawingOptions { * Basic draw editor in order to generate an Ink annotation. */ class InkEditor extends DrawingEditor { + #points = null; + + #erased = false; + static _type = "ink"; static _editorType = AnnotationEditorType.INK; @@ -68,6 +72,7 @@ class InkEditor extends DrawingEditor { constructor(params) { super({ ...params, name: "inkEditor" }); + this._erasable = true; this._willKeepAspectRatio = true; this.defaultL10nId = "pdfjs-editor-ink-editor"; } @@ -312,6 +317,158 @@ class InkEditor extends DrawingEditor { return null; } + + /** + * Erase everything in a radius of (x,y) position. + * @param {number} x + * @param {number} y + * @param {number} radius + */ + erase(x, y, radius) { + this.#points ||= this.serializeDraw(false).points; + + const radius2 = radius * radius; + const newPaths = []; + let modified = false; + + for (const path of this.#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]); + 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.#points = newPaths; + this.#erased = true; + // remove svg path if no points are left + if (newPaths.length === 0) { + this.parent.drawLayer.updateProperties(this._drawId, { + path: { d: "" }, + }); + } else { + const tempOutline = this.#deserializePoints(); + this.parent.drawLayer.updateProperties(this._drawId, { + path: { d: tempOutline.toSVGPath() }, + }); + } + } + } + + endErase() { + // if nothing has been erased + if (!this.#erased) { + return {}; + } + + // reset erased flag + this.#erased = false; + const oldOutline = this._drawOutlines; + const drawingOptions = { ...this._drawingOptions }; + const undo = () => { + this._addOutlines({ + drawOutlines: oldOutline, + drawId: this._drawId, + drawingOptions, + }); + }; + + if (this.#points.length === 0) { + this.remove(); + return { cmd: () => this.remove(), undo }; + } + + const newOutlines = this.#deserializePoints(); + const cmd = () => + this._addOutlines({ + drawOutlines: newOutlines, + drawId: this._drawId, + drawingOptions, + }); + cmd(); + + this.#points = null; + + return { cmd, undo }; + } + + #deserializePoints() { + const { + viewport: { + rawDims: { pageWidth, pageHeight, pageX, pageY }, + }, + } = this.parent; + + const thickness = this._drawingOptions["stroke-width"]; + const rotation = this.rotation; + + const newOutline = InkEditor.deserializeDraw( + pageX, + pageY, + pageWidth, + pageHeight, + InkEditor._INNER_MARGIN, + { + paths: { points: this.#points }, + rotation, + thickness, + } + ); + + return newOutline; + } + + #pagePointToLayer(px, py) { + const [pageX, pageY] = this.pageTranslation; + const [pageW, pageH] = this.pageDimensions; + const { width: layerW, height: layerH } = + this.parent.div.getBoundingClientRect(); + + const nx = (px - pageX) / pageW; + const ny = (py - pageY) / pageH; + + let rx, ry; + switch ((this.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 { InkDrawingOptions, InkEditor }; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 6a1239403e351..3e68471314712 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -42,6 +42,87 @@ 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; + } + + 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) { + 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 clearPointerIds() { + CurrentPointers.#pointerId = NaN; + CurrentPointers.#pointerIds = null; + } + + static clearTimeStamp() { + CurrentPointers.#moveTimestamp = NaN; + } +} + /** * Class to create some unique ids for the different editors. */ @@ -1796,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; @@ -1840,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(); @@ -2740,5 +2828,6 @@ export { bindEvents, ColorManager, CommandManager, + CurrentPointers, KeyboardManager, }; diff --git a/src/shared/util.js b/src/shared/util.js index 729c1e698c809..5c2b10582b871 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,11 +90,13 @@ const AnnotationEditorParamsType = { INK_COLOR: 21, INK_THICKNESS: 22, INK_OPACITY: 23, + ERASER_THICKNESS: 25, HIGHLIGHT_COLOR: 31, HIGHLIGHT_THICKNESS: 32, HIGHLIGHT_FREE: 33, HIGHLIGHT_SHOW_ALL: 34, DRAW_STEP: 41, + ERASER_STEP: 42, }; // Permission flags from Table 22, Section 7.6.3.2 of the PDF specification. diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 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/app.js b/web/app.js index af4ce3d5bc37a..ea680f7c46989 100644 --- a/web/app.js +++ b/web/app.js @@ -676,6 +676,10 @@ const PDFViewerApplication = { } } + if (!AppOptions.get("enableEraser")) { + appConfig.toolbar?.editorEraserButton?.parentElement.remove(); + } + if (appConfig.secondaryToolbar) { if (AppOptions.get("enableAltText")) { appConfig.secondaryToolbar.imageAltTextSettingsButton?.classList.remove( diff --git a/web/app_options.js b/web/app_options.js index 254b5cbb65f8c..58e5564abdc81 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -228,6 +228,11 @@ const defaultOptions = { value: true, kind: OptionKind.VIEWER, }, + enableEraser: { + /** @type {boolean} */ + value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableGuessAltText: { /** @type {boolean} */ value: true, diff --git a/web/images/toolbarButton-editorEraser.svg b/web/images/toolbarButton-editorEraser.svg new file mode 100644 index 0000000000000..8bf602c09c479 --- /dev/null +++ b/web/images/toolbarButton-editorEraser.svg @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 4241804476dfc..d23008c11c4cf 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -2479,7 +2479,8 @@ class PDFViewer { editId, isFromKeyboard, mustEnterInEditMode, - editComment + editComment, + true ); if ( mode !== this.#annotationEditorMode || 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 @@ +
+ + +