1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-22 16:18:08 +02:00

Merge pull request #15110 from calixteman/editing_a11y

[Editor] Improve a11y for newly added element (#15109)
This commit is contained in:
Jonas Jenwald 2022-07-19 20:02:53 +02:00 committed by GitHub
commit f46895d750
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 467 additions and 96 deletions

View file

@ -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,

View file

@ -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";
@ -50,8 +51,14 @@ class AnnotationEditorLayer {
#isCleaningUp = false;
#textLayerMap = new WeakMap();
#textNodes = new Map();
#uiManager;
#waitingEditors = new Set();
static _initialized = false;
static _keyboardManager = new KeyboardManager([
@ -88,6 +95,7 @@ class AnnotationEditorLayer {
if (!AnnotationEditorLayer._initialized) {
AnnotationEditorLayer._initialized = true;
FreeTextEditor.initialize(options.l10n);
InkEditor.initialize(options.l10n);
options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]);
}
@ -98,11 +106,40 @@ class AnnotationEditorLayer {
this.#boundClick = this.click.bind(this);
this.#boundMousedown = this.mousedown.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 longer 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;
}
get #hasTextLayer() {
return !!this.div.parentNode.querySelector(".textLayer .endOfContent");
}
/**
@ -230,6 +267,9 @@ class AnnotationEditorLayer {
*/
enable() {
this.div.style.pointerEvents = "auto";
for (const editor of this.#editors.values()) {
editor.enableEditing();
}
}
/**
@ -237,6 +277,9 @@ class AnnotationEditorLayer {
*/
disable() {
this.div.style.pointerEvents = "none";
for (const editor of this.#editors.values()) {
editor.disableEditing();
}
}
/**
@ -276,6 +319,7 @@ class AnnotationEditorLayer {
detach(editor) {
this.#editors.delete(editor.id);
this.removePointerInTextLayer(editor);
}
/**
@ -311,12 +355,12 @@ 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();
@ -324,6 +368,147 @@ 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
@ -340,6 +525,7 @@ class AnnotationEditorLayer {
editor.isAttachedToDOM = true;
}
this.moveDivInDOM(editor);
editor.onceAdded();
}
@ -493,6 +679,8 @@ class AnnotationEditorLayer {
const endY = event.clientY - rect.y;
editor.translate(endX - editor.startX, endY - editor.startY);
this.moveDivInDOM(editor);
editor.div.focus();
}
/**
@ -517,13 +705,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);
}
@ -548,6 +743,9 @@ class AnnotationEditorLayer {
this.viewport = parameters.viewport;
bindEvents(this, this.div, ["dragover", "drop", "keydown"]);
this.setDimensions();
for (const editor of this.#uiManager.getEditors(this.pageIndex)) {
this.add(editor);
}
this.updateMode();
}

View file

@ -220,7 +220,7 @@ class AnnotationEditor {
this.div.setAttribute("data-editor-rotation", (360 - this.rotation) % 360);
this.div.className = this.name;
this.div.setAttribute("id", this.id);
this.div.tabIndex = 100;
this.div.tabIndex = 0;
const [tx, ty] = this.getInitialTranslation();
this.translate(tx, ty);
@ -454,6 +454,26 @@ class AnnotationEditor {
*/
updateParams(type, value) {}
/**
* When the user disables the editing mode some editors can change some of
* their properties.
*/
disableEditing() {}
/**
* When the user enables the editing mode some editors can change some of
* their properties.
*/
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}
@ -461,6 +481,13 @@ class AnnotationEditor {
get propertiesToUpdate() {
return {};
}
/**
* Get the div which really contains the displayed content.
*/
get contentDiv() {
return this.div;
}
}
export { AnnotationEditor };

View file

@ -60,7 +60,13 @@ class FreeTextEditor extends AnnotationEditor {
}
static initialize(l10n) {
this._l10nPromise = l10n.get("free_text_default_content");
this._l10nPromise = new Map(
["free_text_default_content", "editor_free_text_aria_label"].map(str => [
str,
l10n.get(str),
])
);
const style = getComputedStyle(document.documentElement);
if (
@ -117,7 +123,6 @@ class FreeTextEditor extends AnnotationEditor {
];
}
/** @inheritdoc */
get propertiesToUpdate() {
return [
[AnnotationEditorParamsType.FREETEXT_SIZE, this.#fontSize],
@ -204,6 +209,7 @@ class FreeTextEditor extends AnnotationEditor {
this.overlayDiv.classList.remove("enabled");
this.editorDiv.contentEditable = true;
this.div.draggable = false;
this.div.removeAttribute("tabIndex");
}
/** @inheritdoc */
@ -213,6 +219,7 @@ class FreeTextEditor extends AnnotationEditor {
this.overlayDiv.classList.add("enabled");
this.editorDiv.contentEditable = false;
this.div.draggable = true;
this.div.tabIndex = 0;
}
/** @inheritdoc */
@ -300,6 +307,34 @@ class FreeTextEditor extends AnnotationEditor {
this.editorDiv.focus();
}
/**
* onkeydown callback.
* @param {MouseEvent} event
*/
keyup(event) {
if (event.key === "Enter") {
this.enableEditMode();
this.editorDiv.focus();
}
}
/** @inheritdoc */
disableEditing() {
this.editorDiv.setAttribute("role", "comment");
this.editorDiv.removeAttribute("aria-multiline");
}
/** @inheritdoc */
enableEditing() {
this.editorDiv.setAttribute("role", "textbox");
this.editorDiv.setAttribute("aria-multiline", true);
}
/** @inheritdoc */
getIdForTextLayer() {
return this.editorDiv.id;
}
/** @inheritdoc */
render() {
if (this.div) {
@ -314,12 +349,18 @@ class FreeTextEditor extends AnnotationEditor {
super.render();
this.editorDiv = document.createElement("div");
this.editorDiv.tabIndex = 0;
this.editorDiv.className = "internal";
FreeTextEditor._l10nPromise.then(msg =>
this.editorDiv.setAttribute("default-content", msg)
);
this.editorDiv.setAttribute("id", `${this.id}-editor`);
this.enableEditing();
FreeTextEditor._l10nPromise
.get("editor_free_text_aria_label")
.then(msg => this.editorDiv?.setAttribute("aria-label", msg));
FreeTextEditor._l10nPromise
.get("free_text_default_content")
.then(msg => this.editorDiv?.setAttribute("default-content", msg));
this.editorDiv.contentEditable = true;
const { style } = this.editorDiv;
@ -335,7 +376,7 @@ class FreeTextEditor extends AnnotationEditor {
// TODO: implement paste callback.
// The goal is to sanitize and have something suitable for this
// editor.
bindEvents(this, this.div, ["dblclick"]);
bindEvents(this, this.div, ["dblclick", "keyup"]);
if (this.width) {
// This editor was created in using copy (ctrl+c).
@ -354,6 +395,10 @@ class FreeTextEditor extends AnnotationEditor {
return this.div;
}
get contentDiv() {
return this.editorDiv;
}
/** @inheritdoc */
static deserialize(data, parent) {
const editor = super.deserialize(data, parent);

View file

@ -58,6 +58,8 @@ class InkEditor extends AnnotationEditor {
static _defaultThickness = 1;
static _l10nPromise;
constructor(params) {
super({ ...params, name: "inkEditor" });
this.color = params.color || null;
@ -76,6 +78,15 @@ class InkEditor extends AnnotationEditor {
this.#boundCanvasMousedown = this.canvasMousedown.bind(this);
}
static initialize(l10n) {
this._l10nPromise = new Map(
["editor_ink_canvas_aria_label", "editor_ink_aria_label"].map(str => [
str,
l10n.get(str),
])
);
}
static updateDefaultParams(type, value) {
switch (type) {
case AnnotationEditorParamsType.INK_THICKNESS:
@ -390,6 +401,10 @@ class InkEditor extends AnnotationEditor {
this.#fitToContent();
this.parent.addInkEditorIfNeeded(/* isCommitting = */ true);
// 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);
}
/** @inheritdoc */
@ -477,6 +492,10 @@ class InkEditor extends AnnotationEditor {
this.canvas = document.createElement("canvas");
this.canvas.width = this.canvas.height = 0;
this.canvas.className = "inkEditorCanvas";
InkEditor._l10nPromise
.get("editor_ink_canvas_aria_label")
.then(msg => this.canvas?.setAttribute("aria-label", msg));
this.div.append(this.canvas);
this.ctx = this.canvas.getContext("2d");
}
@ -507,6 +526,11 @@ class InkEditor extends AnnotationEditor {
}
super.render();
InkEditor._l10nPromise
.get("editor_ink_aria_label")
.then(msg => this.div?.setAttribute("aria-label", msg));
const [x, y, w, h] = this.#getInitialBBox();
this.setAt(x, y, 0, 0);
this.setDims(w, h);

View file

@ -426,6 +426,8 @@ class AnnotationEditorUIManager {
#boundOnPageChanging = this.onPageChanging.bind(this);
#boundOnTextLayerRendered = this.onTextLayerRendered.bind(this);
#previousStates = {
isEditing: false,
isEmpty: true,
@ -439,11 +441,13 @@ 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.#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();
}
@ -458,6 +462,12 @@ class AnnotationEditorUIManager {
this.#currentPageIndex = pageNumber - 1;
}
onTextLayerRendered({ pageNumber }) {
const pageIndex = pageNumber - 1;
const layer = this.#allLayers.get(pageIndex);
layer?.onTextLayerRendered();
}
/**
* Execute an action for a given name.
* For example, the user can click on the "Undo" entry in the context menu

View file

@ -41,15 +41,7 @@ import {
VerbosityLevel,
} from "./shared/util.js";
import {
build,
getDocument,
LoopbackPort,
PDFDataRangeTransport,
PDFWorker,
setPDFNetworkStreamFactory,
version,
} from "./display/api.js";
import {
binarySearchFirstItem,
getFilenameFromUrl,
getPdfFilenameFromUrl,
getXfaPageViewport,
@ -60,6 +52,15 @@ import {
PixelsPerInch,
RenderingCancelledException,
} from "./display/display_utils.js";
import {
build,
getDocument,
LoopbackPort,
PDFDataRangeTransport,
PDFWorker,
setPDFNetworkStreamFactory,
version,
} from "./display/api.js";
import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js";
import { AnnotationEditorUIManager } from "./display/editor/tools.js";
import { AnnotationLayer } from "./display/annotation_layer.js";
@ -116,6 +117,7 @@ export {
AnnotationEditorUIManager,
AnnotationLayer,
AnnotationMode,
binarySearchFirstItem,
build,
CMapCompressionType,
createPromiseCapability,