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