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
@ -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();
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ async function runTests(results) {
|
||||
"find_spec.js",
|
||||
"freetext_editor_spec.js",
|
||||
"ink_editor_spec.js",
|
||||
"a11y_spec.js",
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -66,4 +66,44 @@ describe("accessibility", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Annotation", () => {
|
||||
let pages;
|
||||
|
||||
beforeAll(async () => {
|
||||
pages = await loadAndWait(
|
||||
"tracemonkey_a11y.pdf",
|
||||
".textLayer .endOfContent"
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePages(pages);
|
||||
});
|
||||
|
||||
function getSpans(page) {
|
||||
return page.evaluate(() => {
|
||||
const elements = document.querySelectorAll(
|
||||
`.textLayer span[aria-owns]:not([role="presentation"])`
|
||||
);
|
||||
const results = [];
|
||||
for (const element of elements) {
|
||||
results.push(element.innerText);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
it("must check that some spans are linked to some annotations thanks to aria-owns", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const spanContents = await getSpans(page);
|
||||
|
||||
expect(spanContents)
|
||||
.withContext(`In ${browserName}`)
|
||||
.toEqual(["Languages", "@intel.com", "Abstract", "Introduction"]);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -15,7 +15,7 @@
|
||||
|
||||
const {
|
||||
closePages,
|
||||
editorPrefix,
|
||||
getEditorSelector,
|
||||
getSelectedEditors,
|
||||
loadAndWait,
|
||||
} = require("./test_utils.js");
|
||||
@ -51,9 +51,9 @@ describe("Editor", () => {
|
||||
|
||||
const data = "Hello PDF.js World !!";
|
||||
await page.mouse.click(rect.x + 100, rect.y + 100);
|
||||
await page.type(`${editorPrefix}0 .internal`, data);
|
||||
await page.type(`${getEditorSelector(0)} .internal`, data);
|
||||
|
||||
const editorRect = await page.$eval(`${editorPrefix}0`, el => {
|
||||
const editorRect = await page.$eval(getEditorSelector(0), el => {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return {
|
||||
x,
|
||||
@ -73,7 +73,7 @@ describe("Editor", () => {
|
||||
.withContext(`In ${browserName}`)
|
||||
.toEqual(1);
|
||||
|
||||
const content = await page.$eval(`${editorPrefix}0`, el =>
|
||||
const content = await page.$eval(getEditorSelector(0), el =>
|
||||
el.innerText.trimEnd()
|
||||
);
|
||||
expect(content).withContext(`In ${browserName}`).toEqual(data);
|
||||
@ -84,7 +84,7 @@ describe("Editor", () => {
|
||||
it("must copy/paste", async () => {
|
||||
await Promise.all(
|
||||
pages.map(async ([browserName, page]) => {
|
||||
const editorRect = await page.$eval(`${editorPrefix}0`, el => {
|
||||
const editorRect = await page.$eval(getEditorSelector(0), el => {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
});
|
||||
@ -107,11 +107,11 @@ describe("Editor", () => {
|
||||
.withContext(`In ${browserName}`)
|
||||
.toEqual(2);
|
||||
|
||||
const content = await page.$eval(`${editorPrefix}0`, el =>
|
||||
const content = await page.$eval(getEditorSelector(0), el =>
|
||||
el.innerText.trimEnd()
|
||||
);
|
||||
|
||||
let pastedContent = await page.$eval(`${editorPrefix}1`, el =>
|
||||
let pastedContent = await page.$eval(getEditorSelector(1), el =>
|
||||
el.innerText.trimEnd()
|
||||
);
|
||||
|
||||
@ -131,7 +131,7 @@ describe("Editor", () => {
|
||||
.withContext(`In ${browserName}`)
|
||||
.toEqual(3);
|
||||
|
||||
pastedContent = await page.$eval(`${editorPrefix}2`, el =>
|
||||
pastedContent = await page.$eval(getEditorSelector(2), el =>
|
||||
el.innerText.trimEnd()
|
||||
);
|
||||
expect(pastedContent)
|
||||
@ -155,7 +155,7 @@ describe("Editor", () => {
|
||||
for (const n of [0, 1, 2]) {
|
||||
const hasEditor = await page.evaluate(sel => {
|
||||
return !!document.querySelector(sel);
|
||||
}, `${editorPrefix}${n}`);
|
||||
}, getEditorSelector(n));
|
||||
|
||||
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
|
||||
}
|
||||
@ -177,9 +177,9 @@ describe("Editor", () => {
|
||||
|
||||
const data = "Hello PDF.js World !!";
|
||||
await page.mouse.click(rect.x + 100, rect.y + 100);
|
||||
await page.type(`${editorPrefix}3 .internal`, data);
|
||||
await page.type(`${getEditorSelector(3)} .internal`, data);
|
||||
|
||||
const editorRect = await page.$eval(`${editorPrefix}3`, el => {
|
||||
const editorRect = await page.$eval(getEditorSelector(3), el => {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
});
|
||||
@ -205,7 +205,7 @@ describe("Editor", () => {
|
||||
|
||||
let hasEditor = await page.evaluate(sel => {
|
||||
return !!document.querySelector(sel);
|
||||
}, `${editorPrefix}4`);
|
||||
}, getEditorSelector(4));
|
||||
|
||||
expect(hasEditor).withContext(`In ${browserName}`).toEqual(true);
|
||||
|
||||
@ -215,7 +215,7 @@ describe("Editor", () => {
|
||||
|
||||
hasEditor = await page.evaluate(sel => {
|
||||
return !!document.querySelector(sel);
|
||||
}, `${editorPrefix}4`);
|
||||
}, getEditorSelector(4));
|
||||
|
||||
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
|
||||
|
||||
@ -227,7 +227,7 @@ describe("Editor", () => {
|
||||
|
||||
let length = await page.evaluate(sel => {
|
||||
return document.querySelectorAll(sel).length;
|
||||
}, `${editorPrefix}5, ${editorPrefix}6`);
|
||||
}, `${getEditorSelector(5)}, ${getEditorSelector(6)}`);
|
||||
expect(length).withContext(`In ${browserName}`).toEqual(2);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@ -238,7 +238,7 @@ describe("Editor", () => {
|
||||
|
||||
length = await page.evaluate(sel => {
|
||||
return document.querySelectorAll(sel).length;
|
||||
}, `${editorPrefix}5, ${editorPrefix}6`);
|
||||
}, `${getEditorSelector(5)}, ${getEditorSelector(6)}`);
|
||||
expect(length).withContext(`In ${browserName}`).toEqual(0);
|
||||
})
|
||||
);
|
||||
@ -273,7 +273,7 @@ describe("Editor", () => {
|
||||
stacksRect.x + stacksRect.width + 1,
|
||||
stacksRect.y + stacksRect.height / 2
|
||||
);
|
||||
await page.type(`${editorPrefix}7 .internal`, data);
|
||||
await page.type(`${getEditorSelector(7)} .internal`, data);
|
||||
|
||||
// Commit.
|
||||
await page.keyboard.press("Escape");
|
||||
@ -283,9 +283,9 @@ describe("Editor", () => {
|
||||
return span?.getAttribute("aria-owns") || null;
|
||||
});
|
||||
|
||||
expect(ariaOwns)
|
||||
expect(ariaOwns.endsWith("_7-editor"))
|
||||
.withContext(`In ${browserName}`)
|
||||
.toEqual(`${editorPrefix}7-editor`.slice(1));
|
||||
.toEqual(true);
|
||||
})
|
||||
);
|
||||
});
|
||||
@ -308,9 +308,9 @@ describe("Editor", () => {
|
||||
|
||||
const data = "Hello PDF.js World !!";
|
||||
await page.mouse.click(rect.x + 100, rect.y + 100);
|
||||
await page.type(`${editorPrefix}8 .internal`, data);
|
||||
await page.type(`${getEditorSelector(8)} .internal`, data);
|
||||
|
||||
const editorRect = await page.$eval(`${editorPrefix}8`, el => {
|
||||
const editorRect = await page.$eval(getEditorSelector(8), el => {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
});
|
||||
@ -385,9 +385,9 @@ describe("Editor", () => {
|
||||
rect.x + (i + 1) * 100,
|
||||
rect.y + (i + 1) * 100
|
||||
);
|
||||
await page.type(`${editorPrefix}${i} .internal`, data);
|
||||
await page.type(`${getEditorSelector(i)} .internal`, data);
|
||||
|
||||
const editorRect = await page.$eval(`${editorPrefix}${i}`, el => {
|
||||
const editorRect = await page.$eval(getEditorSelector(i), el => {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
return {
|
||||
x,
|
||||
|
@ -73,19 +73,17 @@ function getComputedStyleSelector(id) {
|
||||
return `getComputedStyle(${getQuerySelector(id)})`;
|
||||
}
|
||||
exports.getComputedStyleSelector = getComputedStyleSelector;
|
||||
|
||||
const editorPrefix = "#pdfjs_internal_editor_";
|
||||
exports.editorPrefix = editorPrefix;
|
||||
exports.getEditorSelector = n => `#pdfjs_internal_editor_${n}`;
|
||||
|
||||
function getSelectedEditors(page) {
|
||||
return page.evaluate(prefix => {
|
||||
return page.evaluate(() => {
|
||||
const elements = document.querySelectorAll(".selectedEditor");
|
||||
const results = [];
|
||||
for (const { id } of elements) {
|
||||
results.push(parseInt(id.slice(prefix.length)));
|
||||
results.push(parseInt(id.split("_").at(-1)));
|
||||
}
|
||||
results.sort();
|
||||
return results;
|
||||
}, editorPrefix.slice(1));
|
||||
});
|
||||
}
|
||||
exports.getSelectedEditors = getSelectedEditors;
|
||||
|
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -534,3 +534,4 @@
|
||||
!bug1675139.pdf
|
||||
!issue15092.pdf
|
||||
!bug1782186.pdf
|
||||
!tracemonkey_a11y.pdf
|
||||
|
BIN
test/pdfs/tracemonkey_a11y.pdf
Normal file
BIN
test/pdfs/tracemonkey_a11y.pdf
Normal file
Binary file not shown.
@ -21,6 +21,8 @@
|
||||
/** @typedef {import("../src/display/editor/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("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
/** @typedef {import("./interfaces").IL10n} IL10n */
|
||||
|
||||
import { AnnotationEditorLayer } from "pdfjs-lib";
|
||||
@ -31,6 +33,7 @@ import { NullL10n } from "./l10n_utils.js";
|
||||
* @property {number} mode - Editor mode
|
||||
* @property {HTMLDivElement} pageDiv
|
||||
* @property {PDFPageProxy} pdfPage
|
||||
* @property {TextAccessibilityManager} accessibilityManager
|
||||
* @property {AnnotationStorage} annotationStorage
|
||||
* @property {IL10n} l10n - Localization service.
|
||||
* @property {AnnotationEditorUIManager} uiManager
|
||||
@ -46,6 +49,7 @@ class AnnotationEditorLayerBuilder {
|
||||
this.pageDiv = options.pageDiv;
|
||||
this.pdfPage = options.pdfPage;
|
||||
this.annotationStorage = options.annotationStorage || null;
|
||||
this.accessibilityManager = options.accessibilityManager;
|
||||
this.l10n = options.l10n || NullL10n;
|
||||
this.annotationEditorLayer = null;
|
||||
this.div = null;
|
||||
@ -83,6 +87,7 @@ class AnnotationEditorLayerBuilder {
|
||||
uiManager: this.#uiManager,
|
||||
div: this.div,
|
||||
annotationStorage: this.annotationStorage,
|
||||
accessibilityManager: this.accessibilityManager,
|
||||
pageIndex: this.pdfPage._pageIndex,
|
||||
l10n: this.l10n,
|
||||
viewport: clonedViewport,
|
||||
|
@ -210,7 +210,6 @@
|
||||
|
||||
.annotationLayer .popup {
|
||||
position: absolute;
|
||||
z-index: 200;
|
||||
max-width: calc(180px * var(--scale-factor));
|
||||
background-color: rgba(255, 255, 153, 1);
|
||||
box-shadow: 0 calc(2px * var(--scale-factor)) calc(5px * var(--scale-factor))
|
||||
|
@ -19,6 +19,8 @@
|
||||
/** @typedef {import("./interfaces").IDownloadManager} IDownloadManager */
|
||||
/** @typedef {import("./interfaces").IL10n} IL10n */
|
||||
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./textaccessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
|
||||
import { AnnotationLayer } from "pdfjs-lib";
|
||||
import { NullL10n } from "./l10n_utils.js";
|
||||
@ -40,6 +42,7 @@ import { NullL10n } from "./l10n_utils.js";
|
||||
* [fieldObjectsPromise]
|
||||
* @property {Object} [mouseState]
|
||||
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
|
||||
* @property {TextAccessibilityManager} accessibilityManager
|
||||
*/
|
||||
|
||||
class AnnotationLayerBuilder {
|
||||
@ -60,6 +63,7 @@ class AnnotationLayerBuilder {
|
||||
fieldObjectsPromise = null,
|
||||
mouseState = null,
|
||||
annotationCanvasMap = null,
|
||||
accessibilityManager = null,
|
||||
}) {
|
||||
this.pageDiv = pageDiv;
|
||||
this.pdfPage = pdfPage;
|
||||
@ -74,6 +78,7 @@ class AnnotationLayerBuilder {
|
||||
this._fieldObjectsPromise = fieldObjectsPromise;
|
||||
this._mouseState = mouseState;
|
||||
this._annotationCanvasMap = annotationCanvasMap;
|
||||
this._accessibilityManager = accessibilityManager;
|
||||
|
||||
this.div = null;
|
||||
this._cancelled = false;
|
||||
@ -112,6 +117,7 @@ class AnnotationLayerBuilder {
|
||||
fieldObjects,
|
||||
mouseState: this._mouseState,
|
||||
annotationCanvasMap: this._annotationCanvasMap,
|
||||
accessibilityManager: this._accessibilityManager,
|
||||
};
|
||||
|
||||
if (this.div) {
|
||||
|
@ -30,6 +30,8 @@
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./interfaces").IPDFTextLayerFactory} IPDFTextLayerFactory */
|
||||
/** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
|
||||
import {
|
||||
AnnotationEditorType,
|
||||
@ -1637,6 +1639,7 @@ class BaseViewer {
|
||||
* @property {boolean} [enhanceTextSelection]
|
||||
* @property {EventBus} eventBus
|
||||
* @property {TextHighlighter} highlighter
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -1650,6 +1653,7 @@ class BaseViewer {
|
||||
enhanceTextSelection = false,
|
||||
eventBus,
|
||||
highlighter,
|
||||
accessibilityManager = null,
|
||||
}) {
|
||||
return new TextLayerBuilder({
|
||||
textLayerDiv,
|
||||
@ -1660,6 +1664,7 @@ class BaseViewer {
|
||||
? false
|
||||
: enhanceTextSelection,
|
||||
highlighter,
|
||||
accessibilityManager,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1698,6 +1703,7 @@ class BaseViewer {
|
||||
* [fieldObjectsPromise]
|
||||
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap] - Map some
|
||||
* annotation ids with canvases used to render them.
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -1716,6 +1722,7 @@ class BaseViewer {
|
||||
mouseState = this._scriptingManager?.mouseState,
|
||||
fieldObjectsPromise = this.pdfDocument?.getFieldObjects(),
|
||||
annotationCanvasMap = null,
|
||||
accessibilityManager = null,
|
||||
}) {
|
||||
return new AnnotationLayerBuilder({
|
||||
pageDiv,
|
||||
@ -1731,6 +1738,7 @@ class BaseViewer {
|
||||
mouseState,
|
||||
fieldObjectsPromise,
|
||||
annotationCanvasMap,
|
||||
accessibilityManager,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1741,6 +1749,7 @@ class BaseViewer {
|
||||
* @property {PDFPageProxy} pdfPage
|
||||
* @property {IL10n} l10n
|
||||
* @property {AnnotationStorage} [annotationStorage] - Storage for annotation
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
* data in forms.
|
||||
*/
|
||||
|
||||
@ -1752,6 +1761,7 @@ class BaseViewer {
|
||||
uiManager = this.#annotationEditorUIManager,
|
||||
pageDiv,
|
||||
pdfPage,
|
||||
accessibilityManager = null,
|
||||
l10n,
|
||||
annotationStorage = this.pdfDocument?.annotationStorage,
|
||||
}) {
|
||||
@ -1760,6 +1770,7 @@ class BaseViewer {
|
||||
pageDiv,
|
||||
pdfPage,
|
||||
annotationStorage,
|
||||
accessibilityManager,
|
||||
l10n,
|
||||
});
|
||||
}
|
||||
|
@ -92,6 +92,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#viewer.textLayer-visible .textLayer span[aria-owns] {
|
||||
background-color: rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#viewer.textLayer-hover .textLayer span:hover {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
color: rgba(0, 0, 0, 1);
|
||||
|
@ -30,6 +30,8 @@
|
||||
/** @typedef {import("./interfaces").IPDFTextLayerFactory} IPDFTextLayerFactory */
|
||||
/** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */
|
||||
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
|
||||
import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js";
|
||||
import { AnnotationLayerBuilder } from "./annotation_layer_builder.js";
|
||||
@ -60,6 +62,7 @@ class DefaultAnnotationLayerFactory {
|
||||
* [fieldObjectsPromise]
|
||||
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap] - Map some
|
||||
* annotation ids with canvases used to render them.
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -78,6 +81,7 @@ class DefaultAnnotationLayerFactory {
|
||||
mouseState = null,
|
||||
fieldObjectsPromise = null,
|
||||
annotationCanvasMap = null,
|
||||
accessibilityManager = null,
|
||||
}) {
|
||||
return new AnnotationLayerBuilder({
|
||||
pageDiv,
|
||||
@ -92,6 +96,7 @@ class DefaultAnnotationLayerFactory {
|
||||
fieldObjectsPromise,
|
||||
mouseState,
|
||||
annotationCanvasMap,
|
||||
accessibilityManager,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -107,6 +112,7 @@ class DefaultAnnotationEditorLayerFactory {
|
||||
* @property {PDFPageProxy} pdfPage
|
||||
* @property {IL10n} l10n
|
||||
* @property {AnnotationStorage} [annotationStorage] - Storage for annotation
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
* data in forms.
|
||||
*/
|
||||
|
||||
@ -118,6 +124,7 @@ class DefaultAnnotationEditorLayerFactory {
|
||||
uiManager = null,
|
||||
pageDiv,
|
||||
pdfPage,
|
||||
accessibilityManager = null,
|
||||
l10n,
|
||||
annotationStorage = null,
|
||||
}) {
|
||||
@ -125,6 +132,7 @@ class DefaultAnnotationEditorLayerFactory {
|
||||
uiManager,
|
||||
pageDiv,
|
||||
pdfPage,
|
||||
accessibilityManager,
|
||||
l10n,
|
||||
annotationStorage,
|
||||
});
|
||||
@ -163,6 +171,7 @@ class DefaultTextLayerFactory {
|
||||
* @property {boolean} [enhanceTextSelection]
|
||||
* @property {EventBus} eventBus
|
||||
* @property {TextHighlighter} highlighter
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -176,6 +185,7 @@ class DefaultTextLayerFactory {
|
||||
enhanceTextSelection = false,
|
||||
eventBus,
|
||||
highlighter,
|
||||
accessibilityManager = null,
|
||||
}) {
|
||||
return new TextLayerBuilder({
|
||||
textLayerDiv,
|
||||
@ -184,6 +194,7 @@ class DefaultTextLayerFactory {
|
||||
enhanceTextSelection,
|
||||
eventBus,
|
||||
highlighter,
|
||||
accessibilityManager,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,8 @@
|
||||
/** @typedef {import("./text_layer_builder").TextLayerBuilder} TextLayerBuilder */
|
||||
/** @typedef {import("./ui_utils").RenderingStates} RenderingStates */
|
||||
/** @typedef {import("./xfa_layer_builder").XfaLayerBuilder} XfaLayerBuilder */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
|
||||
/**
|
||||
* @interface
|
||||
@ -162,6 +164,7 @@ class IPDFTextLayerFactory {
|
||||
* @property {boolean} [enhanceTextSelection]
|
||||
* @property {EventBus} eventBus
|
||||
* @property {TextHighlighter} highlighter
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -175,6 +178,7 @@ class IPDFTextLayerFactory {
|
||||
enhanceTextSelection = false,
|
||||
eventBus,
|
||||
highlighter,
|
||||
accessibilityManager,
|
||||
}) {}
|
||||
}
|
||||
|
||||
@ -199,6 +203,7 @@ class IPDFAnnotationLayerFactory {
|
||||
* [fieldObjectsPromise]
|
||||
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap] - Map some
|
||||
* annotation ids with canvases used to render them.
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -217,6 +222,7 @@ class IPDFAnnotationLayerFactory {
|
||||
mouseState = null,
|
||||
fieldObjectsPromise = null,
|
||||
annotationCanvasMap = null,
|
||||
accessibilityManager = null,
|
||||
}) {}
|
||||
}
|
||||
|
||||
@ -231,6 +237,7 @@ class IPDFAnnotationEditorLayerFactory {
|
||||
* @property {PDFPageProxy} pdfPage
|
||||
* @property {IL10n} l10n
|
||||
* @property {AnnotationStorage} [annotationStorage] - Storage for annotation
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
* data in forms.
|
||||
*/
|
||||
|
||||
@ -244,6 +251,7 @@ class IPDFAnnotationEditorLayerFactory {
|
||||
pdfPage,
|
||||
l10n,
|
||||
annotationStorage = null,
|
||||
accessibilityManager,
|
||||
}) {}
|
||||
}
|
||||
|
||||
|
@ -51,6 +51,7 @@ import {
|
||||
} from "./ui_utils.js";
|
||||
import { compatibilityParams } from "./app_options.js";
|
||||
import { NullL10n } from "./l10n_utils.js";
|
||||
import { TextAccessibilityManager } from "./text_accessibility.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} PDFPageViewOptions
|
||||
@ -697,6 +698,7 @@ class PDFPageView {
|
||||
|
||||
let textLayer = null;
|
||||
if (this.textLayerMode !== TextLayerMode.DISABLE && this.textLayerFactory) {
|
||||
this._accessibilityManager ||= new TextAccessibilityManager();
|
||||
const textLayerDiv = document.createElement("div");
|
||||
textLayerDiv.className = "textLayer";
|
||||
textLayerDiv.style.width = canvasWrapper.style.width;
|
||||
@ -716,6 +718,7 @@ class PDFPageView {
|
||||
this.textLayerMode === TextLayerMode.ENABLE_ENHANCE,
|
||||
eventBus: this.eventBus,
|
||||
highlighter: this.textHighlighter,
|
||||
accessibilityManager: this._accessibilityManager,
|
||||
});
|
||||
}
|
||||
this.textLayer = textLayer;
|
||||
@ -733,6 +736,7 @@ class PDFPageView {
|
||||
renderForms: this.#annotationMode === AnnotationMode.ENABLE_FORMS,
|
||||
l10n: this.l10n,
|
||||
annotationCanvasMap: this._annotationCanvasMap,
|
||||
accessibilityManager: this._accessibilityManager,
|
||||
});
|
||||
}
|
||||
|
||||
@ -824,6 +828,7 @@ class PDFPageView {
|
||||
pageDiv: div,
|
||||
pdfPage,
|
||||
l10n: this.l10n,
|
||||
accessibilityManager: this._accessibilityManager,
|
||||
}
|
||||
);
|
||||
this._renderAnnotationEditorLayer();
|
||||
|
246
web/text_accessibility.js
Normal file
246
web/text_accessibility.js
Normal file
@ -0,0 +1,246 @@
|
||||
/* 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 "pdfjs-lib";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 };
|
@ -17,6 +17,8 @@
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("./event_utils").EventBus} EventBus */
|
||||
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
|
||||
import { renderTextLayer } from "pdfjs-lib";
|
||||
|
||||
@ -32,6 +34,7 @@ const EXPAND_DIVS_TIMEOUT = 300; // ms
|
||||
* highlighting text from the find controller.
|
||||
* @property {boolean} enhanceTextSelection - Option to turn on improved
|
||||
* text selection.
|
||||
* @property {TextAccessibilityManager} [accessibilityManager]
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -47,6 +50,7 @@ class TextLayerBuilder {
|
||||
viewport,
|
||||
highlighter = null,
|
||||
enhanceTextSelection = false,
|
||||
accessibilityManager = null,
|
||||
}) {
|
||||
this.textLayerDiv = textLayerDiv;
|
||||
this.eventBus = eventBus;
|
||||
@ -60,6 +64,7 @@ class TextLayerBuilder {
|
||||
this.textLayerRenderTask = null;
|
||||
this.highlighter = highlighter;
|
||||
this.enhanceTextSelection = enhanceTextSelection;
|
||||
this.accessibilityManager = accessibilityManager;
|
||||
|
||||
this._bindMouse();
|
||||
}
|
||||
@ -97,6 +102,7 @@ class TextLayerBuilder {
|
||||
|
||||
this.textDivs.length = 0;
|
||||
this.highlighter?.setTextMapping(this.textDivs, this.textContentItemsStr);
|
||||
this.accessibilityManager?.setTextMapping(this.textDivs);
|
||||
|
||||
const textLayerFrag = document.createDocumentFragment();
|
||||
this.textLayerRenderTask = renderTextLayer({
|
||||
@ -114,6 +120,7 @@ class TextLayerBuilder {
|
||||
this.textLayerDiv.append(textLayerFrag);
|
||||
this._finishRendering();
|
||||
this.highlighter?.enable();
|
||||
this.accessibilityManager?.enable();
|
||||
},
|
||||
function (reason) {
|
||||
// Cancelled or failed to render text layer; skipping errors.
|
||||
@ -130,6 +137,7 @@ class TextLayerBuilder {
|
||||
this.textLayerRenderTask = null;
|
||||
}
|
||||
this.highlighter?.disable();
|
||||
this.accessibilityManager?.disable();
|
||||
}
|
||||
|
||||
setTextContentStream(readableStream) {
|
||||
|
Loading…
Reference in New Issue
Block a user