From d6b9ca48a53507ebf8903f5f15b3ca9d14ea1585 Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
Date: Thu, 21 Jul 2022 10:42:15 +0200
Subject: [PATCH] [Editor] Add the ability to make multiple selections (bug
 1779582)

- several editors can be selected/unselected using ctrl+click;
- and then they can be copied, pasted, their properties can be changed.
---
 src/display/editor/annotation_editor_layer.js |  69 ++++--
 src/display/editor/editor.js                  | 120 +++++++---
 src/display/editor/freetext.js                |  18 ++
 src/display/editor/ink.js                     |  19 +-
 src/display/editor/tools.js                   | 211 +++++++++++-------
 test/integration/freetext_editor_spec.js      | 172 ++++++++++++++
 web/annotation_editor_layer_builder.css       |  31 +--
 7 files changed, 490 insertions(+), 150 deletions(-)

diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js
index 497ac484d..5205c82a2 100644
--- a/src/display/editor/annotation_editor_layer.js
+++ b/src/display/editor/annotation_editor_layer.js
@@ -136,7 +136,7 @@ class AnnotationEditorLayer {
     } else {
       this.enableClick();
     }
-    this.setActiveEditor(null);
+    this.#uiManager.unselectAll();
   }
 
   addInkEditorIfNeeded(isCommitting) {
@@ -210,14 +210,6 @@ class AnnotationEditorLayer {
     }
 
     this.#uiManager.setActiveEditor(editor);
-
-    if (currentActive && currentActive !== editor) {
-      currentActive.commitOrRemove();
-    }
-
-    if (editor) {
-      this.#uiManager.unselectAll();
-    }
   }
 
   enableClick() {
@@ -250,11 +242,19 @@ class AnnotationEditorLayer {
     this.#uiManager.removeEditor(editor);
     this.detach(editor);
     this.annotationStorage.removeKey(editor.id);
-    editor.div.remove();
-    editor.isAttachedToDOM = false;
-    if (this.#uiManager.isActive(editor) || this.#editors.size === 0) {
-      this.setActiveEditor(null);
-    }
+    editor.div.style.display = "none";
+    setTimeout(() => {
+      // When the div is removed from DOM the focus can move on the
+      // document.body, so we just slightly postpone the removal in
+      // order to let an element potentially grab the focus before
+      // the body.
+      editor.div.style.display = "";
+      editor.div.remove();
+      editor.isAttachedToDOM = false;
+      if (document.activeElement === document.body) {
+        this.#uiManager.focusMainContainer();
+      }
+    }, 0);
 
     if (!this.#isCleaningUp) {
       this.addInkEditorIfNeeded(/* isCommitting = */ false);
@@ -271,10 +271,6 @@ class AnnotationEditorLayer {
       return;
     }
 
-    if (this.#uiManager.isActive(editor)) {
-      editor.parent?.setActiveEditor(null);
-    }
-
     this.attach(editor);
     editor.pageIndex = this.pageIndex;
     editor.parent?.detach(editor);
@@ -546,6 +542,42 @@ class AnnotationEditorLayer {
     return editor;
   }
 
+  /**
+   * Set the last selected editor.
+   * @param {AnnotationEditor} editor
+   */
+  setSelected(editor) {
+    this.#uiManager.setSelected(editor);
+  }
+
+  /**
+   * Check if the editor is selected.
+   * @param {AnnotationEditor} editor
+   */
+  isSelected(editor) {
+    return this.#uiManager.isSelected(editor);
+  }
+
+  /**
+   * Unselect an editor.
+   * @param {AnnotationEditor} editor
+   */
+  unselect(editor) {
+    this.#uiManager.unselect(editor);
+  }
+
+  get isMultipleSelection() {
+    return this.#uiManager.isMultipleSelection;
+  }
+
+  /**
+   * An editor just got a mousedown with ctrl key pressed.
+   * @param {boolean}} isMultiple
+   */
+  set isMultipleSelection(isMultiple) {
+    this.#uiManager.isMultipleSelection = isMultiple;
+  }
+
   /**
    * Mouseclick callback.
    * @param {MouseEvent} event
@@ -662,7 +694,6 @@ class AnnotationEditorLayer {
    * @param {Object} parameters
    */
   update(parameters) {
-    this.setActiveEditor(null);
     this.viewport = parameters.viewport;
     this.setDimensions();
     this.updateMode();
diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js
index e906faa3f..aaa33f9e4 100644
--- a/src/display/editor/editor.js
+++ b/src/display/editor/editor.js
@@ -16,12 +16,8 @@
 // eslint-disable-next-line max-len
 /** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
 
-import {
-  AnnotationEditorPrefix,
-  shadow,
-  unreachable,
-} from "../../shared/util.js";
 import { bindEvents, ColorManager } from "./tools.js";
+import { shadow, unreachable } from "../../shared/util.js";
 
 /**
  * @typedef {Object} AnnotationEditorParameters
@@ -35,8 +31,20 @@ import { bindEvents, ColorManager } from "./tools.js";
  * Base class for editors.
  */
 class AnnotationEditor {
+  #boundFocusin = this.focusin.bind(this);
+
+  #boundFocusout = this.focusout.bind(this);
+
+  #isEditing = false;
+
+  #isFocused = false;
+
   #isInEditMode = false;
 
+  #wasSelected = false;
+
+  #wasFocused = false;
+
   #zIndex = AnnotationEditor._zIndex++;
 
   static _colorManager = new ColorManager();
@@ -88,17 +96,32 @@ class AnnotationEditor {
     this.div.style.zIndex = this.#zIndex;
   }
 
+  #select() {
+    if (this.#wasSelected) {
+      this.parent.unselect(this);
+      this.unselect();
+      this.#wasSelected = true;
+    } else {
+      this.parent.setSelected(this);
+      this.select();
+    }
+  }
+
   /**
    * onfocus callback.
    */
-  focusin(/* event */) {
-    this.parent.setActiveEditor(this);
+  focusin(event) {
+    this.#isFocused =
+      event.target === this.div ||
+      !!event.relatedTarget?.closest(`#${this.id}`);
+    if (event.target === this.div) {
+      this.#select();
+    }
   }
 
   /**
    * onblur callback.
    * @param {FocusEvent} event
-   * @returns {undefined}
    */
   focusout(event) {
     if (!this.isAttachedToDOM) {
@@ -116,10 +139,14 @@ class AnnotationEditor {
 
     event.preventDefault();
 
-    this.commitOrRemove();
-
-    if (!target?.id?.startsWith(AnnotationEditorPrefix)) {
-      this.parent.setActiveEditor(null);
+    this.#isFocused = false;
+    if (!this.parent.isMultipleSelection) {
+      this.commitOrRemove();
+      if (target?.closest(".annotationEditorLayer")) {
+        // We only unselect the element when another editor (or its parent)
+        // is grabbing the focus.
+        this.parent.unselect(this);
+      }
     }
   }
 
@@ -228,15 +255,13 @@ class AnnotationEditor {
 
     this.setInForeground();
 
+    this.div.addEventListener("focusin", this.#boundFocusin);
+    this.div.addEventListener("focusout", this.#boundFocusout);
+
     const [tx, ty] = this.getInitialTranslation();
     this.translate(tx, ty);
 
-    bindEvents(this, this.div, [
-      "dragstart",
-      "focusin",
-      "focusout",
-      "mousedown",
-    ]);
+    bindEvents(this, this.div, ["dragstart", "mousedown", "mouseup"]);
 
     return this.div;
   }
@@ -250,6 +275,23 @@ class AnnotationEditor {
       // Avoid to focus this editor because of a non-left click.
       event.preventDefault();
     }
+
+    const isMultipleSelection = (this.parent.isMultipleSelection =
+      event.ctrlKey || event.shiftKey);
+    this.#wasSelected = isMultipleSelection && this.parent.isSelected(this);
+    this.#wasFocused = this.#isFocused;
+  }
+
+  /**
+   * Onmouseup callback.
+   * @param {MouseEvent} event
+   */
+  mouseup(event) {
+    if (this.#wasFocused) {
+      this.#select();
+    }
+    this.parent.isMultipleSelection = false;
+    this.#wasFocused = false;
   }
 
   getRect(tx, ty) {
@@ -331,7 +373,6 @@ class AnnotationEditor {
 
   /**
    * Enable edit mode.
-   * @returns {undefined}
    */
   enableEditMode() {
     this.#isInEditMode = true;
@@ -339,7 +380,6 @@ class AnnotationEditor {
 
   /**
    * Disable edit mode.
-   * @returns {undefined}
    */
   disableEditMode() {
     this.#isInEditMode = false;
@@ -374,10 +414,9 @@ class AnnotationEditor {
    * Rebuild the editor in case it has been removed on undo.
    *
    * To implement in subclasses.
-   * @returns {undefined}
    */
   rebuild() {
-    unreachable("An editor must be rebuildable");
+    this.div?.addEventListener("focusin", this.#boundFocusin);
   }
 
   /**
@@ -386,7 +425,6 @@ class AnnotationEditor {
    * new annotation to add to the pdf document.
    *
    * To implement in subclasses.
-   * @returns {undefined}
    */
   serialize() {
     unreachable("An editor must be serializable");
@@ -423,10 +461,11 @@ class AnnotationEditor {
   /**
    * Remove this editor.
    * It's used on ctrl+backspace action.
-   *
-   * @returns {undefined}
    */
   remove() {
+    this.div.removeEventListener("focusin", this.#boundFocusin);
+    this.div.removeEventListener("focusout", this.#boundFocusout);
+
     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.
@@ -439,18 +478,14 @@ class AnnotationEditor {
    * Select this editor.
    */
   select() {
-    if (this.div) {
-      this.div.classList.add("selectedEditor");
-    }
+    this.div?.classList.add("selectedEditor");
   }
 
   /**
    * Unselect this editor.
    */
   unselect() {
-    if (this.div) {
-      this.div.classList.remove("selectedEditor");
-    }
+    this.div?.classList.remove("selectedEditor");
   }
 
   /**
@@ -494,6 +529,29 @@ class AnnotationEditor {
   get contentDiv() {
     return this.div;
   }
+
+  /**
+   * If true then the editor is currently edited.
+   * @type {boolean}
+   */
+  get isEditing() {
+    return this.#isEditing;
+  }
+
+  /**
+   * When set to true, it means that this editor is currently edited.
+   * @param {boolean} value
+   */
+  set isEditing(value) {
+    this.#isEditing = value;
+    if (value) {
+      this.select();
+      this.parent.setSelected(this);
+      this.parent.setActiveEditor(this);
+    } else {
+      this.parent.setActiveEditor(null);
+    }
+  }
 }
 
 export { AnnotationEditor };
diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js
index 8fe3761f0..ddd1f944f 100644
--- a/src/display/editor/freetext.js
+++ b/src/display/editor/freetext.js
@@ -30,6 +30,10 @@ import { AnnotationEditor } from "./editor.js";
  * Basic text editor in order to create a FreeTex annotation.
  */
 class FreeTextEditor extends AnnotationEditor {
+  #boundEditorDivBlur = this.editorDivBlur.bind(this);
+
+  #boundEditorDivFocus = this.editorDivFocus.bind(this);
+
   #boundEditorDivKeydown = this.editorDivKeydown.bind(this);
 
   #color;
@@ -199,6 +203,7 @@ class FreeTextEditor extends AnnotationEditor {
 
   /** @inheritdoc */
   rebuild() {
+    super.rebuild();
     if (this.div === null) {
       return;
     }
@@ -220,6 +225,8 @@ class FreeTextEditor extends AnnotationEditor {
     this.div.draggable = false;
     this.div.removeAttribute("tabIndex");
     this.editorDiv.addEventListener("keydown", this.#boundEditorDivKeydown);
+    this.editorDiv.addEventListener("focus", this.#boundEditorDivFocus);
+    this.editorDiv.addEventListener("blur", this.#boundEditorDivBlur);
   }
 
   /** @inheritdoc */
@@ -231,6 +238,8 @@ class FreeTextEditor extends AnnotationEditor {
     this.div.draggable = true;
     this.div.tabIndex = 0;
     this.editorDiv.removeEventListener("keydown", this.#boundEditorDivKeydown);
+    this.editorDiv.removeEventListener("focus", this.#boundEditorDivFocus);
+    this.editorDiv.removeEventListener("blur", this.#boundEditorDivBlur);
   }
 
   /** @inheritdoc */
@@ -251,6 +260,7 @@ class FreeTextEditor extends AnnotationEditor {
 
   /** @inheritdoc */
   remove() {
+    this.isEditing = false;
     this.parent.setEditingState(true);
     super.remove();
   }
@@ -333,6 +343,14 @@ class FreeTextEditor extends AnnotationEditor {
     FreeTextEditor._keyboardManager.exec(this, event);
   }
 
+  editorDivFocus(event) {
+    this.isEditing = true;
+  }
+
+  editorDivBlur(event) {
+    this.isEditing = false;
+  }
+
   /** @inheritdoc */
   disableEditing() {
     this.editorDiv.setAttribute("role", "comment");
diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js
index d77ef5aec..5f3d72cbe 100644
--- a/src/display/editor/ink.js
+++ b/src/display/editor/ink.js
@@ -123,8 +123,16 @@ class InkEditor extends AnnotationEditor {
   /** @inheritdoc */
   get propertiesToUpdate() {
     return [
-      [AnnotationEditorParamsType.INK_THICKNESS, this.thickness],
-      [AnnotationEditorParamsType.INK_COLOR, this.color],
+      [
+        AnnotationEditorParamsType.INK_THICKNESS,
+        this.thickness || InkEditor._defaultThickness,
+      ],
+      [
+        AnnotationEditorParamsType.INK_COLOR,
+        this.color ||
+          InkEditor._defaultColor ||
+          AnnotationEditor._defaultLineColor,
+      ],
     ];
   }
 
@@ -174,6 +182,7 @@ class InkEditor extends AnnotationEditor {
 
   /** @inheritdoc */
   rebuild() {
+    super.rebuild();
     if (this.div === null) {
       return;
     }
@@ -284,6 +293,7 @@ class InkEditor extends AnnotationEditor {
    * @param {number} y
    */
   #startDrawing(x, y) {
+    this.isEditing = true;
     if (!this.#isCanvasInitialized) {
       this.#isCanvasInitialized = true;
       this.#setCanvasDims();
@@ -390,6 +400,7 @@ class InkEditor extends AnnotationEditor {
       return;
     }
 
+    this.isEditing = false;
     this.disableEditMode();
 
     // This editor must be on top of the main ink editor.
@@ -408,8 +419,8 @@ class InkEditor extends AnnotationEditor {
   }
 
   /** @inheritdoc */
-  focusin(/* event */) {
-    super.focusin();
+  focusin(event) {
+    super.focusin(event);
     this.enableEditMode();
   }
 
diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js
index 068861c46..32478508d 100644
--- a/src/display/editor/tools.js
+++ b/src/display/editor/tools.js
@@ -377,19 +377,19 @@ class AnnotationEditorUIManager {
 
   #currentPageIndex = 0;
 
+  #isMultipleSelection = false;
+
   #editorTypes = null;
 
   #eventBus = null;
 
   #idManager = new IdManager();
 
-  #isAllSelected = false;
-
   #isEnabled = false;
 
   #mode = AnnotationEditorType.NONE;
 
-  #previousActiveEditor = null;
+  #selectedEditors = new Set();
 
   #boundKeydown = this.keydown.bind(this);
 
@@ -435,6 +435,7 @@ class AnnotationEditorUIManager {
       ],
       AnnotationEditorUIManager.prototype.delete,
     ],
+    [["Escape"], AnnotationEditorUIManager.prototype.unselectAll],
   ]);
 
   constructor(container, eventBus) {
@@ -456,6 +457,7 @@ class AnnotationEditorUIManager {
     this.#allLayers.clear();
     this.#allEditors.clear();
     this.#activeEditor = null;
+    this.#selectedEditors.clear();
     this.#clipboardManager.destroy();
     this.#commandManager.destroy();
   }
@@ -470,6 +472,10 @@ class AnnotationEditorUIManager {
     layer?.onTextLayerRendered();
   }
 
+  focusMainContainer() {
+    this.#container.focus();
+  }
+
   #addKeyboardManager() {
     // The keyboard events are caught at the container level in order to be able
     // to execute some callbacks even if the current page doesn't have focus.
@@ -631,10 +637,10 @@ class AnnotationEditorUIManager {
    * @param {*} value
    */
   updateParams(type, value) {
-    (this.#activeEditor || this.#previousActiveEditor)?.updateParams(
-      type,
-      value
-    );
+    for (const editor of this.#selectedEditors) {
+      editor.updateParams(type, value);
+    }
+
     for (const editorType of this.#editorTypes) {
       editorType.updateDefaultParams(type, value);
     }
@@ -656,6 +662,7 @@ class AnnotationEditorUIManager {
    * Disable all the layers.
    */
   #disableAll() {
+    this.unselectAll();
     if (this.#isEnabled) {
       this.#isEnabled = false;
       for (const layer of this.#allLayers.values()) {
@@ -702,6 +709,9 @@ class AnnotationEditorUIManager {
    */
   removeEditor(editor) {
     this.#allEditors.delete(editor.id);
+    if (this.hasSelection) {
+      this.#selectedEditors.delete(editor);
+    }
   }
 
   /**
@@ -726,24 +736,80 @@ class AnnotationEditorUIManager {
       return;
     }
 
-    this.#previousActiveEditor = this.#activeEditor;
-
     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 {
-        for (const editorType of this.#editorTypes) {
-          this.#dispatchUpdateUI(editorType.defaultPropertiesToUpdate);
-        }
-      }
     }
   }
 
+  /**
+   * Set the last selected editor.
+   * @param {AnnotationEditor} editor
+   */
+  setSelected(editor) {
+    if (!this.#isMultipleSelection) {
+      if (this.#selectedEditors.has(editor)) {
+        if (this.#selectedEditors.size > 1) {
+          for (const ed of this.#selectedEditors) {
+            if (ed !== editor) {
+              ed.unselect();
+            }
+          }
+          this.#selectedEditors.clear();
+          this.#selectedEditors.add(editor);
+          this.#dispatchUpdateUI(editor.propertiesToUpdate);
+        }
+        return;
+      }
+
+      for (const ed of this.#selectedEditors) {
+        ed.unselect();
+      }
+      this.#selectedEditors.clear();
+    }
+    this.#selectedEditors.add(editor);
+    this.#dispatchUpdateUI(editor.propertiesToUpdate);
+    this.#dispatchUpdateStates({
+      hasSelectedEditor: this.hasSelection,
+    });
+  }
+
+  /**
+   * Check if the editor is selected.
+   * @param {AnnotationEditor} editor
+   */
+  isSelected(editor) {
+    return this.#selectedEditors.has(editor);
+  }
+
+  /**
+   * Unselect an editor.
+   * @param {AnnotationEditor} editor
+   */
+  unselect(editor) {
+    editor.unselect();
+    this.#selectedEditors.delete(editor);
+    this.#dispatchUpdateStates({
+      hasSelectedEditor: this.hasSelection,
+    });
+  }
+
+  get hasSelection() {
+    return this.#selectedEditors.size !== 0;
+  }
+
+  get isMultipleSelection() {
+    return this.#isMultipleSelection;
+  }
+
+  /**
+   * An editor just got a mousedown with ctrl key pressed.
+   * @param {boolean} isMultiple
+   */
+  set isMultipleSelection(isMultiple) {
+    this.#isMultipleSelection = isMultiple;
+  }
+
   /**
    * Undo the last command.
    */
@@ -795,52 +861,26 @@ class AnnotationEditorUIManager {
     return false;
   }
 
-  /**
-   * Unselect the current editor.
-   */
-  unselect() {
-    if (this.#activeEditor) {
-      this.#activeEditor.parent.setActiveEditor(null);
-    }
-  }
-
   /**
    * Delete the current editor or all.
    */
   delete() {
-    let cmd, undo;
-    if (this.#isAllSelected) {
-      this.#previousActiveEditor = this.#activeEditor = null;
-      const editors = Array.from(this.#allEditors.values());
-      cmd = () => {
-        for (const editor of editors) {
-          if (!editor.isEmpty()) {
-            editor.remove();
-          }
-        }
-      };
-
-      undo = () => {
-        for (const editor of editors) {
-          this.#addEditorToLayer(editor);
-        }
-      };
-
-      this.addCommands({ cmd, undo, mustExec: true });
-    } else {
-      if (!this.#activeEditor) {
-        return;
-      }
-      const editor = this.#activeEditor;
-      this.#previousActiveEditor = this.#activeEditor = null;
-      cmd = () => {
-        editor.remove();
-      };
-      undo = () => {
-        this.#addEditorToLayer(editor);
-      };
+    if (!this.hasSelection) {
+      return;
     }
 
+    const editors = [...this.#selectedEditors];
+    const cmd = () => {
+      for (const editor of editors) {
+        editor.remove();
+      }
+    };
+    const undo = () => {
+      for (const editor of editors) {
+        this.#addEditorToLayer(editor);
+      }
+    };
+
     this.addCommands({ cmd, undo, mustExec: true });
   }
 
@@ -848,8 +888,8 @@ class AnnotationEditorUIManager {
    * Copy the selected editor.
    */
   copy() {
-    if (this.#activeEditor) {
-      this.#clipboardManager.copy(this.#activeEditor);
+    if (this.hasSelection) {
+      this.#clipboardManager.copy([...this.#selectedEditors]);
       this.#dispatchUpdateStates({ hasEmptyClipboard: false });
     }
   }
@@ -858,10 +898,8 @@ class AnnotationEditorUIManager {
    * Cut the selected editor.
    */
   cut() {
-    if (this.#activeEditor) {
-      this.#clipboardManager.copy(this.#activeEditor);
-      this.delete();
-    }
+    this.copy();
+    this.delete();
   }
 
   /**
@@ -873,42 +911,63 @@ class AnnotationEditorUIManager {
       return;
     }
 
+    this.unselectAll();
+
     const layer = this.#allLayers.get(this.#currentPageIndex);
     const newEditors = this.#clipboardManager
       .paste()
       .map(data => layer.deserialize(data));
 
     const cmd = () => {
-      newEditors.map(editor => this.#addEditorToLayer(editor));
+      for (const editor of newEditors) {
+        this.#addEditorToLayer(editor);
+      }
+      this.#selectEditors(newEditors);
     };
     const undo = () => {
-      newEditors.map(editor => editor.remove());
+      for (const editor of newEditors) {
+        editor.remove();
+      }
     };
     this.addCommands({ cmd, undo, mustExec: true });
   }
 
   /**
-   * Select all the editors.
+   * Select the editors.
+   * @param {Array<AnnotationEditor>} editors
    */
-  selectAll() {
-    this.#isAllSelected = true;
-    for (const editor of this.#allEditors.values()) {
+  #selectEditors(editors) {
+    this.#selectedEditors.clear();
+    for (const editor of editors) {
+      if (editor.isEmpty()) {
+        continue;
+      }
+      this.#selectedEditors.add(editor);
       editor.select();
     }
     this.#dispatchUpdateStates({ hasSelectedEditor: true });
   }
 
   /**
-   * Unselect all the editors.
+   * Select all the editors.
+   */
+  selectAll() {
+    for (const editor of this.#selectedEditors) {
+      editor.commit();
+    }
+    this.#selectEditors(this.#allEditors.values());
+  }
+
+  /**
+   * Unselect all the selected editors.
    */
   unselectAll() {
-    this.#isAllSelected = false;
-
-    for (const editor of this.#allEditors.values()) {
+    for (const editor of this.#selectedEditors) {
       editor.unselect();
     }
+    this.#selectedEditors.clear();
     this.#dispatchUpdateStates({
-      hasSelectedEditor: this.#activeEditor !== null,
+      hasSelectedEditor: false,
     });
   }
 
diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js
index e72c62867..722ba2cad 100644
--- a/test/integration/freetext_editor_spec.js
+++ b/test/integration/freetext_editor_spec.js
@@ -252,4 +252,176 @@ describe("Editor", () => {
       );
     });
   });
+
+  describe("FreeText (multiselection)", () => {
+    let pages;
+
+    beforeAll(async () => {
+      pages = await loadAndWait("tracemonkey.pdf", ".annotationEditorLayer");
+    });
+
+    afterAll(async () => {
+      await closePages(pages);
+    });
+
+    function getSelected(page) {
+      return page.evaluate(prefix => {
+        const elements = document.querySelectorAll(".selectedEditor");
+        const results = [];
+        for (const element of elements) {
+          results.push(parseInt(element.id.slice(prefix.length)));
+        }
+        results.sort();
+        return results;
+      }, editorPrefix.slice(1));
+    }
+
+    it("must select/unselect several editors and check copy, paste and delete operations", async () => {
+      await Promise.all(
+        pages.map(async ([browserName, page]) => {
+          await page.click("#editorFreeText");
+
+          const rect = await page.$eval(".annotationEditorLayer", el => {
+            // With Chrome something is wrong when serializing a DomRect,
+            // hence we extract the values and just return them.
+            const { x, y } = el.getBoundingClientRect();
+            return { x, y };
+          });
+
+          const editorCenters = [];
+          for (let i = 0; i < 4; i++) {
+            const data = `FreeText ${i}`;
+            await page.mouse.click(
+              rect.x + (i + 1) * 100,
+              rect.y + (i + 1) * 100
+            );
+            await page.type(`${editorPrefix}${i} .internal`, data);
+
+            const editorRect = await page.$eval(`${editorPrefix}${i}`, el => {
+              const { x, y, width, height } = el.getBoundingClientRect();
+              return {
+                x,
+                y,
+                width,
+                height,
+              };
+            });
+            editorCenters.push({
+              x: editorRect.x + editorRect.width / 2,
+              y: editorRect.y + editorRect.height / 2,
+            });
+
+            // Commit.
+            await page.mouse.click(
+              editorRect.x,
+              editorRect.y + 2 * editorRect.height
+            );
+          }
+
+          await page.keyboard.down("Control");
+          await page.keyboard.press("a");
+          await page.keyboard.up("Control");
+
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([0, 1, 2, 3]);
+
+          await page.keyboard.down("Control");
+          await page.mouse.click(editorCenters[1].x, editorCenters[1].y);
+
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([0, 2, 3]);
+
+          await page.mouse.click(editorCenters[2].x, editorCenters[2].y);
+
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([0, 3]);
+
+          await page.mouse.click(editorCenters[1].x, editorCenters[1].y);
+          await page.keyboard.up("Control");
+
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([0, 1, 3]);
+
+          await page.keyboard.down("Control");
+          await page.keyboard.press("c");
+          await page.keyboard.up("Control");
+
+          await page.keyboard.down("Control");
+          await page.keyboard.press("v");
+          await page.keyboard.up("Control");
+
+          // 0,1,3 are unselected and new pasted editors are selected.
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([4, 5, 6]);
+
+          // No ctrl here, hence all are unselected and 2 is selected.
+          await page.mouse.click(editorCenters[2].x, editorCenters[2].y);
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([2]);
+
+          await page.mouse.click(editorCenters[1].x, editorCenters[1].y);
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([1]);
+
+          await page.keyboard.down("Control");
+
+          await page.mouse.click(editorCenters[3].x, editorCenters[3].y);
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([1, 3]);
+
+          await page.keyboard.up("Control");
+
+          // Delete 1 and 3.
+          await page.keyboard.press("Backspace");
+
+          await page.keyboard.down("Control");
+          await page.keyboard.press("a");
+          await page.keyboard.up("Control");
+
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([0, 2, 4, 5, 6]);
+
+          // Create an empty editor.
+          await page.mouse.click(rect.x + 700, rect.y + 100);
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([7]);
+
+          // Set the focus to 2 and check that only 2 is selected.
+          await page.mouse.click(editorCenters[2].x, editorCenters[2].y);
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([2]);
+
+          // Create an empty editor.
+          await page.mouse.click(rect.x + 700, rect.y + 100);
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([8]);
+          // Dismiss it.
+          await page.keyboard.press("Escape");
+
+          // Select all.
+          await page.keyboard.down("Control");
+          await page.keyboard.press("a");
+          await page.keyboard.up("Control");
+
+          // Check that all the editors are correctly selected (and the focus
+          // didn't move to the body when the empty editor was removed).
+          expect(await getSelected(page))
+            .withContext(`In ${browserName}`)
+            .toEqual([0, 2, 4, 5, 6]);
+        })
+      );
+    });
+  });
 });
diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css
index 4abd21cc5..44ebc00c0 100644
--- a/web/annotation_editor_layer_builder.css
+++ b/web/annotation_editor_layer_builder.css
@@ -47,6 +47,11 @@
   transform-origin: 0 0;
 }
 
+.annotationEditorLayer .selectedEditor {
+  outline: var(--focus-outline);
+  resize: none;
+}
+
 .annotationEditorLayer .freeTextEditor {
   position: absolute;
   background: transparent;
@@ -94,21 +99,17 @@
   outline: none;
 }
 
-.annotationEditorLayer .freeTextEditor:focus-within {
-  outline: var(--focus-outline);
-}
-
-.annotationEditorLayer .inkEditor:not(:focus) {
+.annotationEditorLayer .inkEditor.disabled {
   resize: none;
 }
 
-.annotationEditorLayer .freeTextEditor:hover:not(:focus-within),
-.annotationEditorLayer .inkEditor:hover:not(:focus) {
-  outline: var(--hover-outline);
+.annotationEditorLayer .inkEditor.disabled.selectedEditor {
+  resize: horizontal;
 }
 
-.annotationEditorLayer .inkEditor.disabled:focus {
-  resize: horizontal;
+.annotationEditorLayer .freeTextEditor:hover:not(.selectedEditor),
+.annotationEditorLayer .inkEditor:hover:not(.selectedEditor) {
+  outline: var(--hover-outline);
 }
 
 .annotationEditorLayer .inkEditor {
@@ -123,11 +124,6 @@
   cursor: auto;
 }
 
-.annotationEditorLayer .inkEditor:focus {
-  outline: var(--focus-outline);
-  resize: both;
-}
-
 .annotationEditorLayer .inkEditor.editing {
   resize: none;
   cursor: var(--editorInk-editing-cursor), pointer;
@@ -140,8 +136,3 @@
   width: 100%;
   height: 100%;
 }
-
-.annotationEditorLayer .selectedEditor {
-  outline: var(--focus-outline);
-  resize: none;
-}