From 08f67693901be37e3de7faf900e707b230a2b1f8 Mon Sep 17 00:00:00 2001 From: calixteman Date: Sat, 1 Aug 2026 15:58:39 +0200 Subject: [PATCH] Scan backwards to delete a word in a text field Finding the word to delete with a regex is quadratic in the value length, so each "delete word backward" keystroke could take a long time in a large field. --- src/display/annotation_layer.js | 20 ++++++--- test/integration/scripting_spec.mjs | 63 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 2c1303a6d..6a1661974 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -1811,11 +1811,21 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { switch (event.inputType) { // https://rawgit.com/w3c/input-events/v1/index.html#interface-InputEvent-Attributes case "deleteWordBackward": { - const match = value - .substring(0, selectionStart) - .match(/\w*\W*$/); - if (match) { - selStart -= match[0].length; + // The previous unanchored regex could take quadratic time, so + // scan backwards over the trailing non-word characters and + // then the word. + const wordCharPattern = /\w/; + while ( + selStart > 0 && + !wordCharPattern.test(value[selStart - 1]) + ) { + selStart--; + } + while ( + selStart > 0 && + wordCharPattern.test(value[selStart - 1]) + ) { + selStart--; } break; } diff --git a/test/integration/scripting_spec.mjs b/test/integration/scripting_spec.mjs index 88a5886d6..0f3141119 100644 --- a/test/integration/scripting_spec.mjs +++ b/test/integration/scripting_spec.mjs @@ -1204,6 +1204,69 @@ describe("Interaction", () => { ); }); + it("must efficiently delete a word from a large field", async () => { + const nonWordLength = 200000; + + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + + const result = await page.$eval( + getSelector("27R"), + (element, length) => { + element.value = `${"!".repeat(length)}a`; + element.setSelectionRange( + element.value.length, + element.value.length + ); + + const eventBus = window.PDFViewerApplication.eventBus; + const eventBusPrototype = Object.getPrototypeOf(eventBus); + const originalDispatch = eventBusPrototype.dispatch; + let selection; + eventBusPrototype.dispatch = function (eventName, data) { + if ( + this === eventBus && + eventName === "dispatcheventinsandbox" + ) { + const { selEnd, selStart } = data.detail; + selection = { selEnd, selStart }; + return; + } + originalDispatch.call(this, eventName, data); + }; + + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "deleteWordBackward", + }); + try { + const startTime = performance.now(); + element.dispatchEvent(event); + return { + duration: performance.now() - startTime, + selection, + }; + } finally { + eventBusPrototype.dispatch = originalDispatch; + } + }, + nonWordLength + ); + expect(result.selection) + .withContext(`In ${browserName}`) + .toEqual({ + selEnd: nonWordLength + 1, + selStart: nonWordLength, + }); + expect(result.duration) + .withContext(`In ${browserName}`) + .toBeLessThan(1000); + }) + ); + }); + it("must check that an infinite loop is not triggered", async () => { await Promise.all( pages.map(async ([browserName, page]) => {