Skip to content

Commit 8306934

Browse files
calixtemanrousek
authored andcommitted
[Editor] Improve a11y for newly added element (mozilla#15109)
- In the annotationEditorLayer, reorder the editors in the DOM according the position of the elements on the screen; - add an aria-owns attribute on the "nearest" element in the text layer which points to the added editor.
1 parent 9d637d6 commit 8306934

15 files changed

+467
-96
lines changed

l10n/en-US/viewer.properties

+5
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,8 @@ editor_free_text_font_color=Font Color
265265
editor_free_text_font_size=Font Size
266266
editor_ink_line_color=Line Color
267267
editor_ink_line_thickness=Line Thickness
268+
269+
# Editor aria
270+
editor_free_text_aria_label=FreeText Editor
271+
editor_ink_aria_label=Ink Editor
272+
editor_ink_canvas_aria_label=User-created image

src/display/display_utils.js

+33
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,40 @@ function getColorValues(colors) {
601601
span.remove();
602602
}
603603

604+
/**
605+
* Use binary search to find the index of the first item in a given array which
606+
* passes a given condition. The items are expected to be sorted in the sense
607+
* that if the condition is true for one item in the array, then it is also true
608+
* for all following items.
609+
*
610+
* @returns {number} Index of the first array element to pass the test,
611+
* or |items.length| if no such element exists.
612+
*/
613+
function binarySearchFirstItem(items, condition, start = 0) {
614+
let minIndex = start;
615+
let maxIndex = items.length - 1;
616+
617+
if (maxIndex < 0 || !condition(items[maxIndex])) {
618+
return items.length;
619+
}
620+
if (condition(items[minIndex])) {
621+
return minIndex;
622+
}
623+
624+
while (minIndex < maxIndex) {
625+
const currentIndex = (minIndex + maxIndex) >> 1;
626+
const currentItem = items[currentIndex];
627+
if (condition(currentItem)) {
628+
maxIndex = currentIndex;
629+
} else {
630+
minIndex = currentIndex + 1;
631+
}
632+
}
633+
return minIndex; /* === maxIndex */
634+
}
635+
604636
export {
637+
binarySearchFirstItem,
605638
deprecated,
606639
DOMCanvasFactory,
607640
DOMCMapReaderFactory,

src/display/editor/annotation_editor_layer.js

+205-7
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
2121
/** @typedef {import("../../web/interfaces").IL10n} IL10n */
2222

23+
import { AnnotationEditorType, shadow } from "../../shared/util.js";
2324
import { bindEvents, KeyboardManager } from "./tools.js";
24-
import { AnnotationEditorType } from "../../shared/util.js";
25+
import { binarySearchFirstItem } from "../display_utils.js";
2526
import { FreeTextEditor } from "./freetext.js";
2627
import { InkEditor } from "./ink.js";
2728

@@ -50,8 +51,14 @@ class AnnotationEditorLayer {
5051

5152
#isCleaningUp = false;
5253

54+
#textLayerMap = new WeakMap();
55+
56+
#textNodes = new Map();
57+
5358
#uiManager;
5459

60+
#waitingEditors = new Set();
61+
5562
static _initialized = false;
5663

5764
static _keyboardManager = new KeyboardManager([
@@ -88,6 +95,7 @@ class AnnotationEditorLayer {
8895
if (!AnnotationEditorLayer._initialized) {
8996
AnnotationEditorLayer._initialized = true;
9097
FreeTextEditor.initialize(options.l10n);
98+
InkEditor.initialize(options.l10n);
9199

92100
options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]);
93101
}
@@ -98,11 +106,40 @@ class AnnotationEditorLayer {
98106
this.#boundClick = this.click.bind(this);
99107
this.#boundMousedown = this.mousedown.bind(this);
100108

101-
for (const editor of this.#uiManager.getEditors(options.pageIndex)) {
102-
this.add(editor);
109+
this.#uiManager.addLayer(this);
110+
}
111+
112+
get textLayerElements() {
113+
// When zooming the text layer is removed from the DOM and sometimes
114+
// it's rebuilt hence the nodes are no longer valid.
115+
116+
const textLayer = this.div.parentNode
117+
.getElementsByClassName("textLayer")
118+
.item(0);
119+
120+
if (!textLayer) {
121+
return shadow(this, "textLayerElements", null);
103122
}
104123

105-
this.#uiManager.addLayer(this);
124+
let textChildren = this.#textLayerMap.get(textLayer);
125+
if (textChildren) {
126+
return textChildren;
127+
}
128+
129+
textChildren = textLayer.querySelectorAll(`span[role="presentation"]`);
130+
if (textChildren.length === 0) {
131+
return shadow(this, "textLayerElements", null);
132+
}
133+
134+
textChildren = Array.from(textChildren);
135+
textChildren.sort(AnnotationEditorLayer.#compareElementPositions);
136+
this.#textLayerMap.set(textLayer, textChildren);
137+
138+
return textChildren;
139+
}
140+
141+
get #hasTextLayer() {
142+
return !!this.div.parentNode.querySelector(".textLayer .endOfContent");
106143
}
107144

108145
/**
@@ -230,13 +267,19 @@ class AnnotationEditorLayer {
230267
*/
231268
enable() {
232269
this.div.style.pointerEvents = "auto";
270+
for (const editor of this.#editors.values()) {
271+
editor.enableEditing();
272+
}
233273
}
234274

235275
/**
236276
* Disable editor creation.
237277
*/
238278
disable() {
239279
this.div.style.pointerEvents = "none";
280+
for (const editor of this.#editors.values()) {
281+
editor.disableEditing();
282+
}
240283
}
241284

242285
/**
@@ -276,6 +319,7 @@ class AnnotationEditorLayer {
276319

277320
detach(editor) {
278321
this.#editors.delete(editor.id);
322+
this.removePointerInTextLayer(editor);
279323
}
280324

281325
/**
@@ -311,19 +355,160 @@ class AnnotationEditorLayer {
311355
}
312356

313357
if (this.#uiManager.isActive(editor)) {
314-
editor.parent.setActiveEditor(null);
358+
editor.parent?.setActiveEditor(null);
315359
}
316360

317361
this.attach(editor);
318362
editor.pageIndex = this.pageIndex;
319-
editor.parent.detach(editor);
363+
editor.parent?.detach(editor);
320364
editor.parent = this;
321365
if (editor.div && editor.isAttachedToDOM) {
322366
editor.div.remove();
323367
this.div.append(editor.div);
324368
}
325369
}
326370

371+
/**
372+
* Compare the positions of two elements, it must correspond to
373+
* the visual ordering.
374+
*
375+
* @param {HTMLElement} e1
376+
* @param {HTMLElement} e2
377+
* @returns {number}
378+
*/
379+
static #compareElementPositions(e1, e2) {
380+
const rect1 = e1.getBoundingClientRect();
381+
const rect2 = e2.getBoundingClientRect();
382+
383+
if (rect1.y + rect1.height <= rect2.y) {
384+
return -1;
385+
}
386+
387+
if (rect2.y + rect2.height <= rect1.y) {
388+
return +1;
389+
}
390+
391+
const centerX1 = rect1.x + rect1.width / 2;
392+
const centerX2 = rect2.x + rect2.width / 2;
393+
394+
return centerX1 - centerX2;
395+
}
396+
397+
/**
398+
* Function called when the text layer has finished rendering.
399+
*/
400+
onTextLayerRendered() {
401+
this.#textNodes.clear();
402+
for (const editor of this.#waitingEditors) {
403+
if (editor.isAttachedToDOM) {
404+
this.addPointerInTextLayer(editor);
405+
}
406+
}
407+
this.#waitingEditors.clear();
408+
}
409+
410+
/**
411+
* Remove an aria-owns id from a node in the text layer.
412+
* @param {AnnotationEditor} editor
413+
*/
414+
removePointerInTextLayer(editor) {
415+
if (!this.#hasTextLayer) {
416+
this.#waitingEditors.delete(editor);
417+
return;
418+
}
419+
420+
const { id } = editor;
421+
const node = this.#textNodes.get(id);
422+
if (!node) {
423+
return;
424+
}
425+
426+
this.#textNodes.delete(id);
427+
let owns = node.getAttribute("aria-owns");
428+
if (owns?.includes(id)) {
429+
owns = owns
430+
.split(" ")
431+
.filter(x => x !== id)
432+
.join(" ");
433+
if (owns) {
434+
node.setAttribute("aria-owns", owns);
435+
} else {
436+
node.removeAttribute("aria-owns");
437+
node.setAttribute("role", "presentation");
438+
}
439+
}
440+
}
441+
442+
/**
443+
* Find the text node which is the nearest and add an aria-owns attribute
444+
* in order to correctly position this editor in the text flow.
445+
* @param {AnnotationEditor} editor
446+
*/
447+
addPointerInTextLayer(editor) {
448+
if (!this.#hasTextLayer) {
449+
// The text layer needs to be there, so we postpone the association.
450+
this.#waitingEditors.add(editor);
451+
return;
452+
}
453+
454+
this.removePointerInTextLayer(editor);
455+
456+
const children = this.textLayerElements;
457+
if (!children) {
458+
return;
459+
}
460+
const { contentDiv } = editor;
461+
const id = editor.getIdForTextLayer();
462+
463+
const index = binarySearchFirstItem(
464+
children,
465+
node =>
466+
AnnotationEditorLayer.#compareElementPositions(contentDiv, node) < 0
467+
);
468+
const node = children[Math.max(0, index - 1)];
469+
const owns = node.getAttribute("aria-owns");
470+
if (!owns?.includes(id)) {
471+
node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
472+
}
473+
node.removeAttribute("role");
474+
475+
this.#textNodes.set(id, node);
476+
}
477+
478+
/**
479+
* Move a div in the DOM in order to respect the visual order.
480+
* @param {HTMLDivElement} div
481+
*/
482+
moveDivInDOM(editor) {
483+
this.addPointerInTextLayer(editor);
484+
485+
const { div, contentDiv } = editor;
486+
if (!this.div.hasChildNodes()) {
487+
this.div.append(div);
488+
return;
489+
}
490+
491+
const children = Array.from(this.div.childNodes).filter(
492+
node => node !== div
493+
);
494+
495+
if (children.length === 0) {
496+
return;
497+
}
498+
499+
const index = binarySearchFirstItem(
500+
children,
501+
node =>
502+
AnnotationEditorLayer.#compareElementPositions(contentDiv, node) < 0
503+
);
504+
505+
if (index === 0) {
506+
children[0].before(div);
507+
} else {
508+
children[index - 1].after(div);
509+
}
510+
}
511+
327512
/**
328513
* Add a new editor in the current view.
329514
* @param {AnnotationEditor} editor
@@ -340,6 +525,7 @@ class AnnotationEditorLayer {
340525
editor.isAttachedToDOM = true;
341526
}
342527

528+
this.moveDivInDOM(editor);
343529
editor.onceAdded();
344530
}
345531

@@ -493,6 +679,8 @@ class AnnotationEditorLayer {
493679
const endY = event.clientY - rect.y;
494680

495681
editor.translate(endX - editor.startX, endY - editor.startY);
682+
this.moveDivInDOM(editor);
683+
editor.div.focus();
496684
}
497685

498686
/**
@@ -517,13 +705,20 @@ class AnnotationEditorLayer {
517705
* Destroy the main editor.
518706
*/
519707
destroy() {
708+
if (this.#uiManager.getActive()?.parent === this) {
709+
this.#uiManager.setActiveEditor(null);
710+
}
711+
520712
for (const editor of this.#editors.values()) {
713+
this.removePointerInTextLayer(editor);
521714
editor.isAttachedToDOM = false;
522715
editor.div.remove();
523716
editor.parent = null;
524-
this.div = null;
525717
}
718+
this.#textNodes.clear();
719+
this.div = null;
526720
this.#editors.clear();
721+
this.#waitingEditors.clear();
527722
this.#uiManager.removeLayer(this);
528723
}
529724

@@ -548,6 +743,9 @@ class AnnotationEditorLayer {
548743
this.viewport = parameters.viewport;
549744
bindEvents(this, this.div, ["dragover", "drop", "keydown"]);
550745
this.setDimensions();
746+
for (const editor of this.#uiManager.getEditors(this.pageIndex)) {
747+
this.add(editor);
748+
}
551749
this.updateMode();
552750
}
553751

0 commit comments

Comments
 (0)