diff --git a/external/builder/builder.mjs b/external/builder/builder.mjs index 223beff1eb17a..ae88dde2d3c89 100644 --- a/external/builder/builder.mjs +++ b/external/builder/builder.mjs @@ -73,10 +73,7 @@ function preprocess(inFilename, outFilename, defines) { const out = []; let i = 0; function readLine() { - if (i < totalLines) { - return lines[i++]; - } - return null; + return i < totalLines ? lines[i++] : null; } const writeLine = typeof outFilename === "function" @@ -127,10 +124,7 @@ function preprocess(inFilename, outFilename, defines) { function expand(line) { line = line.replaceAll(/__\w+__/g, function (variable) { variable = variable.substring(2, variable.length - 2); - if (variable in defines) { - return defines[variable]; - } - return ""; + return variable in defines ? defines[variable] : ""; }); writeLine(line); } diff --git a/src/core/cff_parser.js b/src/core/cff_parser.js index 1ed1e236a0af4..d6d5a854bef74 100644 --- a/src/core/cff_parser.js +++ b/src/core/cff_parser.js @@ -1428,10 +1428,9 @@ class CFFFDSelect { } getFDIndex(glyphIndex) { - if (glyphIndex < 0 || glyphIndex >= this.fdSelect.length) { - return -1; - } - return this.fdSelect[glyphIndex]; + return glyphIndex < 0 || glyphIndex >= this.fdSelect.length + ? -1 + : this.fdSelect[glyphIndex]; } } diff --git a/src/core/chunked_stream.js b/src/core/chunked_stream.js index 9b2f1d32c9caa..dfcbb51bc6b1e 100644 --- a/src/core/chunked_stream.js +++ b/src/core/chunked_stream.js @@ -239,10 +239,10 @@ class ChunkedStream extends Stream { }; Object.defineProperty(ChunkedStreamSubstream.prototype, "isDataLoaded", { get() { - if (this.numChunksLoaded === this.numChunks) { - return true; - } - return this.getMissingChunks().length === 0; + return ( + this.numChunksLoaded === this.numChunks || + this.getMissingChunks().length === 0 + ); }, configurable: true, }); diff --git a/src/core/decode_stream.js b/src/core/decode_stream.js index 619d6efc066bc..ddc7a4ae02e38 100644 --- a/src/core/decode_stream.js +++ b/src/core/decode_stream.js @@ -111,10 +111,9 @@ class DecodeStream extends BaseStream { async getImageData(length, decoderOptions) { if (!this.canAsyncDecodeImageFromBuffer) { - if (this.isAsyncDecoder) { - return this.decodeImage(null, length, decoderOptions); - } - return this.getBytes(length, decoderOptions); + return this.isAsyncDecoder + ? this.decodeImage(null, length, decoderOptions) + : this.getBytes(length, decoderOptions); } const data = await this.stream.asyncGetBytes(); return this.decodeImage(data, length, decoderOptions); diff --git a/src/core/parser.js b/src/core/parser.js index 97e88b8fff41a..5500b2a7f1298 100644 --- a/src/core/parser.js +++ b/src/core/parser.js @@ -186,10 +186,7 @@ class Parser { } if (typeof buf1 === "string") { - if (cipherTransform) { - return cipherTransform.decryptString(buf1); - } - return buf1; + return cipherTransform ? cipherTransform.decryptString(buf1) : buf1; } // simple object diff --git a/src/core/stream.js b/src/core/stream.js index 9b9b430552a91..bda2007eb4b5a 100644 --- a/src/core/stream.js +++ b/src/core/stream.js @@ -39,10 +39,7 @@ class Stream extends BaseStream { } getByte() { - if (this.pos >= this.end) { - return -1; - } - return this.bytes[this.pos++]; + return this.pos >= this.end ? -1 : this.bytes[this.pos++]; } getBytes(length) { diff --git a/src/core/string_utils.js b/src/core/string_utils.js index 53806c606e035..0711c166ed5a0 100644 --- a/src/core/string_utils.js +++ b/src/core/string_utils.js @@ -16,19 +16,15 @@ import { stringToBytes, Util, warn } from "../shared/util.js"; function isAscii(str) { - return ( - typeof str === "string" && - // eslint-disable-next-line no-control-regex - (!str || /^[\x00-\x7F]*$/.test(str)) - ); + // eslint-disable-next-line no-control-regex + return typeof str === "string" && (!str || /^[\x00-\x7F]*$/.test(str)); } // If the string is null or undefined then it is returned as is. function stringToAsciiOrUTF16BE(str) { - if (str === null || str === undefined) { - return str; - } - return isAscii(str) ? str : stringToUTF16String(str, /* bigEndian = */ true); + return str === null || str === undefined || isAscii(str) + ? str + : stringToUTF16String(str, /* bigEndian = */ true); } function stringToUTF16HexString(str) { diff --git a/src/core/to_unicode_map.js b/src/core/to_unicode_map.js index e1a0e5c9b34a4..4731c1db6a950 100644 --- a/src/core/to_unicode_map.js +++ b/src/core/to_unicode_map.js @@ -83,10 +83,9 @@ class IdentityToUnicodeMap { } get(i) { - if (this.firstChar <= i && i <= this.lastChar) { - return String.fromCharCode(i); - } - return undefined; + return this.firstChar <= i && i <= this.lastChar + ? String.fromCharCode(i) + : undefined; } charCodeOf(v) { diff --git a/src/core/xfa/fonts.js b/src/core/xfa/fonts.js index e14b6442d52e6..39573ace63133 100644 --- a/src/core/xfa/fonts.js +++ b/src/core/xfa/fonts.js @@ -154,10 +154,7 @@ class FontFinder { function selectFont(xfaFont, typeface) { if (xfaFont.posture === "italic") { - if (xfaFont.weight === "bold") { - return typeface.bolditalic; - } - return typeface.italic; + return xfaFont.weight === "bold" ? typeface.bolditalic : typeface.italic; } else if (xfaFont.weight === "bold") { return typeface.bold; } diff --git a/src/core/xfa/template.js b/src/core/xfa/template.js index 6731d44092abb..a9c52738670a9 100644 --- a/src/core/xfa/template.js +++ b/src/core/xfa/template.js @@ -6073,10 +6073,9 @@ class Value extends XFAObject { [$text]() { if (this.exData) { - if (typeof this.exData[$content] === "string") { - return this.exData[$content].trim(); - } - return this.exData[$content][$text]().trim(); + return typeof this.exData[$content] === "string" + ? this.exData[$content].trim() + : this.exData[$content][$text]().trim(); } for (const name of Object.getOwnPropertyNames(this)) { if (name === "image") { diff --git a/src/core/xfa/xfa_object.js b/src/core/xfa/xfa_object.js index ee395ec28f967..ce6936190e9d4 100644 --- a/src/core/xfa/xfa_object.js +++ b/src/core/xfa/xfa_object.js @@ -284,10 +284,9 @@ class XFAObject { } [$text]() { - if (this[_children].length === 0) { - return this[$content]; - } - return this[_children].map(c => c[$text]()).join(""); + return this[_children].length === 0 + ? this[$content] + : this[_children].map(c => c[$text]()).join(""); } get [_attributeNames]() { @@ -329,11 +328,7 @@ class XFAObject { } [$getChildren](name = null) { - if (!name) { - return this[_children]; - } - - return this[name]; + return !name ? this[_children] : this[name]; } [$dump]() { @@ -680,11 +675,9 @@ class XFAObject { } [$getChildren](name = null) { - if (!name) { - return this[_children]; - } - - return this[_children].filter(c => c[$nodeName] === name); + return !name + ? this[_children] + : this[_children].filter(c => c[$nodeName] === name); } [$getChildrenByClass](name) { @@ -909,11 +902,9 @@ class XmlObject extends XFAObject { } [$getChildren](name = null) { - if (!name) { - return this[_children]; - } - - return this[_children].filter(c => c[$nodeName] === name); + return !name + ? this[_children] + : this[_children].filter(c => c[$nodeName] === name); } [$getAttributes]() { diff --git a/src/core/xml_parser.js b/src/core/xml_parser.js index 67df43ae26d61..3d3ffb983e084 100644 --- a/src/core/xml_parser.js +++ b/src/core/xml_parser.js @@ -324,10 +324,9 @@ class SimpleDOMNode { } get textContent() { - if (!this.childNodes) { - return this.nodeValue || ""; - } - return this.childNodes.map(child => child.textContent).join(""); + return !this.childNodes + ? this.nodeValue || "" + : this.childNodes.map(child => child.textContent).join(""); } get children() { diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index b584f6da68c5e..2c1303a6d2c4f 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -3122,10 +3122,7 @@ class PopupElement { } get isVisible() { - if (this.#commentManager) { - return false; - } - return this.#container.hidden === false; + return !this.#commentManager && this.#container.hidden === false; } } diff --git a/src/display/canvas_dependency_tracker.js b/src/display/canvas_dependency_tracker.js index 7929c63590e14..0df2528c5d8a1 100644 --- a/src/display/canvas_dependency_tracker.js +++ b/src/display/canvas_dependency_tracker.js @@ -188,10 +188,7 @@ class CanvasBBoxTracker { } getOpenMarker() { - if (this._savesStack.length === 0) { - return null; - } - return this._savesStack.at(-1); + return this._savesStack.length === 0 ? null : this._savesStack.at(-1); } recordCloseMarker(opIdx, onSavePopped) { diff --git a/src/display/editor/alt_text.js b/src/display/editor/alt_text.js index 201d120149dd5..91b5b13864e2d 100644 --- a/src/display/editor/alt_text.js +++ b/src/display/editor/alt_text.js @@ -131,17 +131,15 @@ class AltText { } isEmpty() { - if (this.#useNewAltTextFlow) { - return this.#altText === null; - } - return !this.#altText && !this.#altTextDecorative; + return this.#useNewAltTextFlow + ? this.#altText === null + : !this.#altText && !this.#altTextDecorative; } hasData() { - if (this.#useNewAltTextFlow) { - return this.#altText !== null || !!this.#guessedText; - } - return this.isEmpty(); + return this.#useNewAltTextFlow + ? this.#altText !== null || !!this.#guessedText + : this.isEmpty(); } get guessedText() { diff --git a/src/display/editor/drawers/inkdraw.js b/src/display/editor/drawers/inkdraw.js index b2dbeebb7495b..c1ff9ab79b024 100644 --- a/src/display/editor/drawers/inkdraw.js +++ b/src/display/editor/drawers/inkdraw.js @@ -622,10 +622,7 @@ class InkDrawOutline extends Outline { } updateProperty(name, value) { - if (name === "stroke-width") { - return this.#updateThickness(value); - } - return null; + return name === "stroke-width" ? this.#updateThickness(value) : null; } #updateThickness(thickness) { diff --git a/src/display/editor/signature.js b/src/display/editor/signature.js index 272c1c62627e8..015952aa329c0 100644 --- a/src/display/editor/signature.js +++ b/src/display/editor/signature.js @@ -254,10 +254,9 @@ class SignatureEditor extends DrawingEditor { /** @inheritdoc */ get toolbarButtons() { - if (this._uiManager.signatureManager) { - return [["editSignature", this._uiManager.signatureManager]]; - } - return super.toolbarButtons; + return this._uiManager.signatureManager + ? [["editSignature", this._uiManager.signatureManager]] + : super.toolbarButtons; } addSignature(data, heightInPage, description, uuid) { diff --git a/src/display/touch_manager.js b/src/display/touch_manager.js index 08de4600160b4..7ca8a96361ce4 100644 --- a/src/display/touch_manager.js +++ b/src/display/touch_manager.js @@ -15,6 +15,10 @@ import { OutputScale, stopEvent } from "./display_utils.js"; +function preventDefault(evt) { + evt.preventDefault(); +} + class TouchManager { #container; @@ -135,8 +139,13 @@ class TouchManager { opt.capture = true; container.addEventListener("pointerdown", stopEvent, opt); container.addEventListener("pointermove", stopEvent, opt); - container.addEventListener("pointercancel", stopEvent, opt); - container.addEventListener("pointerup", stopEvent, opt); + // `pointerup` and `pointercancel` are only default-prevented: a + // `stopPropagation` in the capture phase also skips the bubble-phase + // listeners of the very node it's called on, hence swallowing them here + // would prevent any session in flight, e.g. an editor being resized, from + // ever being ended. + container.addEventListener("pointercancel", preventDefault, opt); + container.addEventListener("pointerup", preventDefault, opt); this.#onPinchStart?.(); } @@ -189,7 +198,7 @@ class TouchManager { const pDistance = Math.hypot(prevGapX, prevGapY) || 1; if ( !this.#isPinching && - Math.abs(pDistance - distance) <= TouchManager.MIN_TOUCH_DISTANCE_TO_PINCH + Math.abs(pDistance - distance) <= this.MIN_TOUCH_DISTANCE_TO_PINCH ) { return; } @@ -207,7 +216,12 @@ class TouchManager { return; } - const origin = [(screen0X + screen1X) / 2, (screen0Y + screen1Y) / 2]; + // The distances are in screen CSS pixels, but the origin must be in client + // coordinates, like the one coming from a wheel event. + const origin = [ + (touch0.clientX + touch1.clientX) / 2, + (touch0.clientY + touch1.clientY) / 2, + ]; this.#onPinching?.(origin, pDistance, distance); } diff --git a/src/pdf.sandbox.external.js b/src/pdf.sandbox.external.js index 1be8ea60d4be8..1aa82fe184232 100644 --- a/src/pdf.sandbox.external.js +++ b/src/pdf.sandbox.external.js @@ -130,18 +130,12 @@ export class SandboxSupportBase { } this.win.alert(cMsg); }, - confirm: cMsg => { - if (typeof cMsg !== "string") { - return false; - } - return this.win.confirm(cMsg); - }, - prompt: (cQuestion, cDefault) => { - if (typeof cQuestion !== "string" || typeof cDefault !== "string") { - return null; - } - return this.win.prompt(cQuestion, cDefault); - }, + confirm: cMsg => + typeof cMsg !== "string" ? false : this.win.confirm(cMsg), + prompt: (cQuestion, cDefault) => + typeof cQuestion !== "string" || typeof cDefault !== "string" + ? null + : this.win.prompt(cQuestion, cDefault), parseURL: cUrl => { const url = new this.win.URL(cUrl); const props = [ diff --git a/src/scripting_api/aform.js b/src/scripting_api/aform.js index 26969d8de8edd..6b0173e6609c6 100644 --- a/src/scripting_api/aform.js +++ b/src/scripting_api/aform.js @@ -52,11 +52,9 @@ class AForm { } AFMergeChange(event = globalThis.event) { - if (event.willCommit) { - return event.value.toString(); - } - - return this._app._eventDispatcher.mergeChange(event); + return event.willCommit + ? event.value.toString() + : this._app._eventDispatcher.mergeChange(event); } AFParseDateEx(cString, cOrder) { @@ -102,10 +100,7 @@ class AForm { } AFMakeArrayFromList(string) { - if (typeof string === "string") { - return string.split(/, ?/g); - } - return string; + return typeof string === "string" ? string.split(/, ?/g) : string; } AFNumber_Format( @@ -616,11 +611,9 @@ class AForm { } AFExactMatch(rePatterns, str) { - if (rePatterns instanceof RegExp) { - return str.match(rePatterns)?.[0] === str || 0; - } - - return rePatterns.findIndex(re => str.match(re)?.[0] === str) + 1; + return rePatterns instanceof RegExp + ? str.match(rePatterns)?.[0] === str || 0 + : rePatterns.findIndex(re => str.match(re)?.[0] === str) + 1; } } diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index 4f0994fd23b23..fc7971c0104aa 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -98,10 +98,7 @@ class Field extends PDFObject { } get currentValueIndices() { - if (!this._isChoice) { - return 0; - } - return this._currentValueIndices; + return !this._isChoice ? 0 : this._currentValueIndices; } set currentValueIndices(indices) { @@ -683,17 +680,13 @@ class CheckboxField extends RadioButtonField { } isBoxChecked(nWidget) { - if (this._value === "Off") { - return false; - } - return super.isBoxChecked(nWidget); + return this._value === "Off" ? false : super.isBoxChecked(nWidget); } isDefaultChecked(nWidget) { - if (this.defaultValue === "Off") { - return this._value === "Off"; - } - return super.isDefaultChecked(nWidget); + return this.defaultValue === "Off" + ? this._value === "Off" + : super.isDefaultChecked(nWidget); } checkThisBox(nWidget, bCheckIt = true) { diff --git a/src/scripting_api/util.js b/src/scripting_api/util.js index 56957656c9a40..9077dbc338cc7 100644 --- a/src/scripting_api/util.js +++ b/src/scripting_api/util.js @@ -252,10 +252,9 @@ class Util extends PDFObject { const patterns = /(mmmm|mmm|mm|m|dddd|ddd|dd|d|yyyy|yy|HH|H|hh|h|MM|M|ss|s|tt|t|\\.)/g; return cFormat.replaceAll(patterns, function (match, pattern) { - if (pattern in handlers) { - return handlers[pattern](data); - } - return pattern.charCodeAt(1); + return pattern in handlers + ? handlers[pattern](data) + : pattern.charCodeAt(1); }); } diff --git a/test/driver.js b/test/driver.js index 7ba6d6a8c21fa..86374280c70b9 100644 --- a/test/driver.js +++ b/test/driver.js @@ -1007,10 +1007,9 @@ class Driver { } _getLastPageNumber(task) { - if (!task.pdfDoc) { - return task.firstPage || 1; - } - return task.lastPage || task.pdfDoc.numPages; + return !task.pdfDoc + ? task.firstPage || 1 + : task.lastPage || task.pdfDoc.numPages; } _nextPage(task, loadError) { diff --git a/test/integration/ink_editor_spec.mjs b/test/integration/ink_editor_spec.mjs index b9d2eb9e89a59..870bfed9cda2d 100644 --- a/test/integration/ink_editor_spec.mjs +++ b/test/integration/ink_editor_spec.mjs @@ -31,10 +31,12 @@ import { kbUndo, loadAndWait, moveEditor, + pinch, scrollIntoView, selectEditor, selectEditors, switchToEditor, + unselectEditor, waitForAnnotationModeChanged, waitForNoElement, waitForPointerUp, @@ -65,6 +67,26 @@ const drawLine = async (page, x0, y0, x1, y1) => { await awaitPromise(clickHandle); }; +// Draw an editor large enough to have room for two fingers on it, and leave it +// selected since that's what makes it resizable with a touchscreen. +const drawAndSelectEditor = async page => { + await switchToInk(page); + const { x, y, width, height } = await getRect(page, ".annotationEditorLayer"); + await drawLine( + page, + x + 0.15 * width, + y + 0.1 * height, + x + 0.75 * width, + y + 0.35 * height + ); + await commit(page); + + return { + layer: { x, y, width, height }, + editor: await getRect(page, getEditorSelector(0)), + }; +}; + describe("Ink Editor", () => { describe("Basic operations", () => { let pages; @@ -1419,30 +1441,9 @@ describe("Pinch to resize a drawing", () => { await closePages(pages); }); - // Spread two fingers apart, centered on the editor. - async function pinchOut(page, selector) { - const { x, y, width, height } = await getRect(page, selector); - const centerX = x + width / 2; - const centerY = y + height / 2; - const finger0 = await page.touchscreen.touchStart(centerX - 25, centerY); - const finger1 = await page.touchscreen.touchStart(centerX + 25, centerY); - for (let i = 1; i <= 12; i++) { - const gap = 25 + i * 12; - await finger0.move(centerX - gap, centerY); - await finger1.move(centerX + gap, centerY); - } - await finger0.end(); - await finger1.end(); - } - it("must keep resizing a drawing which came back with an undo", async () => { await Promise.all( pages.map(async ([browserName, page]) => { - if (browserName === "firefox") { - pending( - "Touch events are not supported on devices without touch screen in Firefox." - ); - } await switchToInk(page); const rect = await getRect(page, ".annotationEditorLayer"); @@ -1462,11 +1463,113 @@ describe("Pinch to resize a drawing", () => { await page.waitForSelector(editorSelector); await selectEditor(page, editorSelector); - const { width: before } = await getRect(page, editorSelector); - await pinchOut(page, editorSelector); + const before = await getRect(page, editorSelector); + const startGap = Math.min(before.width, before.height) * 0.2; + const endGap = Math.max(before.width, before.height) * 1.8; + await pinch(page, { + centerX: before.x + before.width / 2, + centerY: before.y + before.height / 2, + startGap, + endGap, + }); const { width: after } = await getRect(page, editorSelector); - expect(after).withContext(`In ${browserName}`).toBeGreaterThan(before); + expect(after) + .withContext(`In ${browserName}`) + .toBeGreaterThan(before.width); + }) + ); + }); +}); + +describe("Resize with a touchscreen", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must check that the resize session is ended when the finger is lifted while another one is down", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const { layer } = await drawAndSelectEditor(page); + + // Grabbing a resizer starts a resize session, which disables the + // pointer events of the editor layer until the finger is lifted. + const resizer = await getRect( + page, + `${getEditorSelector(0)} .resizer.bottomRight` + ); + await pinch(page, { + steps: 0, + startPoints: [ + { + x: resizer.x + resizer.width / 2, + y: resizer.y + resizer.height / 2, + }, + { + x: layer.x + 0.3 * layer.width, + y: layer.y + 0.9 * layer.height, + }, + ], + afterFirstStart: () => + page.waitForSelector(".annotationEditorLayer.disabled"), + afterFirstEnd: () => + // The `pointerup` of the resizing finger must still be dispatched, + // else the resize session would never end and the editor would keep + // being resized by the plain mouse moves coming afterwards. + page.waitForSelector(".annotationEditorLayer:not(.disabled)"), + }); + }) + ); + }); +}); + +describe("Tap after a two-finger gesture", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must check that the tap following a two-finger gesture selects the editor", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const { layer, editor } = await drawAndSelectEditor(page); + const editorSelector = getEditorSelector(0); + + // One finger on the editor, which starts a drag session, and a second + // one elsewhere on the page, which turns it into a two-finger gesture. + await pinch(page, { + steps: 0, + startPoints: [ + { + x: editor.x + 0.5 * editor.width, + y: editor.y + 0.8 * editor.height, + }, + { + x: layer.x + 0.3 * layer.width, + y: layer.y + 0.9 * layer.height, + }, + ], + }); + + // Once every finger is up, no listener may be left behind to swallow + // the `pointerdown` of the next tap on the editor. + await unselectEditor(page, editorSelector); + await page.touchscreen.tap( + editor.x + 0.5 * editor.width, + editor.y + 0.8 * editor.height + ); + await waitForSelectedEditor(page, editorSelector); }) ); }); diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index 8aab655d6bc63..2ba8a7e5a62b6 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -600,6 +600,77 @@ async function dragAndDrop(page, selector, translations, steps = 1) { await page.waitForSelector("#viewer:not(.noUserSelect)"); } +// Move two fingers, horizontally centered on (centerX, centerY), from startGap +// to endGap: it's a pinch out when endGap is larger than startGap. +// Keep in mind that `TouchManager` starts to pinch only once the distance +// between the two fingers changed by more than `MIN_TOUCH_DISTANCE_TO_PINCH` +// (35 CSS pixels), hence the first moves are swallowed and the resulting zoom +// factor is smaller than endGap / startGap. +// Explicit start/end points can be used for tests which need an asymmetric +// gesture, or hooks around each touch lifetime. +async function pinch( + page, + { + afterEnd = null, + afterFirstEnd = null, + afterFirstStart = null, + afterStart = null, + beforeEnd = null, + centerX = 0, + centerY = 0, + centerDeltaX = 0, + centerDeltaY = 0, + startGap = 0, + endGap = startGap, + endPoints = null, + startPoints = null, + steps = 12, + } +) { + const normalizePoint = point => + Array.isArray(point) ? { x: point[0], y: point[1] } : point; + const start = ( + startPoints || [ + { x: centerX - startGap, y: centerY }, + { x: centerX + startGap, y: centerY }, + ] + ).map(normalizePoint); + let end; + if (endPoints) { + end = endPoints.map(normalizePoint); + } else if (startPoints) { + end = start; + } else { + end = [ + { x: centerX + centerDeltaX - endGap, y: centerY + centerDeltaY }, + { x: centerX + centerDeltaX + endGap, y: centerY + centerDeltaY }, + ]; + } + + const finger0 = await page.touchscreen.touchStart(start[0].x, start[0].y); + await afterFirstStart?.(finger0); + const finger1 = await page.touchscreen.touchStart(start[1].x, start[1].y); + await afterStart?.([finger0, finger1]); + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + await finger0.move( + start[0].x + (end[0].x - start[0].x) * t, + start[0].y + (end[0].y - start[0].y) * t + ); + await finger1.move( + start[1].x + (end[1].x - start[1].x) * t, + start[1].y + (end[1].y - start[1].y) * t + ); + } + + await beforeEnd?.([finger0, finger1]); + await finger0.end(); + await afterFirstEnd?.([finger0, finger1]); + await finger1.end(); + await afterEnd?.([finger0, finger1]); +} + function waitForPageChanging(page) { return createPromise(page, resolve => { window.PDFViewerApplication.eventBus.on("pagechanging", resolve, { @@ -1163,6 +1234,7 @@ export { paste, pasteFromClipboard, PDI, + pinch, scrollIntoView, selectEditor, selectEditors, diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index 4ef44035a1ea3..b3b299d741278 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -20,6 +20,7 @@ import { getRect, getSpanRectFromText, loadAndWait, + pinch, scrollIntoView, showViewsManager, waitAndClick, @@ -1527,7 +1528,11 @@ describe("PDF viewer", () => { beforeEach(async () => { pages = await loadAndWait( "tracemonkey.pdf", - `.page[data-page-number = "1"] .endOfContent` + `.page[data-page-number = "1"] .endOfContent`, + // Pin the zoom: the drift checked below is proportional to the zoom + // level reached at the end of the pinch, and the default `page-fit` + // depends on the size of the window. + 50 ); }); @@ -1535,20 +1540,9 @@ describe("PDF viewer", () => { await closePages(pages); }); - it("keeps the content under the pinch centre fixed on the screen", async () => { + it("keeps the content under the pinch center fixed on the screen", async () => { await Promise.all( pages.map(async ([browserName, page]) => { - if (browserName === "firefox") { - pending( - "Touch events are not supported on devices without touch screen in Firefox." - ); - } - if (browserName === "chrome") { - pending( - "Pinch zoom emulation is not supported for WebDriver BiDi in Chrome." - ); - } - const rect = await getSpanRectFromText(page, 1, "type-stable"); const originX = rect.x + rect.width / 2; const originY = rect.y + rect.height / 2; @@ -1564,14 +1558,17 @@ describe("PDF viewer", () => { }; window.PDFViewerApplication.eventBus.on("textlayerrendered", cb); }); - const client = await page.target().createCDPSession(); - await client.send("Input.synthesizePinchGesture", { - x: originX, - y: originY, - scaleFactor: 3, - gestureSourceType: "touch", + // Spread the two fingers from 50 to 200 pixels apart: the first + // moves are swallowed until the distance between them changed by + // more than 35 pixels, hence a zoom factor of about 200/85 = 2.4. + await pinch(page, { + centerX: originX, + centerY: originY, + startGap: 25, + endGap: 100, }); await awaitPromise(rendered); + const spanHandle = await page.evaluateHandle(() => Array.from( document.querySelectorAll( @@ -1579,7 +1576,21 @@ describe("PDF viewer", () => { ) ).find(span => span.textContent.includes("type-stable")) ); - expect(await spanHandle.isIntersectingViewport()).toBeTrue(); + expect(await spanHandle.isIntersectingViewport()) + .withContext(`In ${browserName}`) + .toBeTrue(); + + // The text which was under the fingers must still be at the same + // height: only vertically because a page which is larger than its + // container isn't centered in it anymore. + // A few pixels are tolerated because the origin is preserved by + // scrolling: Chrome snaps the scroll offsets to the device pixels and + // the discarded fractions show up as a small drift. It's exact in + // Firefox, which keeps them. + const newRect = await getSpanRectFromText(page, 1, "type-stable"); + expect(Math.abs(newRect.y + newRect.height / 2 - originY)) + .withContext(`In ${browserName}`) + .toBeLessThan(5); }) ); }); diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js index 619a9c2126f2a..cd1cbea0aa054 100644 --- a/test/unit/test_utils.js +++ b/test/unit/test_utils.js @@ -38,10 +38,9 @@ const WASM_URL = isNodeJS class DefaultFileReaderFactory { static async fetch(params) { - if (isNodeJS) { - return fetchDataNode(params.path); - } - return fetchDataDOM(params.path, /* type = */ "bytes"); + return isNodeJS + ? fetchDataNode(params.path) + : fetchDataDOM(params.path, /* type = */ "bytes"); } } diff --git a/web/event_utils.js b/web/event_utils.js index 3c4f501c5cce8..694c8fd00cdfc 100644 --- a/web/event_utils.js +++ b/web/event_utils.js @@ -14,6 +14,7 @@ */ import { INTERNAL_EVT, internalOpt } from "./internal_evt.js"; +import { makeSet } from "pdfjs-lib"; const WaitOnType = { EVENT: "event", @@ -72,7 +73,7 @@ async function waitOnEventOrTimeout({ target, name, delay = 0 }) { * and `off` methods. To raise an event, the `dispatch` method shall be used. */ class EventBus { - #listeners = Object.create(null); + #listeners = new Map(); constructor() { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { @@ -101,8 +102,7 @@ class EventBus { signal.addEventListener("abort", onAbort); } - const eventListeners = (this.#listeners[eventName] ??= []); - eventListeners.push({ + this.#listeners.getOrInsertComputed(eventName, makeSet).add({ listener, internal: options?.internal === INTERNAL_EVT, once: options?.once === true, @@ -116,17 +116,11 @@ class EventBus { * @param {Object} [options] */ off(eventName, listener, options = null) { - const eventListeners = this.#listeners[eventName]; - if (!eventListeners) { - return; - } - for (let i = 0, ii = eventListeners.length; i < ii; i++) { - const evt = eventListeners[i]; - if (evt.listener === listener) { - evt.rmAbort?.(); // Ensure that the `AbortSignal` listener is removed. - eventListeners.splice(i, 1); - return; - } + const eventListeners = this.#listeners.get(eventName); + const evt = eventListeners?.keys().find(e => e.listener === listener); + if (evt) { + evt.rmAbort?.(); // Ensure that the `AbortSignal` listener is removed. + eventListeners.delete(evt); } } @@ -135,14 +129,14 @@ class EventBus { * @param {Object} data */ dispatch(eventName, data) { - const eventListeners = this.#listeners[eventName]; - if (!eventListeners?.length) { + const eventListeners = this.#listeners.get(eventName); + if (!eventListeners?.size) { return; } let extListeners; - // Making copy of the listeners array in case if it will be modified + // Always create a copy of the listeners in case they are modified // during dispatch. - for (const { listener, internal, once } of eventListeners.slice(0)) { + for (const { listener, internal, once } of new Set(eventListeners)) { if (once) { this.off(eventName, listener); } diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 268888c61f6b3..4ff43ed32d476 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1600,13 +1600,10 @@ class PDFViewer { } get #pageWidthScaleFactor() { - if ( - this._spreadMode !== SpreadMode.NONE && + return this._spreadMode !== SpreadMode.NONE && this._scrollMode !== ScrollMode.HORIZONTAL - ) { - return 2; - } - return 1; + ? 2 + : 1; } #setScale(value, options) { @@ -1890,19 +1887,20 @@ class PDFViewer { container.scrollLeft - firstPage.x, container.scrollTop - firstPage.y ); - const intLeft = Math.round(topLeft[0]); - const intTop = Math.round(topLeft[1]); + const [left, top] = topLeft; let pdfOpenParams = `#page=${pageNumber}`; if (!this.isInPresentationMode) { - pdfOpenParams += `&zoom=${normalizedScaleValue},${intLeft},${intTop}`; + pdfOpenParams += + `&zoom=${normalizedScaleValue},` + + `${Math.round(left)},${Math.round(top)}`; } this._location = { pageNumber, scale: normalizedScaleValue, - top: intTop, - left: intLeft, + top, + left, rotation: this._pagesRotation, pdfOpenParams, };