pdf.js/src/display/editor/freetext.js
Calixte Denizet 9af89381cd [Editor] Add a very basic and incomplete workaround for issue #15780
The main issue is due to the fact that an editor's parent can be null when
we want to serialize it and that lead to an exception which break all the
saving/printing process.
So this incomplete patch fixes only the saving/printing issue but not the
underlying problem (i.e. having a null parent) and doesn't bring that much
complexity, so it should help to uplift it the next Firefox release.
2022-12-06 16:22:24 +01:00

509 lines
13 KiB
JavaScript

/* Copyright 2022 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* 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 { bindEvents, KeyboardManager } from "./tools.js";
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);
#boundEditorDivInput = this.editorDivInput.bind(this);
#boundEditorDivKeydown = this.editorDivKeydown.bind(this);
#color;
#content = "";
#editorDivId = `${this.id}-editor`;
#hasAlreadyBeenCommitted = false;
#fontSize;
static _freeTextDefaultContent = "";
static _l10nPromise;
static _internalPadding = 0;
static _defaultColor = null;
static _defaultFontSize = 10;
static _keyboardManager = new KeyboardManager([
[
["ctrl+Enter", "mac+meta+Enter", "Escape", "mac+Escape"],
FreeTextEditor.prototype.commitOrRemove,
],
]);
static _type = "freetext";
constructor(params) {
super({ ...params, name: "freeTextEditor" });
this.#color =
params.color ||
FreeTextEditor._defaultColor ||
AnnotationEditor._defaultLineColor;
this.#fontSize = params.fontSize || FreeTextEditor._defaultFontSize;
}
static initialize(l10n) {
this._l10nPromise = new Map(
["free_text2_default_content", "editor_free_text2_aria_label"].map(
str => [str, l10n.get(str)]
)
);
const style = getComputedStyle(document.documentElement);
if (
typeof PDFJSDev === "undefined" ||
PDFJSDev.test("!PRODUCTION || TESTING")
) {
const lineHeight = parseFloat(
style.getPropertyValue("--freetext-line-height")
);
assert(
lineHeight === LINE_FACTOR,
"Update the CSS variable to agree with the constant."
);
}
this._internalPadding = parseFloat(
style.getPropertyValue("--freetext-padding")
);
}
static updateDefaultParams(type, value) {
switch (type) {
case AnnotationEditorParamsType.FREETEXT_SIZE:
FreeTextEditor._defaultFontSize = value;
break;
case AnnotationEditorParamsType.FREETEXT_COLOR:
FreeTextEditor._defaultColor = value;
break;
}
}
/** @inheritdoc */
updateParams(type, value) {
switch (type) {
case AnnotationEditorParamsType.FREETEXT_SIZE:
this.#updateFontSize(value);
break;
case AnnotationEditorParamsType.FREETEXT_COLOR:
this.#updateColor(value);
break;
}
}
static get defaultPropertiesToUpdate() {
return [
[
AnnotationEditorParamsType.FREETEXT_SIZE,
FreeTextEditor._defaultFontSize,
],
[
AnnotationEditorParamsType.FREETEXT_COLOR,
FreeTextEditor._defaultColor || AnnotationEditor._defaultLineColor,
],
];
}
get propertiesToUpdate() {
return [
[AnnotationEditorParamsType.FREETEXT_SIZE, this.#fontSize],
[AnnotationEditorParamsType.FREETEXT_COLOR, this.#color],
];
}
/**
* Update the font size and make this action as undoable.
* @param {number} fontSize
*/
#updateFontSize(fontSize) {
const setFontsize = size => {
this.editorDiv.style.fontSize = `calc(${size}px * var(--scale-factor))`;
this.translate(0, -(size - this.#fontSize) * this.parent.scaleFactor);
this.#fontSize = size;
this.#setEditorDimensions();
};
const savedFontsize = this.#fontSize;
this.parent.addCommands({
cmd: () => {
setFontsize(fontSize);
},
undo: () => {
setFontsize(savedFontsize);
},
mustExec: true,
type: AnnotationEditorParamsType.FREETEXT_SIZE,
overwriteIfSameType: true,
keepUndo: true,
});
}
/**
* Update the color and make this action undoable.
* @param {string} color
*/
#updateColor(color) {
const savedColor = this.#color;
this.parent.addCommands({
cmd: () => {
this.#color = color;
this.editorDiv.style.color = color;
},
undo: () => {
this.#color = savedColor;
this.editorDiv.style.color = savedColor;
},
mustExec: true,
type: AnnotationEditorParamsType.FREETEXT_COLOR,
overwriteIfSameType: true,
keepUndo: true,
});
}
/** @inheritdoc */
getInitialTranslation() {
// The start of the base line is where the user clicked.
return [
-FreeTextEditor._internalPadding * this.parent.scaleFactor,
-(FreeTextEditor._internalPadding + this.#fontSize) *
this.parent.scaleFactor,
];
}
/** @inheritdoc */
rebuild() {
super.rebuild();
if (this.div === null) {
return;
}
if (!this.isAttachedToDOM) {
// At some point this editor was removed and we're rebuilting it,
// hence we must add it to its parent.
this.parent.add(this);
}
}
/** @inheritdoc */
enableEditMode() {
if (this.isInEditMode()) {
return;
}
this.parent.setEditingState(false);
this.parent.updateToolbar(AnnotationEditorType.FREETEXT);
super.enableEditMode();
this.overlayDiv.classList.remove("enabled");
this.editorDiv.contentEditable = true;
this.div.draggable = false;
this.div.removeAttribute("aria-activedescendant");
this.editorDiv.addEventListener("keydown", this.#boundEditorDivKeydown);
this.editorDiv.addEventListener("focus", this.#boundEditorDivFocus);
this.editorDiv.addEventListener("blur", this.#boundEditorDivBlur);
this.editorDiv.addEventListener("input", this.#boundEditorDivInput);
}
/** @inheritdoc */
disableEditMode() {
if (!this.isInEditMode()) {
return;
}
this.parent.setEditingState(true);
super.disableEditMode();
this.overlayDiv.classList.add("enabled");
this.editorDiv.contentEditable = false;
this.div.setAttribute("aria-activedescendant", this.#editorDivId);
this.div.draggable = true;
this.editorDiv.removeEventListener("keydown", this.#boundEditorDivKeydown);
this.editorDiv.removeEventListener("focus", this.#boundEditorDivFocus);
this.editorDiv.removeEventListener("blur", this.#boundEditorDivBlur);
this.editorDiv.removeEventListener("input", this.#boundEditorDivInput);
// On Chrome, the focus is given to <body> when contentEditable is set to
// false, hence we focus the div.
this.div.focus();
// In case the blur callback hasn't been called.
this.isEditing = false;
this.parent.div.classList.add("freeTextEditing");
}
/** @inheritdoc */
focusin(event) {
super.focusin(event);
if (event.target !== this.editorDiv) {
this.editorDiv.focus();
}
}
/** @inheritdoc */
onceAdded() {
if (this.width) {
// The editor was created in using ctrl+c.
return;
}
this.enableEditMode();
this.editorDiv.focus();
}
/** @inheritdoc */
isEmpty() {
return !this.editorDiv || this.editorDiv.innerText.trim() === "";
}
/** @inheritdoc */
remove() {
this.isEditing = false;
this.parent.setEditingState(true);
this.parent.div.classList.add("freeTextEditing");
super.remove();
}
/**
* Extract the text from this editor.
* @returns {string}
*/
#extractText() {
const divs = this.editorDiv.getElementsByTagName("div");
if (divs.length === 0) {
return this.editorDiv.innerText;
}
const buffer = [];
for (const div of divs) {
buffer.push(div.innerText.replace(/\r\n?|\n/, ""));
}
return buffer.join("\n");
}
#setEditorDimensions() {
const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
const rect = this.div.getBoundingClientRect();
this.width = rect.width / parentWidth;
this.height = rect.height / parentHeight;
}
/**
* Commit the content we have in this editor.
* @returns {undefined}
*/
commit() {
super.commit();
if (!this.#hasAlreadyBeenCommitted) {
// This editor has something and it's the first time
// it's commited so we can add it in the undo/redo stack.
this.#hasAlreadyBeenCommitted = true;
this.parent.addUndoableEditor(this);
}
this.disableEditMode();
this.#content = this.#extractText().trimEnd();
this.#setEditorDimensions();
}
/** @inheritdoc */
shouldGetKeyboardEvents() {
return this.isInEditMode();
}
/**
* ondblclick callback.
* @param {MouseEvent} event
*/
dblclick(event) {
this.enableEditMode();
this.editorDiv.focus();
}
/**
* onkeydown callback.
* @param {KeyboardEvent} event
*/
keydown(event) {
if (event.target === this.div && event.key === "Enter") {
this.enableEditMode();
this.editorDiv.focus();
}
}
editorDivKeydown(event) {
FreeTextEditor._keyboardManager.exec(this, event);
}
editorDivFocus(event) {
this.isEditing = true;
}
editorDivBlur(event) {
this.isEditing = false;
}
editorDivInput(event) {
this.parent.div.classList.toggle("freeTextEditing", this.isEmpty());
}
/** @inheritdoc */
disableEditing() {
this.editorDiv.setAttribute("role", "comment");
this.editorDiv.removeAttribute("aria-multiline");
}
/** @inheritdoc */
enableEditing() {
this.editorDiv.setAttribute("role", "textbox");
this.editorDiv.setAttribute("aria-multiline", true);
}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
let baseX, baseY;
if (this.width) {
baseX = this.x;
baseY = this.y;
}
super.render();
this.editorDiv = document.createElement("div");
this.editorDiv.className = "internal";
this.editorDiv.setAttribute("id", this.#editorDivId);
this.enableEditing();
FreeTextEditor._l10nPromise
.get("editor_free_text2_aria_label")
.then(msg => this.editorDiv?.setAttribute("aria-label", msg));
FreeTextEditor._l10nPromise
.get("free_text2_default_content")
.then(msg => this.editorDiv?.setAttribute("default-content", msg));
this.editorDiv.contentEditable = true;
const { style } = this.editorDiv;
style.fontSize = `calc(${this.#fontSize}px * var(--scale-factor))`;
style.color = this.#color;
this.div.append(this.editorDiv);
this.overlayDiv = document.createElement("div");
this.overlayDiv.classList.add("overlay", "enabled");
this.div.append(this.overlayDiv);
// TODO: implement paste callback.
// The goal is to sanitize and have something suitable for this
// editor.
bindEvents(this, this.div, ["dblclick", "keydown"]);
if (this.width) {
// This editor was created in using copy (ctrl+c).
const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
this.setAt(
baseX * parentWidth,
baseY * parentHeight,
this.width * parentWidth,
this.height * parentHeight
);
for (const line of this.#content.split("\n")) {
const div = document.createElement("div");
div.append(
line ? document.createTextNode(line) : document.createElement("br")
);
this.editorDiv.append(div);
}
this.div.draggable = true;
this.editorDiv.contentEditable = false;
} else {
this.div.draggable = false;
this.editorDiv.contentEditable = true;
}
return this.div;
}
get contentDiv() {
return this.editorDiv;
}
/** @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;
return editor;
}
/** @inheritdoc */
serialize() {
if (this._serialized !== undefined) {
return this._serialized;
}
if (this.isEmpty()) {
return null;
}
const padding = FreeTextEditor._internalPadding * this.parent.scaleFactor;
const rect = this.getRect(padding, padding);
const color = AnnotationEditor._colorManager.convert(
getComputedStyle(this.editorDiv).color
);
return {
annotationType: AnnotationEditorType.FREETEXT,
color,
fontSize: this.#fontSize,
value: this.#content,
pageIndex: this.parent.pageIndex,
rect,
rotation: this.rotation,
};
}
}
export { FreeTextEditor };