From 3c17dbb43ed71021eddd548f4eddfeaf8f779119 Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
Date: Mon, 18 Jul 2022 14:47:09 +0200
Subject: [PATCH] [Editor] Use serialized data when copying/pasting

- in using the global clipboard, it'll be possible to copy from a
  pdf and paste in an other one;
- it'll allow to edit a previously created annotation;
- copy the editors in the current page.
---
 src/display/editor/annotation_editor_layer.js |  15 +++
 src/display/editor/editor.js                  |  60 +++++++--
 src/display/editor/freetext.js                |  39 +++---
 src/display/editor/ink.js                     | 124 +++++++++++-------
 src/display/editor/tools.js                   |  50 +++++--
 test/integration/freetext_editor_spec.js      |  14 +-
 6 files changed, 203 insertions(+), 99 deletions(-)

diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js
index ecc29b3f7..c646d84c7 100644
--- a/src/display/editor/annotation_editor_layer.js
+++ b/src/display/editor/annotation_editor_layer.js
@@ -400,6 +400,21 @@ class AnnotationEditorLayer {
     return null;
   }
 
+  /**
+   * Create a new editor
+   * @param {Object} data
+   * @returns {AnnotationEditor}
+   */
+  deserialize(data) {
+    switch (data.annotationType) {
+      case AnnotationEditorType.FREETEXT:
+        return FreeTextEditor.deserialize(data, this);
+      case AnnotationEditorType.INK:
+        return InkEditor.deserialize(data, this);
+    }
+    return null;
+  }
+
   /**
    * Create and add a new editor.
    * @param {MouseEvent} event
diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js
index 110e8464d..796f3c85b 100644
--- a/src/display/editor/editor.js
+++ b/src/display/editor/editor.js
@@ -290,6 +290,26 @@ class AnnotationEditor {
     }
   }
 
+  getRectInCurrentCoords(rect, pageHeight) {
+    const [x1, y1, x2, y2] = rect;
+
+    const width = x2 - x1;
+    const height = y2 - y1;
+
+    switch (this.rotation) {
+      case 0:
+        return [x1, pageHeight - y2, width, height];
+      case 90:
+        return [x1, pageHeight - y1, height, width];
+      case 180:
+        return [x2, pageHeight - y1, width, height];
+      case 270:
+        return [x2, pageHeight - y2, height, width];
+      default:
+        throw new Error("Invalid rotation");
+    }
+  }
+
   /**
    * Executed once this editor has been rendered.
    */
@@ -336,18 +356,6 @@ class AnnotationEditor {
     return false;
   }
 
-  /**
-   * Copy the elements of an editor in order to be able to build
-   * a new one from these data.
-   * It's used on ctrl+c action.
-   *
-   * To implement in subclasses.
-   * @returns {AnnotationEditor}
-   */
-  copy() {
-    unreachable("An editor must be copyable");
-  }
-
   /**
    * Check if this editor needs to be rebuilt or not.
    * @returns {boolean}
@@ -378,6 +386,34 @@ class AnnotationEditor {
     unreachable("An editor must be serializable");
   }
 
+  /**
+   * Deserialize the editor.
+   * The result of the deserialization is a new editor.
+   *
+   * @param {Object} data
+   * @param {AnnotationEditorLayer} parent
+   * @returns {AnnotationEditor}
+   */
+  static deserialize(data, parent) {
+    const editor = new this.prototype.constructor({
+      parent,
+      id: parent.getNextId(),
+    });
+    editor.rotation = data.rotation;
+
+    const [pageWidth, pageHeight] = parent.pageDimensions;
+    const [x, y, width, height] = editor.getRectInCurrentCoords(
+      data.rect,
+      pageHeight
+    );
+    editor.x = x / pageWidth;
+    editor.y = y / pageHeight;
+    editor.width = width / pageWidth;
+    editor.height = height / pageHeight;
+
+    return editor;
+  }
+
   /**
    * Remove this editor.
    * It's used on ctrl+backspace action.
diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js
index 0bada5621..836924084 100644
--- a/src/display/editor/freetext.js
+++ b/src/display/editor/freetext.js
@@ -13,11 +13,15 @@
  * limitations under the License.
  */
 
