From 353b0f17fdf4c19b87a4d21afe92b445ff26fb79 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 | 216 +++++++++++++++++- src/display/editor/editor.js | 29 ++- src/display/editor/freetext.js | 60 ++++- src/display/editor/ink.js | 20 ++ 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/pdf_find_controller.js | 4 +- web/ui_utils.js | 35 +-- 14 files changed, 465 insertions(+), 96 deletions(-) diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 8f8f3b310d9905..f3c3eb373b8ba8 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 bd1bd5a0991929..3262e6419d0676 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 b387707659d3de..b3afaca67df005 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"; @@ -46,8 +47,14 @@ class AnnotationEditorLayer { #editors = new Map(); + #textLayerMap = new WeakMap(); + + #textNodes = new Map(); + #uiManager; + #waitingEditors = new Set(); + static _initialized = false; static _keyboardManager = new KeyboardManager([ @@ -78,6 +85,7 @@ class AnnotationEditorLayer { if (!AnnotationEditorLayer._initialized) { AnnotationEditorLayer._initialized = true; FreeTextEditor.initialize(options.l10n); + InkEditor.initialize(options.l10n); options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]); } @@ -88,11 +96,40 @@ class AnnotationEditorLayer { this.#boundClick = this.click.bind(this); this.#boundMouseover = this.mouseover.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 more 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; + } + + #hasTextLayer() { + return !!this.div.parentNode.querySelector(".textLayer .endOfContent"); } /** @@ -227,6 +264,9 @@ class AnnotationEditorLayer { */ enable() { this.div.style.pointerEvents = "auto"; + for (const editor of this.#editors.values()) { + editor.enableEditing(); + } } /** @@ -234,6 +274,9 @@ class AnnotationEditorLayer { */ disable() { this.div.style.pointerEvents = "none"; + for (const editor of this.#editors.values()) { + editor.disableEditing(); + } } /** @@ -270,6 +313,7 @@ class AnnotationEditorLayer { detach(editor) { this.#editors.delete(editor.id); + this.removePointerInTextLayer(editor); } /** @@ -303,12 +347,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(); @@ -316,6 +360,150 @@ 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 + */ + 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 + * @returns {undefined} + */ + 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 position correctly this editorn in the text flow. + * @param {AnnotationEditor} editor + * @returns {undefined} + */ + 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 + * @returns {undefined} + */ + #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 @@ -332,6 +520,7 @@ class AnnotationEditorLayer { editor.isAttachedToDOM = true; } + this.#moveDivInDOM(editor); editor.onceAdded(); } @@ -456,6 +645,8 @@ class AnnotationEditorLayer { const endY = event.clientY - rect.y; editor.translate(endX - editor.startX, endY - editor.startY); + this.#moveDivInDOM(editor); + editor.div.focus(); } /** @@ -480,13 +671,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); } @@ -499,6 +697,10 @@ class AnnotationEditorLayer { bindEvents(this, this.div, ["dragover", "drop", "keydown"]); this.div.addEventListener("click", this.#boundClick); this.setDimensions(); + + for (const editor of this.#uiManager.getEditors(this.pageIndex)) { + this.add(editor); + } } /** diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index eb91a39b5ca9d1..4fa16e1935e88d 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -213,7 +213,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); @@ -411,6 +411,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} @@ -418,6 +438,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 85f99ec7c62439..00a088b467e1c3 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -56,7 +56,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 ( @@ -133,7 +139,6 @@ class FreeTextEditor extends AnnotationEditor { ]; } - /** @inheritdoc */ get propertiesToUpdate() { return [ [AnnotationEditorParamsType.FREETEXT_SIZE, this.#fontSize], @@ -220,6 +225,7 @@ class FreeTextEditor extends AnnotationEditor { this.overlayDiv.classList.remove("enabled"); this.editorDiv.contentEditable = true; this.div.draggable = false; + this.div.removeAttribute("tabIndex"); } /** @inheritdoc */ @@ -229,6 +235,7 @@ class FreeTextEditor extends AnnotationEditor { this.overlayDiv.classList.add("enabled"); this.editorDiv.contentEditable = false; this.div.draggable = true; + this.div.tabIndex = 0; } /** @inheritdoc */ @@ -316,6 +323,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) { @@ -330,12 +365,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.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; @@ -351,7 +392,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). @@ -370,6 +411,11 @@ class FreeTextEditor extends AnnotationEditor { return this.div; } + /** @inheritdoc */ + get contentDiv() { + return this.editorDiv; + } + /** @inheritdoc */ serialize() { const padding = FreeTextEditor._internalPadding * this.parent.scaleFactor; diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 37645de3885370..0042409999f582 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -51,6 +51,8 @@ class InkEditor extends AnnotationEditor { static _defaultThickness = 1; + static _l10nPromise; + constructor(params) { super({ ...params, name: "inkEditor" }); this.color = @@ -72,6 +74,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), + ]) + ); + } + /** @inheritdoc */ copy() { const editor = new InkEditor({ @@ -494,6 +505,10 @@ class InkEditor extends AnnotationEditor { #createCanvas() { this.canvas = document.createElement("canvas"); 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"); } @@ -525,6 +540,11 @@ class InkEditor extends AnnotationEditor { super.render(); this.div.classList.add("editing"); + + 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 f0614e40ef53a9..43b08b932ca98a 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -427,6 +427,10 @@ class AnnotationEditorUIManager { constructor(eventBus) { this.#eventBus = eventBus; this.#eventBus._on("editingaction", this.#boundOnEditingAction); + this.#eventBus._on( + "textlayerrendered", + this.#onTextLayerRendered.bind(this) + ); } destroy() { @@ -444,6 +448,12 @@ class AnnotationEditorUIManager { this.#commandManager.destroy(); } + #onTextLayerRendered(event) { + const pageIndex = event.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 8a55278126b263..4350684562a7c6 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 ed275087c4aa3a..b914033087dfa8 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}8 .internal`, data); + + const editorRect = await page.$eval(`${editorPrefix}8`, 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}8-editor`.slice(1)); + }) + ); + }); }); }); diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js index 55ebe6e7d5d143..56488e628f33ac 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 7040081750b0bd..23a825349c235f 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 412941ce89c889..e8cfbcee974954 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/pdf_find_controller.js b/web/pdf_find_controller.js index cfadec222daab4..d7c31e096d090d 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 cb5aff78d668d4..02dc8f24f89398 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,