pdf.js/src/display/editor/annotation_editor_layer.js
Calixte Denizet a989b5a879 Set the dimensions of the various layers at their creation
- Use a unique helper function in display/display_utils.js;
- Move those dimensions in css' side.
2022-12-10 14:35:06 +01:00

580 lines
14 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.
*/
/** @typedef {import("./editor.js").AnnotationEditor} AnnotationEditor */
// eslint-disable-next-line max-len
/** @typedef {import("./tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
/** @typedef {import("../display_utils.js").PageViewport} PageViewport */
// eslint-disable-next-line max-len
/** @typedef {import("../../web/text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
/** @typedef {import("../../web/interfaces").IL10n} IL10n */
import { AnnotationEditorType, FeatureTest } from "../../shared/util.js";
import { bindEvents } from "./tools.js";
import { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js";
import { setLayerDimensions } from "../display_utils.js";
/**
* @typedef {Object} AnnotationEditorLayerOptions
* @property {Object} mode
* @property {HTMLDivElement} div
* @property {AnnotationEditorUIManager} uiManager
* @property {boolean} enabled
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {number} pageIndex
* @property {IL10n} l10n
*/
/**
* @typedef {Object} RenderEditorLayerOptions
* @property {PageViewport} viewport
*/
/**
* Manage all the different editors on a page.
*/
class AnnotationEditorLayer {
#accessibilityManager;
#allowClick = false;
#boundPointerup = this.pointerup.bind(this);
#boundPointerdown = this.pointerdown.bind(this);
#editors = new Map();
#hadPointerDown = false;
#isCleaningUp = false;
#uiManager;
static _initialized = false;
/**
* @param {AnnotationEditorLayerOptions} options
*/
constructor(options) {
if (!AnnotationEditorLayer._initialized) {
AnnotationEditorLayer._initialized = true;
FreeTextEditor.initialize(options.l10n);
InkEditor.initialize(options.l10n);
}
options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]);
this.#uiManager = options.uiManager;
this.pageIndex = options.pageIndex;
this.div = options.div;
this.#accessibilityManager = options.accessibilityManager;
this.#uiManager.addLayer(this);
}
/**
* Update the toolbar if it's required to reflect the tool currently used.
* @param {number} mode
*/
updateToolbar(mode) {
this.#uiManager.updateToolbar(mode);
}
/**
* The mode has changed: it must be updated.
* @param {number} mode
*/
updateMode(mode = this.#uiManager.getMode()) {
this.#cleanup();
if (mode === AnnotationEditorType.INK) {
// We always want to an ink editor ready to draw in.
this.addInkEditorIfNeeded(false);
this.disableClick();
} else {
this.enableClick();
}
this.#uiManager.unselectAll();
this.div.classList.toggle(
"freeTextEditing",
mode === AnnotationEditorType.FREETEXT
);
this.div.classList.toggle("inkEditing", mode === AnnotationEditorType.INK);
}
addInkEditorIfNeeded(isCommitting) {
if (
!isCommitting &&
this.#uiManager.getMode() !== AnnotationEditorType.INK
) {
return;
}
if (!isCommitting) {
// We're removing an editor but an empty one can already exist so in this
// case we don't need to create a new one.
for (const editor of this.#editors.values()) {
if (editor.isEmpty()) {
editor.setInBackground();
return;
}
}
}
const editor = this.#createAndAddNewEditor({ offsetX: 0, offsetY: 0 });
editor.setInBackground();
}
/**
* Set the editing state.
* @param {boolean} isEditing
*/
setEditingState(isEditing) {
this.#uiManager.setEditingState(isEditing);
}
/**
* Add some commands into the CommandManager (undo/redo stuff).
* @param {Object} params
*/
addCommands(params) {
this.#uiManager.addCommands(params);
}
/**
* Enable pointer events on the main div in order to enable
* editor creation.
*/
enable() {
this.div.style.pointerEvents = "auto";
for (const editor of this.#editors.values()) {
editor.enableEditing();
}
}
/**
* Disable editor creation.
*/
disable() {
this.div.style.pointerEvents = "none";
for (const editor of this.#editors.values()) {
editor.disableEditing();
}
}
/**
* Set the current editor.
* @param {AnnotationEditor} editor
*/
setActiveEditor(editor) {
const currentActive = this.#uiManager.getActive();
if (currentActive === editor) {
return;
}
this.#uiManager.setActiveEditor(editor);
}
enableClick() {
this.div.addEventListener("pointerdown", this.#boundPointerdown);
this.div.addEventListener("pointerup", this.#boundPointerup);
}
disableClick() {
this.div.removeEventListener("pointerdown", this.#boundPointerdown);
this.div.removeEventListener("pointerup", this.#boundPointerup);
}
attach(editor) {
this.#editors.set(editor.id, editor);
}
detach(editor) {
this.#editors.delete(editor.id);
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
}
/**
* Remove an editor.
* @param {AnnotationEditor} editor
*/
remove(editor) {
// Since we can undo a removal we need to keep the
// parent property as it is, so don't null it!
this.#uiManager.removeEditor(editor);
this.detach(editor);
editor.div.style.display = "none";
setTimeout(() => {
// When the div is removed from DOM the focus can move on the
// document.body, so we just slightly postpone the removal in
// order to let an element potentially grab the focus before
// the body.
editor.div.style.display = "";
editor.div.remove();
editor.isAttachedToDOM = false;
if (document.activeElement === document.body) {
this.#uiManager.focusMainContainer();
}
}, 0);
if (!this.#isCleaningUp) {
this.addInkEditorIfNeeded(/* isCommitting = */ false);
}
}
/**
* An editor can have a different parent, for example after having
* being dragged and droped from a page to another.
* @param {AnnotationEditor} editor
*/
#changeParent(editor) {
if (editor.parent === this) {
return;
}
this.attach(editor);
editor.parent?.detach(editor);
editor.setParent(this);
if (editor.div && editor.isAttachedToDOM) {
editor.div.remove();
this.div.append(editor.div);
}
}
/**
* Add a new editor in the current view.
* @param {AnnotationEditor} editor
*/
add(editor) {
this.#changeParent(editor);
this.#uiManager.addEditor(editor);
this.attach(editor);
if (!editor.isAttachedToDOM) {
const div = editor.render();
this.div.append(div);
editor.isAttachedToDOM = true;
}
this.moveEditorInDOM(editor);
editor.onceAdded();
this.#uiManager.addToAnnotationStorage(editor);
}
moveEditorInDOM(editor) {
this.#accessibilityManager?.moveElementInDOM(
this.div,
editor.div,
editor.contentDiv,
/* isRemovable = */ true
);
}
/**
* Add or rebuild depending if it has been removed or not.
* @param {AnnotationEditor} editor
*/
addOrRebuild(editor) {
if (editor.needsToBeRebuilt()) {
editor.rebuild();
} else {
this.add(editor);
}
}
/**
* Add a new editor and make this addition undoable.
* @param {AnnotationEditor} editor
*/
addANewEditor(editor) {
const cmd = () => {
this.addOrRebuild(editor);
};
const undo = () => {
editor.remove();
};
this.addCommands({ cmd, undo, mustExec: true });
}
/**
* Add a new editor and make this addition undoable.
* @param {AnnotationEditor} editor
*/
addUndoableEditor(editor) {
const cmd = () => {
this.addOrRebuild(editor);
};
const undo = () => {
editor.remove();
};
this.addCommands({ cmd, undo, mustExec: false });
}
/**
* Get an id for an editor.
* @returns {string}
*/
getNextId() {
return this.#uiManager.getId();
}
/**
* Create a new editor
* @param {Object} params
* @returns {AnnotationEditor}
*/
#createNewEditor(params) {
switch (this.#uiManager.getMode()) {
case AnnotationEditorType.FREETEXT:
return new FreeTextEditor(params);
case AnnotationEditorType.INK:
return new InkEditor(params);
}
return null;
}
/**
* Create a new editor
* @param {Object} data
* @returns {AnnotationEditor}
*/
deserialize(data) {
switch (data.annotationType) {
case AnnotationEditorType.FREETEXT:
return FreeTextEditor.deserialize(data, this, this.#uiManager);
case AnnotationEditorType.INK:
return InkEditor.deserialize(data, this, this.#uiManager);
}
return null;
}
/**
* Create and add a new editor.
* @param {PointerEvent} event
* @returns {AnnotationEditor}
*/
#createAndAddNewEditor(event) {
const id = this.getNextId();
const editor = this.#createNewEditor({
parent: this,
id,
x: event.offsetX,
y: event.offsetY,
uiManager: this.#uiManager,
});
if (editor) {
this.add(editor);
}
return editor;
}
/**
* Set the last selected editor.
* @param {AnnotationEditor} editor
*/
setSelected(editor) {
this.#uiManager.setSelected(editor);
}
/**
* Add or remove an editor the current selection.
* @param {AnnotationEditor} editor
*/
toggleSelected(editor) {
this.#uiManager.toggleSelected(editor);
}
/**
* Check if the editor is selected.
* @param {AnnotationEditor} editor
*/
isSelected(editor) {
return this.#uiManager.isSelected(editor);
}
/**
* Unselect an editor.
* @param {AnnotationEditor} editor
*/
unselect(editor) {
this.#uiManager.unselect(editor);
}
/**
* Pointerup callback.
* @param {PointerEvent} event
*/
pointerup(event) {
const { isMac } = FeatureTest.platform;
if (event.button !== 0 || (event.ctrlKey && isMac)) {
// Don't create an editor on right click.
return;
}
if (event.target !== this.div) {
return;
}
if (!this.#hadPointerDown) {
// It can happen when the user starts a drag inside a text editor
// and then releases the mouse button outside of it. In such a case
// we don't want to create a new editor, hence we check that a pointerdown
// occured on this div previously.
return;
}
this.#hadPointerDown = false;
if (!this.#allowClick) {
this.#allowClick = true;
return;
}
this.#createAndAddNewEditor(event);
}
/**
* Pointerdown callback.
* @param {PointerEvent} event
*/
pointerdown(event) {
const { isMac } = FeatureTest.platform;
if (event.button !== 0 || (event.ctrlKey && isMac)) {
// Do nothing on right click.
return;
}
if (event.target !== this.div) {
return;
}
this.#hadPointerDown = true;
const editor = this.#uiManager.getActive();
this.#allowClick = !editor || editor.isEmpty();
}
/**
* Drag callback.
* @param {DragEvent} event
*/
drop(event) {
const id = event.dataTransfer.getData("text/plain");
const editor = this.#uiManager.getEditor(id);
if (!editor) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "move";
this.#changeParent(editor);
const rect = this.div.getBoundingClientRect();
const endX = event.clientX - rect.x;
const endY = event.clientY - rect.y;
editor.translate(endX - editor.startX, endY - editor.startY);
this.moveEditorInDOM(editor);
editor.div.focus();
}
/**
* Dragover callback.
* @param {DragEvent} event
*/
dragover(event) {
event.preventDefault();
}
/**
* Destroy the main editor.
*/
destroy() {
if (this.#uiManager.getActive()?.parent === this) {
this.#uiManager.setActiveEditor(null);
}
for (const editor of this.#editors.values()) {
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
editor.setParent(null);
editor.isAttachedToDOM = false;
editor.div.remove();
}
this.div = null;
this.#editors.clear();
this.#uiManager.removeLayer(this);
}
#cleanup() {
// When we're cleaning up, some editors are removed but we don't want
// to add a new one which will induce an addition in this.#editors, hence
// an infinite loop.
this.#isCleaningUp = true;
for (const editor of this.#editors.values()) {
if (editor.isEmpty()) {
editor.remove();
}
}
this.#isCleaningUp = false;
}
/**
* Render the main editor.
* @param {RenderEditorLayerOptions} parameters
*/
render({ viewport }) {
this.viewport = viewport;
setLayerDimensions(this.div, viewport);
bindEvents(this, this.div, ["dragover", "drop"]);
for (const editor of this.#uiManager.getEditors(this.pageIndex)) {
this.add(editor);
}
this.updateMode();
}
/**
* Update the main editor.
* @param {RenderEditorLayerOptions} parameters
*/
update({ viewport }) {
// Editors have their dimensions/positions in percent so to avoid any
// issues (see #15582), we must commit the current one before changing
// the viewport.
this.#uiManager.commitOrRemove();
this.viewport = viewport;
setLayerDimensions(this.div, { rotation: viewport.rotation });
this.updateMode();
}
/**
* Get page dimensions.
* @returns {Object} dimensions.
*/
get pageDimensions() {
const [pageLLx, pageLLy, pageURx, pageURy] = this.viewport.viewBox;
const width = pageURx - pageLLx;
const height = pageURy - pageLLy;
return [width, height];
}
}
export { AnnotationEditorLayer };