From 3c17dbb43ed71021eddd548f4eddfeaf8f779119 Mon Sep 17 00:00:00 2001 From: Calixte Denizet 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 ecc29b3f76c8e..c646d84c7fcde 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 110e8464d317d..796f3c85b67e0 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 0bada56217219..8369240840877 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 => `
${line}
`) + .join(""); + + return editor; + } + /** @inheritdoc */ serialize() { if (this.isEmpty()) { diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 1767d894191e8..afcb62e587719 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} 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 83d8696c65ed2..ab4258c254a13 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} 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 ed275087c4aa3..35c911e1f7aaa 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); })