[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.
This commit is contained in:
Calixte Denizet 2022-07-18 14:47:09 +02:00
parent 75b8647a32
commit 3c17dbb43e
6 changed files with 203 additions and 99 deletions

View File

@ -400,6 +400,21 @@ class AnnotationEditorLayer {
return null; 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. * Create and add a new editor.
* @param {MouseEvent} event * @param {MouseEvent} event

View File

@ -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. * Executed once this editor has been rendered.
*/ */
@ -336,18 +356,6 @@ class AnnotationEditor {
return false; 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. * Check if this editor needs to be rebuilt or not.
* @returns {boolean} * @returns {boolean}
@ -378,6 +386,34 @@ class AnnotationEditor {
unreachable("An editor must be serializable"); 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. * Remove this editor.
* It's used on ctrl+backspace action. * It's used on ctrl+backspace action.

View File

@ -13,11 +13,15 @@
* limitations under the License. * limitations under the License.
*/ */
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
import { import {
AnnotationEditorParamsType, AnnotationEditorParamsType,
AnnotationEditorType, AnnotationEditorType,
assert, assert,
LINE_FACTOR, LINE_FACTOR,
Util,
} from "../../shared/util.js"; } from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js"; import { AnnotationEditor } from "./editor.js";
import { bindEvents } from "./tools.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) { static updateDefaultParams(type, value) {
switch (type) { switch (type) {
case AnnotationEditorParamsType.FREETEXT_SIZE: case AnnotationEditorParamsType.FREETEXT_SIZE:
@ -370,6 +354,21 @@ class FreeTextEditor extends AnnotationEditor {
return this.div; 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 */ /** @inheritdoc */
serialize() { serialize() {
if (this.isEmpty()) { if (this.isEmpty()) {

View File

@ -76,34 +76,6 @@ class InkEditor extends AnnotationEditor {
this.#boundCanvasMousedown = this.canvasMousedown.bind(this); 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) { static updateDefaultParams(type, value) {
switch (type) { switch (type) {
case AnnotationEditorParamsType.INK_THICKNESS: case AnnotationEditorParamsType.INK_THICKNESS:
@ -351,7 +323,7 @@ class InkEditor extends AnnotationEditor {
const xy = [x, y]; const xy = [x, y];
bezier = [[xy, xy.slice(), xy.slice(), xy]]; bezier = [[xy, xy.slice(), xy.slice(), xy]];
} }
const path2D = this.#buildPath2D(bezier); const path2D = InkEditor.#buildPath2D(bezier);
this.currentPath.length = 0; this.currentPath.length = 0;
const cmd = () => { const cmd = () => {
@ -543,7 +515,6 @@ class InkEditor extends AnnotationEditor {
if (this.width) { if (this.width) {
// This editor was created in using copy (ctrl+c). // This editor was created in using copy (ctrl+c).
this.#isCanvasInitialized = true;
const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions; const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
this.setAt( this.setAt(
baseX * parentWidth, baseX * parentWidth,
@ -551,9 +522,11 @@ class InkEditor extends AnnotationEditor {
this.width * parentWidth, this.width * parentWidth,
this.height * parentHeight this.height * parentHeight
); );
this.setDims(this.width * parentWidth, this.height * parentHeight); this.#isCanvasInitialized = true;
this.#setCanvasDims(); this.#setCanvasDims();
this.setDims(this.width * parentWidth, this.height * parentHeight);
this.#redraw(); this.#redraw();
this.#setMinDims();
this.div.classList.add("disabled"); this.div.classList.add("disabled");
} else { } else {
this.div.classList.add("editing"); this.div.classList.add("editing");
@ -570,8 +543,8 @@ class InkEditor extends AnnotationEditor {
return; return;
} }
const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions; const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
this.canvas.width = this.width * parentWidth; this.canvas.width = Math.ceil(this.width * parentWidth);
this.canvas.height = this.height * parentHeight; this.canvas.height = Math.ceil(this.height * parentHeight);
this.#updateTransform(); this.#updateTransform();
} }
@ -610,10 +583,7 @@ class InkEditor extends AnnotationEditor {
this.height = height / parentHeight; this.height = height / parentHeight;
if (this.#disableEditing) { if (this.#disableEditing) {
const padding = this.#getPadding(); this.#setScaleFactor(width, height);
const scaleFactorW = (width - padding) / this.#baseWidth;
const scaleFactorH = (height - padding) / this.#baseHeight;
this.scaleFactor = Math.min(scaleFactorW, scaleFactorH);
} }
this.#setCanvasDims(); this.#setCanvasDims();
@ -622,6 +592,13 @@ class InkEditor extends AnnotationEditor {
this.canvas.style.visibility = "visible"; 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. * Update the canvas transform.
*/ */
@ -642,7 +619,7 @@ class InkEditor extends AnnotationEditor {
* @param {Arra<Array<number>} bezier * @param {Arra<Array<number>} bezier
* @returns {Path2D} * @returns {Path2D}
*/ */
#buildPath2D(bezier) { static #buildPath2D(bezier) {
const path2D = new Path2D(); const path2D = new Path2D();
for (let i = 0, ii = bezier.length; i < ii; i++) { for (let i = 0, ii = bezier.length; i < ii; i++) {
const [first, control1, control2, second] = bezier[i]; const [first, control1, control2, second] = bezier[i];
@ -859,14 +836,7 @@ class InkEditor extends AnnotationEditor {
this.height = height / parentHeight; this.height = height / parentHeight;
this.#aspectRatio = width / height; this.#aspectRatio = width / height;
const { style } = this.div; this.#setMinDims();
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`;
}
const prevTranslationX = this.translationX; const prevTranslationX = this.translationX;
const prevTranslationY = this.translationY; 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 */ /** @inheritdoc */
serialize() { serialize() {
if (this.isEmpty()) { if (this.isEmpty()) {

View File

@ -284,16 +284,25 @@ class KeyboardManager {
* It has to be used as a singleton. * It has to be used as a singleton.
*/ */
class ClipboardManager { class ClipboardManager {
constructor() { #elements = null;
this.element = null;
}
/** /**
* Copy an element. * Copy an element.
* @param {AnnotationEditor} element * @param {AnnotationEditor|Array<AnnotationEditor>} element
*/ */
copy(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} * @returns {AnnotationEditor|null}
*/ */
paste() { paste() {
return this.element?.copy() || null; return this.#elements;
} }
/** /**
@ -309,11 +318,11 @@ class ClipboardManager {
* @returns {boolean} * @returns {boolean}
*/ */
isEmpty() { isEmpty() {
return this.element === null; return this.#elements === null;
} }
destroy() { destroy() {
this.element = null; this.#elements = null;
} }
} }
@ -399,6 +408,8 @@ class AnnotationEditorUIManager {
#commandManager = new CommandManager(); #commandManager = new CommandManager();
#currentPageIndex = 0;
#editorTypes = null; #editorTypes = null;
#eventBus = null; #eventBus = null;
@ -415,6 +426,8 @@ class AnnotationEditorUIManager {
#boundOnEditingAction = this.onEditingAction.bind(this); #boundOnEditingAction = this.onEditingAction.bind(this);
#boundOnPageChanging = this.onPageChanging.bind(this);
#previousStates = { #previousStates = {
isEditing: false, isEditing: false,
isEmpty: true, isEmpty: true,
@ -427,10 +440,12 @@ class AnnotationEditorUIManager {
constructor(eventBus) { constructor(eventBus) {
this.#eventBus = eventBus; this.#eventBus = eventBus;
this.#eventBus._on("editingaction", this.#boundOnEditingAction); this.#eventBus._on("editingaction", this.#boundOnEditingAction);
this.#eventBus._on("pagechanging", this.#boundOnPageChanging);
} }
destroy() { destroy() {
this.#eventBus._off("editingaction", this.#boundOnEditingAction); this.#eventBus._off("editingaction", this.#boundOnEditingAction);
this.#eventBus._off("pagechanging", this.#boundOnPageChanging);
for (const layer of this.#allLayers.values()) { for (const layer of this.#allLayers.values()) {
layer.destroy(); layer.destroy();
} }
@ -441,6 +456,10 @@ class AnnotationEditorUIManager {
this.#commandManager.destroy(); this.#commandManager.destroy();
} }
onPageChanging({ pageNumber }) {
this.#currentPageIndex = pageNumber - 1;
}
/** /**
* Execute an action for a given name. * Execute an action for a given name.
* For example, the user can click on the "Undo" entry in the context menu * For example, the user can click on the "Undo" entry in the context menu
@ -841,18 +860,21 @@ class AnnotationEditorUIManager {
* @returns {undefined} * @returns {undefined}
*/ */
paste() { paste() {
const editor = this.#clipboardManager.paste(); if (this.#clipboardManager.isEmpty()) {
if (!editor) {
return; 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 = () => { const cmd = () => {
this.#addEditorToLayer(editor); newEditors.map(editor => this.#addEditorToLayer(editor));
}; };
const undo = () => { const undo = () => {
editor.remove(); newEditors.map(editor => editor.remove());
}; };
this.addCommands({ cmd, undo, mustExec: true }); this.addCommands({ cmd, undo, mustExec: true });
} }

View File

@ -95,7 +95,7 @@ describe("Editor", () => {
el.innerText.trimEnd() el.innerText.trimEnd()
); );
let pastedContent = await page.$eval(`${editorPrefix}2`, el => let pastedContent = await page.$eval(`${editorPrefix}1`, el =>
el.innerText.trimEnd() el.innerText.trimEnd()
); );
@ -111,7 +111,7 @@ describe("Editor", () => {
await page.keyboard.press("v"); await page.keyboard.press("v");
await page.keyboard.up("Control"); await page.keyboard.up("Control");
pastedContent = await page.$eval(`${editorPrefix}4`, el => pastedContent = await page.$eval(`${editorPrefix}2`, el =>
el.innerText.trimEnd() el.innerText.trimEnd()
); );
expect(pastedContent) expect(pastedContent)
@ -132,7 +132,7 @@ describe("Editor", () => {
await page.keyboard.press("Backspace"); await page.keyboard.press("Backspace");
await page.keyboard.up("Control"); 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 => { const hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel); return !!document.querySelector(sel);
}, `${editorPrefix}${n}`); }, `${editorPrefix}${n}`);
@ -153,9 +153,9 @@ describe("Editor", () => {
const data = "Hello PDF.js World !!"; const data = "Hello PDF.js World !!";
await page.mouse.click(rect.x + 100, rect.y + 100); 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(); const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height }; return { x, y, width, height };
}); });
@ -181,7 +181,7 @@ describe("Editor", () => {
let hasEditor = await page.evaluate(sel => { let hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel); return !!document.querySelector(sel);
}, `${editorPrefix}7`); }, `${editorPrefix}4`);
expect(hasEditor).withContext(`In ${browserName}`).toEqual(true); expect(hasEditor).withContext(`In ${browserName}`).toEqual(true);
@ -191,7 +191,7 @@ describe("Editor", () => {
hasEditor = await page.evaluate(sel => { hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel); return !!document.querySelector(sel);
}, `${editorPrefix}7`); }, `${editorPrefix}4`);
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false); expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
}) })