text_accessibility.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /* Copyright 2022 Mozilla Foundation
  2. *
  3. * Licensed under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License.
  5. * You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software
  10. * distributed under the License is distributed on an "AS IS" BASIS,
  11. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. * See the License for the specific language governing permissions and
  13. * limitations under the License.
  14. */
  15. import { binarySearchFirstItem } from "./ui_utils.js";
  16. /**
  17. * This class aims to provide some methods:
  18. * - to reorder elements in the DOM with respect to the visual order;
  19. * - to create a link, using aria-owns, between spans in the textLayer and
  20. * annotations in the annotationLayer. The goal is to help to know
  21. * where the annotations are in the text flow.
  22. */
  23. class TextAccessibilityManager {
  24. #enabled = false;
  25. #textChildren = null;
  26. #textNodes = new Map();
  27. #waitingElements = new Map();
  28. setTextMapping(textDivs) {
  29. this.#textChildren = textDivs;
  30. }
  31. /**
  32. * Compare the positions of two elements, it must correspond to
  33. * the visual ordering.
  34. *
  35. * @param {HTMLElement} e1
  36. * @param {HTMLElement} e2
  37. * @returns {number}
  38. */
  39. static #compareElementPositions(e1, e2) {
  40. const rect1 = e1.getBoundingClientRect();
  41. const rect2 = e2.getBoundingClientRect();
  42. if (rect1.width === 0 && rect1.height === 0) {
  43. return +1;
  44. }
  45. if (rect2.width === 0 && rect2.height === 0) {
  46. return -1;
  47. }
  48. const top1 = rect1.y;
  49. const bot1 = rect1.y + rect1.height;
  50. const mid1 = rect1.y + rect1.height / 2;
  51. const top2 = rect2.y;
  52. const bot2 = rect2.y + rect2.height;
  53. const mid2 = rect2.y + rect2.height / 2;
  54. if (mid1 <= top2 && mid2 >= bot1) {
  55. return -1;
  56. }
  57. if (mid2 <= top1 && mid1 >= bot2) {
  58. return +1;
  59. }
  60. const centerX1 = rect1.x + rect1.width / 2;
  61. const centerX2 = rect2.x + rect2.width / 2;
  62. return centerX1 - centerX2;
  63. }
  64. /**
  65. * Function called when the text layer has finished rendering.
  66. */
  67. enable() {
  68. if (this.#enabled) {
  69. throw new Error("TextAccessibilityManager is already enabled.");
  70. }
  71. if (!this.#textChildren) {
  72. throw new Error("Text divs and strings have not been set.");
  73. }
  74. this.#enabled = true;
  75. this.#textChildren = this.#textChildren.slice();
  76. this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions);
  77. if (this.#textNodes.size > 0) {
  78. // Some links have been made before this manager has been disabled, hence
  79. // we restore them.
  80. const textChildren = this.#textChildren;
  81. for (const [id, nodeIndex] of this.#textNodes) {
  82. const element = document.getElementById(id);
  83. if (!element) {
  84. // If the page was *fully* reset the element no longer exists, and it
  85. // will be re-inserted later (i.e. when the annotationLayer renders).
  86. this.#textNodes.delete(id);
  87. continue;
  88. }
  89. this.#addIdToAriaOwns(id, textChildren[nodeIndex]);
  90. }
  91. }
  92. for (const [element, isRemovable] of this.#waitingElements) {
  93. this.addPointerInTextLayer(element, isRemovable);
  94. }
  95. this.#waitingElements.clear();
  96. }
  97. disable() {
  98. if (!this.#enabled) {
  99. return;
  100. }
  101. // Don't clear this.#textNodes which is used to rebuild the aria-owns
  102. // in case it's re-enabled at some point.
  103. this.#waitingElements.clear();
  104. this.#textChildren = null;
  105. this.#enabled = false;
  106. }
  107. /**
  108. * Remove an aria-owns id from a node in the text layer.
  109. * @param {HTMLElement} element
  110. */
  111. removePointerInTextLayer(element) {
  112. if (!this.#enabled) {
  113. this.#waitingElements.delete(element);
  114. return;
  115. }
  116. const children = this.#textChildren;
  117. if (!children || children.length === 0) {
  118. return;
  119. }
  120. const { id } = element;
  121. const nodeIndex = this.#textNodes.get(id);
  122. if (nodeIndex === undefined) {
  123. return;
  124. }
  125. const node = children[nodeIndex];
  126. this.#textNodes.delete(id);
  127. let owns = node.getAttribute("aria-owns");
  128. if (owns?.includes(id)) {
  129. owns = owns
  130. .split(" ")
  131. .filter(x => x !== id)
  132. .join(" ");
  133. if (owns) {
  134. node.setAttribute("aria-owns", owns);
  135. } else {
  136. node.removeAttribute("aria-owns");
  137. node.setAttribute("role", "presentation");
  138. }
  139. }
  140. }
  141. #addIdToAriaOwns(id, node) {
  142. const owns = node.getAttribute("aria-owns");
  143. if (!owns?.includes(id)) {
  144. node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
  145. }
  146. node.removeAttribute("role");
  147. }
  148. /**
  149. * Find the text node which is the nearest and add an aria-owns attribute
  150. * in order to correctly position this editor in the text flow.
  151. * @param {HTMLElement} element
  152. * @param {boolean} isRemovable
  153. */
  154. addPointerInTextLayer(element, isRemovable) {
  155. const { id } = element;
  156. if (!id) {
  157. return;
  158. }
  159. if (!this.#enabled) {
  160. // The text layer needs to be there, so we postpone the association.
  161. this.#waitingElements.set(element, isRemovable);
  162. return;
  163. }
  164. if (isRemovable) {
  165. this.removePointerInTextLayer(element);
  166. }
  167. const children = this.#textChildren;
  168. if (!children || children.length === 0) {
  169. return;
  170. }
  171. const index = binarySearchFirstItem(
  172. children,
  173. node =>
  174. TextAccessibilityManager.#compareElementPositions(element, node) < 0
  175. );
  176. const nodeIndex = Math.max(0, index - 1);
  177. this.#addIdToAriaOwns(id, children[nodeIndex]);
  178. this.#textNodes.set(id, nodeIndex);
  179. }
  180. /**
  181. * Move a div in the DOM in order to respect the visual order.
  182. * @param {HTMLDivElement} element
  183. */
  184. moveElementInDOM(container, element, contentElement, isRemovable) {
  185. this.addPointerInTextLayer(contentElement, isRemovable);
  186. if (!container.hasChildNodes()) {
  187. container.append(element);
  188. return;
  189. }
  190. const children = Array.from(container.childNodes).filter(
  191. node => node !== element
  192. );
  193. if (children.length === 0) {
  194. return;
  195. }
  196. const elementToCompare = contentElement || element;
  197. const index = binarySearchFirstItem(
  198. children,
  199. node =>
  200. TextAccessibilityManager.#compareElementPositions(
  201. elementToCompare,
  202. node
  203. ) < 0
  204. );
  205. if (index === 0) {
  206. children[0].before(element);
  207. } else {
  208. children[index - 1].after(element);
  209. }
  210. }
  211. }
  212. export { TextAccessibilityManager };