[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;
}
/**
* 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

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.
*/
@ -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.

View File

@ -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()) {

View File

@ -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()) {

View File

@ -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 });
}

View File

@ -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);
})