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

@ -265,3 +265,8 @@ editor_free_text_font_color=Font Color
editor_free_text_font_size=Font Size editor_free_text_font_size=Font Size
editor_ink_line_color=Line Color editor_ink_line_color=Line Color
editor_ink_line_thickness=Line Thickness 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

View File

@ -601,7 +601,40 @@ function getColorValues(colors) {
span.remove(); 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 { export {
binarySearchFirstItem,
deprecated, deprecated,
DOMCanvasFactory, DOMCanvasFactory,
DOMCMapReaderFactory, DOMCMapReaderFactory,

View File

@ -20,8 +20,9 @@
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */ /** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
/** @typedef {import("../../web/interfaces").IL10n} IL10n */ /** @typedef {import("../../web/interfaces").IL10n} IL10n */
import { AnnotationEditorType, shadow } from "../../shared/util.js";
import { bindEvents, KeyboardManager } from "./tools.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 { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js"; import { InkEditor } from "./ink.js";
@ -50,8 +51,14 @@ class AnnotationEditorLayer {
#isCleaningUp = false; #isCleaningUp = false;
#textLayerMap = new WeakMap();
#textNodes = new Map();
#uiManager; #uiManager;
#waitingEditors = new Set();
static _initialized = false; static _initialized = false;
static _keyboardManager = new KeyboardManager([ static _keyboardManager = new KeyboardManager([
@ -88,6 +95,7 @@ class AnnotationEditorLayer {
if (!AnnotationEditorLayer._initialized) { if (!AnnotationEditorLayer._initialized) {
AnnotationEditorLayer._initialized = true; AnnotationEditorLayer._initialized = true;
FreeTextEditor.initialize(options.l10n); FreeTextEditor.initialize(options.l10n);
InkEditor.initialize(options.l10n);
options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]); options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]);
} }
@ -98,11 +106,40 @@ class AnnotationEditorLayer {
this.#boundClick = this.click.bind(this); this.#boundClick = this.click.bind(this);
this.#boundMousedown = this.mousedown.bind(this); this.#boundMousedown = this.mousedown.bind(this);
for (const editor of this.#uiManager.getEditors(options.pageIndex)) { this.#uiManager.addLayer(this);
this.add(editor); }
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() { enable() {
this.div.style.pointerEvents = "auto"; this.div.style.pointerEvents = "auto";
for (const editor of this.#editors.values()) {
editor.enableEditing();
}
} }
/** /**
@ -237,6 +277,9 @@ class AnnotationEditorLayer {
*/ */
disable() { disable() {
this.div.style.pointerEvents = "none"; this.div.style.pointerEvents = "none";
for (const editor of this.#editors.values()) {
editor.disableEditing();
}
} }
/** /**
@ -276,6 +319,7 @@ class AnnotationEditorLayer {
detach(editor) { detach(editor) {
this.#editors.delete(editor.id); this.#editors.delete(editor.id);
this.removePointerInTextLayer(editor);
} }
/** /**
@ -311,12 +355,12 @@ class AnnotationEditorLayer {
} }
if (this.#uiManager.isActive(editor)) { if (this.#uiManager.isActive(editor)) {
editor.parent.setActiveEditor(null); editor.parent?.setActiveEditor(null);
} }
this.attach(editor); this.attach(editor);
editor.pageIndex = this.pageIndex; editor.pageIndex = this.pageIndex;
editor.parent.detach(editor); editor.parent?.detach(editor);
editor.parent = this; editor.parent = this;
if (editor.div && editor.isAttachedToDOM) { if (editor.div && editor.isAttachedToDOM) {
editor.div.remove(); 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. * Add a new editor in the current view.
* @param {AnnotationEditor} editor * @param {AnnotationEditor} editor
@ -340,6 +525,7 @@ class AnnotationEditorLayer {
editor.isAttachedToDOM = true; editor.isAttachedToDOM = true;
} }
this.moveDivInDOM(editor);
editor.onceAdded(); editor.onceAdded();
} }
@ -493,6 +679,8 @@ class AnnotationEditorLayer {
const endY = event.clientY - rect.y; const endY = event.clientY - rect.y;
editor.translate(endX - editor.startX, endY - editor.startY); 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 the main editor.
*/ */
destroy() { destroy() {
if (this.#uiManager.getActive()?.parent === this) {
this.#uiManager.setActiveEditor(null);
}
for (const editor of this.#editors.values()) { for (const editor of this.#editors.values()) {
this.removePointerInTextLayer(editor);
editor.isAttachedToDOM = false; editor.isAttachedToDOM = false;
editor.div.remove(); editor.div.remove();
editor.parent = null; editor.parent = null;
this.div = null;
} }
this.#textNodes.clear();
this.div = null;
this.#editors.clear(); this.#editors.clear();
this.#waitingEditors.clear();
this.#uiManager.removeLayer(this); this.#uiManager.removeLayer(this);
} }
@ -548,6 +743,9 @@ class AnnotationEditorLayer {
this.viewport = parameters.viewport; this.viewport = parameters.viewport;
bindEvents(this, this.div, ["dragover", "drop", "keydown"]); bindEvents(this, this.div, ["dragover", "drop", "keydown"]);
this.setDimensions(); this.setDimensions();
for (const editor of this.#uiManager.getEditors(this.pageIndex)) {
this.add(editor);
}
this.updateMode(); this.updateMode();
} }

View File

@ -220,7 +220,7 @@ class AnnotationEditor {
this.div.setAttribute("data-editor-rotation", (360 - this.rotation) % 360); this.div.setAttribute("data-editor-rotation", (360 - this.rotation) % 360);
this.div.className = this.name; this.div.className = this.name;
this.div.setAttribute("id", this.id); this.div.setAttribute("id", this.id);
this.div.tabIndex = 100; this.div.tabIndex = 0;
const [tx, ty] = this.getInitialTranslation(); const [tx, ty] = this.getInitialTranslation();
this.translate(tx, ty); this.translate(tx, ty);
@ -454,6 +454,26 @@ class AnnotationEditor {
*/ */
updateParams(type, value) {} 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. * Get some properties to update in the UI.
* @returns {Object} * @returns {Object}
@ -461,6 +481,13 @@ class AnnotationEditor {
get propertiesToUpdate() { get propertiesToUpdate() {
return {}; return {};
} }
/**
* Get the div which really contains the displayed content.
*/
get contentDiv() {
return this.div;
}
} }
export { AnnotationEditor }; export { AnnotationEditor };

View File

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

View File

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

View File

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

View File

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

View File

@ -197,5 +197,59 @@ describe("Editor", () => {
}) })
); );
}); });
it("must check that aria-owns is correct", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const [adobeComRect, oldAriaOwns] = await page.$eval(
".textLayer",
el => {
for (const span of el.querySelectorAll(
`span[role="presentation"]`
)) {
if (span.innerText.includes("adobe.com")) {
span.setAttribute("pdfjs", true);
const { x, y, width, height } = span.getBoundingClientRect();
return [
{ x, y, width, height },
span.getAttribute("aria-owns"),
];
}
}
return null;
}
);
expect(oldAriaOwns).withContext(`In ${browserName}`).toEqual(null);
const data = "Hello PDF.js World !!";
await page.mouse.click(
adobeComRect.x + adobeComRect.width + 10,
adobeComRect.y + adobeComRect.height / 2
);
await page.type(`${editorPrefix}5 .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}5`, el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
// Commit.
await page.mouse.click(
editorRect.x,
editorRect.y + 2 * editorRect.height
);
const ariaOwns = await page.$eval(".textLayer", el => {
const span = el.querySelector(`span[pdfjs="true"]`);
return span?.getAttribute("aria-owns") || null;
});
expect(ariaOwns)
.withContext(`In ${browserName}`)
.toEqual(`${editorPrefix}5-editor`.slice(1));
})
);
});
}); });
}); });

