From 3f99a4dbbd25032ceb2fa9bb6a701ce4d5b96d9e Mon Sep 17 00:00:00 2001 From: Peter Knego Date: Fri, 21 Aug 2026 16:27:51 +0000 Subject: [PATCH] Make find-and-replace search from the caret's position when opened (Fixes #156) The findMatchState of a code-input element is only constructed when its find-and-replace dialog is first created, and it is what stores the position matches are searched from. Reopening the dialog reuses the cached one, so every search after the first carried on from wherever the previous search left off rather than from the caret, unlike the IDE behaviour users expect. Set that position from the caret each time the dialog is opened, next to where the selection is already saved so it applies whether the dialog is being created or reopened. Two of the added tests are ordered so the match the previous one left focused differs from the correct answer, so they fail without this change; the other two lock in the surrounding wrapping and reopening behaviour. --- plugins/find-and-replace.js | 10 +++++++++- tests/tester.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/plugins/find-and-replace.js b/plugins/find-and-replace.js index e312ed9..564ac90 100644 --- a/plugins/find-and-replace.js +++ b/plugins/find-and-replace.js @@ -186,7 +186,9 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin { } /** - * Show a find-and-replace dialog. + * Show a find-and-replace dialog. The first match focused is the first one at or + * after the caret's position when this is called, wrapping back round to the start + * of the code if there is no such match. * @param {codeInput.CodeInput} codeInputElement the `` element. * @param {boolean} replacePartExpanded whether the replace part of the find-and-replace dialog should be expanded */ @@ -412,6 +414,12 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin { dialog.selectionStart = codeInputElement.textareaElement.selectionStart; dialog.selectionEnd = codeInputElement.textareaElement.selectionEnd; + // Start searching from the caret's position at the moment the dialog was + // opened, like most IDEs do. The findMatchState is only constructed when the + // dialog is first created, so without this every reopening of the dialog would + // carry on from wherever the previous search left off instead. + dialog.findMatchState.focusedMatchStartIndex = dialog.selectionStart; + if(dialog.selectionStart < dialog.selectionEnd) { // Copy selected text to Find input let textToFind = codeInputElement.textareaElement.value.substring(dialog.selectionStart, dialog.selectionEnd); diff --git a/tests/tester.js b/tests/tester.js index f619b12..a1cc92b 100644 --- a/tests/tester.js +++ b/tests/tester.js @@ -761,6 +761,38 @@ console.log("I've got another line!", 2 < 3, "should be true."); codeInputElement.querySelector(".code-input_find-and-replace_dialog").dispatchEvent(new KeyboardEvent("keydown", { "key": "Escape" })); codeInputElement.querySelector(".code-input_find-and-replace_dialog").dispatchEvent(new KeyboardEvent("keyup", { "key": "Escape" })); + // Opening the dialog searches from the caret's position at that moment, rather than + // carrying on from wherever the previous search left off. In the code above, "hi" is + // at indexes 3, 16 and 29. + // Open the dialog with the caret at the given index, then close it, which selects the + // focused match; returns the index that match starts at. + async function findFromCaret(caret) { + findInput.value = "hi"; + textarea.focus(); + textarea.selectionStart = textarea.selectionEnd = caret; + if(navigator.platform.startsWith("Mac") || navigator.platform === "iPhone") { + textarea.dispatchEvent(new KeyboardEvent("keydown", { "cancelable": true, "key": "f", "metaKey": true })); + } else { + textarea.dispatchEvent(new KeyboardEvent("keydown", { "cancelable": true, "key": "f", "ctrlKey": true })); + } + await waitAsync(250); // Wait for highlighting so matches update + + const dialog = codeInputElement.querySelector(".code-input_find-and-replace_dialog"); + dialog.dispatchEvent(new KeyboardEvent("keydown", { "key": "Escape" })); + dialog.dispatchEvent(new KeyboardEvent("keyup", { "key": "Escape" })); + await waitAsync(150); // Wait for the dialog to close and select the focused match + + return textarea.selectionStart; + } + + // These two are ordered so the match the previous one left focused differs from the + // correct answer, so they fail if the caret's position is ignored. + assertEqual("FindAndReplace", "Finds First Match After Caret when Reopened", await findFromCaret(17), 29); + assertEqual("FindAndReplace", "Finds Match Starting Exactly at Caret", await findFromCaret(16), 16); + // These two lock in the surrounding behaviour, and pass either way. + assertEqual("FindAndReplace", "Wraps to First Match when Caret is After Last Match", await findFromCaret(30), 3); + assertEqual("FindAndReplace", "Keeps Focused Match when Reopened without Moving Caret", await findFromCaret(3), 3); + // GoToLine // Replace all code textarea.selectionStart = 0;