Merge pull request #15110 from calixteman/editing_a11y
[Editor] Improve a11y for newly added element (#15109)
This commit is contained in:
commit
f46895d750
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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 };
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
20
src/pdf.js
20
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,
|
||||
|
@ -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));
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user