Merge pull request #14023 from Snuffleupagus/AnnotationLayer-getElementsByName

Re-factor `document.getElementsByName` lookups in the AnnotationLayer (issue 14003)
This commit is contained in:
Jonas Jenwald 2021-09-23 14:05:55 +02:00 committed by GitHub
commit c914e9f0a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 245 additions and 78 deletions

View File

@ -34,6 +34,7 @@ import { AnnotationStorage } from "./annotation_storage.js";
import { ColorConverters } from "../shared/scripting_utils.js";
const DEFAULT_TAB_INDEX = 1000;
const GetElementsByNameSet = new WeakSet();
/**
* @typedef {Object} AnnotationElementParameters
@ -50,6 +51,7 @@ const DEFAULT_TAB_INDEX = 1000;
* @property {Object} svgFactory
* @property {boolean} [enableScripting]
* @property {boolean} [hasJSActions]
* @property {Object} [fieldObjects]
* @property {Object} [mouseState]
*/
@ -159,6 +161,7 @@ class AnnotationElement {
this.annotationStorage = parameters.annotationStorage;
this.enableScripting = parameters.enableScripting;
this.hasJSActions = parameters.hasJSActions;
this._fieldObjects = parameters.fieldObjects;
this._mouseState = parameters.mouseState;
if (isRenderable) {
@ -363,6 +366,51 @@ class AnnotationElement {
unreachable("Abstract method `AnnotationElement.render` called");
}
/**
* @private
* @returns {Array}
*/
_getElementsByName(name, skipId = null) {
const fields = [];
if (this._fieldObjects) {
const fieldObj = this._fieldObjects[name];
if (fieldObj) {
for (const { page, id, exportValues } of fieldObj) {
if (page === -1) {
continue;
}
if (id === skipId) {
continue;
}
const exportValue =
typeof exportValues === "string" ? exportValues : null;
const domElement = document.getElementById(id);
if (domElement && !GetElementsByNameSet.has(domElement)) {
warn(`_getElementsByName - element not allowed: ${id}`);
continue;
}
fields.push({ id, exportValue, domElement });
}
}
return fields;
}
// Fallback to a regular DOM lookup, to ensure that the standalone
// viewer components won't break.
for (const domElement of document.getElementsByName(name)) {
const { id, exportValue } = domElement;
if (id === skipId) {
continue;
}
if (!GetElementsByNameSet.has(domElement)) {
continue;
}
fields.push({ id, exportValue, domElement });
}
return fields;
}
static get platform() {
const platform = typeof navigator !== "undefined" ? navigator.platform : "";
@ -687,13 +735,14 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
setPropertyOnSiblings(base, key, value, keyInStorage) {
const storage = this.annotationStorage;
for (const element of document.getElementsByName(base.name)) {
if (element !== base) {
element[key] = value;
const data = Object.create(null);
data[keyInStorage] = value;
storage.setValue(element.getAttribute("id"), data);
for (const element of this._getElementsByName(
base.name,
/* skipId = */ base.id
)) {
if (element.domElement) {
element.domElement[key] = value;
}
storage.setValue(element.id, { [keyInStorage]: value });
}
}
@ -728,6 +777,9 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
element.type = "text";
element.setAttribute("value", textContent);
}
GetElementsByNameSet.add(element);
element.disabled = this.data.readOnly;
element.name = this.data.fieldName;
element.tabIndex = DEFAULT_TAB_INDEX;
elementData.userValue = textContent;
@ -900,9 +952,6 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
element.addEventListener("blur", blurListener);
}
element.disabled = this.data.readOnly;
element.name = this.data.fieldName;
if (this.data.maxLen !== null) {
element.maxLength = this.data.maxLen;
}
@ -978,6 +1027,7 @@ class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement {
this.container.className = "buttonWidgetAnnotation checkBox";
const element = document.createElement("input");
GetElementsByNameSet.add(element);
element.disabled = data.readOnly;
element.type = "checkbox";
element.name = data.fieldName;
@ -988,19 +1038,14 @@ class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement {
element.setAttribute("exportValue", data.exportValue);
element.tabIndex = DEFAULT_TAB_INDEX;
element.addEventListener("change", function (event) {
const name = event.target.name;
const checked = event.target.checked;
for (const checkbox of document.getElementsByName(name)) {
if (checkbox !== event.target) {
checkbox.checked =
checked &&
checkbox.getAttribute("exportValue") === data.exportValue;
storage.setValue(
checkbox.parentNode.getAttribute("data-annotation-id"),
{ value: false }
);
element.addEventListener("change", event => {
const { name, checked } = event.target;
for (const checkbox of this._getElementsByName(name, /* skipId = */ id)) {
const curChecked = checked && checkbox.exportValue === data.exportValue;
if (checkbox.domElement) {
checkbox.domElement.checked = curChecked;
}
storage.setValue(checkbox.id, { value: curChecked });
}
storage.setValue(id, { value: checked });
});
@ -1057,6 +1102,7 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement {
}
const element = document.createElement("input");
GetElementsByNameSet.add(element);
element.disabled = data.readOnly;
element.type = "radio";
element.name = data.fieldName;
@ -1066,26 +1112,26 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement {
element.setAttribute("id", id);
element.tabIndex = DEFAULT_TAB_INDEX;
element.addEventListener("change", function (event) {
const { target } = event;
for (const radio of document.getElementsByName(target.name)) {
if (radio !== target) {
storage.setValue(radio.getAttribute("id"), { value: false });
}
element.addEventListener("change", event => {
const { name, checked } = event.target;
for (const radio of this._getElementsByName(name, /* skipId = */ id)) {
storage.setValue(radio.id, { value: false });
}
storage.setValue(id, { value: target.checked });
storage.setValue(id, { value: checked });
});
if (this.enableScripting && this.hasJSActions) {
const pdfButtonValue = data.buttonValue;
element.addEventListener("updatefromsandbox", jsEvent => {
const actions = {
value(event) {
value: event => {
const checked = pdfButtonValue === event.detail.value;
for (const radio of document.getElementsByName(event.target.name)) {
const radioId = radio.getAttribute("id");
radio.checked = radioId === id && checked;
storage.setValue(radioId, { value: radio.checked });
for (const radio of this._getElementsByName(event.target.name)) {
const curChecked = checked && radio.id === id;
if (radio.domElement) {
radio.domElement.checked = curChecked;
}
storage.setValue(radio.id, { value: curChecked });
}
},
};
@ -1158,6 +1204,7 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement {
const fontSizeStyle = `calc(${fontSize}px * var(--zoom-factor))`;
const selectElement = document.createElement("select");
GetElementsByNameSet.add(selectElement);
selectElement.disabled = this.data.readOnly;
selectElement.name = this.data.fieldName;
selectElement.setAttribute("id", id);
@ -2090,6 +2137,7 @@ class AnnotationLayer {
parameters.annotationStorage || new AnnotationStorage(),
enableScripting: parameters.enableScripting,
hasJSActions: parameters.hasJSActions,
fieldObjects: parameters.fieldObjects,
mouseState: parameters.mouseState || { isDown: false },
});
if (element.isRenderable) {

View File

@ -1017,9 +1017,9 @@ class PDFDocumentProxy {
}
/**
* @returns {Promise<Array<Object> | null>} A promise that is resolved with an
* {Array<Object>} containing /AcroForm field data for the JS sandbox,
* or `null` when no field data is present in the PDF file.
* @returns {Promise<Object<string, Array<Object>> | null>} A promise that is
* resolved with an {Object} containing /AcroForm field data for the JS
* sandbox, or `null` when no field data is present in the PDF file.
*/
getFieldObjects() {
return this._transport.getFieldObjects();
@ -2480,6 +2480,7 @@ class WorkerTransport {
Promise.all(waitOn).then(() => {
this.commonObjs.clear();
this.fontLoader.clear();
this._getFieldObjectsPromise = null;
this._hasJSActionsPromise = null;
if (this._networkStream) {
@ -2921,7 +2922,8 @@ class WorkerTransport {
}
getFieldObjects() {
return this.messageHandler.sendWithPromise("GetFieldObjects", null);
return (this._getFieldObjectsPromise ||=
this.messageHandler.sendWithPromise("GetFieldObjects", null));
}
hasJSActions() {
@ -3050,6 +3052,7 @@ class WorkerTransport {
if (!keepLoadedFonts) {
this.fontLoader.clear();
}
this._getFieldObjectsPromise = null;
this._hasJSActionsPromise = null;
}

View File

@ -126,3 +126,98 @@ describe("Text widget", () => {
});
});
});
describe("Annotation and storage", () => {
describe("issue14023.pdf", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("issue14023.pdf", "[data-annotation-id='64R']");
});
afterAll(async () => {
await closePages(pages);
});
it("must let checkboxes with the same name behave like radio buttons", async () => {
const text1 = "hello world!";
const text2 = "!dlrow olleh";
await Promise.all(
pages.map(async ([browserName, page]) => {
// Text field.
await page.type("#\\36 4R", text1);
// Checkbox.
await page.click("[data-annotation-id='65R']");
// Radio.
await page.click("[data-annotation-id='67R']");
for (const [pageNumber, textId, checkId, radio1Id, radio2Id] of [
[2, "#\\31 8R", "#\\31 9R", "#\\32 1R", "#\\32 0R"],
[5, "#\\32 3R", "#\\32 4R", "#\\32 2R", "#\\32 5R"],
]) {
await page.evaluate(n => {
window.document
.querySelectorAll(`[data-page-number="${n}"][class="page"]`)[0]
.scrollIntoView();
}, pageNumber);
// Need to wait to have a displayed text input.
await page.waitForSelector(textId, {
timeout: 0,
});
const text = await page.$eval(textId, el => el.value);
expect(text).withContext(`In ${browserName}`).toEqual(text1);
let checked = await page.$eval(checkId, el => el.checked);
expect(checked).toEqual(true);
checked = await page.$eval(radio1Id, el => el.checked);
expect(checked).toEqual(false);
checked = await page.$eval(radio2Id, el => el.checked);
expect(checked).toEqual(false);
}
// Change data on page 5 and check that other pages changed.
// Text field.
await page.type("#\\32 3R", text2);
// Checkbox.
await page.click("[data-annotation-id='24R']");
// Radio.
await page.click("[data-annotation-id='25R']");
for (const [pageNumber, textId, checkId, radio1Id, radio2Id] of [
[1, "#\\36 4R", "#\\36 5R", "#\\36 7R", "#\\36 8R"],
[2, "#\\31 8R", "#\\31 9R", "#\\32 1R", "#\\32 0R"],
]) {
await page.evaluate(n => {
window.document
.querySelectorAll(`[data-page-number="${n}"][class="page"]`)[0]
.scrollIntoView();
}, pageNumber);
// Need to wait to have a displayed text input.
await page.waitForSelector(textId, {
timeout: 0,
});
const text = await page.$eval(textId, el => el.value);
expect(text)
.withContext(`In ${browserName}`)
.toEqual(text2 + text1);
let checked = await page.$eval(checkId, el => el.checked);
expect(checked).toEqual(false);
checked = await page.$eval(radio1Id, el => el.checked);
expect(checked).toEqual(false);
checked = await page.$eval(radio2Id, el => el.checked);
expect(checked).toEqual(false);
}
})
);
});
});
});

View File

@ -112,6 +112,7 @@
!issue11651.pdf
!issue11878.pdf
!issue13916.pdf
!issue14023.pdf
!bad-PageLabels.pdf
!decodeACSuccessive.pdf
!filled-background.pdf

BIN
test/pdfs/issue14023.pdf Normal file

Binary file not shown.

View File

@ -33,6 +33,8 @@ import { SimpleLinkService } from "./pdf_link_service.js";
* @property {IL10n} l10n - Localization service.
* @property {boolean} [enableScripting]
* @property {Promise<boolean>} [hasJSActionsPromise]
* @property {Promise<Object<string, Array<Object>> | null>}
* [fieldObjectsPromise]
* @property {Object} [mouseState]
*/
@ -51,6 +53,7 @@ class AnnotationLayerBuilder {
l10n = NullL10n,
enableScripting = false,
hasJSActionsPromise = null,
fieldObjectsPromise = null,
mouseState = null,
}) {
this.pageDiv = pageDiv;
@ -63,6 +66,7 @@ class AnnotationLayerBuilder {
this.annotationStorage = annotationStorage;
this.enableScripting = enableScripting;
this._hasJSActionsPromise = hasJSActionsPromise;
this._fieldObjectsPromise = fieldObjectsPromise;
this._mouseState = mouseState;
this.div = null;
@ -75,46 +79,49 @@ class AnnotationLayerBuilder {
* @returns {Promise<void>} A promise that is resolved when rendering of the
* annotations is complete.
*/
render(viewport, intent = "display") {
return Promise.all([
this.pdfPage.getAnnotations({ intent }),
this._hasJSActionsPromise,
]).then(([annotations, hasJSActions = false]) => {
if (this._cancelled || annotations.length === 0) {
return;
}
async render(viewport, intent = "display") {
const [annotations, hasJSActions = false, fieldObjects = null] =
await Promise.all([
this.pdfPage.getAnnotations({ intent }),
this._hasJSActionsPromise,
this._fieldObjectsPromise,
]);
const parameters = {
viewport: viewport.clone({ dontFlip: true }),
div: this.div,
annotations,
page: this.pdfPage,
imageResourcesPath: this.imageResourcesPath,
renderForms: this.renderForms,
linkService: this.linkService,
downloadManager: this.downloadManager,
annotationStorage: this.annotationStorage,
enableScripting: this.enableScripting,
hasJSActions,
mouseState: this._mouseState,
};
if (this._cancelled || annotations.length === 0) {
return;
}
if (this.div) {
// If an annotationLayer already exists, refresh its children's
// transformation matrices.
AnnotationLayer.update(parameters);
} else {
// Create an annotation layer div and render the annotations
// if there is at least one annotation.
this.div = document.createElement("div");
this.div.className = "annotationLayer";
this.pageDiv.appendChild(this.div);
parameters.div = this.div;
const parameters = {
viewport: viewport.clone({ dontFlip: true }),
div: this.div,
annotations,
page: this.pdfPage,
imageResourcesPath: this.imageResourcesPath,
renderForms: this.renderForms,
linkService: this.linkService,
downloadManager: this.downloadManager,
annotationStorage: this.annotationStorage,
enableScripting: this.enableScripting,
hasJSActions,
fieldObjects,
mouseState: this._mouseState,
};
AnnotationLayer.render(parameters);
this.l10n.translate(this.div);
}
});
if (this.div) {
// If an annotationLayer already exists, refresh its children's
// transformation matrices.
AnnotationLayer.update(parameters);
} else {
// Create an annotation layer div and render the annotations
// if there is at least one annotation.
this.div = document.createElement("div");
this.div.className = "annotationLayer";
this.pageDiv.appendChild(this.div);
parameters.div = this.div;
AnnotationLayer.render(parameters);
this.l10n.translate(this.div);
}
}
cancel() {
@ -144,6 +151,8 @@ class DefaultAnnotationLayerFactory {
* @param {boolean} [enableScripting]
* @param {Promise<boolean>} [hasJSActionsPromise]
* @param {Object} [mouseState]
* @param {Promise<Object<string, Array<Object>> | null>}
* [fieldObjectsPromise]
* @returns {AnnotationLayerBuilder}
*/
createAnnotationLayerBuilder(
@ -155,7 +164,8 @@ class DefaultAnnotationLayerFactory {
l10n = NullL10n,
enableScripting = false,
hasJSActionsPromise = null,
mouseState = null
mouseState = null,
fieldObjectsPromise = null
) {
return new AnnotationLayerBuilder({
pageDiv,
@ -167,6 +177,7 @@ class DefaultAnnotationLayerFactory {
annotationStorage,
enableScripting,
hasJSActionsPromise,
fieldObjectsPromise,
mouseState,
});
}

View File

@ -1318,6 +1318,8 @@ class BaseViewer {
* @param {boolean} [enableScripting]
* @param {Promise<boolean>} [hasJSActionsPromise]
* @param {Object} [mouseState]
* @param {Promise<Object<string, Array<Object>> | null>}
* [fieldObjectsPromise]
* @returns {AnnotationLayerBuilder}
*/
createAnnotationLayerBuilder(
@ -1329,7 +1331,8 @@ class BaseViewer {
l10n = NullL10n,
enableScripting = null,
hasJSActionsPromise = null,
mouseState = null
mouseState = null,
fieldObjectsPromise = null
) {
return new AnnotationLayerBuilder({
pageDiv,
@ -1344,6 +1347,8 @@ class BaseViewer {
enableScripting: enableScripting ?? this.enableScripting,
hasJSActionsPromise:
hasJSActionsPromise || this.pdfDocument?.hasJSActions(),
fieldObjectsPromise:
fieldObjectsPromise || this.pdfDocument?.getFieldObjects(),
mouseState: mouseState || this._scriptingManager?.mouseState,
});
}

View File

@ -166,6 +166,8 @@ class IPDFAnnotationLayerFactory {
* @param {boolean} [enableScripting]
* @param {Promise<boolean>} [hasJSActionsPromise]
* @param {Object} [mouseState]
* @param {Promise<Object<string, Array<Object>> | null>}
* [fieldObjectsPromise]
* @returns {AnnotationLayerBuilder}
*/
createAnnotationLayerBuilder(
@ -177,7 +179,8 @@ class IPDFAnnotationLayerFactory {
l10n = undefined,
enableScripting = false,
hasJSActionsPromise = null,
mouseState = null
mouseState = null,
fieldObjectsPromise = null
) {}
}

View File

@ -675,7 +675,8 @@ class PDFPageView {
this.l10n,
/* enableScripting = */ null,
/* hasJSActionsPromise = */ null,
/* mouseState = */ null
/* mouseState = */ null,
/* fieldObjectsPromise = */ null
);
}
this._renderAnnotationLayer();