View File

@ -14,6 +14,7 @@
*/ */
import { import {
binarySearchFirstItem,
DOMCanvasFactory, DOMCanvasFactory,
DOMSVGFactory, DOMSVGFactory,
getFilenameFromUrl, getFilenameFromUrl,
@ -25,6 +26,39 @@ import { bytesToString } from "../../src/shared/util.js";
import { isNodeJS } from "../../src/shared/is_node.js"; import { isNodeJS } from "../../src/shared/is_node.js";
describe("display_utils", function () { describe("display_utils", function () {
describe("binary search", function () {
function isTrue(boolean) {
return boolean;
}
function isGreater3(number) {
return number > 3;
}
it("empty array", function () {
expect(binarySearchFirstItem([], isTrue)).toEqual(0);
});
it("single boolean entry", function () {
expect(binarySearchFirstItem([false], isTrue)).toEqual(1);
expect(binarySearchFirstItem([true], isTrue)).toEqual(0);
});
it("three boolean entries", function () {
expect(binarySearchFirstItem([true, true, true], isTrue)).toEqual(0);
expect(binarySearchFirstItem([false, true, true], isTrue)).toEqual(1);
expect(binarySearchFirstItem([false, false, true], isTrue)).toEqual(2);
expect(binarySearchFirstItem([false, false, false], isTrue)).toEqual(3);
});
it("three numeric entries", function () {
expect(binarySearchFirstItem([0, 1, 2], isGreater3)).toEqual(3);
expect(binarySearchFirstItem([2, 3, 4], isGreater3)).toEqual(2);
expect(binarySearchFirstItem([4, 5, 6], isGreater3)).toEqual(0);
});
it("three numeric entries and a start index", function () {
expect(binarySearchFirstItem([0, 1, 2, 3, 4], isGreater3, 2)).toEqual(4);
expect(binarySearchFirstItem([2, 3, 4], isGreater3, 2)).toEqual(2);
expect(binarySearchFirstItem([4, 5, 6], isGreater3, 1)).toEqual(1);
});
});
describe("DOMCanvasFactory", function () { describe("DOMCanvasFactory", function () {
let canvasFactory; let canvasFactory;

View File

@ -15,7 +15,6 @@
import { import {
backtrackBeforeAllVisibleElements, backtrackBeforeAllVisibleElements,
binarySearchFirstItem,
getPageSizeInches, getPageSizeInches,
getVisibleElements, getVisibleElements,
isPortraitOrientation, isPortraitOrientation,
@ -25,39 +24,6 @@ import {
} from "../../web/ui_utils.js"; } from "../../web/ui_utils.js";
describe("ui_utils", function () { describe("ui_utils", function () {
describe("binary search", function () {
function isTrue(boolean) {
return boolean;
}
function isGreater3(number) {
return number > 3;
}
it("empty array", function () {
expect(binarySearchFirstItem([], isTrue)).toEqual(0);
});
it("single boolean entry", function () {
expect(binarySearchFirstItem([false], isTrue)).toEqual(1);
expect(binarySearchFirstItem([true], isTrue)).toEqual(0);
});
it("three boolean entries", function () {
expect(binarySearchFirstItem([true, true, true], isTrue)).toEqual(0);
expect(binarySearchFirstItem([false, true, true], isTrue)).toEqual(1);
expect(binarySearchFirstItem([false, false, true], isTrue)).toEqual(2);
expect(binarySearchFirstItem([false, false, false], isTrue)).toEqual(3);
});
it("three numeric entries", function () {
expect(binarySearchFirstItem([0, 1, 2], isGreater3)).toEqual(3);
expect(binarySearchFirstItem([2, 3, 4], isGreater3)).toEqual(2);
expect(binarySearchFirstItem([4, 5, 6], isGreater3)).toEqual(0);
});
it("three numeric entries and a start index", function () {
expect(binarySearchFirstItem([0, 1, 2, 3, 4], isGreater3, 2)).toEqual(4);
expect(binarySearchFirstItem([2, 3, 4], isGreater3, 2)).toEqual(2);
expect(binarySearchFirstItem([4, 5, 6], isGreater3, 1)).toEqual(1);
});
});
describe("isValidRotation", function () { describe("isValidRotation", function () {
it("should reject non-integer angles", function () { it("should reject non-integer angles", function () {
expect(isValidRotation()).toEqual(false); expect(isValidRotation()).toEqual(false);

View File

@ -77,6 +77,7 @@ class AnnotationEditorLayerBuilder {
this.div = document.createElement("div"); this.div = document.createElement("div");
this.div.className = "annotationEditorLayer"; this.div.className = "annotationEditorLayer";
this.div.tabIndex = 0; this.div.tabIndex = 0;
this.pageDiv.append(this.div);
this.annotationEditorLayer = new AnnotationEditorLayer({ this.annotationEditorLayer = new AnnotationEditorLayer({
uiManager: this.#uiManager, uiManager: this.#uiManager,
@ -84,6 +85,7 @@ class AnnotationEditorLayerBuilder {
annotationStorage: this.annotationStorage, annotationStorage: this.annotationStorage,
pageIndex: this.pdfPage._pageIndex, pageIndex: this.pdfPage._pageIndex,
l10n: this.l10n, l10n: this.l10n,
viewport: clonedViewport,
}); });
const parameters = { const parameters = {
@ -94,12 +96,11 @@ class AnnotationEditorLayerBuilder {
}; };
this.annotationEditorLayer.render(parameters); this.annotationEditorLayer.render(parameters);
this.pageDiv.append(this.div);
} }
cancel() { cancel() {
this._cancelled = true; this._cancelled = true;
this.destroy();
} }
hide() { hide() {
@ -121,8 +122,8 @@ class AnnotationEditorLayerBuilder {
return; return;
} }
this.pageDiv = null; this.pageDiv = null;
this.div.remove();
this.annotationEditorLayer.destroy(); this.annotationEditorLayer.destroy();
this.div.remove();
} }
} }

View File

@ -83,6 +83,9 @@ const DEFAULT_L10N_STRINGS = {
"Web fonts are disabled: unable to use embedded PDF fonts.", "Web fonts are disabled: unable to use embedded PDF fonts.",
free_text_default_content: "Enter text…", free_text_default_content: "Enter text…",
editor_free_text_aria_label: "FreeText Editor",
editor_ink_aria_label: "Ink Editor",
editor_ink_canvas_aria_label: "User-created image",
}; };
function getL10nFallback(key, args) { function getL10nFallback(key, args) {

View File

@ -17,9 +17,9 @@
/** @typedef {import("./event_utils").EventBus} EventBus */ /** @typedef {import("./event_utils").EventBus} EventBus */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */ /** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
import { binarySearchFirstItem, scrollIntoView } from "./ui_utils.js"; import { binarySearchFirstItem, createPromiseCapability } from "pdfjs-lib";
import { createPromiseCapability } from "pdfjs-lib";
import { getCharacterType } from "./pdf_find_utils.js"; import { getCharacterType } from "./pdf_find_utils.js";
import { scrollIntoView } from "./ui_utils.js";
const FindState = { const FindState = {
FOUND: 0, FOUND: 0,

View File

@ -13,6 +13,8 @@
* limitations under the License. * limitations under the License.
*/ */
import { binarySearchFirstItem } from "pdfjs-lib";
const DEFAULT_SCALE_VALUE = "auto"; const DEFAULT_SCALE_VALUE = "auto";
const DEFAULT_SCALE = 1.0; const DEFAULT_SCALE = 1.0;
const DEFAULT_SCALE_DELTA = 1.1; const DEFAULT_SCALE_DELTA = 1.1;
@ -221,38 +223,6 @@ function removeNullCharacters(str, replaceInvisible = false) {
return str.replace(NullCharactersRegExp, ""); return str.replace(NullCharactersRegExp, "");
} }
/**
* 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 */
}
/** /**
* Approximates float number as a fraction using Farey sequence (max order * Approximates float number as a fraction using Farey sequence (max order
* of 8). * of 8).
@ -840,7 +810,6 @@ export {
approximateFraction, approximateFraction,
AutoPrintRegExp, AutoPrintRegExp,
backtrackBeforeAllVisibleElements, // only exported for testing backtrackBeforeAllVisibleElements, // only exported for testing
binarySearchFirstItem,
DEFAULT_SCALE, DEFAULT_SCALE,
DEFAULT_SCALE_DELTA, DEFAULT_SCALE_DELTA,
DEFAULT_SCALE_VALUE, DEFAULT_SCALE_VALUE,