diff --git a/app/src/asset/pdf/annotation_editor_layer_builder.js b/app/src/asset/pdf/annotation_editor_layer_builder.js new file mode 100644 index 000000000..2daa21a1e --- /dev/null +++ b/app/src/asset/pdf/annotation_editor_layer_builder.js @@ -0,0 +1,135 @@ +/* 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 */ +// eslint-disable-next-line max-len +/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */ +/** @typedef {import("./interfaces").IL10n} IL10n */ + +import { AnnotationEditorLayer } from "./pdfjs"; +import { NullL10n } from "./l10n_utils.js"; + +/** + * @typedef {Object} AnnotationEditorLayerBuilderOptions + * @property {number} mode - Editor mode + * @property {HTMLDivElement} pageDiv + * @property {PDFPageProxy} pdfPage + * @property {TextAccessibilityManager} accessibilityManager + * @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.accessibilityManager = options.accessibilityManager; + 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; + } + + const clonedViewport = viewport.clone({ dontFlip: true }); + if (this.div) { + this.annotationEditorLayer.update({ viewport: clonedViewport }); + this.show(); + return; + } + + // Create an AnnotationEditor layer div + this.div = document.createElement("div"); + this.div.className = "annotationEditorLayer"; + this.div.tabIndex = 0; + this.pageDiv.append(this.div); + + this.annotationEditorLayer = new AnnotationEditorLayer({ + uiManager: this.#uiManager, + div: this.div, + annotationStorage: this.annotationStorage, + accessibilityManager: this.accessibilityManager, + pageIndex: this.pdfPage._pageIndex, + l10n: this.l10n, + viewport: clonedViewport, + }); + + const parameters = { + viewport: clonedViewport, + div: this.div, + annotations: null, + intent, + }; + + this.annotationEditorLayer.render(parameters); + } + + cancel() { + this._cancelled = true; + this.destroy(); + } + + 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.annotationEditorLayer.destroy(); + this.div.remove(); + } +} + +export { AnnotationEditorLayerBuilder }; diff --git a/app/src/asset/pdf/annotation_editor_params.js b/app/src/asset/pdf/annotation_editor_params.js new file mode 100644 index 000000000..78e2777a7 --- /dev/null +++ b/app/src/asset/pdf/annotation_editor_params.js @@ -0,0 +1,95 @@ +/* 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 { AnnotationEditorParamsType } from "./pdfjs"; + +class AnnotationEditorParams { + /** + * @param {AnnotationEditorParamsOptions} options + * @param {EventBus} eventBus + */ + constructor(options, eventBus) { + this.eventBus = eventBus; + this.#bindListeners(options); + } + + #bindListeners({ + editorFreeTextFontSize, + editorFreeTextColor, + editorInkColor, + editorInkThickness, + editorInkOpacity, + }) { + editorFreeTextFontSize.addEventListener("input", evt => { + this.eventBus.dispatch("switchannotationeditorparams", { + source: this, + type: AnnotationEditorParamsType.FREETEXT_SIZE, + value: editorFreeTextFontSize.valueAsNumber, + }); + }); + editorFreeTextColor.addEventListener("input", evt => { + this.eventBus.dispatch("switchannotationeditorparams", { + source: this, + type: AnnotationEditorParamsType.FREETEXT_COLOR, + value: editorFreeTextColor.value, + }); + }); + editorInkColor.addEventListener("input", evt => { + this.eventBus.dispatch("switchannotationeditorparams", { + source: this, + type: AnnotationEditorParamsType.INK_COLOR, + value: editorInkColor.value, + }); + }); + editorInkThickness.addEventListener("input", evt => { + this.eventBus.dispatch("switchannotationeditorparams", { + source: this, + type: AnnotationEditorParamsType.INK_THICKNESS, + value: editorInkThickness.valueAsNumber, + }); + }); + editorInkOpacity.addEventListener("input", evt => { + this.eventBus.dispatch("switchannotationeditorparams", { + source: this, + type: AnnotationEditorParamsType.INK_OPACITY, + value: editorInkOpacity.valueAsNumber, + }); + }); + + this.eventBus._on("annotationeditorparamschanged", evt => { + for (const [type, value] of evt.details) { + switch (type) { + case AnnotationEditorParamsType.FREETEXT_SIZE: + editorFreeTextFontSize.value = value; + break; + case AnnotationEditorParamsType.FREETEXT_COLOR: + editorFreeTextColor.value = value; + break; + case AnnotationEditorParamsType.INK_COLOR: + editorInkColor.value = value; + break; + case AnnotationEditorParamsType.INK_THICKNESS: + editorInkThickness.value = value; + break; + case AnnotationEditorParamsType.INK_OPACITY: + editorInkOpacity.value = value; + break; + } + } + }); + } +} + +export { AnnotationEditorParams }; diff --git a/app/src/asset/pdf/text_accessibility.js b/app/src/asset/pdf/text_accessibility.js new file mode 100644 index 000000000..19741c4bf --- /dev/null +++ b/app/src/asset/pdf/text_accessibility.js @@ -0,0 +1,253 @@ +/* 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 { binarySearchFirstItem } from "./ui_utils.js"; + +/** + * This class aims to provide some methods: + * - to reorder elements in the DOM with respect to the visual order; + * - to create a link, using aria-owns, between spans in the textLayer and + * annotations in the annotationLayer. The goal is to help to know + * where the annotations are in the text flow. + */ +class TextAccessibilityManager { + #enabled = false; + + #textChildren = null; + + #textNodes = new Map(); + + #waitingElements = new Map(); + + setTextMapping(textDivs) { + this.#textChildren = textDivs; + } + + /** + * Compare the positions of two elements, it must correspond to + * the visual ordering. + * + * @param {HTMLElement} e1 + * @param {HTMLElement} e2 + * @returns {number} + */ + static #compareElementPositions(e1, e2) { + const rect1 = e1.getBoundingClientRect(); + const rect2 = e2.getBoundingClientRect(); + + if (rect1.width === 0 && rect1.height === 0) { + return +1; + } + + if (rect2.width === 0 && rect2.height === 0) { + return -1; + } + + const top1 = rect1.y; + const bot1 = rect1.y + rect1.height; + const mid1 = rect1.y + rect1.height / 2; + + const top2 = rect2.y; + const bot2 = rect2.y + rect2.height; + const mid2 = rect2.y + rect2.height / 2; + + if (mid1 <= top2 && mid2 >= bot1) { + return -1; + } + + if (mid2 <= top1 && mid1 >= bot2) { + return +1; + } + + const centerX1 = rect1.x + rect1.width / 2; + const centerX2 = rect2.x + rect2.width / 2; + + return centerX1 - centerX2; + } + + /** + * Function called when the text layer has finished rendering. + */ + enable() { + if (this.#enabled) { + throw new Error("TextAccessibilityManager is already enabled."); + } + if (!this.#textChildren) { + throw new Error("Text divs and strings have not been set."); + } + + this.#enabled = true; + this.#textChildren = this.#textChildren.slice(); + this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions); + + if (this.#textNodes.size > 0) { + // Some links have been made before this manager has been disabled, hence + // we restore them. + const textChildren = this.#textChildren; + for (const [id, nodeIndex] of this.#textNodes) { + const element = document.getElementById(id); + if (!element) { + // If the page was *fully* reset the element no longer exists, and it + // will be re-inserted later (i.e. when the annotationLayer renders). + this.#textNodes.delete(id); + continue; + } + this.#addIdToAriaOwns(id, textChildren[nodeIndex]); + } + } + + for (const [element, isRemovable] of this.#waitingElements) { + this.addPointerInTextLayer(element, isRemovable); + } + this.#waitingElements.clear(); + } + + disable() { + if (!this.#enabled) { + return; + } + + // Don't clear this.#textNodes which is used to rebuild the aria-owns + // in case it's re-enabled at some point. + + this.#waitingElements.clear(); + this.#textChildren = null; + this.#enabled = false; + } + + /** + * Remove an aria-owns id from a node in the text layer. + * @param {HTMLElement} element + */ + removePointerInTextLayer(element) { + if (!this.#enabled) { + this.#waitingElements.delete(element); + return; + } + + const children = this.#textChildren; + if (!children || children.length === 0) { + return; + } + + const { id } = element; + const nodeIndex = this.#textNodes.get(id); + if (nodeIndex === undefined) { + return; + } + + const node = children[nodeIndex]; + + this.#textNodes.delete(id); + let owns = node.getAttribute("aria-owns"); + if (owns?.includes(id)) { + owns = owns + .split(" ") + .filter(x => x !== id) + .join(" "); + if (owns) { + node.setAttribute("aria-owns", owns); + } else { + node.removeAttribute("aria-owns"); + node.setAttribute("role", "presentation"); + } + } + } + + #addIdToAriaOwns(id, node) { + const owns = node.getAttribute("aria-owns"); + if (!owns?.includes(id)) { + node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id); + } + node.removeAttribute("role"); + } + + /** + * Find the text node which is the nearest and add an aria-owns attribute + * in order to correctly position this editor in the text flow. + * @param {HTMLElement} element + * @param {boolean} isRemovable + */ + addPointerInTextLayer(element, isRemovable) { + const { id } = element; + if (!id) { + return; + } + + if (!this.#enabled) { + // The text layer needs to be there, so we postpone the association. + this.#waitingElements.set(element, isRemovable); + return; + } + + if (isRemovable) { + this.removePointerInTextLayer(element); + } + + const children = this.#textChildren; + if (!children || children.length === 0) { + return; + } + + const index = binarySearchFirstItem( + children, + node => + TextAccessibilityManager.#compareElementPositions(element, node) < 0 + ); + + const nodeIndex = Math.max(0, index - 1); + this.#addIdToAriaOwns(id, children[nodeIndex]); + this.#textNodes.set(id, nodeIndex); + } + + /** + * Move a div in the DOM in order to respect the visual order. + * @param {HTMLDivElement} element + */ + moveElementInDOM(container, element, contentElement, isRemovable) { + this.addPointerInTextLayer(contentElement, isRemovable); + + if (!container.hasChildNodes()) { + container.append(element); + return; + } + + const children = Array.from(container.childNodes).filter( + node => node !== element + ); + + if (children.length === 0) { + return; + } + + const elementToCompare = contentElement || element; + const index = binarySearchFirstItem( + children, + node => + TextAccessibilityManager.#compareElementPositions( + elementToCompare, + node + ) < 0 + ); + + if (index === 0) { + children[0].before(element); + } else { + children[index - 1].after(element); + } + } +} + +export { TextAccessibilityManager };