This commit is contained in:
parent
c738230bd9
commit
c3e7ad0f4c
3 changed files with 483 additions and 0 deletions
135
app/src/asset/pdf/annotation_editor_layer_builder.js
Normal file
135
app/src/asset/pdf/annotation_editor_layer_builder.js
Normal file
|
@ -0,0 +1,135 @@
|
|||
/* Copyright 2022 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
|
||||
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
|
||||
// eslint-disable-next-line max-len
|
||||
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
|
||||
/** @typedef {import("./interfaces").IL10n} IL10n */
|
||||
|
||||
import { AnnotationEditorLayer } from "./pdfjs";
|
||||
import { NullL10n } from "./l10n_utils.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} AnnotationEditorLayerBuilderOptions
|
||||
* @property {number} mode - Editor mode
|
||||
* @property {HTMLDivElement} pageDiv
|
||||
* @property {PDFPageProxy} pdfPage
|
||||
* @property {TextAccessibilityManager} accessibilityManager
|
||||
* @property {AnnotationStorage} annotationStorage
|
||||
* @property {IL10n} l10n - Localization service.
|
||||
* @property {AnnotationEditorUIManager} uiManager
|
||||
*/
|
||||
|
||||
class AnnotationEditorLayerBuilder {
|
||||
#uiManager;
|
||||
|
||||
/**
|
||||
* @param {AnnotationEditorLayerBuilderOptions} options
|
||||
*/
|
||||
constructor(options) {
|
||||
this.pageDiv = options.pageDiv;
|
||||
this.pdfPage = options.pdfPage;
|
||||
this.annotationStorage = options.annotationStorage || null;
|
||||
this.accessibilityManager = options.accessibilityManager;
|
||||
this.l10n = options.l10n || NullL10n;
|
||||
this.annotationEditorLayer = null;
|
||||
this.div = null;
|
||||
this._cancelled = false;
|
||||
this.#uiManager = options.uiManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PageViewport} viewport
|
||||
* @param {string} intent (default value is 'display')
|
||||
*/
|
||||
async render(viewport, intent = "display") {
|
||||
if (intent !== "display") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedViewport = viewport.clone({ dontFlip: true });
|
||||
if (this.div) {
|
||||
this.annotationEditorLayer.update({ viewport: clonedViewport });
|
||||
this.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an AnnotationEditor layer div
|
||||
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,
|
||||
div: this.div,
|
||||
annotationStorage: this.annotationStorage,
|
||||
accessibilityManager: this.accessibilityManager,
|
||||
pageIndex: this.pdfPage._pageIndex,
|
||||
l10n: this.l10n,
|
||||
viewport: clonedViewport,
|
||||
});
|
||||
|
||||
const parameters = {
|
||||
viewport: clonedViewport,
|
||||
div: this.div,
|
||||
annotations: null,
|
||||
intent,
|
||||
};
|
||||
|
||||
this.annotationEditorLayer.render(parameters);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this._cancelled = true;
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.div) {
|
||||
return;
|
||||
}
|
||||
this.div.hidden = true;
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.div) {
|
||||
return;
|
||||
}
|
||||
this.div.hidden = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.div) {
|
||||
return;
|
||||
}
|
||||
this.pageDiv = null;
|
||||
this.annotationEditorLayer.destroy();
|
||||
this.div.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export { AnnotationEditorLayerBuilder };
|
95
app/src/asset/pdf/annotation_editor_params.js
Normal file
95
app/src/asset/pdf/annotation_editor_params.js
Normal file
|
@ -0,0 +1,95 @@
|
|||
/* Copyright 2022 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AnnotationEditorParamsType } from "./pdfjs";
|
||||
|
||||
class AnnotationEditorParams {
|
||||
/**
|
||||
* @param {AnnotationEditorParamsOptions} options
|
||||
* @param {EventBus} eventBus
|
||||
*/
|
||||
constructor(options, eventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.#bindListeners(options);
|
||||
}
|
||||
|
||||
#bindListeners({
|
||||
editorFreeTextFontSize,
|
||||
editorFreeTextColor,
|
||||
editorInkColor,
|
||||
editorInkThickness,
|
||||
editorInkOpacity,
|
||||
}) {
|
||||
editorFreeTextFontSize.addEventListener("input", evt => {
|
||||
this.eventBus.dispatch("switchannotationeditorparams", {
|
||||
source: this,
|
||||
type: AnnotationEditorParamsType.FREETEXT_SIZE,
|
||||
value: editorFreeTextFontSize.valueAsNumber,
|
||||
});
|
||||
});
|
||||
editorFreeTextColor.addEventListener("input", evt => {
|
||||
this.eventBus.dispatch("switchannotationeditorparams", {
|
||||
source: this,
|
||||
type: AnnotationEditorParamsType.FREETEXT_COLOR,
|
||||
value: editorFreeTextColor.value,
|
||||
});
|
||||
});
|
||||
editorInkColor.addEventListener("input", evt => {
|
||||
this.eventBus.dispatch("switchannotationeditorparams", {
|
||||
source: this,
|
||||
type: AnnotationEditorParamsType.INK_COLOR,
|
||||
value: editorInkColor.value,
|
||||
});
|
||||
});
|
||||
editorInkThickness.addEventListener("input", evt => {
|
||||
this.eventBus.dispatch("switchannotationeditorparams", {
|
||||
source: this,
|
||||
type: AnnotationEditorParamsType.INK_THICKNESS,
|
||||
value: editorInkThickness.valueAsNumber,
|
||||
});
|
||||
});
|
||||
editorInkOpacity.addEventListener("input", evt => {
|
||||
this.eventBus.dispatch("switchannotationeditorparams", {
|
||||
source: this,
|
||||
type: AnnotationEditorParamsType.INK_OPACITY,
|
||||
value: editorInkOpacity.valueAsNumber,
|
||||
});
|
||||
});
|
||||
|
||||
this.eventBus._on("annotationeditorparamschanged", evt => {
|
||||
for (const [type, value] of evt.details) {
|
||||
switch (type) {
|
||||
case AnnotationEditorParamsType.FREETEXT_SIZE:
|
||||
editorFreeTextFontSize.value = value;
|
||||
break;
|
||||
case AnnotationEditorParamsType.FREETEXT_COLOR:
|
||||
editorFreeTextColor.value = value;
|
||||
break;
|
||||
case AnnotationEditorParamsType.INK_COLOR:
|
||||
editorInkColor.value = value;
|
||||
break;
|
||||
case AnnotationEditorParamsType.INK_THICKNESS:
|
||||
editorInkThickness.value = value;
|
||||
break;
|
||||
case AnnotationEditorParamsType.INK_OPACITY:
|
||||
editorInkOpacity.value = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { AnnotationEditorParams };
|
253
app/src/asset/pdf/text_accessibility.js
Normal file
253
app/src/asset/pdf/text_accessibility.js
Normal file
|
@ -0,0 +1,253 @@
|
|||
/* Copyright 2022 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { binarySearchFirstItem } from "./ui_utils.js";
|
||||
|
||||
/**
|
||||
* This class aims to provide some methods:
|
||||
* - to reorder elements in the DOM with respect to the visual order;
|
||||
* - to create a link, using aria-owns, between spans in the textLayer and
|
||||
* annotations in the annotationLayer. The goal is to help to know
|
||||
* where the annotations are in the text flow.
|
||||
*/
|
||||
class TextAccessibilityManager {
|
||||
#enabled = false;
|
||||
|
||||
#textChildren = null;
|
||||
|
||||
#textNodes = new Map();
|
||||
|
||||
#waitingElements = new Map();
|
||||
|
||||
setTextMapping(textDivs) {
|
||||
this.#textChildren = textDivs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.width === 0 && rect1.height === 0) {
|
||||
return +1;
|
||||
}
|
||||
|
||||
if (rect2.width === 0 && rect2.height === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const top1 = rect1.y;
|
||||
const bot1 = rect1.y + rect1.height;
|
||||
const mid1 = rect1.y + rect1.height / 2;
|
||||
|
||||
const top2 = rect2.y;
|
||||
const bot2 = rect2.y + rect2.height;
|
||||
const mid2 = rect2.y + rect2.height / 2;
|
||||
|
||||
if (mid1 <= top2 && mid2 >= bot1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mid2 <= top1 && mid1 >= bot2) {
|
||||
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.
|
||||
*/
|
||||
enable() {
|
||||
if (this.#enabled) {
|
||||
throw new Error("TextAccessibilityManager is already enabled.");
|
||||
}
|
||||
if (!this.#textChildren) {
|
||||
throw new Error("Text divs and strings have not been set.");
|
||||
}
|
||||
|
||||
this.#enabled = true;
|
||||
this.#textChildren = this.#textChildren.slice();
|
||||
this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions);
|
||||
|
||||
if (this.#textNodes.size > 0) {
|
||||
// Some links have been made before this manager has been disabled, hence
|
||||
// we restore them.
|
||||
const textChildren = this.#textChildren;
|
||||
for (const [id, nodeIndex] of this.#textNodes) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
// If the page was *fully* reset the element no longer exists, and it
|
||||
// will be re-inserted later (i.e. when the annotationLayer renders).
|
||||
this.#textNodes.delete(id);
|
||||
continue;
|
||||
}
|
||||
this.#addIdToAriaOwns(id, textChildren[nodeIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [element, isRemovable] of this.#waitingElements) {
|
||||
this.addPointerInTextLayer(element, isRemovable);
|
||||
}
|
||||
this.#waitingElements.clear();
|
||||
}
|
||||
|
||||
disable() {
|
||||
if (!this.#enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't clear this.#textNodes which is used to rebuild the aria-owns
|
||||
// in case it's re-enabled at some point.
|
||||
|
||||
this.#waitingElements.clear();
|
||||
this.#textChildren = null;
|
||||
this.#enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an aria-owns id from a node in the text layer.
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
removePointerInTextLayer(element) {
|
||||
if (!this.#enabled) {
|
||||
this.#waitingElements.delete(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const children = this.#textChildren;
|
||||
if (!children || children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = element;
|
||||
const nodeIndex = this.#textNodes.get(id);
|
||||
if (nodeIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = children[nodeIndex];
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#addIdToAriaOwns(id, node) {
|
||||
const owns = node.getAttribute("aria-owns");
|
||||
if (!owns?.includes(id)) {
|
||||
node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
|
||||
}
|
||||
node.removeAttribute("role");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {HTMLElement} element
|
||||
* @param {boolean} isRemovable
|
||||
*/
|
||||
addPointerInTextLayer(element, isRemovable) {
|
||||
const { id } = element;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#enabled) {
|
||||
// The text layer needs to be there, so we postpone the association.
|
||||
this.#waitingElements.set(element, isRemovable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRemovable) {
|
||||
this.removePointerInTextLayer(element);
|
||||
}
|
||||
|
||||
const children = this.#textChildren;
|
||||
if (!children || children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = binarySearchFirstItem(
|
||||
children,
|
||||
node =>
|
||||
TextAccessibilityManager.#compareElementPositions(element, node) < 0
|
||||
);
|
||||
|
||||
const nodeIndex = Math.max(0, index - 1);
|
||||
this.#addIdToAriaOwns(id, children[nodeIndex]);
|
||||
this.#textNodes.set(id, nodeIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a div in the DOM in order to respect the visual order.
|
||||
* @param {HTMLDivElement} element
|
||||
*/
|
||||
moveElementInDOM(container, element, contentElement, isRemovable) {
|
||||
this.addPointerInTextLayer(contentElement, isRemovable);
|
||||
|
||||
if (!container.hasChildNodes()) {
|
||||
container.append(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const children = Array.from(container.childNodes).filter(
|
||||
node => node !== element
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementToCompare = contentElement || element;
|
||||
const index = binarySearchFirstItem(
|
||||
children,
|
||||
node =>
|
||||
TextAccessibilityManager.#compareElementPositions(
|
||||
elementToCompare,
|
||||
node
|
||||
) < 0
|
||||
);
|
||||
|
||||
if (index === 0) {
|
||||
children[0].before(element);
|
||||
} else {
|
||||
children[index - 1].after(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { TextAccessibilityManager };
|
Loading…
Add table
Reference in a new issue