Skip to content

Commit

Permalink
[Editor] Improve a11y for newly added element (#15109)
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
calixteman committed Jul 6, 2022
1 parent bde4663 commit 353b0f1
Show file tree
Hide file tree
Showing 14 changed files with 465 additions and 96 deletions.
5 changes: 5 additions & 0 deletions l10n/en-US/viewer.properties
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,8 @@ editor_free_text_font_color=Font Color
editor_free_text_font_size=Font Size
editor_ink_line_color=Line Color
editor_ink_line_thickness=Line Thickness

# Editor aria
editor_free_text_aria_label=FreeText Editor
editor_ink_aria_label=Ink Editor
editor_ink_canvas_aria_label=User created image
33 changes: 33 additions & 0 deletions src/display/display_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,40 @@ function getColorValues(colors) {
span.remove();
}

/**
* Use binary search to find the index of the first item in a given array which
* passes a given condition. The items are expected to be sorted in the sense
* that if the condition is true for one item in the array, then it is also true
* for all following items.
*
* @returns {number} Index of the first array element to pass the test,
* or |items.length| if no such element exists.
*/
function binarySearchFirstItem(items, condition, start = 0) {
let minIndex = start;
let maxIndex = items.length - 1;

if (maxIndex < 0 || !condition(items[maxIndex])) {
return items.length;
}
if (condition(items[minIndex])) {
return minIndex;
}

while (minIndex < maxIndex) {
const currentIndex = (minIndex + maxIndex) >> 1;
const currentItem = items[currentIndex];
if (condition(currentItem)) {
maxIndex = currentIndex;
} else {
minIndex = currentIndex + 1;
}
}
return minIndex; /* === maxIndex */
}

export {
binarySearchFirstItem,
deprecated,
DOMCanvasFactory,
DOMCMapReaderFactory,
Expand Down
216 changes: 209 additions & 7 deletions src/display/editor/annotation_editor_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
/** @typedef {import("../../web/interfaces").IL10n} IL10n */

import { AnnotationEditorType, shadow } from "../../shared/util.js";
import { bindEvents, KeyboardManager } from "./tools.js";
import { AnnotationEditorType } from "../../shared/util.js";
import { binarySearchFirstItem } from "../display_utils.js";
import { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js";

Expand All @@ -46,8 +47,14 @@ class AnnotationEditorLayer {

#editors = new Map();

#textLayerMap = new WeakMap();

#textNodes = new Map();

#uiManager;

#waitingEditors = new Set();

static _initialized = false;

static _keyboardManager = new KeyboardManager([
Expand Down Expand Up @@ -78,6 +85,7 @@ class AnnotationEditorLayer {
if (!AnnotationEditorLayer._initialized) {
AnnotationEditorLayer._initialized = true;
FreeTextEditor.initialize(options.l10n);
InkEditor.initialize(options.l10n);

options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]);
}
Expand All @@ -88,11 +96,40 @@ class AnnotationEditorLayer {
this.#boundClick = this.click.bind(this);
this.#boundMouseover = this.mouseover.bind(this);

for (const editor of this.#uiManager.getEditors(options.pageIndex)) {
this.add(editor);
this.#uiManager.addLayer(this);
}

get textLayerElements() {
// When zooming the text layer is removed from the DOM and sometimes
// it's rebuilt hence the nodes are no more valid.

const textLayer = this.div.parentNode
.getElementsByClassName("textLayer")
.item(0);

if (!textLayer) {
return shadow(this, "textLayerElements", null);
}

this.#uiManager.addLayer(this);
let textChildren = this.#textLayerMap.get(textLayer);
if (textChildren) {
return textChildren;
}

textChildren = textLayer.querySelectorAll(`span[role="presentation"]`);
if (textChildren.length === 0) {
return shadow(this, "textLayerElements", null);
}

textChildren = Array.from(textChildren);
textChildren.sort(AnnotationEditorLayer.#compareElementPositions);
this.#textLayerMap.set(textLayer, textChildren);

return textChildren;
}

#hasTextLayer() {
return !!this.div.parentNode.querySelector(".textLayer .endOfContent");
}

/**
Expand Down Expand Up @@ -227,13 +264,19 @@ class AnnotationEditorLayer {
*/
enable() {
this.div.style.pointerEvents = "auto";
for (const editor of this.#editors.values()) {
editor.enableEditing();
}
}

/**
* Disable editor creation.
*/
disable() {
this.div.style.pointerEvents = "none";
for (const editor of this.#editors.values()) {
editor.disableEditing();
}
}

/**
Expand Down Expand Up @@ -270,6 +313,7 @@ class AnnotationEditorLayer {

detach(editor) {
this.#editors.delete(editor.id);
this.removePointerInTextLayer(editor);
}

/**
Expand Down Expand Up @@ -303,19 +347,163 @@ class AnnotationEditorLayer {
}

if (this.#uiManager.isActive(editor)) {
editor.parent.setActiveEditor(null);
editor.parent?.setActiveEditor(null);
}

this.attach(editor);
editor.pageIndex = this.pageIndex;
editor.parent.detach(editor);
editor.parent?.detach(editor);
editor.parent = this;
if (editor.div && editor.isAttachedToDOM) {
editor.div.remove();
this.div.append(editor.div);
}
}

/**
* 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.y + rect1.height <= rect2.y) {
return -1;
}

if (rect2.y + rect2.height <= rect1.y) {
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
*/
onTextLayerRendered() {
this.#textNodes.clear();
for (const editor of this.#waitingEditors) {
if (editor.isAttachedToDOM) {
this.addPointerInTextLayer(editor);
}
}
this.#waitingEditors.clear();
}

/**
* Remove an aria-owns id from a node in the text layer.
* @param {AnnotationEditor} editor
* @returns {undefined}
*/
removePointerInTextLayer(editor) {
if (!this.#hasTextLayer()) {
this.#waitingEditors.delete(editor);
return;
}

const { id } = editor;
const node = this.#textNodes.get(id);
if (!node) {
return;
}

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");
}
}
}

/**
* Find the text node which is the nearest and add an aria-owns attribute
* in order to position correctly this editorn in the text flow.
* @param {AnnotationEditor} editor
* @returns {undefined}
*/
addPointerInTextLayer(editor) {
if (!this.#hasTextLayer()) {
// The text layer needs to be there, so we postpone the association.
this.#waitingEditors.add(editor);
return;
}

this.removePointerInTextLayer(editor);

const children = this.textLayerElements;
if (!children) {
return;
}
const { contentDiv } = editor;
const id = editor.getIdForTextLayer();

const index = binarySearchFirstItem(
children,
node =>
AnnotationEditorLayer.#compareElementPositions(contentDiv, node) < 0
);
const node = children[Math.max(0, index - 1)];
const owns = node.getAttribute("aria-owns");
if (!owns?.includes(id)) {
node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
}
node.removeAttribute("role");

this.#textNodes.set(id, node);
}

/**
* Move a div in the DOM in order to respect the visual order.
* @param {HTMLDivElement} div
* @returns {undefined}
*/
#moveDivInDOM(editor) {
this.addPointerInTextLayer(editor);

const { div, contentDiv } = editor;
if (!this.div.hasChildNodes()) {
this.div.append(div);
return;
}

const children = Array.from(this.div.childNodes).filter(
node => node !== div
);

if (children.length === 0) {
return;
}

const index = binarySearchFirstItem(
children,
node =>
AnnotationEditorLayer.#compareElementPositions(contentDiv, node) < 0
);

if (index === 0) {
children[0].before(div);
} else {
children[index - 1].after(div);
}
}

/**
* Add a new editor in the current view.
* @param {AnnotationEditor} editor
Expand All @@ -332,6 +520,7 @@ class AnnotationEditorLayer {
editor.isAttachedToDOM = true;
}

this.#moveDivInDOM(editor);
editor.onceAdded();
}

Expand Down Expand Up @@ -456,6 +645,8 @@ class AnnotationEditorLayer {
const endY = event.clientY - rect.y;

editor.translate(endX - editor.startX, endY - editor.startY);
this.#moveDivInDOM(editor);
editor.div.focus();
}

/**
Expand All @@ -480,13 +671,20 @@ class AnnotationEditorLayer {
* Destroy the main editor.
*/
destroy() {
if (this.#uiManager.getActive()?.parent === this) {
this.#uiManager.setActiveEditor(null);
}

for (const editor of this.#editors.values()) {
this.removePointerInTextLayer(editor);
editor.isAttachedToDOM = false;
editor.div.remove();
editor.parent = null;
this.div = null;
}
this.#textNodes.clear();
this.div = null;
this.#editors.clear();
this.#waitingEditors.clear();
this.#uiManager.removeLayer(this);
}

Expand All @@ -499,6 +697,10 @@ class AnnotationEditorLayer {
bindEvents(this, this.div, ["dragover", "drop", "keydown"]);
this.div.addEventListener("click", this.#boundClick);
this.setDimensions();

for (const editor of this.#uiManager.getEditors(this.pageIndex)) {
this.add(editor);
}
}

/**
Expand Down
Loading

0 comments on commit 353b0f1

Please sign in to comment.