+// eslint-disable-next-line max-len
+/** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
+
 import {
   AnnotationEditorParamsType,
   AnnotationEditorType,
   assert,
   LINE_FACTOR,
+  Util,
 } from "../../shared/util.js";
 import { AnnotationEditor } from "./editor.js";
 import { bindEvents } from "./tools.js";
@@ -77,26 +81,6 @@ class FreeTextEditor extends AnnotationEditor {
     );
   }
 
-  /** @inheritdoc */
-  copy() {
-    const [width, height] = this.parent.viewportBaseDimensions;
-    const editor = new FreeTextEditor({
-      parent: this.parent,
-      id: this.parent.getNextId(),
-      x: this.x * width,
-      y: this.y * height,
-    });
-
-    editor.width = this.width;
-    editor.height = this.height;
-    editor.#color = this.#color;
-    editor.#fontSize = this.#fontSize;
-    editor.#content = this.#content;
-    editor.#contentHTML = this.#contentHTML;
-
-    return editor;
-  }
-
   static updateDefaultParams(type, value) {
     switch (type) {
       case AnnotationEditorParamsType.FREETEXT_SIZE:
@@ -370,6 +354,21 @@ class FreeTextEditor extends AnnotationEditor {
     return this.div;
   }
 
+  /** @inheritdoc */
+  static deserialize(data, parent) {
+    const editor = super.deserialize(data, parent);
+
+    editor.#fontSize = data.fontSize;
+    editor.#color = Util.makeHexColor(...data.color);
+    editor.#content = data.value;
+    editor.#contentHTML = data.value
+      .split("\n")
+      .map(line => `<div>${line}</div>`)
+      .join("");
+
+    return editor;
+  }
+
   /** @inheritdoc */
   serialize() {
     if (this.isEmpty()) {
diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js
index 1767d8941..afcb62e58 100644
--- a/src/display/editor/ink.js
+++ b/src/display/editor/ink.js
@@ -76,34 +76,6 @@ class InkEditor extends AnnotationEditor {
     this.#boundCanvasMousedown = this.canvasMousedown.bind(this);
   }
 
-  /** @inheritdoc */
-  copy() {
-    const editor = new InkEditor({
-      parent: this.parent,
-      id: this.parent.getNextId(),
-    });
-
-    editor.x = this.x;
-    editor.y = this.y;
-    editor.width = this.width;
-    editor.height = this.height;
-    editor.color = this.color;
-    editor.thickness = this.thickness;
-    editor.paths = this.paths.slice();
-    editor.bezierPath2D = this.bezierPath2D.slice();
-    editor.scaleFactor = this.scaleFactor;
-    editor.translationX = this.translationX;
-    editor.translationY = this.translationY;
-    editor.#aspectRatio = this.#aspectRatio;
-    editor.#baseWidth = this.#baseWidth;
-    editor.#baseHeight = this.#baseHeight;
-    editor.#disableEditing = this.#disableEditing;
-    editor.#realWidth = this.#realWidth;
-    editor.#realHeight = this.#realHeight;
-
-    return editor;
-  }
-
   static updateDefaultParams(type, value) {
     switch (type) {
       case AnnotationEditorParamsType.INK_THICKNESS:
@@ -351,7 +323,7 @@ class InkEditor extends AnnotationEditor {
       const xy = [x, y];
       bezier = [[xy, xy.slice(), xy.slice(), xy]];
     }
-    const path2D = this.#buildPath2D(bezier);
+    const path2D = InkEditor.#buildPath2D(bezier);
     this.currentPath.length = 0;
 
     const cmd = () => {
@@ -543,7 +515,6 @@ class InkEditor extends AnnotationEditor {
 
     if (this.width) {
       // This editor was created in using copy (ctrl+c).
-      this.#isCanvasInitialized = true;
       const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
       this.setAt(
         baseX * parentWidth,
@@ -551,9 +522,11 @@ class InkEditor extends AnnotationEditor {
         this.width * parentWidth,
         this.height * parentHeight
       );
-      this.setDims(this.width * parentWidth, this.height * parentHeight);
+      this.#isCanvasInitialized = true;
       this.#setCanvasDims();
+      this.setDims(this.width * parentWidth, this.height * parentHeight);
       this.#redraw();
+      this.#setMinDims();
       this.div.classList.add("disabled");
     } else {
       this.div.classList.add("editing");
@@ -570,8 +543,8 @@ class InkEditor extends AnnotationEditor {
       return;
     }
     const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
-    this.canvas.width = this.width * parentWidth;
-    this.canvas.height = this.height * parentHeight;
+    this.canvas.width = Math.ceil(this.width * parentWidth);
+    this.canvas.height = Math.ceil(this.height * parentHeight);
     this.#updateTransform();
   }
 
@@ -610,10 +583,7 @@ class InkEditor extends AnnotationEditor {
     this.height = height / parentHeight;
 
     if (this.#disableEditing) {
-      const padding = this.#getPadding();
-      const scaleFactorW = (width - padding) / this.#baseWidth;
-      const scaleFactorH = (height - padding) / this.#baseHeight;
-      this.scaleFactor = Math.min(scaleFactorW, scaleFactorH);
+      this.#setScaleFactor(width, height);
     }
 
     this.#setCanvasDims();
@@ -622,6 +592,13 @@ class InkEditor extends AnnotationEditor {
     this.canvas.style.visibility = "visible";
   }
 
+  #setScaleFactor(width, height) {
+    const padding = this.#getPadding();
+    const scaleFactorW = (width - padding) / this.#baseWidth;
+    const scaleFactorH = (height - padding) / this.#baseHeight;
+    this.scaleFactor = Math.min(scaleFactorW, scaleFactorH);
+  }
+
   /**
    * Update the canvas transform.
    */
@@ -642,7 +619,7 @@ class InkEditor extends AnnotationEditor {
    * @param {Arra<Array<number>} bezier
    * @returns {Path2D}
    */
-  #buildPath2D(bezier) {
+  static #buildPath2D(bezier) {
     const path2D = new Path2D();
     for (let i = 0, ii = bezier.length; i < ii; i++) {
       const [first, control1, control2, second] = bezier[i];
@@ -859,14 +836,7 @@ class InkEditor extends AnnotationEditor {
     this.height = height / parentHeight;
 
     this.#aspectRatio = width / height;
-    const { style } = this.div;
-    if (this.#aspectRatio >= 1) {
-      style.minHeight = `${RESIZER_SIZE}px`;
-      style.minWidth = `${Math.round(this.#aspectRatio * RESIZER_SIZE)}px`;
-    } else {
-      style.minWidth = `${RESIZER_SIZE}px`;
-      style.minHeight = `${Math.round(RESIZER_SIZE / this.#aspectRatio)}px`;
-    }
+    this.#setMinDims();
 
     const prevTranslationX = this.translationX;
     const prevTranslationY = this.translationY;
@@ -886,6 +856,68 @@ class InkEditor extends AnnotationEditor {
     );
   }
 
+  #setMinDims() {
+    const { style } = this.div;
+    if (this.#aspectRatio >= 1) {
+      style.minHeight = `${RESIZER_SIZE}px`;
+      style.minWidth = `${Math.round(this.#aspectRatio * RESIZER_SIZE)}px`;
+    } else {
+      style.minWidth = `${RESIZER_SIZE}px`;
+      style.minHeight = `${Math.round(RESIZER_SIZE / this.#aspectRatio)}px`;
+    }
+  }
+
+  /** @inheritdoc */
+  static deserialize(data, parent) {
+    const editor = super.deserialize(data, parent);
+
+    editor.thickness = data.thickness;
+    editor.color = Util.makeHexColor(...data.color);
+
+    const [pageWidth, pageHeight] = parent.pageDimensions;
+    const width = editor.width * pageWidth;
+    const height = editor.height * pageHeight;
+    const scaleFactor = parent.scaleFactor;
+    const padding = data.thickness / 2;
+
+    editor.#aspectRatio = width / height;
+    editor.#disableEditing = true;
+    editor.#realWidth = Math.round(width);
+    editor.#realHeight = Math.round(height);
+
+    for (const { bezier } of data.paths) {
+      const path = [];
+      editor.paths.push(path);
+      let p0 = scaleFactor * (bezier[0] - padding);
+      let p1 = scaleFactor * (height - bezier[1] - padding);
+      for (let i = 2, ii = bezier.length; i < ii; i += 6) {
+        const p10 = scaleFactor * (bezier[i] - padding);
+        const p11 = scaleFactor * (height - bezier[i + 1] - padding);
+        const p20 = scaleFactor * (bezier[i + 2] - padding);
+        const p21 = scaleFactor * (height - bezier[i + 3] - padding);
+        const p30 = scaleFactor * (bezier[i + 4] - padding);
+        const p31 = scaleFactor * (height - bezier[i + 5] - padding);
+        path.push([
+          [p0, p1],
+          [p10, p11],
+          [p20, p21],
+          [p30, p31],
+        ]);
+        p0 = p30;
+        p1 = p31;
+      }
+      const path2D = this.#buildPath2D(path);
+      editor.bezierPath2D.push(path2D);
+    }
+
+    const bbox = editor.#getBbox();
+    editor.#baseWidth = bbox[2] - bbox[0];
+    editor.#baseHeight = bbox[3] - bbox[1];
+    editor.#setScaleFactor(width, height);
+
+    return editor;
+  }
+
   /** @inheritdoc */
   serialize() {
     if (this.isEmpty()) {
diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js
index 83d8696c6..ab4258c25 100644
--- a/src/display/editor/tools.js
+++ b/src/display/editor/tools.js
@@ -284,16 +284,25 @@ class KeyboardManager {
  * It has to be used as a singleton.
  */
 class ClipboardManager {
-  constructor() {
-    this.element = null;
-  }
+  #elements = null;
 
   /**
    * Copy an element.
-   * @param {AnnotationEditor} element
+   * @param {AnnotationEditor|Array<AnnotationEditor>} element
    */
   copy(element) {
-    this.element = element.copy();
+    if (!element) {
+      return;
+    }
+    if (Array.isArray(element)) {
+      this.#elements = element.map(el => el.serialize());
+    } else {
+      this.#elements = [element.serialize()];
+    }
+    this.#elements = this.#elements.filter(el => !!el);
+    if (this.#elements.length === 0) {
+      this.#elements = null;
+    }
   }
 
   /**
@@ -301,7 +310,7 @@ class ClipboardManager {
    * @returns {AnnotationEditor|null}
    */
   paste() {
-    return this.element?.copy() || null;
+    return this.#elements;
   }
 
   /**
@@ -309,11 +318,11 @@ class ClipboardManager {
    * @returns {boolean}
    */
   isEmpty() {
-    return this.element === null;
+    return this.#elements === null;
   }
 
   destroy() {
-    this.element = null;
+    this.#elements = null;
   }
 }
 
@@ -399,6 +408,8 @@ class AnnotationEditorUIManager {
 
   #commandManager = new CommandManager();
 
+  #currentPageIndex = 0;
+
   #editorTypes = null;
 
   #eventBus = null;
@@ -415,6 +426,8 @@ class AnnotationEditorUIManager {
 
   #boundOnEditingAction = this.onEditingAction.bind(this);
 
+  #boundOnPageChanging = this.onPageChanging.bind(this);
+
   #previousStates = {
     isEditing: false,
     isEmpty: true,
@@ -427,10 +440,12 @@ class AnnotationEditorUIManager {
   constructor(eventBus) {
     this.#eventBus = eventBus;
     this.#eventBus._on("editingaction", this.#boundOnEditingAction);
+    this.#eventBus._on("pagechanging", this.#boundOnPageChanging);
   }
 
   destroy() {
     this.#eventBus._off("editingaction", this.#boundOnEditingAction);
+    this.#eventBus._off("pagechanging", this.#boundOnPageChanging);
     for (const layer of this.#allLayers.values()) {
       layer.destroy();
     }
@@ -441,6 +456,10 @@ class AnnotationEditorUIManager {
     this.#commandManager.destroy();
   }
 
+  onPageChanging({ pageNumber }) {
+    this.#currentPageIndex = pageNumber - 1;
+  }
+
   /**
    * Execute an action for a given name.
    * For example, the user can click on the "Undo" entry in the context menu
@@ -841,18 +860,21 @@ class AnnotationEditorUIManager {
    * @returns {undefined}
    */
   paste() {
-    const editor = this.#clipboardManager.paste();
-    if (!editor) {
+    if (this.#clipboardManager.isEmpty()) {
       return;
     }
-    // TODO: paste in the current visible layer.
+
+    const layer = this.#allLayers.get(this.#currentPageIndex);
+    const newEditors = this.#clipboardManager
+      .paste()
+      .map(data => layer.deserialize(data));
+
     const cmd = () => {
-      this.#addEditorToLayer(editor);
+      newEditors.map(editor => this.#addEditorToLayer(editor));
     };
     const undo = () => {
-      editor.remove();
+      newEditors.map(editor => editor.remove());
     };
-
     this.addCommands({ cmd, undo, mustExec: true });
   }
 
diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js
index ed275087c..35c911e1f 100644
--- a/test/integration/freetext_editor_spec.js
+++ b/test/integration/freetext_editor_spec.js
@@ -95,7 +95,7 @@ describe("Editor", () => {
             el.innerText.trimEnd()
           );
 
-          let pastedContent = await page.$eval(`${editorPrefix}2`, el =>
+          let pastedContent = await page.$eval(`${editorPrefix}1`, el =>
             el.innerText.trimEnd()
           );
 
@@ -111,7 +111,7 @@ describe("Editor", () => {
           await page.keyboard.press("v");
           await page.keyboard.up("Control");
 
-          pastedContent = await page.$eval(`${editorPrefix}4`, el =>
+          pastedContent = await page.$eval(`${editorPrefix}2`, el =>
             el.innerText.trimEnd()
           );
           expect(pastedContent)
@@ -132,7 +132,7 @@ describe("Editor", () => {
           await page.keyboard.press("Backspace");
           await page.keyboard.up("Control");
 
-          for (const n of [0, 2, 4]) {
+          for (const n of [0, 1, 2]) {
             const hasEditor = await page.evaluate(sel => {
               return !!document.querySelector(sel);
             }, `${editorPrefix}${n}`);
@@ -153,9 +153,9 @@ describe("Editor", () => {
 
           const data = "Hello PDF.js World !!";
           await page.mouse.click(rect.x + 100, rect.y + 100);
-          await page.type(`${editorPrefix}5 .internal`, data);
+          await page.type(`${editorPrefix}3 .internal`, data);
 
-          const editorRect = await page.$eval(`${editorPrefix}5`, el => {
+          const editorRect = await page.$eval(`${editorPrefix}3`, el => {
             const { x, y, width, height } = el.getBoundingClientRect();
             return { x, y, width, height };
           });
@@ -181,7 +181,7 @@ describe("Editor", () => {
 
           let hasEditor = await page.evaluate(sel => {
             return !!document.querySelector(sel);
-          }, `${editorPrefix}7`);
+          }, `${editorPrefix}4`);
 
           expect(hasEditor).withContext(`In ${browserName}`).toEqual(true);
 
@@ -191,7 +191,7 @@ describe("Editor", () => {
 
           hasEditor = await page.evaluate(sel => {
             return !!document.querySelector(sel);
-          }, `${editorPrefix}7`);
+          }, `${editorPrefix}4`);
 
           expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
         })