diff --git a/src/core/annotation.js b/src/core/annotation.js index 1e32d56f4..4863ea93d 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -2091,6 +2091,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { multipleSelection: this.data.multiSelect, hidden: this.data.hidden, actions: this.data.actions, + items: this.data.options, type, }; } diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 65919d191..8b43bb87c 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -698,25 +698,43 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { .forEach(name => actions[name]()); }); - if (this.data.actions) { - // Even if the field hasn't any actions - // leaving it can still trigger some actions with Calculate - element.addEventListener("keydown", event => { - elementData.beforeInputValue = event.target.value; - // if the key is one of Escape, Enter or Tab - // then the data are committed - let commitKey = -1; - if (event.key === "Escape") { - commitKey = 0; - } else if (event.key === "Enter") { - commitKey = 2; - } else if (event.key === "Tab") { - commitKey = 3; - } - if (commitKey === -1) { - return; - } - // Save the entered value + // Even if the field hasn't any actions + // leaving it can still trigger some actions with Calculate + element.addEventListener("keydown", event => { + elementData.beforeInputValue = event.target.value; + // if the key is one of Escape, Enter or Tab + // then the data are committed + let commitKey = -1; + if (event.key === "Escape") { + commitKey = 0; + } else if (event.key === "Enter") { + commitKey = 2; + } else if (event.key === "Tab") { + commitKey = 3; + } + if (commitKey === -1) { + return; + } + // Save the entered value + elementData.userValue = event.target.value; + this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { + source: this, + detail: { + id, + name: "Keystroke", + value: event.target.value, + willCommit: true, + commitKey, + selStart: event.target.selectionStart, + selEnd: event.target.selectionEnd, + }, + }); + }); + const _blurListener = blurListener; + blurListener = null; + element.addEventListener("blur", event => { + if (this._mouseState.isDown) { + // Focus out using the mouse: data are committed elementData.userValue = event.target.value; this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { source: this, @@ -725,87 +743,67 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { name: "Keystroke", value: event.target.value, willCommit: true, - commitKey, + commitKey: 1, selStart: event.target.selectionStart, selEnd: event.target.selectionEnd, }, }); - }); - const _blurListener = blurListener; - blurListener = null; - element.addEventListener("blur", event => { - if (this._mouseState.isDown) { - // Focus out using the mouse: data are committed - elementData.userValue = event.target.value; - this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { - source: this, - detail: { - id, - name: "Keystroke", - value: event.target.value, - willCommit: true, - commitKey: 1, - selStart: event.target.selectionStart, - selEnd: event.target.selectionEnd, - }, - }); - } - _blurListener(event); - }); - element.addEventListener("mousedown", event => { - elementData.beforeInputValue = event.target.value; - elementData.beforeInputSelectionRange = null; - }); - element.addEventListener("keyup", event => { - // keyup is triggered after input - if (event.target.selectionStart === event.target.selectionEnd) { - elementData.beforeInputSelectionRange = null; - } - }); - element.addEventListener("select", event => { - elementData.beforeInputSelectionRange = [ - event.target.selectionStart, - event.target.selectionEnd, - ]; - }); - - if ("Keystroke" in this.data.actions) { - // We should use beforeinput but this - // event isn't available in Firefox - element.addEventListener("input", event => { - let selStart = -1; - let selEnd = -1; - if (elementData.beforeInputSelectionRange) { - [selStart, selEnd] = elementData.beforeInputSelectionRange; - } - this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { - source: this, - detail: { - id, - name: "Keystroke", - value: elementData.beforeInputValue, - change: event.data, - willCommit: false, - selStart, - selEnd, - }, - }); - }); } + _blurListener(event); + }); + element.addEventListener("mousedown", event => { + elementData.beforeInputValue = event.target.value; + elementData.beforeInputSelectionRange = null; + }); + element.addEventListener("keyup", event => { + // keyup is triggered after input + if (event.target.selectionStart === event.target.selectionEnd) { + elementData.beforeInputSelectionRange = null; + } + }); + element.addEventListener("select", event => { + elementData.beforeInputSelectionRange = [ + event.target.selectionStart, + event.target.selectionEnd, + ]; + }); - this._setEventListeners( - element, - [ - ["focus", "Focus"], - ["blur", "Blur"], - ["mousedown", "Mouse Down"], - ["mouseenter", "Mouse Enter"], - ["mouseleave", "Mouse Exit"], - ["mouseup", "Mouse Up"], - ], - event => event.target.value - ); + if (this.data.actions?.Keystroke) { + // We should use beforeinput but this + // event isn't available in Firefox + element.addEventListener("input", event => { + let selStart = -1; + let selEnd = -1; + if (elementData.beforeInputSelectionRange) { + [selStart, selEnd] = elementData.beforeInputSelectionRange; + } + this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { + source: this, + detail: { + id, + name: "Keystroke", + value: elementData.beforeInputValue, + change: event.data, + willCommit: false, + selStart, + selEnd, + }, + }); + }); } + + this._setEventListeners( + element, + [ + ["focus", "Focus"], + ["blur", "Blur"], + ["mousedown", "Mouse Down"], + ["mouseenter", "Mouse Enter"], + ["mouseleave", "Mouse Exit"], + ["mouseup", "Mouse Up"], + ], + event => event.target.value + ); } if (blurListener) { @@ -1102,23 +1100,112 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { selectElement.appendChild(optionElement); } - function getValue(event) { + const getValue = (event, isExport) => { + const name = isExport ? "value" : "textContent"; const options = event.target.options; - return options[options.selectedIndex].value; - } + if (!event.target.multiple) { + return options.selectedIndex === -1 + ? null + : options[options.selectedIndex][name]; + } + return Array.prototype.filter + .call(options, option => option.selected) + .map(option => option[name]); + }; + + const getItems = event => { + const options = event.target.options; + return Array.prototype.map.call(options, option => { + return { displayValue: option.textContent, exportValue: option.value }; + }); + }; if (this.enableScripting && this.hasJSActions) { selectElement.addEventListener("updatefromsandbox", event => { const { detail } = event; const actions = { value() { - const options = event.target.options; + const options = selectElement.options; const value = detail.value; - const i = options.indexOf(value); - if (i !== -1) { - options.selectedIndex = i; - storage.setValue(id, { value }); + const values = new Set(Array.isArray(value) ? value : [value]); + Array.prototype.forEach.call(options, option => { + option.selected = values.has(option.value); + }); + storage.setValue(id, { + value: getValue(event, /* isExport */ true), + }); + }, + multipleSelection() { + selectElement.multiple = true; + }, + remove() { + const options = selectElement.options; + const index = detail.remove; + options[index].selected = false; + selectElement.remove(index); + if (options.length > 0) { + const i = Array.prototype.findIndex.call( + options, + option => option.selected + ); + if (i === -1) { + options[0].selected = true; + } } + storage.setValue(id, { + value: getValue(event, /* isExport */ true), + items: getItems(event), + }); + }, + clear() { + while (selectElement.length !== 0) { + selectElement.remove(0); + } + storage.setValue(id, { value: null, items: [] }); + }, + insert() { + const { index, displayValue, exportValue } = detail.insert; + const optionElement = document.createElement("option"); + optionElement.textContent = displayValue; + optionElement.value = exportValue; + selectElement.insertBefore( + optionElement, + selectElement.children[index] + ); + storage.setValue(id, { + value: getValue(event, /* isExport */ true), + items: getItems(event), + }); + }, + items() { + const { items } = detail; + while (selectElement.length !== 0) { + selectElement.remove(0); + } + for (const item of items) { + const { displayValue, exportValue } = item; + const optionElement = document.createElement("option"); + optionElement.textContent = displayValue; + optionElement.value = exportValue; + selectElement.appendChild(optionElement); + } + if (selectElement.options.length > 0) { + selectElement.options[0].selected = true; + } + storage.setValue(id, { + value: getValue(event, /* isExport */ true), + items: getItems(event), + }); + }, + indices() { + const indices = new Set(detail.indices); + const options = event.target.options; + Array.prototype.forEach.call(options, (option, i) => { + option.selected = indices.has(i); + }); + storage.setValue(id, { + value: getValue(event, /* isExport */ true), + }); }, focus() { setTimeout(() => event.target.focus({ preventScroll: false }), 0); @@ -1139,15 +1226,17 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { }); selectElement.addEventListener("input", event => { - const value = getValue(event); - storage.setValue(id, { value }); + const exportValue = getValue(event, /* isExport */ true); + const value = getValue(event, /* isExport */ false); + storage.setValue(id, { value: exportValue }); this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { source: this, detail: { id, name: "Keystroke", - changeEx: value, + value, + changeEx: exportValue, willCommit: true, commitKey: 1, keyDown: false, @@ -1164,6 +1253,7 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { ["mouseenter", "Mouse Enter"], ["mouseleave", "Mouse Exit"], ["mouseup", "Mouse Up"], + ["input", "Action"], ], event => event.target.checked ); diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index d22282149..ff0e3b9e7 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -49,7 +49,6 @@ class Field extends PDFObject { this.multiline = data.multiline; this.multipleSelection = !!data.multipleSelection; this.name = data.name; - this.numItems = data.numItems; this.page = data.page; this.password = data.password; this.print = data.print; @@ -68,17 +67,64 @@ class Field extends PDFObject { this.userName = data.userName; // Private - this._document = data.doc; - this._value = data.value || ""; - this._valueAsString = data.valueAsString; this._actions = createActionsMap(data.actions); + this._currentValueIndices = data.currentValueIndices || 0; + this._document = data.doc; this._fillColor = data.fillColor || ["T"]; + this._isChoice = Array.isArray(data.items); + this._items = data.items || []; this._strokeColor = data.strokeColor || ["G", 0]; this._textColor = data.textColor || ["G", 0]; + this._value = data.value || ""; + this._valueAsString = data.valueAsString; this._globalEval = data.globalEval; } + get currentValueIndices() { + if (!this._isChoice) { + return 0; + } + return this._currentValueIndices; + } + + set currentValueIndices(indices) { + if (!this._isChoice) { + return; + } + if (!Array.isArray(indices)) { + indices = [indices]; + } + if ( + !indices.every( + i => + typeof i === "number" && + Number.isInteger(i) && + i >= 0 && + i < this.numItems + ) + ) { + return; + } + + indices.sort(); + + if (this.multipleSelection) { + this._currentValueIndices = indices; + this._value = []; + indices.forEach(i => { + this._value.push(this._items[i].displayValue); + }); + } else { + if (indices.length > 0) { + indices = indices.splice(1, indices.length - 1); + this._currentValueIndices = indices[0]; + this._value = this._items[this._currentValueIndices]; + } + } + this._send({ id: this._id, indices }); + } + get fillColor() { return this._fillColor; } @@ -89,6 +135,17 @@ class Field extends PDFObject { } } + get numItems() { + if (!this._isChoice) { + throw new Error("Not a choice widget"); + } + return this._items.length; + } + + set numItems(_) { + throw new Error("field.numItems is read-only"); + } + get strokeColor() { return this._strokeColor; } @@ -114,8 +171,21 @@ class Field extends PDFObject { } set value(value) { - if (!this.multipleSelection) { - this._value = value; + this._value = value; + if (this._isChoice) { + if (this.multipleSelection) { + const values = new Set(value); + this._currentValueIndices.length = 0; + this._items.forEach(({ displayValue }, i) => { + if (values.has(displayValue)) { + this._currentValueIndices.push(i); + } + }); + } else { + this._currentValueIndices = this._items.findIndex( + ({ displayValue }) => value === displayValue + ); + } } } @@ -129,6 +199,67 @@ class Field extends PDFObject { checkThisBox(nWidget, bCheckIt = true) {} + clearItems() { + if (!this._isChoice) { + throw new Error("Not a choice widget"); + } + this._items = []; + this._send({ id: this._id, clear: null }); + } + + deleteItemAt(nIdx = null) { + if (!this._isChoice) { + throw new Error("Not a choice widget"); + } + if (!this.numItems) { + return; + } + + if (nIdx === null) { + // Current selected item. + nIdx = Array.isArray(this._currentValueIndices) + ? this._currentValueIndices[0] + : this._currentValueIndices; + nIdx = nIdx || 0; + } + + if (nIdx < 0 || nIdx >= this.numItems) { + nIdx = this.numItems - 1; + } + + this._items.splice(nIdx, 1); + if (Array.isArray(this._currentValueIndices)) { + let index = this._currentValueIndices.findIndex(i => i >= nIdx); + if (index !== -1) { + if (this._currentValueIndices[index] === nIdx) { + this._currentValueIndices.splice(index, 1); + } + for (const ii = this._currentValueIndices.length; index < ii; index++) { + --this._currentValueIndices[index]; + } + } + } else { + if (this._currentValueIndices === nIdx) { + this._currentValueIndices = this.numItems > 0 ? 0 : -1; + } else if (this._currentValueIndices > nIdx) { + --this._currentValueIndices; + } + } + + this._send({ id: this._id, remove: nIdx }); + } + + getItemAt(nIdx = -1, bExportValue = false) { + if (!this._isChoice) { + throw new Error("Not a choice widget"); + } + if (nIdx < 0 || nIdx >= this.numItems) { + nIdx = this.numItems - 1; + } + const item = this._items[nIdx]; + return bExportValue ? item.exportValue : item.displayValue; + } + isBoxChecked(nWidget) { return false; } @@ -137,6 +268,41 @@ class Field extends PDFObject { return false; } + insertItemAt(cName, cExport = undefined, nIdx = 0) { + if (!this._isChoice) { + throw new Error("Not a choice widget"); + } + if (!cName) { + return; + } + + if (nIdx < 0 || nIdx > this.numItems) { + nIdx = this.numItems; + } + + if (this._items.some(({ displayValue }) => displayValue === cName)) { + return; + } + + if (cExport === undefined) { + cExport = cName; + } + const data = { displayValue: cName, exportValue: cExport }; + this._items.splice(nIdx, 0, data); + if (Array.isArray(this._currentValueIndices)) { + let index = this._currentValueIndices.findIndex(i => i >= nIdx); + if (index !== -1) { + for (const ii = this._currentValueIndices.length; index < ii; index++) { + ++this._currentValueIndices[index]; + } + } + } else if (this._currentValueIndices >= nIdx) { + ++this._currentValueIndices; + } + + this._send({ id: this._id, insert: { index: nIdx, ...data } }); + } + setAction(cTrigger, cScript) { if (typeof cTrigger !== "string" || typeof cScript !== "string") { return; @@ -151,6 +317,26 @@ class Field extends PDFObject { this._send({ id: this._id, focus: true }); } + setItems(oArray) { + if (!this._isChoice) { + throw new Error("Not a choice widget"); + } + this._items.length = 0; + for (const element of oArray) { + let displayValue, exportValue; + if (Array.isArray(element)) { + displayValue = element[0]?.toString() || ""; + exportValue = element[1]?.toString() || ""; + } else { + displayValue = exportValue = element?.toString() || ""; + } + this._items.push({ displayValue, exportValue }); + } + this._currentValueIndices = 0; + + this._send({ id: this._id, items: this._items }); + } + _isButton() { return false; } diff --git a/test/integration/scripting_spec.js b/test/integration/scripting_spec.js index ec3d50026..2fedfc7a3 100644 --- a/test/integration/scripting_spec.js +++ b/test/integration/scripting_spec.js @@ -489,10 +489,6 @@ describe("Interaction", () => { pages = await loadAndWait("js-authors.pdf", "#\\32 5R"); }); - afterAll(async () => { - await closePages(pages); - }); - it("must print authors in a text field", async () => { await Promise.all( pages.map(async ([browserName, page]) => { @@ -506,4 +502,132 @@ describe("Interaction", () => { ); }); }); + + describe("in listbox_actions.pdf", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("listbox_actions.pdf", "#\\33 3R"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must print selected value in a text field", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + for (const num of [7, 6, 4, 3, 2, 1]) { + await page.click(`option[value=Export${num}]`); + await page.waitForFunction( + `document.querySelector("#\\\\33 3R").value !== ""` + ); + const text = await page.$eval("#\\33 3R", el => el.value); + expect(text) + .withContext(`In ${browserName}`) + .toEqual(`Item${num},Export${num}`); + } + }) + ); + }); + + it("must clear and restore list elements", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Click on ClearItems button. + await page.click("[data-annotation-id='34R']"); + await page.waitForFunction( + `document.querySelector("#\\\\33 0R").children.length === 0` + ); + + // Click on Restore button. + await page.click("[data-annotation-id='37R']"); + await page.waitForFunction( + `document.querySelector("#\\\\33 0R").children.length !== 0` + ); + + for (const num of [7, 6, 4, 3, 2, 1]) { + await page.click(`option[value=Export${num}]`); + await page.waitForFunction( + `document.querySelector("#\\\\33 3R").value !== ""` + ); + const text = await page.$eval("#\\33 3R", el => el.value); + expect(text) + .withContext(`In ${browserName}`) + .toEqual(`Item${num},Export${num}`); + } + }) + ); + }); + + it("must insert new elements", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + let len = 6; + for (const num of [1, 3, 5, 6, 431, -1, 0]) { + ++len; + await clearInput(page, "#\\33 9R"); + await page.type("#\\33 9R", `${num},Insert${num},Tresni${num}`, { + delay: 10, + }); + + // Click on AddItem button. + await page.click("[data-annotation-id='38R']"); + + await page.waitForFunction( + `document.querySelector("#\\\\33 0R").children.length === ${len}` + ); + + // Click on newly added option. + await page.select("#\\33 0R", `Tresni${num}`); + + await page.waitForFunction( + `document.querySelector("#\\\\33 3R").value !== ""` + ); + const text = await page.$eval("#\\33 3R", el => el.value); + expect(text) + .withContext(`In ${browserName}`) + .toEqual(`Insert${num},Tresni${num}`); + } + }) + ); + }); + + it("must delete some element", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + let len = 6; + // Click on Restore button. + await page.click("[data-annotation-id='37R']"); + await page.waitForFunction( + `document.querySelector("#\\\\33 0R").children.length === ${len}` + ); + + for (const num of [2, 5]) { + --len; + await clearInput(page, "#\\33 9R"); + await page.type("#\\33 9R", `${num}`); + + // Click on DeleteItem button. + await page.click("[data-annotation-id='36R']"); + + await page.waitForFunction( + `document.querySelector("#\\\\33 0R").children.length === ${len}` + ); + } + + for (const num of [6, 4, 2, 1]) { + await page.click(`option[value=Export${num}]`); + await page.waitForFunction( + `document.querySelector("#\\\\33 3R").value !== ""` + ); + const text = await page.$eval("#\\33 3R", el => el.value); + expect(text) + .withContext(`In ${browserName}`) + .toEqual(`Item${num},Export${num}`); + } + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index b53fb8b1e..24e86226d 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -403,6 +403,7 @@ !operator-in-TJ-array.pdf !issue7878.pdf !font_ascent_descent.pdf +!listbox_actions.pdf !issue11442_reduced.pdf !issue11549_reduced.pdf !issue8097_reduced.pdf diff --git a/test/pdfs/listbox_actions.pdf b/test/pdfs/listbox_actions.pdf new file mode 100644 index 000000000..cfaaadc80 Binary files /dev/null and b/test/pdfs/listbox_actions.pdf differ