From ec0f9f6dcfa46e250b1f2eaa032603c69ef5741f Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 4 Jul 2022 18:04:32 +0200 Subject: [PATCH] [Editor] Dispatch an event when some global states are changing - this way the context menu in Firefox can take into account what we have in the clipboard, if an editor is selected, ... - when the user will click on a context menu item, an action will be triggered, hence this patch adds what is required to handle it; - some tests will be added in the Firefox' patch. --- src/display/editor/annotation_editor_layer.js | 18 +- src/display/editor/editor.js | 23 +- src/display/editor/freetext.js | 10 +- src/display/editor/ink.js | 16 +- src/display/editor/tools.js | 202 ++++++++++++++++-- web/app.js | 14 ++ web/base_viewer.js | 5 +- web/firefoxcom.js | 18 ++ 8 files changed, 278 insertions(+), 28 deletions(-) diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 8b7cf869d..b38770765 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -67,7 +67,7 @@ class AnnotationEditorLayer { "mac+ctrl+Backspace", "mac+alt+Backspace", ], - AnnotationEditorLayer.prototype.suppress, + AnnotationEditorLayer.prototype.delete, ], ]); @@ -128,6 +128,14 @@ class AnnotationEditorLayer { this.setActiveEditor(null); } + /** + * Set the editing state. + * @param {boolean} isEditing + */ + setEditingState(isEditing) { + this.#uiManager.setEditingState(isEditing); + } + /** * Mouseover callback. * @param {MouseEvent} event @@ -173,8 +181,8 @@ class AnnotationEditorLayer { * Suppress the selected editor or all editors. * @returns {undefined} */ - suppress() { - this.#uiManager.suppress(); + delete() { + this.#uiManager.delete(); } /** @@ -188,7 +196,7 @@ class AnnotationEditorLayer { * Cut the selected editor. */ cut() { - this.#uiManager.cut(this); + this.#uiManager.cut(); } /** @@ -196,7 +204,7 @@ class AnnotationEditorLayer { * @returns {undefined} */ paste() { - this.#uiManager.paste(this); + this.#uiManager.paste(); } /** diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 1e350c883..eb91a39b5 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -218,11 +218,27 @@ class AnnotationEditor { const [tx, ty] = this.getInitialTranslation(); this.translate(tx, ty); - bindEvents(this, this.div, ["dragstart", "focusin", "focusout"]); + bindEvents(this, this.div, [ + "dragstart", + "focusin", + "focusout", + "mousedown", + ]); return this.div; } + /** + * Onmousedown callback. + * @param {MouseEvent} event + */ + mousedown(event) { + if (event.button !== 0) { + // Avoid to focus this editor because of a non-left click. + event.preventDefault(); + } + } + getRect(tx, ty) { const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions; const [pageWidth, pageHeight] = this.parent.pageDimensions; @@ -362,6 +378,11 @@ class AnnotationEditor { * @returns {undefined} */ remove() { + if (!this.isEmpty()) { + // The editor is removed but it can be back at some point thanks to + // undo/redo so we must commit it before. + this.commit(); + } this.parent.remove(this); } diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 95561c42e..85f99ec7c 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -214,6 +214,7 @@ class FreeTextEditor extends AnnotationEditor { /** @inheritdoc */ enableEditMode() { + this.parent.setEditingState(false); this.parent.updateToolbar(AnnotationEditorType.FREETEXT); super.enableEditMode(); this.overlayDiv.classList.remove("enabled"); @@ -223,6 +224,7 @@ class FreeTextEditor extends AnnotationEditor { /** @inheritdoc */ disableEditMode() { + this.parent.setEditingState(true); super.disableEditMode(); this.overlayDiv.classList.add("enabled"); this.editorDiv.contentEditable = false; @@ -245,6 +247,12 @@ class FreeTextEditor extends AnnotationEditor { return this.editorDiv.innerText.trim() === ""; } + /** @inheritdoc */ + remove() { + this.parent.setEditingState(true); + super.remove(); + } + /** * Extract the text from this editor. * @returns {string} @@ -282,7 +290,7 @@ class FreeTextEditor extends AnnotationEditor { commit() { if (!this.#hasAlreadyBeenCommitted) { // This editor has something and it's the first time - // it's commited so we can it in the undo/redo stack. + // it's commited so we can add it in the undo/redo stack. this.#hasAlreadyBeenCommitted = true; this.parent.addUndoableEditor(this); } diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 8d6153fce..37645de38 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -211,8 +211,12 @@ class InkEditor extends AnnotationEditor { return; } + if (!this.isEmpty()) { + this.commit(); + } + // Destroy the canvas. - this.canvas.width = this.canvas.heigth = 0; + this.canvas.width = this.canvas.height = 0; this.canvas.remove(); this.canvas = null; @@ -258,7 +262,10 @@ class InkEditor extends AnnotationEditor { /** @inheritdoc */ isEmpty() { - return this.paths.length === 0; + return ( + this.paths.length === 0 || + (this.paths.length === 1 && this.paths[0].length === 0) + ); } #getInitialBBox() { @@ -415,7 +422,7 @@ class InkEditor extends AnnotationEditor { * @returns {undefined} */ canvasMousedown(event) { - if (!this.isInEditMode() || this.#disableEditing) { + if (event.button !== 0 || !this.isInEditMode() || this.#disableEditing) { return; } @@ -447,6 +454,9 @@ class InkEditor extends AnnotationEditor { * @returns {undefined} */ canvasMouseup(event) { + if (event.button !== 0) { + return; + } if (this.isInEditMode() && this.currentPath.length !== 0) { event.stopPropagation(); this.#endDrawing(event); diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index cc7b499a8..f0614e40e 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -150,6 +150,26 @@ class CommandManager { } } + /** + * Check if there is something to undo. + * @returns {boolean} + */ + hasSomethingToUndo() { + return !isNaN(this.#position); + } + + /** + * Check if there is something to redo. + * @returns {boolean} + */ + hasSomethingToRedo() { + if (isNaN(this.#position) && this.#start < this.#commands.length) { + return true; + } + const next = (this.#position + 1) % this.#maxSize; + return next !== this.#start && next < this.#commands.length; + } + #setCommands(cmds) { if (this.#commands.length < this.#maxSize) { this.#commands.push(cmds); @@ -167,6 +187,10 @@ class CommandManager { } this.#commands[this.#position] = cmds; } + + destroy() { + this.#commands = null; + } } /** @@ -279,6 +303,18 @@ class ClipboardManager { paste() { return this.element?.copy() || null; } + + /** + * Check if the clipboard is empty. + * @returns {boolean} + */ + isEmpty() { + return this.element === null; + } + + destroy() { + this.element = null; + } } class ColorManager { @@ -355,7 +391,7 @@ class AnnotationEditorUIManager { #allEditors = new Map(); - #allLayers = new Set(); + #allLayers = new Map(); #allowClick = true; @@ -377,8 +413,69 @@ class AnnotationEditorUIManager { #previousActiveEditor = null; + #boundOnEditingAction = this.onEditingAction.bind(this); + + #previousStates = { + isEditing: false, + isEmpty: true, + hasEmptyClipboard: true, + hasSomethingToUndo: false, + hasSomethingToRedo: false, + hasSelectedEditor: false, + }; + constructor(eventBus) { this.#eventBus = eventBus; + this.#eventBus._on("editingaction", this.#boundOnEditingAction); + } + + destroy() { + this.#eventBus._off("editingaction", this.#boundOnEditingAction); + for (const layer of this.#allLayers.values()) { + layer.destroy(); + } + this.#allLayers.clear(); + for (const editor of this.#allEditors.values()) { + editor.destroy(); + } + this.#allEditors.clear(); + this.#activeEditor = null; + this.#clipboardManager.destroy(); + this.#commandManager.destroy(); + } + + /** + * Execute an action for a given name. + * For example, the user can click on the "Undo" entry in the context menu + * and it'll trigger the undo action. + * @param {Object} details + */ + onEditingAction(details) { + if ( + ["undo", "redo", "cut", "copy", "paste", "delete", "selectAll"].includes( + details.name + ) + ) { + this[details.name](); + } + } + + /** + * Update the different possible states of this manager, e.g. is the clipboard + * empty or is there something to undo, ... + * @param {Object} details + */ + #dispatchUpdateStates(details) { + const hasChanged = Object.entries(details).some( + ([key, value]) => this.#previousStates[key] !== value + ); + + if (hasChanged) { + this.#eventBus.dispatch("annotationeditorstateschanged", { + source: this, + details: Object.assign(this.#previousStates, details), + }); + } } #dispatchUpdateUI(details) { @@ -388,6 +485,29 @@ class AnnotationEditorUIManager { }); } + /** + * Set the editing state. + * It can be useful to temporarily disable it when the user is editing a + * FreeText annotation. + * @param {boolean} isEditing + */ + setEditingState(isEditing) { + if (isEditing) { + this.#dispatchUpdateStates({ + isEditing: this.#mode !== AnnotationEditorType.NONE, + isEmpty: this.#isEmpty(), + hasSomethingToUndo: this.#commandManager.hasSomethingToUndo(), + hasSomethingToRedo: this.#commandManager.hasSomethingToRedo(), + hasSelectedEditor: false, + hasEmptyClipboard: this.#clipboardManager.isEmpty(), + }); + } else { + this.#dispatchUpdateStates({ + isEditing: false, + }); + } + } + registerEditorTypes(types) { this.#editorTypes = types; for (const editorType of this.#editorTypes) { @@ -408,7 +528,7 @@ class AnnotationEditorUIManager { * @param {AnnotationEditorLayer} layer */ addLayer(layer) { - this.#allLayers.add(layer); + this.#allLayers.set(layer.pageIndex, layer); if (this.#isEnabled) { layer.enable(); } else { @@ -421,7 +541,7 @@ class AnnotationEditorUIManager { * @param {AnnotationEditorLayer} layer */ removeLayer(layer) { - this.#allLayers.delete(layer); + this.#allLayers.delete(layer.pageIndex); } /** @@ -431,10 +551,12 @@ class AnnotationEditorUIManager { updateMode(mode) { this.#mode = mode; if (mode === AnnotationEditorType.NONE) { + this.setEditingState(false); this.#disableAll(); } else { + this.setEditingState(true); this.#enableAll(); - for (const layer of this.#allLayers) { + for (const layer of this.#allLayers.values()) { layer.updateMode(mode); } } @@ -476,7 +598,7 @@ class AnnotationEditorUIManager { #enableAll() { if (!this.#isEnabled) { this.#isEnabled = true; - for (const layer of this.#allLayers) { + for (const layer of this.#allLayers.values()) { layer.enable(); } } @@ -488,7 +610,7 @@ class AnnotationEditorUIManager { #disableAll() { if (this.#isEnabled) { this.#isEnabled = false; - for (const layer of this.#allLayers) { + for (const layer of this.#allLayers.values()) { layer.disable(); } } @@ -534,6 +656,19 @@ class AnnotationEditorUIManager { this.#allEditors.delete(editor.id); } + /** + * Add an editor to the layer it belongs to or add it to the global map. + * @param {AnnotationEditor} editor + */ + #addEditorToLayer(editor) { + const layer = this.#allLayers.get(editor.pageIndex); + if (layer) { + layer.addOrRebuild(editor); + } else { + this.addEditor(editor); + } + } + /** * Set the given editor as the active one. * @param {AnnotationEditor} editor @@ -548,7 +683,9 @@ class AnnotationEditorUIManager { this.#activeEditor = editor; if (editor) { this.#dispatchUpdateUI(editor.propertiesToUpdate); + this.#dispatchUpdateStates({ hasSelectedEditor: true }); } else { + this.#dispatchUpdateStates({ hasSelectedEditor: false }); if (this.#previousActiveEditor) { this.#dispatchUpdateUI(this.#previousActiveEditor.propertiesToUpdate); } else { @@ -564,6 +701,11 @@ class AnnotationEditorUIManager { */ undo() { this.#commandManager.undo(); + this.#dispatchUpdateStates({ + hasSomethingToUndo: this.#commandManager.hasSomethingToUndo(), + hasSomethingToRedo: true, + isEmpty: this.#isEmpty(), + }); } /** @@ -571,6 +713,11 @@ class AnnotationEditorUIManager { */ redo() { this.#commandManager.redo(); + this.#dispatchUpdateStates({ + hasSomethingToUndo: true, + hasSomethingToRedo: this.#commandManager.hasSomethingToRedo(), + isEmpty: this.#isEmpty(), + }); } /** @@ -579,6 +726,25 @@ class AnnotationEditorUIManager { */ addCommands(params) { this.#commandManager.add(params); + this.#dispatchUpdateStates({ + hasSomethingToUndo: true, + hasSomethingToRedo: false, + isEmpty: this.#isEmpty(), + }); + } + + #isEmpty() { + if (this.#allEditors.size === 0) { + return true; + } + + if (this.#allEditors.size === 1) { + for (const editor of this.#allEditors.values()) { + return editor.isEmpty(); + } + } + + return false; } /** @@ -608,10 +774,9 @@ class AnnotationEditorUIManager { } /** - * Suppress some editors from the given layer. - * @param {AnnotationEditorLayer} layer + * Delete the current editor or all. */ - suppress(layer) { + delete() { let cmd, undo; if (this.#isAllSelected) { const editors = Array.from(this.#allEditors.values()); @@ -623,7 +788,7 @@ class AnnotationEditorUIManager { undo = () => { for (const editor of editors) { - layer.addOrRebuild(editor); + this.#addEditorToLayer(editor); } }; @@ -637,7 +802,7 @@ class AnnotationEditorUIManager { editor.remove(); }; undo = () => { - layer.addOrRebuild(editor); + this.#addEditorToLayer(editor); }; } @@ -650,14 +815,14 @@ class AnnotationEditorUIManager { copy() { if (this.#activeEditor) { this.#clipboardManager.copy(this.#activeEditor); + this.#dispatchUpdateStates({ hasEmptyClipboard: false }); } } /** * Cut the selected editor. - * @param {AnnotationEditorLayer} */ - cut(layer) { + cut() { if (this.#activeEditor) { this.#clipboardManager.copy(this.#activeEditor); const editor = this.#activeEditor; @@ -665,7 +830,7 @@ class AnnotationEditorUIManager { editor.remove(); }; const undo = () => { - layer.addOrRebuild(editor); + this.#addEditorToLayer(editor); }; this.addCommands({ cmd, undo, mustExec: true }); @@ -674,16 +839,16 @@ class AnnotationEditorUIManager { /** * Paste a previously copied editor. - * @param {AnnotationEditorLayer} * @returns {undefined} */ - paste(layer) { + paste() { const editor = this.#clipboardManager.paste(); if (!editor) { return; } + // TODO: paste in the current visible layer. const cmd = () => { - layer.addOrRebuild(editor); + this.#addEditorToLayer(editor); }; const undo = () => { editor.remove(); @@ -700,6 +865,7 @@ class AnnotationEditorUIManager { for (const editor of this.#allEditors.values()) { editor.select(); } + this.#dispatchUpdateStates({ hasSelectedEditor: true }); } /** @@ -707,9 +873,11 @@ class AnnotationEditorUIManager { */ unselectAll() { this.#isAllSelected = false; + for (const editor of this.#allEditors.values()) { editor.unselect(); } + this.#dispatchUpdateStates({ hasSelectedEditor: this.hasActive() }); } /** diff --git a/web/app.js b/web/app.js index c60405522..b4b95ca0b 100644 --- a/web/app.js +++ b/web/app.js @@ -186,6 +186,10 @@ class DefaultExternalServices { static get isInAutomation() { return shadow(this, "isInAutomation", false); } + + static updateEditorStates(data) { + throw new Error("Not implemented: updateEditorStates"); + } } const PDFViewerApplication = { @@ -1954,6 +1958,12 @@ const PDFViewerApplication = { eventBus._on("fileinputchange", webViewerFileInputChange); eventBus._on("openfile", webViewerOpenFile); } + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) { + eventBus._on( + "annotationeditorstateschanged", + webViewerAnnotationEditorStatesChanged + ); + } }, bindWindowEvents() { @@ -3076,6 +3086,10 @@ function beforeUnload(evt) { return false; } +function webViewerAnnotationEditorStatesChanged(data) { + PDFViewerApplication.externalServices.updateEditorStates(data); +} + /* Abstract factory for the print service. */ const PDFPrintServiceFactory = { instance: { diff --git a/web/base_viewer.js b/web/base_viewer.js index f1aafadcf..ff8bab134 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -640,6 +640,10 @@ class BaseViewer { if (this._scriptingManager) { this._scriptingManager.setDocument(null); } + if (this.#annotationEditorUIManager) { + this.#annotationEditorUIManager.destroy(); + this.#annotationEditorUIManager = null; + } } this.pdfDocument = pdfDocument; @@ -899,7 +903,6 @@ class BaseViewer { } _resetView() { - this.#annotationEditorUIManager = null; this._pages = []; this._currentPageNumber = 1; this._currentScale = UNKNOWN_SCALE; diff --git a/web/firefoxcom.js b/web/firefoxcom.js index 5819efcb8..0e5172b53 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -271,6 +271,20 @@ class MozL10n { window.addEventListener("save", handleEvent); })(); +(function listenEditingEvent() { + const handleEvent = function ({ detail }) { + if (!PDFViewerApplication.initialized) { + return; + } + PDFViewerApplication.eventBus.dispatch("editingaction", { + source: window, + name: detail.name, + }); + }; + + window.addEventListener("editingaction", handleEvent); +})(); + class FirefoxComDataRangeTransport extends PDFDataRangeTransport { requestDataRange(begin, end) { FirefoxCom.request("requestDataRange", { begin, end }); @@ -384,6 +398,10 @@ class FirefoxExternalServices extends DefaultExternalServices { return new FirefoxPreferences(); } + static updateEditorStates(data) { + FirefoxCom.request("updateEditorStates", data); + } + static createL10n(options) { const mozL10n = document.mozL10n; // TODO refactor mozL10n.setExternalLocalizerServices