From 181f4c4fbdb95a5e33331e2d11552bacdb7041fc Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Sun, 1 Nov 2026 18:46:15 +0100 Subject: [PATCH 1/2] Handle editable combo boxes in the scripting sandbox (bug 1876540) The editable property of choice fields now comes from the Edit flag of combo boxes instead of being true for every field which isn't read-only. Editable combo boxes accept custom values and labels, and their typing is handled as for text fields. Selection replies are flagged, and a rejected selection restores the previous value. --- src/core/annotation.js | 3 +- src/scripting_api/event.js | 72 +++++++++-------- src/scripting_api/field.js | 19 ++++- test/unit/annotation_spec.js | 45 ++++++++++- test/unit/scripting_spec.js | 153 +++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 39 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index f1c55335a2be6..55a3fe29d6a85 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -4016,6 +4016,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { // Process field flags for the display layer. this.data.combo = this.hasFieldFlag(AnnotationFieldFlag.COMBO); + this.data.edit = this.hasFieldFlag(AnnotationFieldFlag.EDIT); this.data.multiSelect = this.hasFieldFlag(AnnotationFieldFlag.MULTISELECT); this._hasText = true; } @@ -4028,7 +4029,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { id: this.data.id, value, defaultValue: this.data.defaultFieldValue, - editable: !this.data.readOnly, + editable: this.data.combo && this.data.edit, name: this.data.fieldName, rect: this.data.rect, numItems: this.data.fieldValue.length, diff --git a/src/scripting_api/event.js b/src/scripting_api/event.js index fe3872db3d195..cde3ad5164ced 100644 --- a/src/scripting_api/event.js +++ b/src/scripting_api/event.js @@ -32,7 +32,7 @@ function createEvent(data) { return { __proto__: EVENT_PROTOTYPE, change: data.change || "", - changeEx: data.changeEx || null, + changeEx: data.changeEx ?? null, commitKey: data.commitKey || 0, fieldFull: data.fieldFull || false, keyDown: data.keyDown || false, @@ -203,47 +203,55 @@ class EventDispatcher { return; } - if (event.rc) { - if (event.willCommit) { + if (event.willCommit) { + if (event.rc) { this.#runValidation(source, event); } else { - if (FieldInternals.getIsChoice(source)) { - FieldInternals.setValue(source, savedChange.changeEx); - FieldInternals.send(source, { - value: FieldInternals.getValue(source), - }); - return; - } + FieldInternals.send(source, { + value: "", + formattedValue: null, + selRange: [0, 0], + }); + } + return; + } - const value = mergeChange(event); - FieldInternals.setValue(source, value); - let selStart, selEnd; - if ( - event.selStart !== savedChange.selStart || - event.selEnd !== savedChange.selEnd - ) { - // Selection has been changed by the script so apply the changes. - selStart = event.selStart; - selEnd = event.selEnd; - } else { - selEnd = selStart = savedChange.selStart + event.change.length; - } - FieldInternals.send(source, { value, selRange: [selStart, selEnd] }); + if ( + FieldInternals.getIsChoice(source) && + (savedChange.changeEx !== null || !source.editable) + ) { + if (event.rc) { + FieldInternals.setValue(source, savedChange.changeEx); } - } else if (!event.willCommit) { + // Distinguish selection replies from script assignments. + FieldInternals.send(source, { + value: FieldInternals.getValue(source), + selection: true, + }); + return; + } + + if (!event.rc) { FieldInternals.send(source, { value: savedChange.value, selRange: [savedChange.selStart, savedChange.selEnd], }); + return; + } + + const value = mergeChange(event); + FieldInternals.setValue(source, value); + let selStart, selEnd; + if ( + event.selStart !== savedChange.selStart || + event.selEnd !== savedChange.selEnd + ) { + selStart = event.selStart; + selEnd = event.selEnd; } else { - // Entry is not valid (rc == false) and it's a commit - // so just clear the field. - FieldInternals.send(source, { - value: "", - formattedValue: null, - selRange: [0, 0], - }); + selEnd = selStart = savedChange.selStart + event.change.length; } + FieldInternals.send(source, { value, selRange: [selStart, selEnd] }); } #formatAll() { diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index df1027bd80ac5..a327128babb67 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -68,6 +68,8 @@ class Field { #hasDateOrTime; + #hasTextEntry; + #hasValue; #id; @@ -155,6 +157,8 @@ class Field { this.#getTerminalChildren = data.getTerminalChildren; this.#globalEval = data.globalEval; this.#hasDateOrTime = !!data.hasDatetimeHTML; + // Changing editable later cannot replace a . + expect( + await myeval( + '(getField("select").editable = true, getField("select").value = "custom", getField("select").value)' + ) + ).toEqual("US"); + expect(send_queue.get(id).value).toEqual("US"); + }); + + it("should select an option with an empty export value", async () => { + const combos = createCombos([true, false], { + value: "FR", + items: [ + { displayValue: "France", exportValue: "FR" }, + { displayValue: "None", exportValue: "" }, + ], + }); + for (const { name, id } of combos) { + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "France", + change: "None", + changeEx: "", + willCommit: false, + }); + expect(send_queue.get(id)) + .withContext(name) + .toEqual({ id, value: "", selection: true }); + expect(await myeval(`getField("${name}").currentValueIndices`)) + .withContext(name) + .toEqual(1); + } + }); + + it("should keep the value of a combo when a selection is rejected", async () => { + const combos = createCombos([true, false], { + value: "A", + items: [ + { displayValue: "Same", exportValue: "A" }, + { displayValue: "Same", exportValue: "B" }, + ], + actions: { + Keystroke: [ + `if (!event.willCommit && event.changeEx === "B") event.rc = false;`, + ], + }, + }); + for (const { name, id } of combos) { + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "Same", + change: "Same", + changeEx: "B", + willCommit: false, + }); + expect(send_queue.get(id)) + .withContext(name) + .toEqual({ id, value: "A", selection: true }); + expect(await myeval(`getField("${name}").value`)) + .withContext(name) + .toEqual("A"); + } + }); + + it("should accept custom values and labels only for editable combos", async () => { + const [{ id }] = createCombos([true], { + value: "FR", + items: [{ displayValue: "France", exportValue: "FR" }], + }); + expect( + await myeval( + '(getField("editable").value = "Custom", [getField("editable").value, getField("editable").currentValueIndices])' + ) + ).toEqual(["Custom", -1]); + expect( + await myeval( + '(getField("editable").value = "France", [getField("editable").value, getField("editable").currentValueIndices])' + ) + ).toEqual(["FR", 0]); + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "C", + change: "ustom", + selStart: 1, + selEnd: 1, + willCommit: false, + }); + expect(await myeval('getField("editable").value')).toEqual("Custom"); + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "Franc", + change: "e", + selStart: 5, + selEnd: 5, + willCommit: false, + }); + // Echo the typed label while storing its export value. + expect(send_queue.get(id)).toEqual({ + id, + value: "France", + selRange: [6, 6], + }); + expect(await myeval('getField("editable").value')).toEqual("FR"); + await sandbox.dispatchEventInSandbox({ + id, + name: "Keystroke", + value: "F", + change: "R", + selStart: 1, + selEnd: 1, + willCommit: false, + }); + expect(send_queue.get(id)).toEqual({ id, value: "FR", selRange: [2, 2] }); + expect( + await myeval( + '[getField("editable").value, getField("editable").currentValueIndices]' + ) + ).toEqual(["FR", 0]); + expect( + await myeval( + '(getField("editable").editable = false, getField("editable").value = "Ignored", getField("editable").value)' + ) + ).toEqual("FR"); + }); + it("should send a value, execute an action and get back a new value", async () => { function compute(n) { let s = 0; From 62730694cc9ff3f7806be37240b7482dd7f80600 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Fri, 30 Oct 2026 11:23:43 +0100 Subject: [PATCH 2/2] Support editable combo boxes (bug 1876540) Fixes #10669. --- l10n/en-US/viewer.ftl | 4 + src/display/annotation_layer.js | 596 ++++++++++++++++++++++----- test/integration/annotation_spec.mjs | 407 ++++++++++++++++++ test/integration/scripting_spec.mjs | 441 ++++++++++++++++++++ test/integration/test_utils.mjs | 32 ++ test/pdfs/.gitignore | 2 + test/pdfs/bug1876540.pdf | Bin 0 -> 3724 bytes test/pdfs/bug1876540_siblings.pdf | Bin 0 -> 990 bytes test/test_manifest.json | 21 + web/annotation_layer_builder.css | 125 ++++++ 10 files changed, 1526 insertions(+), 102 deletions(-) create mode 100644 test/pdfs/bug1876540.pdf create mode 100644 test/pdfs/bug1876540_siblings.pdf diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 1d0e9ed02a4bc..81405d96e6f0b 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -271,6 +271,10 @@ pdfjs-rendering-error = An error occurred while rendering the page. ## Annotations +# .aria-label for the button that opens an editable combo box's choices. +pdfjs-annotation-combo-box-button = + .aria-label = Show choices + # Variables: # $dateObj (Date) - the modification date and time of the annotation pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") } diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 92beb92e423ff..1275b8dabd8aa 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -57,6 +57,7 @@ import { import { AnnotationStorage } from "./annotation_storage.js"; import { ColorConverters } from "../shared/scripting_utils.js"; import { DOMSVGFactory } from "./svg_factory.js"; +import { MathClamp } from "../shared/math_clamp.js"; const DEFAULT_FONT_SIZE = 9; const GetElementsByNameSet = new WeakSet(); @@ -106,6 +107,13 @@ class AnnotationElementFactory { } return new PushButtonWidgetAnnotationElement(parameters); case "Ch": + if ( + parameters.renderForms && + parameters.data.combo && + parameters.data.edit + ) { + return new EditableComboWidgetAnnotationElement(parameters); + } return new ChoiceWidgetAnnotationElement(parameters); case "Sig": return new SignatureWidgetAnnotationElement(parameters); @@ -1241,9 +1249,12 @@ class LinkAnnotationElement extends AnnotationElement { const { id } = field; allIds.push(id); switch (field.type) { - case "text": { + case "text": + case "combobox": + case "listbox": { const value = field.defaultValue || ""; - storage.setValue(id, { value }); + // Clear formatting when resetting the value. + storage.setValue(id, { value, formattedValue: null }); break; } case "checkbox": @@ -1252,12 +1263,6 @@ class LinkAnnotationElement extends AnnotationElement { storage.setValue(id, { value }); break; } - case "combobox": - case "listbox": { - const value = field.defaultValue || ""; - storage.setValue(id, { value }); - break; - } default: continue; } @@ -1473,17 +1478,16 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { super(parameters, { isRenderable }); } - setPropertyOnSiblings(base, key, value, keyInStorage) { - const storage = this.annotationStorage; - for (const element of this._getElementsByName( - base.name, - /* skipId = */ base.id - )) { - if (element.domElement) { - element.domElement[key] = value; - } - storage.setValue(element.id, { [keyInStorage]: value }); - } + _toDisplayValue(value) { + return value ?? ""; + } + + _toStorageValue(value) { + return value; + } + + _getSandboxActions(actions) { + return actions; } render() { @@ -1500,7 +1504,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { const storedData = storage.getValue(id, { value: this.data.fieldValue, }); - let textContent = storedData.value || ""; + let textContent = this._toDisplayValue(storedData.value); const maxLen = storage.getValue(id, { charLimit: this.data.maxLen, }).charLimit; @@ -1566,18 +1570,20 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { } element.addEventListener("input", event => { - storage.setValue(id, { value: event.target.value }); - this.setPropertyOnSiblings( - element, - "value", - event.target.value, - "value" - ); + const displayValue = event.target.value; + const value = this._toStorageValue(displayValue); + storage.setValue(id, { value }); + for (const sibling of this._getElementsByName(element.name, id)) { + if (sibling.domElement) { + sibling.domElement.value = displayValue; + } + storage.setValue(sibling.id, { value }); + } elementData.formattedValue = null; }); element.addEventListener("resetform", event => { - const defaultValue = this.data.defaultFieldValue ?? ""; + const defaultValue = this._toDisplayValue(this.data.defaultFieldValue); element.value = elementData.userValue = defaultValue; elementData.formattedValue = null; }); @@ -1626,82 +1632,97 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { target.value = value; } } - elementData.lastCommittedValue = target.value; + // Equal labels can have different export values. + elementData.lastCommittedValue = this._toStorageValue(target.value); elementData.commitKey = 1; if (!this.data.actions?.has("Focus")) { elementData.focused = true; } }); - element.addEventListener("updatefromsandbox", jsEvent => { - this.container.classList.add("sandboxModified"); - const actions = { - value(event) { - elementData.userValue = event.detail.value ?? ""; - if (!hasDateOrTime) { - storage.setValue(id, { - value: elementData.userValue.toString(), - }); - } - event.target.value = elementData.userValue; - }, - formattedValue(event) { - const { formattedValue } = event.detail; - elementData.formattedValue = formattedValue; - if ( - formattedValue !== null && - formattedValue !== undefined && - event.target !== document.activeElement - ) { - // Input hasn't the focus so display formatted string - event.target.value = formattedValue; - } + const sandboxActions = this._getSandboxActions({ + value: event => { + const { detail } = event; + const value = detail.value ?? ""; + // Keep typed labels unchanged so selRange offsets stay valid. + const isTyped = !!detail.selRange; + elementData.userValue = isTyped + ? value + : this._toDisplayValue(value); + // Preserve formatting for edit replies and explicit format updates. + const isFormatOutdated = + !isTyped && !detail.selection && !("formattedValue" in detail); + if (isFormatOutdated) { + elementData.formattedValue = null; + } + if (!hasDateOrTime) { + const storedValue = isTyped ? this._toStorageValue(value) : value; const data = { - formattedValue, + value: storedValue.toString(), }; - if (hasDateOrTime) { - // If the field is a date or time, we store the formatted value - // in the `value` property, so that it can be used by the - // `Keystroke` action. - data.value = formattedValue; + if (isFormatOutdated) { + data.formattedValue = null; } storage.setValue(id, data); - }, - selRange(event) { - event.target.setSelectionRange(...event.detail.selRange); - }, - charLimit: event => { - const { charLimit } = event.detail; - const { target } = event; - if (charLimit === 0) { - target.removeAttribute("maxLength"); - return; - } + } + event.target.value = elementData.userValue; + }, + formattedValue(event) { + const { formattedValue } = event.detail; + elementData.formattedValue = formattedValue; + if ( + formattedValue !== null && + formattedValue !== undefined && + event.target !== document.activeElement + ) { + event.target.value = formattedValue; + } + const data = { + formattedValue, + }; + if (hasDateOrTime) { + // Date/time fields store formatted text for Keystroke actions. + data.value = formattedValue; + } + storage.setValue(id, data); + }, + selRange(event) { + event.target.setSelectionRange(...event.detail.selRange); + }, + charLimit: event => { + const { charLimit } = event.detail; + const { target } = event; + if (charLimit === 0) { + target.removeAttribute("maxLength"); + return; + } - target.setAttribute("maxLength", charLimit); - let value = elementData.userValue; - if (!value || value.length <= charLimit) { - return; - } - value = value.slice(0, charLimit); - target.value = elementData.userValue = value; - storage.setValue(id, { value }); - - this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { - source: this, - detail: { - id, - name: "Keystroke", - value, - willCommit: true, - commitKey: 1, - selStart: target.selectionStart, - selEnd: target.selectionEnd, - }, - }); - }, - }; - this._dispatchEventFromSandbox(actions, jsEvent); + target.setAttribute("maxLength", charLimit); + let value = elementData.userValue; + if (!value || value.length <= charLimit) { + return; + } + value = value.slice(0, charLimit); + target.value = elementData.userValue = value; + storage.setValue(id, { value }); + + this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { + source: this, + detail: { + id, + name: "Keystroke", + value, + willCommit: true, + commitKey: 1, + selStart: target.selectionStart, + selEnd: target.selectionEnd, + }, + }); + }, + }); + element.addEventListener("updatefromsandbox", jsEvent => { + this.container.classList.add("sandboxModified"); + this._dispatchEventFromSandbox(sandboxActions, jsEvent); }); // Even if the field hasn't any actions @@ -1725,10 +1746,11 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { return; } const { value } = event.target; - if (elementData.lastCommittedValue === value) { + const committedValue = this._toStorageValue(value); + if (elementData.lastCommittedValue === committedValue) { return; } - elementData.lastCommittedValue = value; + elementData.lastCommittedValue = committedValue; // Save the entered value elementData.userValue = value; this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { @@ -1736,7 +1758,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { detail: { id, name: "Keystroke", - value, + value: committedValue, willCommit: true, commitKey, selStart: event.target.selectionStart, @@ -1781,13 +1803,14 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { target.type = "text"; } elementData.userValue = value; - if (elementData.lastCommittedValue !== value) { + const committedValue = this._toStorageValue(value); + if (elementData.lastCommittedValue !== committedValue) { this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { source: this, detail: { id, name: "Keystroke", - value, + value: committedValue, willCommit: true, commitKey: elementData.commitKey, selStart: event.target.selectionStart, @@ -1875,7 +1898,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { ["mouseleave", "Mouse Exit"], ["mouseup", "Mouse Up"], ], - event => event.target.value + event => this._toStorageValue(event.target.value) ); } @@ -1933,6 +1956,378 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { } } +class EditableComboWidgetAnnotationElement extends TextWidgetAnnotationElement { + #items; + + #copyStyle; + + #rebuild; + + #pendingCommit = null; + + constructor(parameters) { + super(parameters); + this.#items = this.annotationStorage.getValue(this.data.id, { + items: this.data.options, + }).items; + } + + _toDisplayValue(value) { + if (Array.isArray(value)) { + value = value[0]; + } + return this.#getOption(value)?.displayValue ?? value ?? ""; + } + + #getOption(value) { + return this.#items.find(({ exportValue }) => exportValue === value); + } + + // Prefer the current field value when labels are shared. + #getItem(label) { + const item = this.#getOption(this.#value); + return item?.displayValue === label + ? item + : this.#items.find(({ displayValue }) => displayValue === label); + } + + _toStorageValue(value) { + return this.#getItem(value)?.exportValue ?? value; + } + + get #value() { + const { value } = this.annotationStorage.getValue(this.data.id, { + value: this.data.fieldValue, + }); + return Array.isArray(value) ? value[0] : value; + } + + #dispatchEvent(detail) { + this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { + source: this, + detail: { id: this.data.id, name: "Keystroke", ...detail }, + }); + } + + _getSandboxActions(actions) { + const target = this.contentElement; + // Update the text widget's cached editing value too. + const setValue = value => actions.value({ detail: { value }, target }); + const setItems = items => { + const previous = this.#getOption(this.#value); + this.#items = items; + this.annotationStorage.setValue(this.data.id, { items }); + this.#rebuild(); + // Refresh the label without discarding existing formatting. + const current = this.#getOption(this.#value); + if ( + previous && + current && + previous.displayValue !== current.displayValue + ) { + const { formattedValue = null } = this.annotationStorage.getRawValue( + this.data.id + ); + const detail = { value: current.exportValue, formattedValue }; + actions.value({ detail, target }); + actions.formattedValue({ detail, target }); + } + }; + // Apply script color changes to the arrow and list too. + const setColor = name => event => { + this._commonActions[name](event); + this.#copyStyle(); + }; + return { + ...actions, + bgColor: setColor("bgColor"), + fgColor: setColor("fgColor"), + fillColor: setColor("fillColor"), + textColor: setColor("textColor"), + selection: event => { + const value = this.#pendingCommit; + this.#pendingCommit = null; + if (value === null || value !== event.detail.value) { + return; + } + // Commit after Action, even when the selection reply is synchronous. + queueMicrotask(() => + this.#dispatchEvent({ value, willCommit: true, commitKey: 1 }) + ); + }, + editable: event => { + const { editable } = event.detail; + event.target.readOnly = !editable; + this.annotationStorage.setValue(this.data.id, { editable }); + }, + items: event => setItems(event.detail.items), + insert: event => { + const { index, displayValue, exportValue } = event.detail.insert; + setItems( + this.#items.toSpliced(index, 0, { displayValue, exportValue }) + ); + }, + remove: event => setItems(this.#items.toSpliced(event.detail.remove, 1)), + clear: () => { + setItems([]); + setValue(""); + }, + indices: event => { + const item = this.#items[event.detail.indices[0]]; + setValue(item?.exportValue ?? ""); + }, + }; + } + + render() { + const container = super.render(); + container.classList.add("comboBox"); + const input = this.contentElement; + const list = document.createElement("div"); + list.id = `${AnnotationPrefix}${this.data.id}_listbox`; + list.className = "comboList"; + list.role = "listbox"; + list.popover = "auto"; + list.setAttribute( + "aria-label", + this.data.alternativeText || this.data.fieldName + ); + const anchor = `--pdfjs-combo-${this.data.id}`; + input.style.setProperty("anchor-name", anchor); + list.style.setProperty("position-anchor", anchor); + input.role = "combobox"; + input.setAttribute("aria-expanded", "false"); + input.setAttribute("aria-controls", list.id); + // Restore the scripted editable state. + if (this.annotationStorage.getRawValue(this.data.id)?.editable === false) { + input.readOnly = true; + } + + const button = document.createElement("button"); + button.className = "comboButton"; + button.type = "button"; + button.tabIndex = -1; + button.dataset.l10nId = "pdfjs-annotation-combo-box-button"; + button.popoverTargetElement = list; + this.#copyStyle = () => { + const { backgroundColor, color, fontSize } = input.style; + Object.assign(button.style, { color, fontSize }); + Object.assign(list.style, { + color, + fontSize, + backgroundImage: + backgroundColor === "transparent" + ? "" + : `linear-gradient(${backgroundColor}, ${backgroundColor})`, + }); + }; + this.#copyStyle(); + button.addEventListener("mousedown", event => { + event.preventDefault(); + // Blur other form fields before picking an option. + const { activeElement } = document; + if (activeElement !== input && GetElementsByNameSet.has(activeElement)) { + input.focus({ preventScroll: true }); + } + }); + if (this.enableScripting && this.hasJSActions) { + this._setEventListeners( + button, + null, + [ + ["mousedown", "Mouse Down"], + ["mouseup", "Mouse Up"], + ], + () => this._toStorageValue(input.value) + ); + } + list.addEventListener("mousedown", event => event.preventDefault()); + + let active = -1; + const isOpen = () => list.matches(":popover-open"); + const currentIndex = () => this.#items.indexOf(this.#getItem(input.value)); + const scrollToOption = option => { + // Scroll the list without scrolling the page. + const top = option.offsetTop; + const bottom = top + option.offsetHeight; + if (top < list.scrollTop) { + list.scrollTop = top; + } else if (bottom > list.scrollTop + list.clientHeight) { + list.scrollTop = bottom - list.clientHeight; + } + }; + const activate = (index, scroll = true) => { + list.children[active]?.setAttribute("aria-selected", "false"); + active = index; + const option = list.children[index]; + if (!option) { + input.removeAttribute("aria-activedescendant"); + return; + } + option.setAttribute("aria-selected", "true"); + input.setAttribute("aria-activedescendant", option.id); + if (scroll) { + scrollToOption(option); + } + }; + const show = () => { + list.showPopover(); + activate(currentIndex()); + input.select(); + }; + const close = () => { + list.hidePopover(); + activate(-1); + }; + const pick = (index, keepOpen = false) => { + const { displayValue, exportValue } = this.#items[index]; + const oldValue = input.value; + // Store the export value before resolving a shared label. + this.annotationStorage.setValue(this.data.id, { value: exportValue }); + input.value = displayValue; + input.dispatchEvent(new Event("input")); + if (this.enableScripting && this.hasJSActions) { + // Unfocused picks commit after the sandbox accepts the selection. + this.#pendingCommit = + document.activeElement === input ? null : exportValue; + this.#dispatchEvent({ + value: oldValue, + change: displayValue, + changeEx: exportValue, + willCommit: false, + }); + this.#dispatchEvent({ name: "Action", value: exportValue }); + } + if (keepOpen) { + activate(index); + } else { + close(); + } + }; + const createOptions = () => { + const fragment = new DocumentFragment(); + for (const [index, { displayValue }] of this.#items.entries()) { + const option = document.createElement("div"); + option.role = "option"; + option.id = `${list.id}_${index}`; + option.textContent = displayValue; + option.setAttribute("aria-selected", "false"); + fragment.append(option); + } + list.replaceChildren(fragment); + }; + this.#rebuild = () => { + activate(-1); + list.replaceChildren(); + button.hidden = this.#items.length === 0; + if (isOpen()) { + if (button.hidden) { + close(); + } else { + createOptions(); + } + } + }; + this.#rebuild(); + // Populate options before keyboard navigation activates one. + list.addEventListener("beforetoggle", event => { + if (event.newState === "open" && !list.hasChildNodes()) { + createOptions(); + } + }); + const getIndex = target => + Array.prototype.indexOf.call(list.children, target); + list.addEventListener("click", ({ target }) => { + const index = getIndex(target); + if (index !== -1) { + pick(index); + } + }); + list.addEventListener("mousemove", event => { + if (event.movementX || event.movementY) { + const index = getIndex(event.target); + if (index !== -1) { + activate(index, /* scroll = */ false); + } + } + }); + list.addEventListener("toggle", () => { + const open = isOpen(); + input.setAttribute("aria-expanded", open); + if (!open) { + activate(-1); + } else if (active === -1) { + list.scrollTop = 0; + activate(currentIndex()); + } + }); + input.addEventListener("blur", close); + input.addEventListener("beforeinput", () => activate(-1)); + container.addEventListener( + "keydown", + event => { + // Without items, the keys are the text field ones. + if (event.isComposing || !this.#items.length) { + return; + } + const open = isOpen(); + switch (event.key) { + case "ArrowDown": + case "ArrowUp": { + const down = event.key === "ArrowDown"; + // Alt+Down opens the list and Alt+Up closes it. + if (event.altKey) { + if (down && !open) { + show(); + } else if (!down && open) { + close(); + } else if (!open) { + return; + } + break; + } + // Select adjacent options without wrapping. + const index = active === -1 ? currentIndex() : active; + const last = this.#items.length - 1; + let next = down ? 0 : last; + if (index !== -1) { + next = MathClamp(index + (down ? 1 : -1), 0, last); + } + if (next !== index) { + pick(next, /* keepOpen = */ open); + } + input.select(); + break; + } + case "Enter": + // Enter toggles the list; commit on blur. + if (!open) { + show(); + } else if (active === -1 || active === currentIndex()) { + close(); + } else { + pick(active); + } + break; + case "Escape": + if (!open) { + return; + } + close(); + break; + default: + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + }, + true + ); + container.append(button, list); + return container; + } +} + class SignatureWidgetAnnotationElement extends WidgetAnnotationElement { constructor(parameters) { super(parameters, { isRenderable: !!parameters.data.hasOwnCanvas }); @@ -2351,9 +2746,6 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { }); selectedValues = getValue(/* isExport */ false); }, - editable(event) { - event.target.disabled = !event.detail.editable; - }, }; this._dispatchEventFromSandbox(actions, jsEvent); }); diff --git a/test/integration/annotation_spec.mjs b/test/integration/annotation_spec.mjs index a8ba8f3583be6..38f003b61bdf6 100644 --- a/test/integration/annotation_spec.mjs +++ b/test/integration/annotation_spec.mjs @@ -14,12 +14,18 @@ */ import { + clearInput, closePages, getAnnotationSelector, getQuerySelector, + getRawValue, getRect, getSelector, + kbOpenList, loadAndWait, + openList, + updateFromSandbox, + waitForSandboxTrip, waitForTextToBe, waitForTooltipToBe, } from "./test_utils.mjs"; @@ -1184,3 +1190,404 @@ describe("Sound annotation", () => { }); }); }); + +describe("Editable combo box", () => { + let pages; + const input = '[name="combo1"]'; + const isOpen = page => + page.$eval("#pdfjs_internal_id_6R_listbox", el => + el.matches(":popover-open") + ); + beforeEach(async () => { + pages = await loadAndWait("bug1876540.pdf", input); + }); + afterEach(async () => closePages(pages)); + + it("must anchor the list after rotating the page", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.pagesRotation = 90; + }); + await page.waitForFunction(() => { + const rect = document.querySelector(".page").getBoundingClientRect(); + return rect.width > rect.height; + }); + await openList(page, "6R"); + const rects = await page.evaluate(() => { + const field = document + .querySelector('[name="combo1"]') + .getBoundingClientRect(); + const list = document.querySelector("#pdfjs_internal_id_6R_listbox"); + const rect = list.getBoundingClientRect(); + return { + gap: Math.min( + Math.abs(rect.top - field.bottom), + Math.abs(rect.bottom - field.top) + ), + transform: getComputedStyle(list).transform, + }; + }); + expect(rects.gap).withContext(browserName).toBeLessThan(2); + expect(rects.transform).toEqual("none"); + }) + ); + }); + + it("must update items and switch to choose-only mode from the sandbox", async () => { + await Promise.all( + pages.map(async ([, page]) => { + await page.waitForFunction( + () => window.PDFViewerApplication.scriptingReady + ); + await page.$eval(input, el => { + const update = detail => + el.dispatchEvent(new CustomEvent("updatefromsandbox", { detail })); + update({ + items: [ + { displayValue: "Spain", exportValue: "ES" }, + { displayValue: "Germany", exportValue: "DE" }, + ], + }); + update({ + insert: { index: 1, displayValue: "Italy", exportValue: "IT" }, + }); + update({ indices: [1], editable: false }); + }); + expect( + await page.$eval(input, el => [el.value, el.readOnly, el.disabled]) + ).toEqual(["Italy", true, false]); + await openList(page, "6R"); + await page.click("#pdfjs_internal_id_6R_listbox_0"); + expect(await page.$eval(input, el => el.value)).toEqual("Spain"); + }) + ); + }); + + it("must use the field colors for the arrow and the list", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.waitForFunction( + () => window.PDFViewerApplication.scriptingReady + ); + await updateFromSandbox(page, input, { + textColor: ["RGB", 1, 0, 0], + bgColor: ["RGB", 0, 0, 1], + }); + expect( + await page.evaluate(() => { + const button = document.querySelector( + '[data-annotation-id="6R"] .comboButton' + ); + const list = document.getElementById( + "pdfjs_internal_id_6R_listbox" + ); + return [ + button.style.color, + list.style.color, + list.style.backgroundImage, + ]; + }) + ) + .withContext(browserName) + .toEqual([ + "rgb(255, 0, 0)", + "rgb(255, 0, 0)", + "linear-gradient(rgb(0, 0, 255), rgb(0, 0, 255))", + ]); + }) + ); + }); + + it("must preserve sandbox editable changes when recreating the page", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.waitForFunction( + () => window.PDFViewerApplication.scriptingReady + ); + for (const editable of [false, true, false]) { + await updateFromSandbox(page, input, { editable }); + await page.evaluate(async () => { + const view = window.PDFViewerApplication.pdfViewer.getPageView(0); + view.reset(); + await view.draw(); + }); + await page.waitForSelector(input); + expect(await page.$eval(input, el => el.readOnly)) + .withContext(`In ${browserName}, editable=${editable}`) + .toEqual(!editable); + } + }) + ); + }); + + it("must map choices, accept custom text, and reset to the default", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + expect( + await page.$eval(input, el => [ + el.role, + el.value, + el.getAttribute("aria-expanded"), + ]) + ) + .withContext(browserName) + .toEqual(["combobox", "France", "false"]); + expect(await page.$eval('[name="combo2"]', el => el.value)).toEqual( + "Something else" + ); + await clearInput(page, input); + await page.type(input, "Custom country"); + await page.click('[name="combo2"]'); + expect((await getRawValue(page, "6R")).value).toEqual("Custom country"); + await openList(page, "6R"); + await page.click("#pdfjs_internal_id_6R_listbox_0"); + expect(await page.$eval(input, el => el.value)).toEqual( + "United States" + ); + expect((await getRawValue(page, "6R")).value).toEqual("US"); + // Commit first, else the commit reply could come after the reset. + await page.click('[name="combo2"]'); + await waitForSandboxTrip(page); + await page.click('[data-annotation-id="10R"] a'); + expect(await page.$eval(input, el => el.value)).toEqual("France"); + }) + ); + }); + + it("must show the current value when opening the list with the mouse", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const combo2 = '[name="combo2"]'; + await clearInput(page, combo2); + await page.type(combo2, "Option 25"); + await openList(page, "7R"); + await page.waitForFunction(() => { + const list = document.querySelector("#pdfjs_internal_id_7R_listbox"); + const option = list.children[24].getBoundingClientRect(); + const rect = list.getBoundingClientRect(); + return option.top >= rect.top && option.bottom <= rect.bottom; + }); + const getHighlighted = () => + page.$$eval("#pdfjs_internal_id_7R_listbox > *", els => + els.filter(el => el.ariaSelected === "true").map(el => el.id) + ); + expect( + await page.$eval("#pdfjs_internal_id_7R_listbox", el => + el.getAttribute("aria-label") + ) + ) + .withContext(browserName) + .toEqual("combo2"); + expect(await getHighlighted()) + .withContext(browserName) + .toEqual(["pdfjs_internal_id_7R_listbox_24"]); + await page.hover("#pdfjs_internal_id_7R_listbox_23"); + expect(await getHighlighted()) + .withContext(browserName) + .toEqual(["pdfjs_internal_id_7R_listbox_23"]); + }) + ); + }); + + it("must toggle the list with Enter", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const getState = () => + page.$eval(input, el => [ + el.value, + el.getAttribute("aria-activedescendant"), + ]); + const listbox = "#pdfjs_internal_id_6R_listbox"; + await page.click(input); + await page.keyboard.press("Enter"); + await page.waitForSelector(`${listbox}:popover-open`); + expect(await getState()) + .withContext(`In ${browserName}, opened`) + .toEqual(["France", "pdfjs_internal_id_6R_listbox_1"]); + await page.keyboard.press("Enter"); + expect(await isOpen(page)) + .withContext(browserName) + .toBeFalse(); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await page.waitForSelector(`${listbox}:popover-open`); + expect(await getState()) + .withContext(`In ${browserName}, reopened`) + .toEqual(["Germany", "pdfjs_internal_id_6R_listbox_2"]); + // Accept an option highlighted with the mouse. + await page.hover("#pdfjs_internal_id_6R_listbox_0"); + await page.keyboard.press("Enter"); + expect(await isOpen(page)) + .withContext(browserName) + .toBeFalse(); + expect(await getState()) + .withContext(`In ${browserName}, accepted`) + .toEqual(["United States", null]); + expect(await page.$eval(input, el => el === document.activeElement)) + .withContext(browserName) + .toBeTrue(); + + // The arrows start from the option whose label is typed. + await page.keyboard.press("Enter"); + await page.waitForSelector(`${listbox}:popover-open`); + await page.keyboard.type("Germany"); + await page.keyboard.press("ArrowUp"); + expect(await getState()) + .withContext(`In ${browserName}, typed`) + .toEqual(["France", "pdfjs_internal_id_6R_listbox_1"]); + }) + ); + }); + + it("must highlight the field when hovering its arrow", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const arrow = '[data-annotation-id="6R"] .comboButton'; + const getBorder = () => + page.$eval(input, el => getComputedStyle(el).borderTopColor); + await page.mouse.move(0, 0); + const idle = await getBorder(); + await page.hover(input); + const hovered = await getBorder(); + expect(hovered).withContext(browserName).not.toEqual(idle); + await page.mouse.move(0, 0); + await page.hover(arrow); + expect(await getBorder()) + .withContext(browserName) + .toEqual(hovered); + + await page.focus(input); + const focused = await getBorder(); + expect(focused).withContext(browserName).not.toEqual(hovered); + await page.mouse.move(0, 0); + expect(await getBorder()) + .withContext(browserName) + .toEqual(focused); + }) + ); + }); + + it("must change the value with the arrows and close the list when leaving the field", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const getState = () => + page.$eval(input, el => [ + el.value, + el.getAttribute("aria-activedescendant"), + window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue( + "6R" + ).value, + ]); + await page.click(input); + // Arrow keys select adjacent options without wrapping. + await page.keyboard.press("ArrowDown"); + expect(await getState()) + .withContext(`In ${browserName}, down`) + .toEqual(["Germany", null, "DE"]); + expect( + await page.$eval( + input, + el => el.selectionStart === 0 && el.selectionEnd === el.value.length + ) + ) + .withContext(`In ${browserName}, selected`) + .toBeTrue(); + await page.keyboard.press("ArrowDown"); + expect(await getState()) + .withContext(`In ${browserName}, last`) + .toEqual(["Germany", null, "DE"]); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowUp"); + expect(await getState()) + .withContext(`In ${browserName}, first`) + .toEqual(["United States", null, "US"]); + expect(await isOpen(page)) + .withContext(browserName) + .toBeFalse(); + + await clearInput(page, input); + await page.type(input, "Custom"); + await page.keyboard.press("ArrowUp"); + expect(await getState()) + .withContext(`In ${browserName}, from a custom value`) + .toEqual(["Germany", null, "DE"]); + + // With the list open, they also change the value. + await kbOpenList(page); + expect(await getState()) + .withContext(`In ${browserName}, opened`) + .toEqual(["Germany", "pdfjs_internal_id_6R_listbox_2", "DE"]); + await page.keyboard.press("ArrowUp"); + expect(await getState()) + .withContext(`In ${browserName}, open, up`) + .toEqual(["France", "pdfjs_internal_id_6R_listbox_1", "FR"]); + await page.keyboard.press("Enter"); + expect(await isOpen(page)) + .withContext(browserName) + .toBeFalse(); + expect(await getState()) + .withContext(`In ${browserName}, accepted`) + .toEqual(["France", null, "FR"]); + + await kbOpenList(page); + await page.keyboard.press("Tab"); + expect(await isOpen(page)) + .withContext(browserName) + .toBeFalse(); + }) + ); + }); + + it("must hide the arrow of read-only combos", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + expect( + await page.$eval('[data-annotation-id="8R"]', el => [ + el.querySelector("input").disabled, + getComputedStyle(el.querySelector(".comboButton")).display, + ]) + ) + .withContext(browserName) + .toEqual([true, "none"]); + }) + ); + }); +}); + +describe("Editable combo box with several widgets", () => { + let pages; + beforeEach(async () => { + pages = await loadAndWait("bug1876540_siblings.pdf", getSelector("7R")); + }); + afterEach(async () => closePages(pages)); + + it("must keep the picked option in all the widgets", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const getStorage = () => + Promise.all( + ["6R", "7R"].map(async id => (await getRawValue(page, id))?.value) + ); + // A and B are both labeled "Same": pick B in the first widget. + await openList(page, "6R"); + await page.click("#pdfjs_internal_id_6R_listbox_1"); + expect(await getStorage()) + .withContext(`In ${browserName}, picked`) + .toEqual(["B", "B"]); + // The sibling highlights B too. + await page.click(getSelector("7R")); + await kbOpenList(page); + expect( + await page.$eval(getSelector("7R"), el => + el.getAttribute("aria-activedescendant") + ) + ) + .withContext(`In ${browserName}, reopened`) + .toEqual("pdfjs_internal_id_7R_listbox_1"); + }) + ); + }); +}); diff --git a/test/integration/scripting_spec.mjs b/test/integration/scripting_spec.mjs index b3920d87e98a4..307b9979dff40 100644 --- a/test/integration/scripting_spec.mjs +++ b/test/integration/scripting_spec.mjs @@ -22,11 +22,15 @@ import { getComputedStyleSelector, getFirstSerialized, getQuerySelector, + getRawValue, getSelector, kbDeleteLastWord, + kbOpenList, kbSelectAll, loadAndWait, + openList, scrollIntoView, + updateFromSandbox, waitForEntryInStorage, waitForSandboxTrip, waitForTimeout, @@ -56,6 +60,18 @@ async function typeAndWaitForSandbox(page, selector, value) { } } +function getComboState(page, id) { + return page.$eval( + getSelector(id), + (el, i) => [ + el.value, + window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue(i) + .value, + ], + id + ); +} + describe("Interaction", () => { async function actAndWaitForInput(page, selector, action, clear = true) { await page.waitForSelector(selector, { @@ -3033,3 +3049,428 @@ describe("Interaction", () => { }); }); }); + +describe("Editable combo scripting", () => { + let pages; + const selector = '[name="combo4"]'; + const getFormattedState = page => + page.$eval(selector, el => { + const { value, formattedValue } = + window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue( + "9R" + ); + return [el.value, value, formattedValue]; + }); + async function typeAndCommit(page, value, formattedValue) { + await clearInput(page, selector); + await typeAndWaitForSandbox(page, selector, value); + await page.click('[name="combo1"]'); + await page.waitForFunction( + (sel, expected) => document.querySelector(sel).value === expected, + {}, + selector, + formattedValue + ); + } + + beforeEach(async () => { + pages = await loadAndWait("bug1876540.pdf", selector); + await Promise.all(pages.map(([, page]) => waitForScripting(page))); + }); + afterEach(async () => closePages(pages)); + + it("must format custom text and reject invalid values", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await typeAndCommit(page, "custom", "CUSTOM"); + expect(await page.$eval(selector, el => el.value)) + .withContext(browserName) + .toEqual("CUSTOM"); + await clearInput(page, selector); + await typeAndWaitForSandbox(page, selector, "bad"); + await page.click('[name="combo2"]'); + await page.waitForFunction( + sel => document.querySelector(sel).value === "", + {}, + selector + ); + }) + ); + }); + + it("must keep typed text matching an export value", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await clearInput(page, selector); + await typeAndWaitForSandbox(page, selector, "DE"); + expect( + await page.$eval(selector, el => [ + el.value, + el.selectionStart, + window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue( + "9R" + ).value, + ]) + ) + .withContext(browserName) + .toEqual(["DE", 2, "DE"]); + await clearInput(page, selector); + await typeAndWaitForSandbox(page, selector, "France"); + expect(await getComboState(page, "9R")) + .withContext(browserName) + .toEqual(["France", "FR"]); + }) + ); + }); + + it("must keep the values set by a script when focusing and leaving the field", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + for (const [detail, displayed, stored] of [ + [{ indices: [2] }, "Germany", "DE"], + [{ clear: null }, "", ""], + [{ value: "" }, "", ""], + ]) { + // Start with formatting that the update must clear. + await typeAndCommit(page, "custom", "CUSTOM"); + + await updateFromSandbox(page, selector, detail); + const context = `In ${browserName}, ${JSON.stringify(detail)}`; + await page.click(selector); + expect(await getFormattedState(page)) + .withContext(`${context}, focused`) + .toEqual([displayed, stored, null]); + await page.click('[name="combo2"]'); + await waitForSandboxTrip(page); + expect(await getFormattedState(page)) + .withContext(`${context}, blurred`) + .toEqual([displayed, stored, null]); + } + }) + ); + }); + + it("must keep the picked option when its label is ambiguous", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // combo5: A and B are both labeled "Same"; combo6: A is labeled "B" + // and B is labeled "Other". + for (const [sel, id, index, expected] of [ + ['[name="combo5"]', "11R", 1, ["Same", "B"]], + ['[name="combo6"]', "12R", 0, ["B", "A"]], + ]) { + const context = `In ${browserName}, ${sel}`; + await page.click(sel); + await openList(page, id); + // Read storage before asynchronous sandbox replies. + expect( + await page.evaluate( + (i, n) => { + document + .getElementById(`pdfjs_internal_id_${i}_listbox_${n}`) + .click(); + return window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue( + i + ).value; + }, + id, + index + ) + ) + .withContext(`${context}, picked`) + .toEqual(expected[1]); + await page.click('[name="combo2"]'); + await waitForSandboxTrip(page); + expect(await getComboState(page, id)) + .withContext(`${context}, committed`) + .toEqual(expected); + + await page.click(sel); + await kbOpenList(page); + expect( + await page.$eval(sel, el => + el.getAttribute("aria-activedescendant") + ) + ) + .withContext(`${context}, reopened`) + .toEqual(`pdfjs_internal_id_${id}_listbox_${index}`); + await page.click('[name="combo2"]'); + await waitForSandboxTrip(page); + } + }) + ); + }); + + it("must close the list with Enter or Escape and commit only on blur", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const listbox = "#pdfjs_internal_id_11R_listbox"; + // combo5 reports its Validate and Blur values in combo2 and combo3. + const getReports = () => + page.evaluate(() => + ["combo2", "combo3"].map( + name => document.querySelector(`[name="${name}"]`).value + ) + ); + // A and B share "Same": pick B. + await page.click('[name="combo5"]'); + await page.keyboard.press("ArrowDown"); + for (const key of ["Enter", "Escape"]) { + await page.keyboard.press("Enter"); + await page.waitForSelector(`${listbox}:popover-open`); + await page.keyboard.press(key); + await page.waitForSelector(`${listbox}:not(:popover-open)`); + await waitForSandboxTrip(page); + expect(await getReports()) + .withContext(`In ${browserName}, after ${key}`) + .toEqual(["Something else", "France"]); + } + await page.click('[name="combo1"]'); + await waitForSandboxTrip(page); + expect(await getReports()) + .withContext(`In ${browserName}, after blur`) + .toEqual(["Validate: B", "Blur: B"]); + }) + ); + }); + + it("must keep the text typed in the open list", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.click(selector); + await page.keyboard.press("Enter"); + await page.waitForSelector( + "#pdfjs_internal_id_9R_listbox:popover-open" + ); + await typeAndWaitForSandbox(page, selector, "x"); + await page.keyboard.press("Enter"); + expect(await page.$eval(selector, el => el.value)) + .withContext(browserName) + .toEqual("x"); + }) + ); + }); + + it("must keep the stored formatting when going back to the committed option", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await typeAndCommit(page, "France", "FR"); + await page.click(selector); + await page.keyboard.press("ArrowDown"); + await waitForSandboxTrip(page); + await page.keyboard.press("ArrowUp"); + await waitForSandboxTrip(page); + await page.click('[name="combo1"]'); + await waitForSandboxTrip(page); + // Printing and saving read this stored formatting. + const { value, formattedValue } = await getRawValue(page, "9R"); + expect([value, formattedValue]) + .withContext(browserName) + .toEqual(["FR", "FR"]); + }) + ); + }); + + it("must run the mouse actions of the arrow button", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // combo6's Mouse Down action writes to combo3. + await openList(page, "12R"); + await waitForSandboxTrip(page); + expect(await page.$eval('[name="combo3"]', el => el.value)) + .withContext(browserName) + .toEqual("Mouse Down"); + }) + ); + }); + + it("must keep the previous option when a selection is rejected", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const combo7 = '[name="combo7"]'; + // combo7 rejects B, which shares A's label. + await page.click(combo7); + await openList(page, "13R"); + await page.$eval("#pdfjs_internal_id_13R_listbox_1", el => el.click()); + await waitForSandboxTrip(page); + expect(await getComboState(page, "13R")) + .withContext(`In ${browserName}, rejected`) + .toEqual(["Same", "A"]); + await page.keyboard.press("ArrowDown"); + await waitForSandboxTrip(page); + expect(await getComboState(page, "13R")) + .withContext(`In ${browserName}, rejected with the arrows`) + .toEqual(["Same", "A"]); + await kbOpenList(page); + expect( + await page.$eval(combo7, el => + el.getAttribute("aria-activedescendant") + ) + ) + .withContext(`In ${browserName}, reopened`) + .toEqual("pdfjs_internal_id_13R_listbox_0"); + await page.keyboard.press("Escape"); + await page.click('[name="combo1"]'); + await waitForSandboxTrip(page); + expect(await getComboState(page, "13R")) + .withContext(`In ${browserName}, left`) + .toEqual(["Same", "A"]); + }) + ); + }); + + it("must commit an option picked with the arrow button only once accepted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // combo7 rejects B; combo5 reports its validated value in combo2. + for (const id of ["13R", "11R"]) { + await openList(page, id); + await page.click(`#pdfjs_internal_id_${id}_listbox_1`); + } + await page.waitForFunction( + () => + document.querySelector('[name="combo2"]').value === "Validate: B" + ); + await waitForSandboxTrip(page); + expect([ + await page.$eval('[name="combo7"]', el => el.value), + (await getRawValue(page, "13R")).value, + (await getRawValue(page, "11R")).value, + ]) + .withContext(browserName) + .toEqual(["Same", "A", "B"]); + }) + ); + }); + + it("must forget the formatted value when resetting the form", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await typeAndCommit(page, "custom", "CUSTOM"); + await page.click('[data-annotation-id="10R"] a'); + await waitForSandboxTrip(page); + expect(await getFormattedState(page)) + .withContext(browserName) + .toEqual(["France", "FR", null]); + }) + ); + }); + + it("must show the new label of the selected option, keeping its formatting", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Relabel FR in both a formatted and an unformatted field. + await typeAndCommit(page, "France", "FR"); + for (const sel of ['[name="combo1"]', selector]) { + await updateFromSandbox(page, sel, { + items: [ + { displayValue: "United States", exportValue: "US" }, + { displayValue: "French", exportValue: "FR" }, + { displayValue: "Germany", exportValue: "DE" }, + ], + }); + } + expect(await getFormattedState(page)) + .withContext(`In ${browserName}, relabeled`) + .toEqual(["FR", "FR", "FR"]); + await page.click(selector); + expect(await getFormattedState(page)) + .withContext(`In ${browserName}, focused`) + .toEqual(["French", "FR", "FR"]); + expect(await getComboState(page, "6R")) + .withContext(`In ${browserName}, unformatted`) + .toEqual(["French", "FR"]); + }) + ); + }); + + it("must run the field action when picking an option", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await openList(page, "9R"); + await page.click("#pdfjs_internal_id_9R_listbox_0"); + // combo4's Action writes its value and combo1's value to combo2. + await page.waitForFunction( + () => document.querySelector('[name="combo2"]').value === "US FR" + ); + expect((await getRawValue(page, "9R")).value) + .withContext(browserName) + .toEqual("US"); + }) + ); + }); + + it("must commit the field being edited before picking with the arrow button", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // combo4's Action must see combo1's committed value. + await clearInput(page, '[name="combo1"]'); + await page.type('[name="combo1"]', "new text"); + await openList(page, "9R"); + await page.click("#pdfjs_internal_id_9R_listbox_0"); + await waitForSandboxTrip(page); + expect(await page.$eval('[name="combo2"]', el => el.value)) + .withContext(browserName) + .toEqual("US new text"); + }) + ); + }); +}); + +describe("Editable combo scripting with a synchronous sandbox", () => { + let pages; + beforeEach(async () => { + pages = await loadAndWait( + "bug1876540.pdf", + '[name="combo8"]', + null /* zoom = */, + { + appSetup: app => { + // Deliver replies synchronously to test event ordering. + const { externalServices } = app; + const createScripting = + externalServices.createScripting.bind(externalServices); + externalServices.createScripting = () => { + const scripting = createScripting(); + const dispatch = scripting.dispatchEventInSandbox.bind(scripting); + let sandbox = null; + scripting._ready.then(s => { + sandbox = s; + }); + scripting.dispatchEventInSandbox = event => + sandbox ? sandbox.dispatchEvent(event) : dispatch(event); + return scripting; + }; + }, + } + ); + await Promise.all(pages.map(([, page]) => waitForScripting(page))); + }); + afterEach(async () => closePages(pages)); + + it("must commit an option picked with the arrow button only once accepted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // combo7 rejects B; combo8 must validate after running Action. + for (const [id, index] of [ + ["13R", 1], + ["14R", 0], + ]) { + await openList(page, id); + await page.click(`#pdfjs_internal_id_${id}_listbox_${index}`); + await waitForSandboxTrip(page); + } + expect( + await page.evaluate(() => [ + window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue( + "13R" + ).value, + document.querySelector('[name="combo2"]').value, + ]) + ) + .withContext(browserName) + .toEqual(["A", "Checked: US"]); + }) + ); + }); +}); diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index a44fcbfe92818..e5a1d91a307ce 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -531,6 +531,23 @@ function getAnnotationStorage(page) { ); } +function getRawValue(page, id) { + return page.evaluate( + i => + window.PDFViewerApplication.pdfDocument.annotationStorage.getRawValue(i), + id + ); +} + +function updateFromSandbox(page, selector, detail) { + return page.$eval( + selector, + (el, data) => + el.dispatchEvent(new CustomEvent("updatefromsandbox", { detail: data })), + detail + ); +} + function waitForEntryInStorage(page, key, value, checker = (x, y) => x === y) { return page.waitForFunction( (k, v, c) => { @@ -978,6 +995,17 @@ async function kbBigMoveDown(page) { } } +async function kbOpenList(page) { + await page.keyboard.down("Alt"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Alt"); +} + +async function openList(page, id) { + await page.click(`[data-annotation-id="${id}"] .comboButton`); + await page.waitForSelector(`#pdfjs_internal_id_${id}_listbox:popover-open`); +} + async function kbDeleteLastWord(page) { if (isMac) { await page.keyboard.down("Alt"); @@ -1248,6 +1276,7 @@ export { getFirstSerialized, getNextEditorId, getQuerySelector, + getRawValue, getRect, getSelector, getSerialized, @@ -1270,6 +1299,7 @@ export { kbGoToEnd, kbModifierDown, kbModifierUp, + kbOpenList, kbRedo, kbSave, kbSelectAll, @@ -1277,6 +1307,7 @@ export { loadAndWait, mockClipboard, moveEditor, + openList, paste, pasteFromClipboard, PDI, @@ -1289,6 +1320,7 @@ export { showViewsManager, switchToEditor, unselectEditor, + updateFromSandbox, waitAndClick, waitForAnnotationEditorLayer, waitForAnnotationModeChanged, diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index bd39857247df5..f22738b971ae2 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -1053,3 +1053,5 @@ !perf_transparency_blend_modes.pdf !perf_transparency_soft_masks.pdf !perf_transparency_groups.pdf +!bug1876540.pdf +!bug1876540_siblings.pdf diff --git a/test/pdfs/bug1876540.pdf b/test/pdfs/bug1876540.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7b461e5e5c293efe2267c25b4fad1f0dacd6f570 GIT binary patch literal 3724 zcmcInZExE)5dQ98aW#-aLa|7s-t4U@V8_vh7Fh~A$vy=6pvbhXO%?-*PSgGR9Z4yo z?aFnt%?T{(th?vl_#Acec@qCbPNNJXH5J*f!lK;-AD7ZjJLM4pU_dtCQ z)i;rLr$A&P@S3L2;5jO8-Ke`DF1jKI=fBlPtG%`a_mCe!bP)uvGf z+jyi4sFLB#HV;D8Qwj+<jzHI-59&Vi5K7vJxW4ZoK#?5T!^R(o{LxC$C_O|qV&$Xnc3D&kqI`3nMeey?0dMg z;;C)LWm2R}-A*oS>sMB;lj5s=kGi)YpTNx2i#7qWq_1u3InrN0@yY$;-&@RJv8MVtnyBxMl;u>oMj~IZ&KqLkvF))b%N(@wDz!C$O8o1QJ zr3NlFaH)Yy4P0vAQUjM7xYWS)T&%h*@{F&USY^c$SYENZ;Jd=M_;M$|uJ{xKd*BgbH44mx#faD_iYfp^zDDN<(!csr5QLMlc!{smKpA6=i>}4 zELe4l?4TIY>MLHLef#t&$u|tnfIT6j*LUxrkALsrTRNoC`Nh`Utr6ocT+{9r%BkQt zkB_W8N7A$G+S=`5GiAEt>6^1N=szU$ey#uMuztEqie($EzSXK~6g2}=4V>c(KNf`| z+mcmG7=;gmz%%RaM=lVgTz@EA_EI~0j?)DvrBqb2u!%s))HvUEZT~cI>=?kk-`$fw z(LL#VMo*4xYnH4v+mD+yx<1lH@2u9h8pmw5{%w+HbDS$bt3ES&q^{#QZ_;o~AA3zt zVt4e%`KIhJEAxx|HNrf|8HLxDrPQh7_>!CB6?wEfuO8N0(v&waXbST>RwERXdL>q@ zRP(dW>g3g-U-C`GV8Xu?^?s-W9@hcm)d+b1aCdAY;jlOmu&W!%qsKQ==eErjV7<}p z@g>XibG}|@0^Yu@=}K?li)T2KJTUItwOsMUBEjkOvbnnpZ=M(R>Fn4%;Gpcz0}XWH zX=D`ssB@KV{mu*AbaJEpNH1*duu&?{K@Bg~t<{-lonn!cI3adMf+_ql&i*b7oUd5= z7n>j6tsO_o=aMaYNT)&1effYS+yEBP%5Z|q4-~B|!h-L}h=;7YBl9F9?8y884>LQm zfbPp8B-5Se2&H?t#Fwu$+xEzCUzfT#ICh?+zI^l9)}a#pHW$yxR{ g!~EfqigWqer(ei=6&75m?0Q}>=$)KgT*bYA0C11=@&Et; literal 0 HcmV?d00001 diff --git a/test/pdfs/bug1876540_siblings.pdf b/test/pdfs/bug1876540_siblings.pdf new file mode 100644 index 0000000000000000000000000000000000000000..84fe1cd85ec4e9d5c18c8725656dbb7aea8a6569 GIT binary patch literal 990 zcmb_bO>5gg5WVYHyo*6Tv@2=#5kUypaYdn&1lzO+Uy9$ohNb8dJ*LP+WMT$e| zr9$XqzTTTRvy=Np@ge+}c_9Lrhez-F8e;wHq#@2v%`-Lu#iwC} zERU$(Qn%*Z?1&&UFAQby$7g-~iG(o;iElBhYh#H>>Wmpbw<8wZPS2vK^pQDVbwOe= zgWpvo*!7;QhbhEj`47mUMJCc0@5FUP#p?WEoz`p&A?B*porn2rTkX_{A~Szm#r1@t z%=fB1X!|Oi<4=EZ;*&ZwG!krQNreDe6hH>_j^nWqtjvEzNz=rkq*wVEg{k!@#hTXQ z2dy3a@_Zs?i#=p6qcDZII}zE9Kb!h1^`xg;Z2WouP1FAda?vPW-rcJGWx~&m-g^OZ zFL=F>CW%b2$6(9hyr>6TM!Uh+$QDXEFBi6mI=!^Xgc`kwi=+3pMABAY#HEr}zqi>& kRY%>pJMOhsHP-c*#hEvme7Y;VKMVi++W-In literal 0 HcmV?d00001 diff --git a/test/test_manifest.json b/test/test_manifest.json index e29a438c802f4..0c3022bafff08 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -15304,5 +15304,26 @@ } }, "about": "Print the /MK /CA star with NeedAppearances." + }, + { + "id": "bug1876540-forms", + "file": "pdfs/bug1876540.pdf", + "md5": "91ed9bc2aaf1286510d9e61a4bf85291", + "rounds": 1, + "type": "eq", + "forms": true + }, + { + "id": "bug1876540-print", + "file": "pdfs/bug1876540.pdf", + "md5": "91ed9bc2aaf1286510d9e61a4bf85291", + "rounds": 1, + "type": "eq", + "print": true, + "annotationStorage": { + "6R": { + "value": "Custom country" + } + } } ] diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 8b57f55ab71ac..e334f7bff248c 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -221,6 +221,128 @@ padding: 0; } + .comboBox { + > input { + padding-inline: 4px 18px; + } + + > .comboButton { + position: absolute; + inset-block: 0; + inset-inline-end: 2px; + width: 18px; + border: none; + background: transparent; + padding: 0; + font-family: sans-serif; + line-height: normal; + + /* Use system FieldText instead of the scripted color. */ + @media (forced-colors: active) { + color: FieldText !important; + } + + &::before { + content: ""; + position: absolute; + inset-inline: 0; + top: 50%; + height: 1lh; + translate: 0 -50%; + background-color: currentColor; + mask: url("data:image/svg+xml;charset=UTF-8,") + center / contain no-repeat; + } + } + + > input:disabled ~ .comboButton { + display: none; + } + + > .comboList { + --combo-list-border-color: var( + --panel-border-color, + light-dark(rgb(240 240 244), rgb(82 82 94)) + ); + --combo-list-border-radius: 6px; + --combo-list-box-shadow: var( + --panel-box-shadow, + 0 0 4px rgb(0 0 0 / 0.2) + ); + /*#if MOZCENTRAL*/ + --combo-option-active-bg: -moz-menuhover; + --combo-option-active-fg: -moz-menuhovertext; + /*#else*/ + --combo-option-active-bg: color-mix( + in srgb, + currentColor 12%, + transparent + ); + --combo-option-active-fg: currentColor; + /*#endif*/ + + /*#if MOZCENTRAL*/ + @media (-moz-windows-mica-popups) { + --combo-list-border-color: color-mix( + in srgb, + CanvasText 30%, + transparent + ); + --combo-list-border-radius: 8px; + --combo-list-box-shadow: 0 14px 40px rgb(0 0 0 / 0.26); + } + /*#endif*/ + + @media (forced-colors: active) { + --combo-list-border-color: Canvas; + --combo-list-box-shadow: none; + --combo-option-active-bg: SelectedItem; + --combo-option-active-fg: SelectedItemText; + /* Override the inline text color. */ + color: CanvasText !important; + } + + padding: 4px; + box-sizing: border-box; + border: 1px solid var(--combo-list-border-color); + border-radius: var(--combo-list-border-radius); + box-shadow: var(--combo-list-box-shadow); + background: Canvas; + font-family: sans-serif; + max-block-size: min(50vh, 20em); + + @supports (position-area: block-end) { + margin: 0; + inset: auto; + position-area: block-end span-inline-end; + position-try-fallbacks: flip-block; + min-inline-size: anchor-size(width); + } + + > [role="option"] { + padding: 0.3333em 6px; + border-radius: var(--border-radius-xsmall, 2px); + white-space: pre; + cursor: default; + + /*#if MOZCENTRAL*/ + @media (-moz-platform: linux) { + padding: 0.25em 0.5em; + } + /*#endif*/ + + &[aria-selected="true"] { + background-color: var(--combo-option-active-bg); + color: var(--combo-option-active-fg); + } + + @media (pointer: coarse) { + padding-block: 0.9167em; + } + } + } + } + .textWidgetAnnotation textarea { resize: none; } @@ -233,12 +355,15 @@ cursor: not-allowed; } + /* Match input hover specificity so :focus still wins. */ .textWidgetAnnotation :is(input, textarea):hover, + .textWidgetAnnotation:has(> :where(.comboButton):hover) > input, .choiceWidgetAnnotation select:hover, .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:hover { border: 2px solid var(--input-hover-border-color); } .textWidgetAnnotation :is(input, textarea):hover, + .textWidgetAnnotation:has(> :where(.comboButton):hover) > input, .choiceWidgetAnnotation select:hover, .buttonWidgetAnnotation.checkBox input:hover { border-radius: 2px;