/* 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 */ // eslint-disable-next-line max-len /** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */ /** @typedef {import("../../web/interfaces").IL10n} IL10n */ import { bindEvents, KeyboardManager } from "./tools.js"; import { AnnotationEditorType } from "../../shared/util.js"; import { FreeTextEditor } from "./freetext.js"; import { InkEditor } from "./ink.js"; /** * @typedef {Object} AnnotationEditorLayerOptions * @property {Object} mode * @property {HTMLDivElement} div * @property {AnnotationEditorUIManager} uiManager * @property {boolean} enabled * @property {AnnotationStorage} annotationStorag * @property {number} pageIndex * @property {IL10n} l10n */ /** * Manage all the different editors on a page. */ class AnnotationEditorLayer { #boundClick; #boundMouseover; #editors = new Map(); #uiManager; static _initialized = false; static _keyboardManager = new KeyboardManager([ [["ctrl+a", "mac+meta+a"], AnnotationEditorLayer.prototype.selectAll], [["ctrl+c", "mac+meta+c"], AnnotationEditorLayer.prototype.copy], [["ctrl+v", "mac+meta+v"], AnnotationEditorLayer.prototype.paste], [["ctrl+x", "mac+meta+x"], AnnotationEditorLayer.prototype.cut], [["ctrl+z", "mac+meta+z"], AnnotationEditorLayer.prototype.undo], [ ["ctrl+y", "ctrl+shift+Z", "mac+meta+shift+Z"], AnnotationEditorLayer.prototype.redo, ], [ [ "Backspace", "alt+Backspace", "ctrl+Backspace", "shift+Backspace", "mac+Backspace", "mac+alt+Backspace", "mac+ctrl+Backspace", "Delete", "ctrl+Delete", "shift+Delete", ], AnnotationEditorLayer.prototype.delete, ], ]); /** * @param {AnnotationEditorLayerOptions} options */ constructor(options) { if (!AnnotationEditorLayer._initialized) { AnnotationEditorLayer._initialized = true; FreeTextEditor.initialize(options.l10n); options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]); } this.#uiManager = options.uiManager; this.annotationStorage = options.annotationStorage; this.pageIndex = options.pageIndex; this.div = options.div; this.#boundClick = this.click.bind(this); this.#boundMouseover = this.mouseover.bind(this); for (const editor of this.#uiManager.getEditors(options.pageIndex)) { this.add(editor); } this.#uiManager.addLayer(this); } /** * Update the toolbar if it's required to reflect the tool currently used. * @param {number} mode * @returns {undefined} */ updateToolbar(mode) { this.#uiManager.updateToolbar(mode); } /** * The mode has changed: it must be updated. * @param {number} mode */ updateMode(mode) { switch (mode) { case AnnotationEditorType.INK: // We want to have the ink editor covering all of the page without // having to click to create it: it must be here when we start to draw. this.div.addEventListener("mouseover", this.#boundMouseover); this.div.removeEventListener("click", this.#boundClick); break; case AnnotationEditorType.FREETEXT: this.div.removeEventListener("mouseover", this.#boundMouseover); this.div.addEventListener("click", this.#boundClick); break; default: this.div.removeEventListener("mouseover", this.#boundMouseover); this.div.removeEventListener("click", this.#boundClick); } this.setActiveEditor(null); } /** * Set the editing state. * @param {boolean} isEditing */ setEditingState(isEditing) { this.#uiManager.setEditingState(isEditing); } /** * Mouseover callback. * @param {MouseEvent} event */ mouseover(event) { if ( event.target === this.div && event.buttons === 0 && !this.#uiManager.hasActive() ) { // The div is the target so there is no ink editor, hence we can // create a new one. // event.buttons === 0 is here to avoid adding a new ink editor // when we drop an editor. const editor = this.#createAndAddNewEditor(event); editor.setInBackground(); } } /** * Add some commands into the CommandManager (undo/redo stuff). * @param {Object} params */ addCommands(params) { this.#uiManager.addCommands(params); } /** * Undo the last command. */ undo() { this.#uiManager.undo(); } /** * Redo the last command. */ redo() { this.#uiManager.redo(); } /** * Suppress the selected editor or all editors. * @returns {undefined} */ delete() { this.#uiManager.delete(); } /** * Copy the selected editor. */ copy() { this.#uiManager.copy(); } /** * Cut the selected editor. */ cut() { this.#uiManager.cut(); } /** * Paste a previously copied editor. * @returns {undefined} */ paste() { this.#uiManager.paste(); } /** * Select all the editors. */ selectAll() { this.#uiManager.selectAll(); } /** * Unselect all the editors. */ unselectAll() { this.#uiManager.unselectAll(); } /** * Enable pointer events on the main div in order to enable * editor creation. */ enable() { this.div.style.pointerEvents = "auto"; } /** * Disable editor creation. */ disable() { this.div.style.pointerEvents = "none"; } /** * Set the current editor. * @param {AnnotationEditor} editor */ setActiveEditor(editor) { const currentActive = this.#uiManager.getActive(); if (currentActive === editor) { return; } this.#uiManager.setActiveEditor(editor); if (currentActive && currentActive !== editor) { currentActive.commitOrRemove(); } if (editor) { this.unselectAll(); this.div.removeEventListener("click", this.#boundClick); } else { // When in Ink mode, setting the editor to null allows the // user to have to make one click in order to start drawing. this.#uiManager.allowClick = this.#uiManager.getMode() === AnnotationEditorType.INK; this.div.addEventListener("click", this.#boundClick); } } attach(editor) { this.#editors.set(editor.id, editor); } detach(editor) { this.#editors.delete(editor.id); } /** * 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); this.annotationStorage.removeKey(editor.id); editor.div.remove(); editor.isAttachedToDOM = false; if (this.#uiManager.isActive(editor) || this.#editors.size === 0) { this.setActiveEditor(null); this.#uiManager.allowClick = true; } } /** * An editor can have a different parent, for example after having * being dragged and droped from a page to another. * @param {AnnotationEditor} editor * @returns {undefined} */ #changeParent(editor) { if (editor.parent === this) { return; } if (this.#uiManager.isActive(editor)) { editor.parent.setActiveEditor(null); } this.attach(editor); editor.pageIndex = this.pageIndex; editor.parent.detach(editor); editor.parent = 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.annotationStorage.setValue(editor.id, editor); this.#uiManager.addEditor(editor); this.attach(editor); if (!editor.isAttachedToDOM) { const div = editor.render(); this.div.append(div); editor.isAttachedToDOM = true; } editor.onceAdded(); } /** * 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 and add a new editor. * @param {MouseEvent} event * @returns {AnnotationEditor} */ #createAndAddNewEditor(event) { const id = this.getNextId(); const editor = this.#createNewEditor({ parent: this, id, x: event.offsetX, y: event.offsetY, }); if (editor) { this.add(editor); } return editor; } /** * Mouseclick callback. * @param {MouseEvent} event * @returns {undefined} */ click(event) { if (!this.#uiManager.allowClick) { this.#uiManager.allowClick = true; return; } this.#createAndAddNewEditor(event); } /** * Drag callback. * @param {DragEvent} event * @returns {undefined} */ 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); } /** * Dragover callback. * @param {DragEvent} event */ dragover(event) { event.preventDefault(); } /** * Keydown callback. * @param {KeyboardEvent} event */ keydown(event) { if (!this.#uiManager.getActive()?.shouldGetKeyboardEvents()) { AnnotationEditorLayer._keyboardManager.exec(this, event); } } /** * Destroy the main editor. */ destroy() { for (const editor of this.#editors.values()) { editor.isAttachedToDOM = false; editor.div.remove(); editor.parent = null; this.div = null; } this.#editors.clear(); this.#uiManager.removeLayer(this); } /** * Render the main editor. * @param {Object} parameters */ render(parameters) { this.viewport = parameters.viewport; bindEvents(this, this.div, ["dragover", "drop", "keydown"]); this.div.addEventListener("click", this.#boundClick); this.setDimensions(); } /** * Update the main editor. * @param {Object} parameters */ update(parameters) { this.setActiveEditor(null); this.viewport = parameters.viewport; this.setDimensions(); } /** * Get the scale factor from the viewport. * @returns {number} */ get scaleFactor() { return this.viewport.scale; } /** * 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]; } get viewportBaseDimensions() { const { width, height, rotation } = this.viewport; return rotation % 180 === 0 ? [width, height] : [height, width]; } /** * Set the dimensions of the main div. */ setDimensions() { const { width, height, rotation } = this.viewport; const flipOrientation = rotation % 180 !== 0, widthStr = Math.floor(width) + "px", heightStr = Math.floor(height) + "px"; this.div.style.width = flipOrientation ? heightStr : widthStr; this.div.style.height = flipOrientation ? widthStr : heightStr; this.div.setAttribute("data-main-rotation", rotation); } } export { AnnotationEditorLayer };