mirror of
https://github.com/mozilla/pdf.js.git
synced 2025-04-22 16:18:08 +02:00
Merge pull request #15237 from calixteman/annotation_a11y
[Annotations] Add some aria-owns in the text layer to link to annotations (bug 1780375)
This commit is contained in:
commit
6b4c2464ad
23 changed files with 436 additions and 246 deletions
|
@ -29,6 +29,7 @@ import {
|
|||
warn,
|
||||
} from "../shared/util.js";
|
||||
import {
|
||||
AnnotationPrefix,
|
||||
DOMSVGFactory,
|
||||
getFilenameFromUrl,
|
||||
PDFDateString,
|
||||
|
@ -1901,7 +1902,8 @@ class PopupElement {
|
|||
}
|
||||
if (this.hideElement.hidden) {
|
||||
this.hideElement.hidden = false;
|
||||
this.container.style.zIndex += 1;
|
||||
this.container.style.zIndex =
|
||||
parseInt(this.container.style.zIndex) + 1000;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1918,7 +1920,8 @@ class PopupElement {
|
|||
}
|
||||
if (!this.hideElement.hidden && !this.pinned) {
|
||||
this.hideElement.hidden = true;
|
||||
this.container.style.zIndex -= 1;
|
||||
this.container.style.zIndex =
|
||||
parseInt(this.container.style.zIndex) - 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2465,6 +2468,19 @@ class FileAttachmentAnnotationElement extends AnnotationElement {
|
|||
*/
|
||||
|
||||
class AnnotationLayer {
|
||||
static #appendElement(element, id, div, accessibilityManager) {
|
||||
const contentElement = element.firstChild || element;
|
||||
contentElement.id = `${AnnotationPrefix}${id}`;
|
||||
|
||||
div.append(element);
|
||||
accessibilityManager?.moveElementInDOM(
|
||||
div,
|
||||
element,
|
||||
contentElement,
|
||||
/* isRemovable = */ false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a new annotation layer with all annotation elements.
|
||||
*
|
||||
|
@ -2473,9 +2489,10 @@ class AnnotationLayer {
|
|||
* @memberof AnnotationLayer
|
||||
*/
|
||||
static render(parameters) {
|
||||
const { annotations, div, viewport } = parameters;
|
||||
const { annotations, div, viewport, accessibilityManager } = parameters;
|
||||
|
||||
this.#setDimensions(div, viewport);
|
||||
let zIndex = 0;
|
||||
|
||||
for (const data of annotations) {
|
||||
if (data.annotationType !== AnnotationType.POPUP) {
|
||||
|
@ -2508,15 +2525,33 @@ class AnnotationLayer {
|
|||
}
|
||||
if (Array.isArray(rendered)) {
|
||||
for (const renderedElement of rendered) {
|
||||
div.append(renderedElement);
|
||||
renderedElement.style.zIndex = zIndex++;
|
||||
AnnotationLayer.#appendElement(
|
||||
renderedElement,
|
||||
data.id,
|
||||
div,
|
||||
accessibilityManager
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// The accessibility manager will move the annotation in the DOM in
|
||||
// order to match the visual ordering.
|
||||
// But if an annotation is above an other one, then we must draw it
|
||||
// after the other one whatever the order is in the DOM, hence the
|
||||
// use of the z-index.
|
||||
rendered.style.zIndex = zIndex++;
|
||||
|
||||
if (element instanceof PopupAnnotationElement) {
|
||||
// Popup annotation elements should not be on top of other
|
||||
// annotation elements to prevent interfering with mouse events.
|
||||
div.prepend(rendered);
|
||||
} else {
|
||||
div.append(rendered);
|
||||
AnnotationLayer.#appendElement(
|
||||
rendered,
|
||||
data.id,
|
||||
div,
|
||||
accessibilityManager
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import { BaseException, stringToBytes, Util, warn } from "../shared/util.js";
|
|||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const AnnotationPrefix = "pdfjs_internal_id_";
|
||||
|
||||
class PixelsPerInch {
|
||||
static CSS = 96.0;
|
||||
|
||||
|
@ -652,6 +654,7 @@ function getCurrentTransformInverse(ctx) {
|
|||
}
|
||||
|
||||
export {
|
||||
AnnotationPrefix,
|
||||
binarySearchFirstItem,
|
||||
deprecated,
|
||||
DOMCanvasFactory,
|
||||
|
|
|
@ -18,11 +18,12 @@
|
|||
/** @typedef {import("./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("../../web/text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
/** @typedef {import("../../web/interfaces").IL10n} IL10n */
|
||||
|
||||
import { AnnotationEditorType, shadow } from "../../shared/util.js";
|
||||
import { bindEvents, KeyboardManager } from "./tools.js";
|
||||
import { binarySearchFirstItem } from "../display_utils.js";
|
||||
import { AnnotationEditorType } from "../../shared/util.js";
|
||||
import { FreeTextEditor } from "./freetext.js";
|
||||
import { InkEditor } from "./ink.js";
|
||||
|
||||
|
@ -33,6 +34,7 @@ import { InkEditor } from "./ink.js";
|
|||
* @property {AnnotationEditorUIManager} uiManager
|
||||
* @property {boolean} enabled
|
||||
* @property {AnnotationStorage} annotationStorage
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
* @property {number} pageIndex
|
||||
* @property {IL10n} l10n
|
||||
*/
|
||||
|
@ -41,6 +43,8 @@ import { InkEditor } from "./ink.js";
|
|||
* Manage all the different editors on a page.
|
||||
*/
|
||||
class AnnotationEditorLayer {
|
||||
#accessibilityManager;
|
||||
|
||||
#allowClick = false;
|
||||
|
||||
#boundPointerup = this.pointerup.bind(this);
|
||||
|
@ -53,14 +57,8 @@ class AnnotationEditorLayer {
|
|||
|
||||
#isCleaningUp = false;
|
||||
|
||||
#textLayerMap = new WeakMap();
|
||||
|
||||
#textNodes = new Map();
|
||||
|
||||
#uiManager;
|
||||
|
||||
#waitingEditors = new Set();
|
||||
|
||||
static _initialized = false;
|
||||
|
||||
/**
|
||||
|
@ -78,43 +76,11 @@ class AnnotationEditorLayer {
|
|||
this.annotationStorage = options.annotationStorage;
|
||||
this.pageIndex = options.pageIndex;
|
||||
this.div = options.div;
|
||||
this.#accessibilityManager = options.accessibilityManager;
|
||||
|
||||
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 longer valid.
|
||||
|
||||
const textLayer = this.div.parentNode
|
||||
.getElementsByClassName("textLayer")
|
||||
.item(0);
|
||||
|
||||
if (!textLayer) {
|
||||
return shadow(this, "textLayerElements", null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
get #hasTextLayer() {
|
||||
return !!this.div.parentNode.querySelector(".textLayer .endOfContent");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the toolbar if it's required to reflect the tool currently used.
|
||||
* @param {number} mode
|
||||
|
@ -228,7 +194,7 @@ class AnnotationEditorLayer {
|
|||
|
||||
detach(editor) {
|
||||
this.#editors.delete(editor.id);
|
||||
this.removePointerInTextLayer(editor);
|
||||
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -281,147 +247,6 @@ class AnnotationEditorLayer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 finished rendering.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 correctly position this editor in the text flow.
|
||||
* @param {AnnotationEditor} editor
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
|
@ -437,11 +262,20 @@ class AnnotationEditorLayer {
|
|||
editor.isAttachedToDOM = true;
|
||||
}
|
||||
|
||||
this.moveDivInDOM(editor);
|
||||
this.moveEditorInDOM(editor);
|
||||
editor.onceAdded();
|
||||
this.addToAnnotationStorage(editor);
|
||||
}
|
||||
|
||||
moveEditorInDOM(editor) {
|
||||
this.#accessibilityManager?.moveElementInDOM(
|
||||
this.div,
|
||||
editor.div,
|
||||
editor.contentDiv,
|
||||
/* isRemovable = */ true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an editor in the annotation storage.
|
||||
* @param {AnnotationEditor} editor
|
||||
|
@ -658,7 +492,7 @@ class AnnotationEditorLayer {
|
|||
const endY = event.clientY - rect.y;
|
||||
|
||||
editor.translate(endX - editor.startX, endY - editor.startY);
|
||||
this.moveDivInDOM(editor);
|
||||
this.moveEditorInDOM(editor);
|
||||
editor.div.focus();
|
||||
}
|
||||
|
||||
|
@ -679,15 +513,13 @@ class AnnotationEditorLayer {
|
|||
}
|
||||
|
||||
for (const editor of this.#editors.values()) {
|
||||
this.removePointerInTextLayer(editor);
|
||||
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
|
||||
editor.isAttachedToDOM = false;
|
||||
editor.div.remove();
|
||||
editor.parent = null;
|
||||
}
|
||||
this.#textNodes.clear();
|
||||
this.div = null;
|
||||
this.#editors.clear();
|
||||
this.#waitingEditors.clear();
|
||||
this.#uiManager.removeLayer(this);
|
||||
}
|
||||
|
||||
|
|
|
@ -489,14 +489,6 @@ class AnnotationEditor {
|
|||
*/
|
||||
enableEditing() {}
|
||||
|
||||
/**
|
||||
* Get the id to use in aria-owns when a link is done in the text layer.
|
||||
* @returns {string}
|
||||
*/
|
||||
getIdForTextLayer() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get some properties to update in the UI.
|
||||
* @returns {Object}
|
||||
|
|
|
@ -385,11 +385,6 @@ class FreeTextEditor extends AnnotationEditor {
|
|||
this.editorDiv.setAttribute("aria-multiline", true);
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getIdForTextLayer() {
|
||||
return this.editorDiv.id;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
render() {
|
||||
if (this.div) {
|
||||
|
|
|
@ -488,7 +488,7 @@ class InkEditor extends AnnotationEditor {
|
|||
|
||||
// When commiting, the position of this editor is changed, hence we must
|
||||
// move it to the right position in the DOM.
|
||||
this.parent.moveDivInDOM(this);
|
||||
this.parent.moveEditorInDOM(this);
|
||||
// After the div has been moved in the DOM, the focus may have been stolen
|
||||
// by document.body, hence we just keep it here.
|
||||
this.div.focus();
|
||||
|
|
|
@ -428,8 +428,6 @@ class AnnotationEditorUIManager {
|
|||
|
||||
#boundOnPageChanging = this.onPageChanging.bind(this);
|
||||
|
||||
#boundOnTextLayerRendered = this.onTextLayerRendered.bind(this);
|
||||
|
||||
#previousStates = {
|
||||
isEditing: false,
|
||||
isEmpty: true,
|
||||
|
@ -474,14 +472,12 @@ class AnnotationEditorUIManager {
|
|||
this.#eventBus = eventBus;
|
||||
this.#eventBus._on("editingaction", this.#boundOnEditingAction);
|
||||
this.#eventBus._on("pagechanging", this.#boundOnPageChanging);
|
||||
this.#eventBus._on("textlayerrendered", this.#boundOnTextLayerRendered);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#removeKeyboardManager();
|
||||
this.#eventBus._off("editingaction", this.#boundOnEditingAction);
|
||||
this.#eventBus._off("pagechanging", this.#boundOnPageChanging);
|
||||
this.#eventBus._off("textlayerrendered", this.#boundOnTextLayerRendered);
|
||||
for (const layer of this.#allLayers.values()) {
|
||||
layer.destroy();
|
||||
}
|
||||
|
@ -497,12 +493,6 @@ class AnnotationEditorUIManager {
|
|||
this.#currentPageIndex = pageNumber - 1;
|
||||
}
|
||||
|
||||
onTextLayerRendered({ pageNumber }) {
|
||||
const pageIndex = pageNumber - 1;
|
||||
const layer = this.#allLayers.get(pageIndex);
|
||||
layer?.onTextLayerRendered();
|
||||
}
|
||||
|
||||
focusMainContainer() {
|
||||
this.#container.focus();
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue