From 624b26e1de58828f2c4be08f656dc395c018fd39 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 28 Jun 2022 18:21:32 +0200 Subject: [PATCH] [Editor] Improve a11y for newly added element (#15109) - In the annotationEditorLayer, reorder the editors in the DOM according the position of the elements on the screen; - add an aria-owns attribute on the "nearest" element in the text layer which points to the added editor. --- l10n/en-US/viewer.properties | 5 + src/display/display_utils.js | 33 +++ src/display/editor/annotation_editor_layer.js | 212 +++++++++++++++++- src/display/editor/editor.js | 29 ++- src/display/editor/freetext.js | 59 ++++- src/display/editor/ink.js | 24 ++ src/display/editor/tools.js | 10 + src/pdf.js | 20 +- test/integration/freetext_editor_spec.js | 54 +++++ test/unit/display_utils_spec.js | 34 +++ test/unit/ui_utils_spec.js | 34 --- web/annotation_editor_layer_builder.js | 7 +- web/l10n_utils.js | 3 + web/pdf_find_controller.js | 4 +- web/ui_utils.js | 35 +-- 15 files changed, 467 insertions(+), 96 deletions(-) diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 8f8f3b310..22172bf31 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -265,3 +265,8 @@ editor_free_text_font_color=Font Color editor_free_text_font_size=Font Size editor_ink_line_color=Line Color editor_ink_line_thickness=Line Thickness + +# Editor aria +editor_free_text_aria_label=FreeText Editor +editor_ink_aria_label=Ink Editor +editor_ink_canvas_aria_label=User-created image diff --git a/src/display/display_utils.js b/src/display/display_utils.js index bd1bd5a09..3262e6419 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -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, diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 5beff243f..d1cecc3bf 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -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(); } diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 796f3c85b..edb77fa7c 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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 }; diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 836924084..bdea9c3e9 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -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); diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index afcb62e58..f0fb88693 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -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); diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index d6f9c1646..7680a5483 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -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 diff --git a/src/pdf.js b/src/pdf.js index 8a5527812..435068456 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -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, diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js index 35c911e1f..e72c62867 100644 --- a/test/integration/freetext_editor_spec.js +++ b/test/integration/freetext_editor_spec.js @@ -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)); + }) + ); + }); }); }); diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js index 55ebe6e7d..56488e628 100644 --- a/test/unit/display_utils_spec.js +++ b/test/unit/display_utils_spec.js @@ -14,6 +14,7 @@ */ import { + binarySearchFirstItem, DOMCanvasFactory, DOMSVGFactory, getFilenameFromUrl, @@ -25,6 +26,39 @@ import { bytesToString } from "../../src/shared/util.js"; import { isNodeJS } from "../../src/shared/is_node.js"; 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 () { let canvasFactory; diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js index 704008175..23a825349 100644 --- a/test/unit/ui_utils_spec.js +++ b/test/unit/ui_utils_spec.js @@ -15,7 +15,6 @@ import { backtrackBeforeAllVisibleElements, - binarySearchFirstItem, getPageSizeInches, getVisibleElements, isPortraitOrientation, @@ -25,39 +24,6 @@ import { } from "../../web/ui_utils.js"; 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 () { it("should reject non-integer angles", function () { expect(isValidRotation()).toEqual(false); diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js index 412941ce8..e8cfbcee9 100644 --- a/web/annotation_editor_layer_builder.js +++ b/web/annotation_editor_layer_builder.js @@ -77,6 +77,7 @@ class AnnotationEditorLayerBuilder { this.div = document.createElement("div"); this.div.className = "annotationEditorLayer"; this.div.tabIndex = 0; + this.pageDiv.append(this.div); this.annotationEditorLayer = new AnnotationEditorLayer({ uiManager: this.#uiManager, @@ -84,6 +85,7 @@ class AnnotationEditorLayerBuilder { annotationStorage: this.annotationStorage, pageIndex: this.pdfPage._pageIndex, l10n: this.l10n, + viewport: clonedViewport, }); const parameters = { @@ -94,12 +96,11 @@ class AnnotationEditorLayerBuilder { }; this.annotationEditorLayer.render(parameters); - - this.pageDiv.append(this.div); } cancel() { this._cancelled = true; + this.destroy(); } hide() { @@ -121,8 +122,8 @@ class AnnotationEditorLayerBuilder { return; } this.pageDiv = null; - this.div.remove(); this.annotationEditorLayer.destroy(); + this.div.remove(); } } diff --git a/web/l10n_utils.js b/web/l10n_utils.js index f0de4eee9..257bfaae1 100644 --- a/web/l10n_utils.js +++ b/web/l10n_utils.js @@ -83,6 +83,9 @@ const DEFAULT_L10N_STRINGS = { "Web fonts are disabled: unable to use embedded PDF fonts.", 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) { diff --git a/web/pdf_find_controller.js b/web/pdf_find_controller.js index cfadec222..d7c31e096 100644 --- a/web/pdf_find_controller.js +++ b/web/pdf_find_controller.js @@ -17,9 +17,9 @@ /** @typedef {import("./event_utils").EventBus} EventBus */ /** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */ -import { binarySearchFirstItem, scrollIntoView } from "./ui_utils.js"; -import { createPromiseCapability } from "pdfjs-lib"; +import { binarySearchFirstItem, createPromiseCapability } from "pdfjs-lib"; import { getCharacterType } from "./pdf_find_utils.js"; +import { scrollIntoView } from "./ui_utils.js"; const FindState = { FOUND: 0, diff --git a/web/ui_utils.js b/web/ui_utils.js index cb5aff78d..02dc8f24f 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -13,6 +13,8 @@ * limitations under the License. */ +import { binarySearchFirstItem } from "pdfjs-lib"; + const DEFAULT_SCALE_VALUE = "auto"; const DEFAULT_SCALE = 1.0; const DEFAULT_SCALE_DELTA = 1.1; @@ -221,38 +223,6 @@ function removeNullCharacters(str, replaceInvisible = false) { 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 * of 8). @@ -840,7 +810,6 @@ export { approximateFraction, AutoPrintRegExp, backtrackBeforeAllVisibleElements, // only exported for testing - binarySearchFirstItem, DEFAULT_SCALE, DEFAULT_SCALE_DELTA, DEFAULT_SCALE_VALUE,