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