diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 86d9388f3e9c0b..371da256f026ed 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -160,6 +160,10 @@ ], "default": 2 }, + "annotationEditorEnabled": { + "type": "boolean", + "default": false + }, "enablePermissions": { "type": "boolean", "default": false diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 2eb5a8d9295525..ff5ec6e6d211b9 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -249,3 +249,10 @@ password_cancel=Cancel printing_not_supported=Warning: Printing is not fully supported by this browser. printing_not_ready=Warning: The PDF is not fully loaded for printing. web_fonts_disabled=Web fonts are disabled: unable to use embedded PDF fonts. + +# Editor +editor_none.title=Disable Annotation Editing +editor_none_label=Disable Editing +freetext_default_content=Enter some text… +editor_free_text.title=Add FreeText Annotation +editor_free_text_label=FreeText Annotation diff --git a/src/display/annotation_storage.js b/src/display/annotation_storage.js index 458dceadc0be98..e2ad831b0ac4e9 100644 --- a/src/display/annotation_storage.js +++ b/src/display/annotation_storage.js @@ -13,6 +13,7 @@ * limitations under the License. */ +import { AnnotationEditor } from "./editor/editor.js"; import { MurmurHash3_64 } from "../shared/murmurhash3.js"; import { objectFromMap } from "../shared/util.js"; @@ -62,6 +63,14 @@ class AnnotationStorage { return this._storage.get(key); } + /** + * Remove a value from the storage. + * @param {string} key + */ + removeKey(key) { + this._storage.delete(key); + } + /** * Set the value for a given key * @@ -123,7 +132,19 @@ class AnnotationStorage { * @ignore */ get serializable() { - return this._storage.size > 0 ? this._storage : null; + if (this._storage.size === 0) { + return null; + } + + const clone = new Map(); + for (const [key, value] of this._storage) { + if (value instanceof AnnotationEditor) { + clone.set(key, value.serialize()); + } else { + clone.set(key, value); + } + } + return clone; } /** diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js new file mode 100644 index 00000000000000..c89b37aeeea207 --- /dev/null +++ b/src/display/editor/annotation_editor_layer.js @@ -0,0 +1,432 @@ +/* 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 { AnnotationEditorType, Util } from "../../shared/util.js"; +import { bindEvents, KeyboardManager } from "./tools.js"; +import { FreeTextEditor } from "./freetext.js"; +import { PixelsPerInch } from "../display_utils.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; + + #editors = new Map(); + + #uiManager; + + static _l10nInitialized = 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, + ], + [ + [ + "ctrl+Backspace", + "mac+Backspace", + "mac+ctrl+Backspace", + "mac+alt+Backspace", + ], + AnnotationEditorLayer.prototype.suppress, + ], + ]); + + /** + * @param {AnnotationEditorLayerOptions} options + */ + constructor(options) { + if (!AnnotationEditorLayer._l10nInitialized) { + AnnotationEditorLayer._l10nInitialized = true; + FreeTextEditor.setL10n(options.l10n); + } + this.#uiManager = options.uiManager; + this.annotationStorage = options.annotationStorage; + this.pageIndex = options.pageIndex; + this.div = options.div; + this.#boundClick = this.click.bind(this); + + for (const editor of this.#uiManager.getEditors(options.pageIndex)) { + this.add(editor); + } + + this.#uiManager.addLayer(this); + } + + /** + * Add some commands into the CommandManager (undo/redo stuff). + * @param {function} cmd + * @param {function} undo + */ + addCommands(cmd, undo) { + this.#uiManager.addCommands(cmd, undo); + } + + /** + * 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} + */ + suppress() { + this.#uiManager.suppress(); + } + + /** + * Copy the selected editor. + */ + copy() { + this.#uiManager.copy(); + } + + /** + * Cut the selected editor. + */ + cut() { + this.#uiManager.cut(this); + } + + /** + * Paste a previously copied editor. + * @returns {undefined} + */ + paste() { + this.#uiManager.paste(this); + } + + /** + * 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) { + if (editor) { + this.unselectAll(); + this.div.removeEventListener("click", this.#boundClick); + } else { + this.#uiManager.allowClick = false; + this.div.addEventListener("click", this.#boundClick); + } + this.#uiManager.setActiveEditor(editor); + } + + 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; + this.div.focus(); + } + } + + /** + * 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; + } + this.attach(editor); + editor.pageIndex = this.pageIndex; + editor.parent.detach(editor); + editor.parent = this; + if (editor.div && editor.isAttachedToDOM) { + editor.div.remove(); + this.div.appendChild(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.appendChild(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); + } + + /** + * 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); + } + return null; + } + + /** + * Mouseclick callback. + * @param {MouseEvent} event + * @returns {undefined} + */ + click(event) { + if (!this.#uiManager.allowClick) { + this.#uiManager.allowClick = true; + return; + } + + const id = this.getNextId(); + const editor = this.#createNewEditor({ + parent: this, + id, + x: event.offsetX, + y: event.offsetY, + }); + if (editor) { + this.addANewEditor(editor); + } + } + + /** + * 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(); + editor.setAt( + event.clientX - rect.x - editor.mouseX, + event.clientY - rect.y - editor.mouseY + ); + } + + /** + * 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; + this.inverseViewportTransform = Util.inverseTransform( + this.viewport.transform + ); + bindEvents(this, this.div, ["dragover", "drop", "keydown"]); + this.div.addEventListener("click", this.#boundClick); + } + + /** + * Update the main editor. + * @param {Object} parameters + */ + update(parameters) { + const transform = Util.transform( + parameters.viewport.transform, + this.inverseViewportTransform + ); + this.viewport = parameters.viewport; + this.inverseViewportTransform = Util.inverseTransform( + this.viewport.transform + ); + for (const editor of this.#editors.values()) { + editor.transform(transform); + } + } + + /** + * Get the scale factor from the viewport. + * @returns {number} + */ + get scaleFactor() { + return this.viewport.scale; + } + + /** + * Get the zoom factor. + * @returns {number} + */ + get zoomFactor() { + return this.viewport.scale / PixelsPerInch.PDF_TO_CSS_UNITS; + } +} + +export { AnnotationEditorLayer }; diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js new file mode 100644 index 00000000000000..0a83fa3b85f37d --- /dev/null +++ b/src/display/editor/editor.js @@ -0,0 +1,305 @@ +/* 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 { unreachable, Util } from "../../shared/util.js"; +import { bindEvents } from "./tools.js"; + +/** + * @typedef {Object} AnnotationEditorParameters + * @property {AnnotationEditorLayer} parent - the layer containing this editor + * @property {string} id - editor id + * @property {number} x - x-coordinate + * @property {number} y - y-coordinate + */ + +/** + * Base class for editors. + */ +class AnnotationEditor { + #isInEditMode = false; + + /** + * @param {AnnotationEditorParameters} parameters + */ + constructor(parameters) { + if (this.constructor === AnnotationEditor) { + unreachable("Cannot initialize AnnotationEditor."); + } + + this.parent = parameters.parent; + this.id = parameters.id; + this.width = this.height = null; + this.pageIndex = parameters.parent.pageIndex; + this.name = parameters.name; + this.div = null; + this.x = Math.round(parameters.x); + this.y = Math.round(parameters.y); + + this.isAttachedToDOM = false; + } + + /** + * onfocus callback. + */ + focusin(/* event */) { + this.parent.setActiveEditor(this); + } + + /** + * onblur callback. + * @param {FocusEvent} event + * @returns {undefined} + */ + focusout(event) { + if (!this.isAttachedToDOM) { + return; + } + + // In case of focusout, the relatedTarget is the element which + // is grabbing the focus. + // So if the related target is an element under the div for this + // editor, then the editor isn't unactive. + const target = event.relatedTarget; + if (target?.closest(`#${this.id}`)) { + return; + } + + event.preventDefault(); + + if (this.isEmpty()) { + this.remove(); + } else { + this.commit(); + } + this.parent.setActiveEditor(null); + } + + /** + * Get the pointer coordinates in order to correctly translate the + * div in case of drag-and-drop. + * @param {MouseEvent} event + */ + mousedown(event) { + this.mouseX = event.offsetX; + this.mouseY = event.offsetY; + } + + /** + * We use drag-and-drop in order to move an editor on a page. + * @param {DragEvent} event + */ + dragstart(event) { + event.dataTransfer.setData("text/plain", this.id); + event.dataTransfer.effectAllowed = "move"; + } + + /** + * Set the editor position within its parent. + * @param {number} x + * @param {number} y + */ + setAt(x, y) { + this.x = Math.round(x); + this.y = Math.round(y); + + this.div.style.left = `${this.x}px`; + this.div.style.top = `${this.y}px`; + } + + /** + * Translate the editor position within its parent. + * @param {number} x + * @param {number} y + */ + translate(x, y) { + this.setAt(this.x + x, this.y + y); + } + + /** + * Set the dimensions of this editor. + * @param {number} width + * @param {number} height + */ + setDims(width, height) { + this.div.style.width = `${width}px`; + this.div.style.height = `${height}px`; + } + + /** + * Render this editor in a div. + * @returns {HTMLDivElement} + */ + render() { + this.div = document.createElement("div"); + this.div.className = this.name; + this.div.setAttribute("id", this.id); + this.div.draggable = true; + this.div.tabIndex = 100; + this.div.style.left = `${this.x}px`; + this.div.style.top = `${this.y}px`; + + bindEvents(this, this.div, [ + "dragstart", + "focusin", + "focusout", + "mousedown", + ]); + + return this.div; + } + + /** + * Executed once this editor has been rendered. + */ + onceAdded() {} + + /** + * Apply the current transform (zoom) to this editor. + * @param {Array} transform + */ + transform(transform) { + const { style } = this.div; + const width = parseFloat(style.width); + const height = parseFloat(style.height); + + const [x1, y1] = Util.applyTransform([this.x, this.y], transform); + + if (!Number.isNaN(width)) { + const [x2] = Util.applyTransform([this.x + width, 0], transform); + this.div.style.width = `${x2 - x1}px`; + } + if (!Number.isNaN(height)) { + const [, y2] = Util.applyTransform([0, this.y + height], transform); + this.div.style.height = `${y2 - y1}px`; + } + this.setAt(x1, y1); + } + + /** + * Check if the editor contains something. + * @returns {boolean} + */ + isEmpty() { + return false; + } + + /** + * Enable edit mode. + * @returns {undefined} + */ + enableEditMode() { + this.#isInEditMode = true; + } + + /** + * Disable edit mode. + * @returns {undefined} + */ + disableEditMode() { + this.#isInEditMode = false; + } + + /** + * Check if the editor is edited. + * @returns {boolean} + */ + isInEditMode() { + return this.#isInEditMode; + } + + /** + * If it returns true, then this editor handle the keyboard + * events itself. + * @returns {boolean} + */ + shouldGetKeyboardEvents() { + 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} + */ + needsToBeRebuilt() { + return this.div && !this.isAttachedToDOM; + } + + /** + * Rebuild the editor in case it has been removed on undo. + * + * To implement in subclasses. + * @returns {undefined} + */ + rebuild() { + unreachable("An editor must be rebuildable"); + } + + /** + * Serialize the editor. + * The result of the serialization will be used to construct a + * new annotation to add to the pdf document. + * + * To implement in subclasses. + * @returns {undefined} + */ + serialize() { + unreachable("An editor must be serializable"); + } + + /** + * Remove this editor. + * It's used on ctrl+backspace action. + * + * @returns {undefined} + */ + remove() { + this.parent.remove(this); + } + + /** + * Select this editor. + */ + select() { + if (this.div) { + this.div.classList.add("selectedEditor"); + } + } + + /** + * Unselect this editor. + */ + unselect() { + if (this.div) { + this.div.classList.remove("selectedEditor"); + } + } +} + +export { AnnotationEditor }; diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js new file mode 100644 index 00000000000000..9a48172882e305 --- /dev/null +++ b/src/display/editor/freetext.js @@ -0,0 +1,225 @@ +/* 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. + */ + +import { AnnotationEditorType, Util } from "../../shared/util.js"; +import { AnnotationEditor } from "./editor.js"; +import { bindEvents } from "./tools.js"; + +/** + * Basic text editor in order to create a FreeTex annotation. + */ +class FreeTextEditor extends AnnotationEditor { + #color; + + #content = ""; + + #contentHTML = ""; + + #fontSize; + + static _freeTextDefaultContent = ""; + + static _l10nPromise; + + constructor(params) { + super({ ...params, name: "freeTextEditor" }); + this.#color = params.color || "CanvasText"; + this.#fontSize = params.fontSize || 10; + } + + static setL10n(l10n) { + this._l10nPromise = l10n.get("freetext_default_content"); + } + + /** @inheritdoc */ + copy() { + const editor = new FreeTextEditor({ + parent: this.parent, + id: this.parent.getNextId(), + x: this.x, + y: this.y, + }); + + 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; + } + + /** @inheritdoc */ + rebuild() { + if (this.div === null) { + return; + } + + if (!this.isAttachedToDOM) { + // At some point this editor has been removed and + // we're rebuilting it, hence we must add it to its + // parent. + this.parent.add(this); + } + } + + /** @inheritdoc */ + enableEditMode() { + super.enableEditMode(); + this.overlayDiv.classList.remove("enabled"); + this.div.draggable = false; + } + + /** @inheritdoc */ + disableEditMode() { + super.disableEditMode(); + this.overlayDiv.classList.add("enabled"); + this.div.draggable = true; + } + + /** @inheritdoc */ + onceAdded() { + if (this.width) { + // The editor has been created in using ctrl+c. + this.div.focus(); + return; + } + this.enableEditMode(); + this.editorDiv.focus(); + } + + /** @inheritdoc */ + isEmpty() { + return this.editorDiv.innerText.trim() === ""; + } + + /** + * 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 (let i = 0, ii = divs.length; i < ii; i++) { + const div = divs[i]; + const first = div.firstChild; + if (first?.nodeName === "#text") { + buffer.push(first.data); + } else { + buffer.push(""); + } + } + return buffer.join("\n"); + } + + /** + * Commit the content we have in this editor. + * @returns {undefined} + */ + commit() { + this.disableEditMode(); + this.#contentHTML = this.editorDiv.innerHTML; + this.#content = this.#extractText().trimEnd(); + + const style = getComputedStyle(this.div); + this.width = parseFloat(style.width); + this.height = parseFloat(style.height); + } + + /** @inheritdoc */ + shouldGetKeyboardEvents() { + return this.isInEditMode(); + } + + /** + * ondblclick callback. + * @param {MouseEvent} event + */ + dblclick(event) { + this.enableEditMode(); + this.editorDiv.focus(); + } + + /** @inheritdoc */ + render() { + if (this.div) { + return this.div; + } + + super.render(); + this.editorDiv = document.createElement("div"); + this.editorDiv.tabIndex = 0; + this.editorDiv.className = "internal"; + + FreeTextEditor._l10nPromise.then(msg => + this.editorDiv.setAttribute("default-content", msg) + ); + this.editorDiv.contentEditable = true; + + const { style } = this.editorDiv; + style.fontSize = `calc(${this.#fontSize}px * var(--zoom-factor))`; + style.minHeight = `calc(${1.5 * this.#fontSize}px * var(--zoom-factor))`; + style.color = this.#color; + + this.div.appendChild(this.editorDiv); + + this.overlayDiv = document.createElement("div"); + this.overlayDiv.classList.add("overlay", "enabled"); + this.div.appendChild(this.overlayDiv); + + // TODO: implement paste callback. + // The goal is to sanitize and have something suitable for this + // editor. + bindEvents(this, this.div, ["dblclick"]); + + if (this.width) { + // This editor has been created in using copy (ctrl+c). + this.setAt(this.x + this.width, this.y + this.height); + // eslint-disable-next-line no-unsanitized/property + this.editorDiv.innerHTML = this.#contentHTML; + } + + return this.div; + } + + /** @inheritdoc */ + serialize() { + const rect = this.div.getBoundingClientRect(); + const [x1, y1] = Util.applyTransform( + [this.x, this.y + rect.height], + this.parent.viewport.inverseTransform + ); + + const [x2, y2] = Util.applyTransform( + [this.x + rect.width, this.y], + this.parent.viewport.inverseTransform + ); + + return { + annotationType: AnnotationEditorType.FREETEXT, + color: [0, 0, 0], + fontSize: this.#fontSize, + value: this.#content, + pageIndex: this.parent.pageIndex, + rect: [x1, y1, x2, y2], + }; + } +} + +export { FreeTextEditor }; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js new file mode 100644 index 00000000000000..61fc17bf7928fa --- /dev/null +++ b/src/display/editor/tools.js @@ -0,0 +1,574 @@ +/* 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("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */ + +import { + AnnotationEditorPrefix, + AnnotationEditorType, + shadow, +} from "../../shared/util.js"; + +function bindEvents(obj, element, names) { + for (const name of names) { + element.addEventListener(name, obj[name].bind(obj)); + } +} +/** + * Class to create some unique ids for the different editors. + */ +class IdManager { + #id = 0; + + /** + * Get a unique id. + * @returns {string} + */ + getId() { + return `${AnnotationEditorPrefix}${this.#id++}`; + } +} + +/** + * Class to handle undo/redo. + * Commands are just saved in a buffer. + * If we hit some memory issues we could likely use a circular buffer. + * It has to be used as a singleton. + */ +class CommandManager { + #commands = []; + + #maxSize = 100; + + // When the position is NaN, it means the buffer is empty. + #position = NaN; + + #start = 0; + + /** + * Add a new couple of commands to be used in case of redo/undo. + * @param {function} cmd + * @param {function} undo + */ + add(cmd, undo) { + const save = [cmd, undo]; + const next = (this.#position + 1) % this.#maxSize; + if (next !== this.#start) { + if (this.#start < next) { + this.#commands = this.#commands.slice(this.#start, next); + } else { + this.#commands = this.#commands + .slice(this.#start) + .concat(this.#commands.slice(0, next)); + } + this.#start = 0; + this.#position = this.#commands.length - 1; + } + this.#setCommands(save); + cmd(); + } + + /** + * Undo the last command. + */ + undo() { + if (isNaN(this.#position)) { + // Nothing to undo. + return; + } + this.#commands[this.#position][1](); + if (this.#position === this.#start) { + this.#position = NaN; + } else { + this.#position = (this.#maxSize + this.#position - 1) % this.#maxSize; + } + } + + /** + * Redo the last command. + */ + redo() { + if (isNaN(this.#position)) { + if (this.#start < this.#commands.length) { + this.#commands[this.#start][0](); + this.#position = this.#start; + } + return; + } + + const next = (this.#position + 1) % this.#maxSize; + if (next !== this.#start && next < this.#commands.length) { + this.#commands[next][0](); + this.#position = next; + } + } + + #setCommands(cmds) { + if (this.#commands.length < this.#maxSize) { + this.#commands.push(cmds); + this.#position = isNaN(this.#position) ? 0 : this.#position + 1; + return; + } + + if (isNaN(this.#position)) { + this.#position = this.#start; + } else { + this.#position = (this.#position + 1) % this.#maxSize; + if (this.#position === this.#start) { + this.#start = (this.#start + 1) % this.#maxSize; + } + } + this.#commands[this.#position] = cmds; + } +} + +/** + * Class to handle the different keyboards shortcuts we can have on mac or + * non-mac OSes. + */ +class KeyboardManager { + /** + * Create a new keyboard manager class. + * @param {Array} callbacks - an array containing an array of shortcuts + * and a callback to call. + * A shortcut is a string like `ctrl+c` or `mac+ctrl+c` for mac OS. + */ + constructor(callbacks) { + this.buffer = []; + this.callbacks = new Map(); + this.allKeys = new Set(); + + const isMac = KeyboardManager.platform.isMac; + for (const [keys, callback] of callbacks) { + for (const key of keys) { + const isMacKey = key.startsWith("mac+"); + if (isMac && isMacKey) { + this.callbacks.set(key.slice(4), callback); + this.allKeys.add(key.split("+").at(-1)); + } else if (!isMac && !isMacKey) { + this.callbacks.set(key, callback); + this.allKeys.add(key.split("+").at(-1)); + } + } + } + } + + static get platform() { + const platform = typeof navigator !== "undefined" ? navigator.platform : ""; + + return shadow(this, "platform", { + isWin: platform.includes("Win"), + isMac: platform.includes("Mac"), + }); + } + + /** + * Serialize an event into a string in order to match a + * potential key for a callback. + * @param {KeyboardEvent} event + * @returns {string} + */ + #serialize(event) { + if (event.altKey) { + this.buffer.push("alt"); + } + if (event.ctrlKey) { + this.buffer.push("ctrl"); + } + if (event.metaKey) { + this.buffer.push("meta"); + } + if (event.shiftKey) { + this.buffer.push("shift"); + } + this.buffer.push(event.key); + const str = this.buffer.join("+"); + this.buffer.length = 0; + + return str; + } + + /** + * Execute a callback, if any, for a given keyboard event. + * The page is used as `this` in the callback. + * @param {AnnotationEditorLayer} page. + * @param {KeyboardEvent} event + * @returns + */ + exec(page, event) { + if (!this.allKeys.has(event.key)) { + return; + } + const callback = this.callbacks.get(this.#serialize(event)); + if (!callback) { + return; + } + callback.bind(page)(); + event.preventDefault(); + } +} + +/** + * Basic clipboard to copy/paste some editors. + * It has to be used as a singleton. + */ +class ClipboardManager { + constructor() { + this.element = null; + } + + /** + * Copy an element. + * @param {AnnotationEditor} element + */ + copy(element) { + this.element = element.copy(); + } + + /** + * Create a new element. + * @returns {AnnotationEditor|null} + */ + paste() { + return this.element?.copy() || null; + } +} + +/** + * A pdf has several pages and each of them when it will rendered + * will have an AnnotationEditorLayer which will contain the some + * new Annotations associated to an editor in order to modify them. + * + * This class is used to manage all the different layers, editors and + * some action like copy/paste, undo/redo, ... + */ +class AnnotationEditorUIManager { + #activeEditor = null; + + #allEditors = new Map(); + + #allLayers = new Set(); + + #allowClick = true; + + #clipboardManager = new ClipboardManager(); + + #commandManager = new CommandManager(); + + #idManager = new IdManager(); + + #isAllSelected = false; + + #isEnabled = false; + + #mode = AnnotationEditorType.NONE; + + /** + * Get an id. + * @returns {string} + */ + getId() { + return this.#idManager.getId(); + } + + /** + * Add a new layer for a page which will contains the editors. + * @param {AnnotationEditorLayer} layer + */ + addLayer(layer) { + this.#allLayers.add(layer); + if (this.#isEnabled) { + layer.enable(); + } else { + layer.disable(); + } + } + + /** + * Remove a layer. + * @param {AnnotationEditorLayer} layer + */ + removeLayer(layer) { + this.#allLayers.delete(layer); + } + + /** + * Change the editor mode (None, FreeText, Ink, ...) + * @param {number} mode + */ + updateMode(mode) { + this.#mode = mode; + if (mode === AnnotationEditorType.NONE) { + this.#disableAll(); + } else { + this.#enableAll(); + } + } + + /** + * Enable all the layers. + */ + #enableAll() { + if (!this.#isEnabled) { + this.#isEnabled = true; + for (const layer of this.#allLayers) { + layer.enable(); + } + } + } + + /** + * Disable all the layers. + */ + #disableAll() { + if (this.#isEnabled) { + this.#isEnabled = false; + for (const layer of this.#allLayers) { + layer.disable(); + } + } + } + + /** + * Get all the editors belonging to a give page. + * @param {number} pageIndex + * @returns {Array} + */ + getEditors(pageIndex) { + const editors = []; + for (const editor of this.#allEditors.values()) { + if (editor.pageIndex === pageIndex) { + editors.push(editor); + } + } + return editors; + } + + /** + * Get an editor with the given id. + * @param {string} id + * @returns {AnnotationEditor} + */ + getEditor(id) { + return this.#allEditors.get(id); + } + + /** + * Add a new editor. + * @param {AnnotationEditor} editor + */ + addEditor(editor) { + this.#allEditors.set(editor.id, editor); + } + + /** + * Remove an editor. + * @param {AnnotationEditor} editor + */ + removeEditor(editor) { + this.#allEditors.delete(editor.id); + } + + /** + * Set the given editor as the active one. + * @param {AnnotationEditor} editor + */ + setActiveEditor(editor) { + this.#activeEditor = editor; + } + + /** + * Undo the last command. + */ + undo() { + this.#commandManager.undo(); + } + + /** + * Redo the last undoed command. + */ + redo() { + this.#commandManager.redo(); + } + + /** + * Add a command to execute (cmd) and another one to undo it. + * @param {function} cmd + * @param {function} undo + */ + addCommands(cmd, undo) { + this.#commandManager.add(cmd, undo); + } + + /** + * When set to true a click on the current layer will trigger + * an editor creation. + * @return {boolean} + */ + get allowClick() { + return this.#allowClick; + } + + /** + * @param {boolean} allow + */ + set allowClick(allow) { + this.#allowClick = allow; + } + + /** + * Unselect the current editor. + */ + unselect() { + if (this.#activeEditor) { + this.#activeEditor.parent.setActiveEditor(null); + } + this.#allowClick = true; + } + + /** + * Suppress some editors from the given layer. + * @param {AnnotationEditorLayer} layer + */ + suppress(layer) { + let cmd, undo; + if (this.#isAllSelected) { + const editors = Array.from(this.#allEditors.values()); + cmd = () => { + for (const editor of editors) { + editor.remove(); + } + }; + + undo = () => { + for (const editor of editors) { + layer.addOrRebuild(editor); + } + }; + + this.addCommands(cmd, undo); + } else { + if (!this.#activeEditor) { + return; + } + const editor = this.#activeEditor; + cmd = () => { + editor.remove(); + }; + undo = () => { + layer.addOrRebuild(editor); + }; + } + + this.addCommands(cmd, undo); + } + + /** + * Copy the selected editor. + */ + copy() { + if (this.#activeEditor) { + this.#clipboardManager.copy(this.#activeEditor); + } + } + + /** + * Cut the selected editor. + * @param {AnnotationEditorLayer} + */ + cut(layer) { + if (this.#activeEditor) { + this.#clipboardManager.copy(this.#activeEditor); + const editor = this.#activeEditor; + const cmd = () => { + editor.remove(); + }; + const undo = () => { + layer.addOrRebuild(editor); + }; + + this.addCommands(cmd, undo); + } + } + + /** + * Paste a previously copied editor. + * @param {AnnotationEditorLayer} + * @returns {undefined} + */ + paste(layer) { + const editor = this.#clipboardManager.paste(); + if (!editor) { + return; + } + const cmd = () => { + layer.addOrRebuild(editor); + }; + const undo = () => { + editor.remove(); + }; + + this.addCommands(cmd, undo); + } + + /** + * Select all the editors. + */ + selectAll() { + this.#isAllSelected = true; + for (const editor of this.#allEditors.values()) { + editor.select(); + } + } + + /** + * Unselect all the editors. + */ + unselectAll() { + this.#isAllSelected = false; + for (const editor of this.#allEditors.values()) { + editor.unselect(); + } + } + + /** + * Is the current editor the one passed as argument? + * @param {AnnotationEditor} editor + * @returns + */ + isActive(editor) { + return this.#activeEditor === editor; + } + + /** + * Get the current active editor. + * @returns {AnnotationEditor|null} + */ + getActive() { + return this.#activeEditor; + } + + /** + * Get the current editor mode. + * @returns {number} + */ + getMode() { + return this.#mode; + } +} + +export { AnnotationEditorUIManager, bindEvents, KeyboardManager }; diff --git a/src/pdf.js b/src/pdf.js index 7c38367d120055..2eebf1c2ecadf6 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -21,6 +21,7 @@ /** @typedef {import("./display/display_utils").PageViewport} PageViewport */ import { + AnnotationEditorType, AnnotationMode, CMapCompressionType, createPromiseCapability, @@ -56,6 +57,8 @@ import { PixelsPerInch, RenderingCancelledException, } from "./display/display_utils.js"; +import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js"; +import { AnnotationEditorUIManager } from "./display/editor/tools.js"; import { AnnotationLayer } from "./display/annotation_layer.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; import { isNodeJS } from "./shared/is_node.js"; @@ -104,6 +107,9 @@ if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("PRODUCTION")) { } export { + AnnotationEditorLayer, + AnnotationEditorType, + AnnotationEditorUIManager, AnnotationLayer, AnnotationMode, build, diff --git a/src/shared/util.js b/src/shared/util.js index 665b59d391539f..e5b930f85ddc50 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -51,6 +51,13 @@ const AnnotationMode = { ENABLE_STORAGE: 3, }; +const AnnotationEditorPrefix = "pdfjs_internal_editor_"; + +const AnnotationEditorType = { + NONE: 0, + FREETEXT: 1, +}; + // Permission flags from Table 22, Section 7.6.3.2 of the PDF specification. const PermissionFlag = { PRINT: 0x04, @@ -1135,6 +1142,8 @@ export { AbortException, AnnotationActionEventType, AnnotationBorderStyleType, + AnnotationEditorPrefix, + AnnotationEditorType, AnnotationFieldFlag, AnnotationFlag, AnnotationMarkedState, diff --git a/test/integration-boot.js b/test/integration-boot.js index 9e11c60d42f6fa..695576a6e1836c 100644 --- a/test/integration-boot.js +++ b/test/integration-boot.js @@ -30,6 +30,7 @@ async function runTests(results) { "annotation_spec.js", "accessibility_spec.js", "find_spec.js", + "freetext_editor_spec.js", ], }); diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js new file mode 100644 index 00000000000000..9637d60d12a4bd --- /dev/null +++ b/test/integration/freetext_editor_spec.js @@ -0,0 +1,200 @@ +/* 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. + */ + +const { closePages, loadAndWait } = require("./test_utils.js"); + +const editorPrefix = "#pdfjs_internal_editor_"; + +describe("Editor", () => { + describe("FreeText", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("tracemonkey.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must write a string in a FreeText editor", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.click("#editorFreeText"); + + const rect = await page.$eval(".annotationEditorLayer", el => { + // With Chrome something is wrong when serializing a DomRect, + // hence we extract the values and just return them. + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + const data = "Hello PDF.js World !!"; + await page.mouse.click(rect.x + 10, rect.y + 10); + await page.type(`${editorPrefix}0 .internal`, data); + + const editorRect = await page.$eval(`${editorPrefix}0`, el => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { + x, + y, + width, + height, + }; + }); + + // Commit. + await page.mouse.click( + editorRect.x, + editorRect.y + 2 * editorRect.height + ); + + const content = await page.$eval(`${editorPrefix}0`, el => + el.innerText.trimEnd() + ); + expect(content).withContext(`In ${browserName}`).toEqual(data); + }) + ); + }); + + it("must copy/paste", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const editorRect = await page.$eval(`${editorPrefix}0`, el => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { x, y, width, height }; + }); + + // Select the editor created previously. + await page.mouse.click( + editorRect.x + editorRect.width / 2, + editorRect.y + editorRect.height / 2 + ); + + await page.keyboard.down("Control"); + await page.keyboard.press("c"); + await page.keyboard.up("Control"); + + await page.keyboard.down("Control"); + await page.keyboard.press("v"); + await page.keyboard.up("Control"); + + const content = await page.$eval(`${editorPrefix}0`, el => + el.innerText.trimEnd() + ); + let pastedContent = await page.$eval(`${editorPrefix}2`, el => + el.innerText.trimEnd() + ); + + expect(pastedContent) + .withContext(`In ${browserName}`) + .toEqual(content); + + await page.keyboard.down("Control"); + await page.keyboard.press("c"); + await page.keyboard.up("Control"); + + await page.keyboard.down("Control"); + await page.keyboard.press("v"); + await page.keyboard.up("Control"); + + pastedContent = await page.$eval(`${editorPrefix}4`, el => + el.innerText.trimEnd() + ); + expect(pastedContent) + .withContext(`In ${browserName}`) + .toEqual(content); + }) + ); + }); + + it("must clear all", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.keyboard.down("Control"); + await page.keyboard.press("a"); + await page.keyboard.up("Control"); + + await page.keyboard.down("Control"); + await page.keyboard.press("Backspace"); + await page.keyboard.up("Control"); + + for (const n of [0, 2, 4]) { + const hasEditor = await page.evaluate(sel => { + return !!document.querySelector(sel); + }, `${editorPrefix}${n}`); + + expect(hasEditor).withContext(`In ${browserName}`).toEqual(false); + } + }) + ); + }); + + it("must check that a paste has been undone", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const rect = await page.$eval(".annotationEditorLayer", el => { + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + const data = "Hello PDF.js World !!"; + await page.mouse.click(rect.x + 10, rect.y + 10); + await page.type(`${editorPrefix}5 .internal`, data); + + const editorRect = await page.$eval(`${editorPrefix}5`, el => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { x, y, width, height }; + }); + + // Commit. + await page.mouse.click( + editorRect.x, + editorRect.y + 2 * editorRect.height + ); + // And select it again. + await page.mouse.click( + editorRect.x + editorRect.width / 2, + editorRect.y + editorRect.height / 2 + ); + + await page.keyboard.down("Control"); + await page.keyboard.press("c"); + await page.keyboard.up("Control"); + + await page.keyboard.down("Control"); + await page.keyboard.press("v"); + await page.keyboard.up("Control"); + + let hasEditor = await page.evaluate(sel => { + return !!document.querySelector(sel); + }, `${editorPrefix}7`); + + expect(hasEditor).withContext(`In ${browserName}`).toEqual(true); + + await page.keyboard.down("Control"); + await page.keyboard.press("z"); + await page.keyboard.up("Control"); + + hasEditor = await page.evaluate(sel => { + return !!document.querySelector(sel); + }, `${editorPrefix}7`); + + expect(hasEditor).withContext(`In ${browserName}`).toEqual(false); + }) + ); + }); + }); +}); diff --git a/test/test.js b/test/test.js index 07817e5fa03607..b22f8c27bb88e1 100644 --- a/test/test.js +++ b/test/test.js @@ -950,6 +950,7 @@ async function startBrowser(browserName, startUrl = "") { // Avoid popup when saving is done "browser.download.always_ask_before_handling_new_types": true, "browser.download.panel.shown": true, + "browser.download.alwaysOpenPanel": false, // Save file in output "browser.download.folderList": 2, "browser.download.dir": tempDir, diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css new file mode 100644 index 00000000000000..a85733b72d4307 --- /dev/null +++ b/web/annotation_editor_layer_builder.css @@ -0,0 +1,85 @@ +/* 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. + */ + +:root { + --focus-outline: solid 2px red; + --hover-outline: dashed 2px blue; +} + +.annotationEditorLayer { + background: transparent; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.annotationEditorLayer .freeTextEditor { + position: absolute; + background: transparent; + border-radius: 3px; + padding: 5px; + resize: none; + width: auto; + height: auto; +} + +.annotationEditorLayer .freeTextEditor .internal { + background: transparent; + border: none; + top: 0; + left: 0; + min-height: 15px; + overflow: visible; + white-space: nowrap; + resize: none; +} + +.annotationEditorLayer .freeTextEditor .overlay { + position: absolute; + display: none; + background: transparent; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.annotationEditorLayer .freeTextEditor .overlay.enabled { + display: block; +} + +.annotationEditorLayer .freeTextEditor .internal:empty::before { + content: attr(default-content); + color: gray; +} + +.annotationEditorLayer .freeTextEditor .internal:focus { + outline: none; +} + +.annotationEditorLayer .freeTextEditor:focus-within { + outline: var(--focus-outline); +} + +.annotationEditorLayer .freeTextEditor:hover:not(:focus-within) { + outline: var(--hover-outline); +} + +.annotationEditorLayer .selectedEditor { + outline: var(--focus-outline); + resize: none; +} diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js new file mode 100644 index 00000000000000..158fe9516904c9 --- /dev/null +++ b/web/annotation_editor_layer_builder.js @@ -0,0 +1,128 @@ +/* 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("../src/display/api").PDFPageProxy} PDFPageProxy */ +// eslint-disable-next-line max-len +/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */ +/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */ +// eslint-disable-next-line max-len +/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */ +// eslint-disable-next-line max-len +/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */ +/** @typedef {import("./interfaces").IL10n} IL10n */ + +import { AnnotationEditorLayer } from "pdfjs-lib"; +import { NullL10n } from "./l10n_utils.js"; + +/** + * @typedef {Object} AnnotationEditorLayerBuilderOptions + * @property {number} mode - Editor mode + * @property {HTMLDivElement} pageDiv + * @property {PDFPageProxy} pdfPage + * @property {AnnotationStorage} annotationStorage + * @property {IL10n} l10n - Localization service. + * @property {AnnotationEditorUIManager} uiManager + */ + +class AnnotationEditorLayerBuilder { + #uiManager; + + /** + * @param {AnnotationEditorLayerBuilderOptions} options + */ + constructor(options) { + this.pageDiv = options.pageDiv; + this.pdfPage = options.pdfPage; + this.annotationStorage = options.annotationStorage || null; + this.l10n = options.l10n || NullL10n; + this.annotationEditorLayer = null; + this.div = null; + this._cancelled = false; + this.#uiManager = options.uiManager; + } + + /** + * @param {PageViewport} viewport + * @param {string} intent (default value is 'display') + */ + async render(viewport, intent = "display") { + if (intent !== "display") { + return; + } + + if (this._cancelled) { + return; + } + + if (this.div) { + this.annotationEditorLayer.update({ viewport: viewport.clone() }); + this.show(); + return; + } + + // Create an AnnotationEditor layer div + this.div = document.createElement("div"); + this.div.className = "annotationEditorLayer"; + this.div.tabIndex = 0; + + this.annotationEditorLayer = new AnnotationEditorLayer({ + uiManager: this.#uiManager, + div: this.div, + annotationStorage: this.annotationStorage, + pageIndex: this.pdfPage._pageIndex, + l10n: this.l10n, + }); + + const parameters = { + viewport: viewport.clone(), + div: this.div, + annotations: null, + intent, + }; + + this.annotationEditorLayer.render(parameters); + + this.pageDiv.appendChild(this.div); + } + + cancel() { + this._cancelled = true; + } + + hide() { + if (!this.div) { + return; + } + this.div.hidden = true; + } + + show() { + if (!this.div) { + return; + } + this.div.hidden = false; + } + + destroy() { + if (!this.div) { + return; + } + this.pageDiv = null; + this.div.remove(); + this.annotationEditorLayer.destroy(); + } +} + +export { AnnotationEditorLayerBuilder }; diff --git a/web/app.js b/web/app.js index 5a990175fd41e3..20ec240f636f8f 100644 --- a/web/app.js +++ b/web/app.js @@ -525,6 +525,7 @@ const PDFViewerApplication = { l10n: this.l10n, textLayerMode: AppOptions.get("textLayerMode"), annotationMode: AppOptions.get("annotationMode"), + annotationEditorEnabled: AppOptions.get("annotationEditorEnabled"), imageResourcesPath: AppOptions.get("imageResourcesPath"), enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), useOnlyCssZoom: AppOptions.get("useOnlyCssZoom"), @@ -560,6 +561,10 @@ const PDFViewerApplication = { this.findBar = new PDFFindBar(appConfig.findBar, eventBus, this.l10n); } + if (AppOptions.get("annotationEditorEnabled")) { + document.getElementById("editorModeButtons").classList.remove("hidden"); + } + this.pdfDocumentProperties = new PDFDocumentProperties( appConfig.documentProperties, this.overlayManager, @@ -1878,6 +1883,10 @@ const PDFViewerApplication = { eventBus._on("namedaction", webViewerNamedAction); eventBus._on("presentationmodechanged", webViewerPresentationModeChanged); eventBus._on("presentationmode", webViewerPresentationMode); + eventBus._on( + "switchannotationeditormode", + webViewerSwitchAnnotationEditorMode + ); eventBus._on("print", webViewerPrint); eventBus._on("download", webViewerDownload); eventBus._on("firstpage", webViewerFirstPage); @@ -2459,6 +2468,13 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { function webViewerPresentationMode() { PDFViewerApplication.requestPresentationMode(); } +function webViewerSwitchAnnotationEditorMode(evt) { + if (evt.toggle) { + PDFViewerApplication.pdfViewer.annotionEditorEnabled = true; + } else { + PDFViewerApplication.pdfViewer.annotationEditorMode = evt.mode; + } +} function webViewerPrint() { PDFViewerApplication.triggerPrinting(); } diff --git a/web/app_options.js b/web/app_options.js index 205f826cd5920d..a5f50ffba158d9 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -58,6 +58,11 @@ const defaultOptions = { value: 2, kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + annotationEditorEnabled: { + /** @type {boolean} */ + value: typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, cursorToolOnLoad: { /** @type {number} */ value: 0, diff --git a/web/base_viewer.js b/web/base_viewer.js index b200a3b80e4642..401468ae225c44 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -22,6 +22,8 @@ /** @typedef {import("./interfaces").IL10n} IL10n */ // eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFAnnotationLayerFactory} IPDFAnnotationLayerFactory */ +// eslint-disable-next-line max-len +/** @typedef {import("./interfaces").IPDFAnnotationEditorLayerFactory} IPDFAnnotationEditorLayerFactory */ /** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */ // eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFStructTreeLayerFactory} IPDFStructTreeLayerFactory */ @@ -30,6 +32,8 @@ /** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */ import { + AnnotationEditorType, + AnnotationEditorUIManager, AnnotationMode, createPromiseCapability, PermissionFlag, @@ -61,6 +65,7 @@ import { VERTICAL_PADDING, watchScroll, } from "./ui_utils.js"; +import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js"; import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { NullL10n } from "./l10n_utils.js"; import { PDFPageView } from "./pdf_page_view.js"; @@ -104,6 +109,8 @@ const PagesCountLimit = { * being rendered. The constants from {@link AnnotationMode} should be used; * see also {@link RenderParameters} and {@link GetOperatorListParameters}. * The default value is `AnnotationMode.ENABLE_FORMS`. + * @property {boolean} [annotationEditorEnabled] - Enables the creation and + * editing of new Annotations. * @property {string} [imageResourcesPath] - Path for image resources, mainly * mainly for annotation icons. Include trailing slash. * @property {boolean} [enablePrintAutoRotate] - Enables automatic rotation of @@ -194,6 +201,7 @@ class PDFPageViewBuffer { * Simple viewer control to display PDF content/pages. * * @implements {IPDFAnnotationLayerFactory} + * @implements {IPDFAnnotationEditorLayerFactory} * @implements {IPDFStructTreeLayerFactory} * @implements {IPDFTextLayerFactory} * @implements {IPDFXfaLayerFactory} @@ -201,6 +209,10 @@ class PDFPageViewBuffer { class BaseViewer { #buffer = null; + #annotationEditorMode = AnnotationEditorType.NONE; + + #annotationEditorUIManager = null; + #annotationMode = AnnotationMode.ENABLE_FORMS; #previousAnnotationMode = null; @@ -268,6 +280,10 @@ class BaseViewer { this.#enablePermissions = options.enablePermissions || false; this.pageColors = options.pageColors || null; + if (options.annotationEditorEnabled === true) { + this.#annotationEditorUIManager = new AnnotationEditorUIManager(); + } + if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { if ( this.pageColors && @@ -699,6 +715,9 @@ class BaseViewer { const annotationLayerFactory = this.#annotationMode !== AnnotationMode.DISABLE ? this : null; const xfaLayerFactory = isPureXfa ? this : null; + const annotationEditorLayerFactory = this.#annotationEditorUIManager + ? this + : null; for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { const pageView = new PDFPageView({ @@ -714,6 +733,7 @@ class BaseViewer { annotationLayerFactory, annotationMode: this.#annotationMode, xfaLayerFactory, + annotationEditorLayerFactory, textHighlighterFactory: this, structTreeLayerFactory: this, imageResourcesPath: this.imageResourcesPath, @@ -1656,6 +1676,30 @@ class BaseViewer { }); } + /** + * @param {HTMLDivElement} pageDiv + * @param {PDFPageProxy} pdfPage + * @param {IL10n} l10n + * @param {AnnotationStorage} [annotationStorage] - Storage for annotation + * data in forms. + * @returns {AnnotationEditorLayerBuilder} + */ + createAnnotationEditorLayerBuilder( + pageDiv, + pdfPage, + l10n, + annotationStorage = null + ) { + return new AnnotationEditorLayerBuilder({ + uiManager: this.#annotationEditorUIManager, + pageDiv, + pdfPage, + annotationStorage: + annotationStorage || this.pdfDocument?.annotationStorage, + l10n, + }); + } + /** * @param {HTMLDivElement} pageDiv * @param {PDFPageProxy} pdfPage @@ -2072,6 +2116,36 @@ class BaseViewer { docStyle.setProperty("--viewer-container-height", `${height}px`); } } + + get annotationEditorMode() { + return this.#annotationEditorMode; + } + + /** + * @param {number} mode - Annotation Editor mode (None, FreeText, Ink, ...) + */ + set annotationEditorMode(mode) { + if (!this.#annotationEditorUIManager) { + throw new Error(`The AnnotationEditor is not enabled.`); + } + + if (this.#annotationEditorMode === mode) { + return; + } + + if (!Object.values(AnnotationEditorType).includes(mode)) { + throw new Error(`Invalid AnnotationEditor mode: ${mode}`); + } + + // If the mode is the same as before, it means that this mode is disabled + // and consequently the mode is NONE. + this.#annotationEditorMode = mode; + this.eventBus.dispatch("annotationeditormodechanged", { + source: this, + mode, + }); + this.#annotationEditorUIManager.updateMode(mode); + } } export { BaseViewer, PagesCountLimit, PDFPageViewBuffer }; diff --git a/web/default_factory.js b/web/default_factory.js index f9289b55234f5b..b09f4598ad9e93 100644 --- a/web/default_factory.js +++ b/web/default_factory.js @@ -21,6 +21,8 @@ /** @typedef {import("./interfaces").IL10n} IL10n */ // eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFAnnotationLayerFactory} IPDFAnnotationLayerFactory */ +// eslint-disable-next-line max-len +/** @typedef {import("./interfaces").IPDFAnnotationEditorLayerFactory} IPDFAnnotationEditorLayerFactory */ /** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */ // eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFStructTreeLayerFactory} IPDFStructTreeLayerFactory */ @@ -29,6 +31,7 @@ /** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */ /** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */ +import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js"; import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { NullL10n } from "./l10n_utils.js"; import { SimpleLinkService } from "./pdf_link_service.js"; @@ -87,6 +90,32 @@ class DefaultAnnotationLayerFactory { } } +/** + * @implements IPDFAnnotationEditorLayerFactory + */ +class DefaultAnnotationEditorLayerFactory { + /** + * @param {HTMLDivElement} pageDiv + * @param {PDFPageProxy} pdfPage + * @param {IL10n} l10n + * @param {AnnotationStorage} [annotationStorage] + * @returns {AnnotationEditorLayerBuilder} + */ + createAnnotationEditorLayerBuilder( + pageDiv, + pdfPage, + l10n, + annotationStorage = null + ) { + return new AnnotationEditorLayerBuilder({ + pageDiv, + pdfPage, + l10n, + annotationStorage, + }); + } +} + /** * @implements IPDFStructTreeLayerFactory */ @@ -161,6 +190,7 @@ class DefaultXfaLayerFactory { } export { + DefaultAnnotationEditorLayerFactory, DefaultAnnotationLayerFactory, DefaultStructTreeLayerFactory, DefaultTextLayerFactory, diff --git a/web/images/toolbarButton-editorFreeText.svg b/web/images/toolbarButton-editorFreeText.svg new file mode 100644 index 00000000000000..f0f11b47c60596 --- /dev/null +++ b/web/images/toolbarButton-editorFreeText.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/web/images/toolbarButton-editorNone.svg b/web/images/toolbarButton-editorNone.svg new file mode 100644 index 00000000000000..43e9789444487a --- /dev/null +++ b/web/images/toolbarButton-editorNone.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/web/interfaces.js b/web/interfaces.js index 7630b5051f0a52..c2c01013c334ca 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -19,6 +19,8 @@ /** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */ // eslint-disable-next-line max-len /** @typedef {import("./annotation_layer_builder").AnnotationLayerBuilder} AnnotationLayerBuilder */ +// eslint-disable-next-line max-len +/** @typedef {import("./annotation_editor_layer_builder").AnnotationEditorLayerBuilder} AnnotationEditorLayerBuilder */ /** @typedef {import("./event_utils").EventBus} EventBus */ // eslint-disable-next-line max-len /** @typedef {import("./struct_tree_builder").StructTreeLayerBuilder} StructTreeLayerBuilder */ @@ -208,6 +210,26 @@ class IPDFAnnotationLayerFactory { ) {} } +/** + * @interface + */ +class IPDFAnnotationEditorLayerFactory { + /** + * @param {HTMLDivElement} pageDiv + * @param {PDFPageProxy} pdfPage + * @param {IL10n} l10n + * @param {AnnotationStorage} [annotationStorage] - Storage for annotation + * data in forms. + * @returns {AnnotationEditorLayerBuilder} + */ + createAnnotationEditorLayerBuilder( + pageDiv, + pdfPage, + l10n = undefined, + annotationStorage = null + ) {} +} + /** * @interface */ @@ -307,6 +329,7 @@ class IL10n { export { IDownloadManager, IL10n, + IPDFAnnotationEditorLayerFactory, IPDFAnnotationLayerFactory, IPDFLinkService, IPDFStructTreeLayerFactory, diff --git a/web/l10n_utils.js b/web/l10n_utils.js index 8e08cd08c45ff8..0189e974462acd 100644 --- a/web/l10n_utils.js +++ b/web/l10n_utils.js @@ -81,6 +81,7 @@ const DEFAULT_L10N_STRINGS = { printing_not_ready: "Warning: The PDF is not fully loaded for printing.", web_fonts_disabled: "Web fonts are disabled: unable to use embedded PDF fonts.", + freetext_default_content: "Enter some text…", }; function getL10nFallback(key, args) { diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index a019f82cd6ceb0..3fc48091725a26 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -22,6 +22,8 @@ // eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFAnnotationLayerFactory} IPDFAnnotationLayerFactory */ // eslint-disable-next-line max-len +/** @typedef {import("./interfaces").IPDFAnnotationEditorLayerFactory} IPDFAnnotationEditorLayerFactory */ +// eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFStructTreeLayerFactory} IPDFStructTreeLayerFactory */ // eslint-disable-next-line max-len /** @typedef {import("./interfaces").IPDFTextLayerFactory} IPDFTextLayerFactory */ @@ -72,6 +74,7 @@ import { NullL10n } from "./l10n_utils.js"; * see also {@link RenderParameters} and {@link GetOperatorListParameters}. * The default value is `AnnotationMode.ENABLE_FORMS`. * @property {IPDFAnnotationLayerFactory} annotationLayerFactory + * @property {IPDFAnnotationEditorLayerFactory} annotationEditorLayerFactory * @property {IPDFXfaLayerFactory} xfaLayerFactory * @property {IPDFStructTreeLayerFactory} structTreeLayerFactory * @property {Object} [textHighlighterFactory] @@ -128,6 +131,7 @@ class PDFPageView { this.renderingQueue = options.renderingQueue; this.textLayerFactory = options.textLayerFactory; this.annotationLayerFactory = options.annotationLayerFactory; + this.annotationEditorLayerFactory = options.annotationEditorLayerFactory; this.xfaLayerFactory = options.xfaLayerFactory; this.textHighlighter = options.textHighlighterFactory?.createTextHighlighter( @@ -148,6 +152,7 @@ class PDFPageView { this._annotationCanvasMap = null; this.annotationLayer = null; + this.annotationEditorLayer = null; this.textLayer = null; this.zoomLayer = null; this.xfaLayer = null; @@ -204,6 +209,24 @@ class PDFPageView { } } + /** + * @private + */ + async _renderAnnotationEditorLayer() { + let error = null; + try { + await this.annotationEditorLayer.render(this.viewport, "display"); + } catch (ex) { + error = ex; + } finally { + this.eventBus.dispatch("annotationeditorlayerrendered", { + source: this, + pageNumber: this.id, + error, + }); + } + } + /** * @private */ @@ -259,9 +282,14 @@ class PDFPageView { reset({ keepZoomLayer = false, keepAnnotationLayer = false, + keepAnnotationEditorLayer = false, keepXfaLayer = false, } = {}) { - this.cancelRendering({ keepAnnotationLayer, keepXfaLayer }); + this.cancelRendering({ + keepAnnotationLayer, + keepAnnotationEditorLayer, + keepXfaLayer, + }); this.renderingState = RenderingStates.INITIAL; const div = this.div; @@ -272,12 +300,15 @@ class PDFPageView { zoomLayerNode = (keepZoomLayer && this.zoomLayer) || null, annotationLayerNode = (keepAnnotationLayer && this.annotationLayer?.div) || null, + annotationEditorLayerNode = + (keepAnnotationEditorLayer && this.annotationEditorLayer?.div) || null, xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null; for (let i = childNodes.length - 1; i >= 0; i--) { const node = childNodes[i]; switch (node) { case zoomLayerNode: case annotationLayerNode: + case annotationEditorLayerNode: case xfaLayerNode: continue; } @@ -290,6 +321,12 @@ class PDFPageView { // so they are not displayed on the already resized page. this.annotationLayer.hide(); } + + if (annotationEditorLayerNode) { + this.annotationEditorLayer.hide(); + } else { + this.annotationEditorLayer?.destroy(); + } if (xfaLayerNode) { // Hide the XFA layer until all elements are resized // so they are not displayed on the already resized page. @@ -347,6 +384,7 @@ class PDFPageView { this.cssTransform({ target: this.svg, redrawAnnotationLayer: true, + redrawAnnotationEditorLayer: true, redrawXfaLayer: true, }); @@ -380,6 +418,7 @@ class PDFPageView { this.cssTransform({ target: this.canvas, redrawAnnotationLayer: true, + redrawAnnotationEditorLayer: true, redrawXfaLayer: true, }); @@ -403,6 +442,7 @@ class PDFPageView { this.reset({ keepZoomLayer: true, keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, keepXfaLayer: true, }); } @@ -411,7 +451,11 @@ class PDFPageView { * PLEASE NOTE: Most likely you want to use the `this.reset()` method, * rather than calling this one directly. */ - cancelRendering({ keepAnnotationLayer = false, keepXfaLayer = false } = {}) { + cancelRendering({ + keepAnnotationLayer = false, + keepAnnotationEditorLayer = false, + keepXfaLayer = false, + } = {}) { if (this.paintTask) { this.paintTask.cancel(); this.paintTask = null; @@ -430,6 +474,13 @@ class PDFPageView { this.annotationLayer = null; this._annotationCanvasMap = null; } + if ( + this.annotationEditorLayer && + (!keepAnnotationEditorLayer || !this.annotationEditorLayer.div) + ) { + this.annotationEditorLayer.cancel(); + this.annotationEditorLayer = null; + } if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) { this.xfaLayer.cancel(); this.xfaLayer = null; @@ -444,6 +495,7 @@ class PDFPageView { cssTransform({ target, redrawAnnotationLayer = false, + redrawAnnotationEditorLayer = false, redrawXfaLayer = false, }) { // Scale target (canvas or svg), its wrapper and page container. @@ -517,6 +569,9 @@ class PDFPageView { if (redrawAnnotationLayer && this.annotationLayer) { this._renderAnnotationLayer(); } + if (redrawAnnotationEditorLayer && this.annotationEditorLayer) { + this._renderAnnotationEditorLayer(); + } if (redrawXfaLayer && this.xfaLayer) { this._renderXfaLayer(); } @@ -567,9 +622,12 @@ class PDFPageView { canvasWrapper.style.height = div.style.height; canvasWrapper.classList.add("canvasWrapper"); - if (this.annotationLayer?.div) { + const lastDivBeforeTextDiv = + this.annotationLayer?.div || this.annotationEditorLayer?.div; + + if (lastDivBeforeTextDiv) { // The annotation layer needs to stay on top. - div.insertBefore(canvasWrapper, this.annotationLayer.div); + div.insertBefore(canvasWrapper, lastDivBeforeTextDiv); } else { div.appendChild(canvasWrapper); } @@ -580,9 +638,9 @@ class PDFPageView { textLayerDiv.className = "textLayer"; textLayerDiv.style.width = canvasWrapper.style.width; textLayerDiv.style.height = canvasWrapper.style.height; - if (this.annotationLayer?.div) { + if (lastDivBeforeTextDiv) { // The annotation layer needs to stay on top. - div.insertBefore(textLayerDiv, this.annotationLayer.div); + div.insertBefore(textLayerDiv, lastDivBeforeTextDiv); } else { div.appendChild(textLayerDiv); } @@ -693,7 +751,18 @@ class PDFPageView { } if (this.annotationLayer) { - this._renderAnnotationLayer(); + this._renderAnnotationLayer().then(() => { + if (this.annotationEditorLayerFactory) { + this.annotationEditorLayer ||= + this.annotationEditorLayerFactory.createAnnotationEditorLayerBuilder( + div, + pdfPage, + this.l10n, + /* annotationStorage = */ null + ); + this._renderAnnotationEditorLayer(); + } + }); } }); }, diff --git a/web/pdf_viewer.css b/web/pdf_viewer.css index 5c3f22b2f5052e..98ddc44e19b676 100644 --- a/web/pdf_viewer.css +++ b/web/pdf_viewer.css @@ -15,6 +15,7 @@ @import url(text_layer_builder.css); @import url(annotation_layer_builder.css); @import url(xfa_layer_builder.css); +@import url(annotation_editor_layer_builder.css); :root { --viewer-container-height: 0; diff --git a/web/toolbar.js b/web/toolbar.js index ae1243ed8e8ef6..5a14eb8fc2ccd3 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -22,6 +22,7 @@ import { MIN_SCALE, noContextMenuHandler, } from "./ui_utils.js"; +import { AnnotationEditorType } from "pdfjs-lib"; const PAGE_NUMBER_LOADING_INDICATOR = "visiblePageIsLoading"; @@ -43,6 +44,9 @@ const PAGE_NUMBER_LOADING_INDICATOR = "visiblePageIsLoading"; * @property {HTMLButtonElement} openFile - Button to open a new document. * @property {HTMLButtonElement} presentationModeButton - Button to switch to * presentation mode. + * @property {HTMLButtonElement} editorNoneButton - Button to disable editing. + * @property {HTMLButtonElement} editorFreeTextButton - Button to switch to Free + * Text edition. * @property {HTMLButtonElement} download - Button to download the document. * @property {HTMLAnchorElement} viewBookmark - Button to obtain a bookmark link * to the current location in the document. @@ -70,6 +74,16 @@ class Toolbar { }, { element: options.download, eventName: "download" }, { element: options.viewBookmark, eventName: null }, + { + element: options.editorNoneButton, + eventName: "switchannotationeditormode", + eventDetails: { mode: AnnotationEditorType.NONE }, + }, + { + element: options.editorFreeTextButton, + eventName: "switchannotationeditormode", + eventDetails: { mode: AnnotationEditorType.FREETEXT }, + }, ]; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this.buttons.push({ element: options.openFile, eventName: "openfile" }); @@ -89,7 +103,7 @@ class Toolbar { this.reset(); // Bind the event listeners for click and various other actions. - this._bindListeners(); + this._bindListeners(options); } setPageNumber(pageNumber, pageLabel) { @@ -121,15 +135,21 @@ class Toolbar { this.updateLoadingIndicatorState(); } - _bindListeners() { + _bindListeners(options) { const { pageNumber, scaleSelect } = this.items; const self = this; // The buttons within the toolbar. - for (const { element, eventName } of this.buttons) { + for (const { element, eventName, eventDetails } of this.buttons) { element.addEventListener("click", evt => { if (eventName !== null) { - this.eventBus.dispatch(eventName, { source: this }); + const details = { source: this }; + if (eventDetails) { + for (const property in eventDetails) { + details[property] = eventDetails[property]; + } + } + this.eventBus.dispatch(eventName, details); } }); } @@ -174,6 +194,23 @@ class Toolbar { this.#adjustScaleWidth(); this._updateUIState(true); }); + + this.#bindEditorToolsListener(options); + } + + #bindEditorToolsListener({ editorNoneButton, editorFreeTextButton }) { + this.eventBus._on("annotationeditormodechanged", evt => { + const editorButtons = [ + [AnnotationEditorType.NONE, editorNoneButton], + [AnnotationEditorType.FREETEXT, editorFreeTextButton], + ]; + + for (const [mode, button] of editorButtons) { + const checked = mode === evt.mode; + button.classList.toggle("toggled", checked); + button.setAttribute("aria-checked", checked); + } + }); } _updateUIState(resetNumPages = false) { diff --git a/web/viewer.css b/web/viewer.css index a8bad89cb7f4b2..f611f1ef111c5c 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -71,6 +71,8 @@ --loading-icon: url(images/loading.svg); --treeitem-expanded-icon: url(images/treeitem-expanded.svg); --treeitem-collapsed-icon: url(images/treeitem-collapsed.svg); + --toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg); + --toolbarButton-editorNone-icon: url(images/toolbarButton-editorNone.svg); --toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg); --toolbarButton-sidebarToggle-icon: url(images/toolbarButton-sidebarToggle.svg); --toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg); @@ -824,6 +826,14 @@ select { mask-image: var(--toolbarButton-presentationMode-icon); } +#editorNone::before { + mask-image: var(--toolbarButton-editorNone-icon); +} + +#editorFreeText::before { + mask-image: var(--toolbarButton-editorFreeText-icon); +} + #print::before, #secondaryPrint::before { mask-image: var(--toolbarButton-print-icon); diff --git a/web/viewer.html b/web/viewer.html index cb698cf0f921d9..e142e84a81ce61 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -263,30 +263,39 @@
- + +
+ + - - - - + Current View
- diff --git a/web/viewer.js b/web/viewer.js index ee56f83773edb9..1446aac4fb7595 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -93,6 +93,8 @@ function getViewerConfiguration() { ? document.getElementById("openFile") : null, print: document.getElementById("print"), + editorFreeTextButton: document.getElementById("editorFreeText"), + editorNoneButton: document.getElementById("editorNone"), presentationModeButton: document.getElementById("presentationMode"), download: document.getElementById("download"), viewBookmark: document.getElementById("viewBookmark"),