admin管理员组

文章数量:1134557

I have a basic text editor where the user can search for a term in the text and navigate to the next or previous occurrence. The issue I’m facing is that when the search term is at the very beginning of the textarea (e.g., the first word "Love"), pressing the "Previous" button doesn’t loop back to the last occurrence. However, this works fine if the search term is not at the start of the text (e.g., searching for the term "never").

Here is the relevant code:

let currentMatchIndex = -1; // Tracks the current match index

function resetIndex() {
  currentMatchIndex = -1;
}

function highlightMatch(startIndex, endIndex) {
  const textarea = document.getElementById('myTextarea');
  textarea.setSelectionRange(startIndex, endIndex);
  textarea.focus();
}

function next() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    const startIndex = text.indexOf(findTerm, currentMatchIndex + 1);
    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.indexOf(findTerm); // Loop back to first occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}

function previous() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    const startIndex = text.lastIndexOf(findTerm, currentMatchIndex - 1);
    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.lastIndexOf(findTerm); // Loop to last occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}
<input type="text" id="findInput" placeholder="Find" onchange="resetIndex()">
<button onclick="next()">Next</button>
<button onclick="previous()">Previous</button>
<textarea id="myTextarea" rows="3" cols="16">Love never dies. Love never dies. Love never dies.</textarea>

I have a basic text editor where the user can search for a term in the text and navigate to the next or previous occurrence. The issue I’m facing is that when the search term is at the very beginning of the textarea (e.g., the first word "Love"), pressing the "Previous" button doesn’t loop back to the last occurrence. However, this works fine if the search term is not at the start of the text (e.g., searching for the term "never").

Here is the relevant code:

let currentMatchIndex = -1; // Tracks the current match index

function resetIndex() {
  currentMatchIndex = -1;
}

function highlightMatch(startIndex, endIndex) {
  const textarea = document.getElementById('myTextarea');
  textarea.setSelectionRange(startIndex, endIndex);
  textarea.focus();
}

function next() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    const startIndex = text.indexOf(findTerm, currentMatchIndex + 1);
    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.indexOf(findTerm); // Loop back to first occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}

function previous() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    const startIndex = text.lastIndexOf(findTerm, currentMatchIndex - 1);
    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.lastIndexOf(findTerm); // Loop to last occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}
<input type="text" id="findInput" placeholder="Find" onchange="resetIndex()">
<button onclick="next()">Next</button>
<button onclick="previous()">Previous</button>
<textarea id="myTextarea" rows="3" cols="16">Love never dies. Love never dies. Love never dies.</textarea>

Issue

When the search term is the first word in the textarea (e.g., "Love"), and I press the "Previous" button, it does not loop back to the last occurrence. However, if I search for a term like "never," it works fine and loops as expected.

Expected behavior

  • When the "Next" button is pressed, it finds the next occurrence.
  • When the "Previous" button is pressed, it loops to the last occurrence when it reaches the first match.

Has anyone encountered this issue before or knows how to fix it? I'm looking for a robust solution that avoids handling edge cases and exceptions with multiple conditional statements.

Share Improve this question edited Jan 3 at 6:36 Mori asked Dec 31, 2024 at 20:04 MoriMori 6,74219 gold badges68 silver badges102 bronze badges 12
  • 'hello world hello'.lastIndexOf('hello', 0) and 'hello world hello'.lastIndexOf('hello', -5) both return 0 — because both cause the method to only look for hello at index 0. from MDN – cmgchess Commented Dec 31, 2024 at 20:29
  • Citing form the docs "If position is less than 0, the behavior is the same as for 0 — that is, the method looks for the specified substring only at index 0." – derpirscher Commented Dec 31, 2024 at 21:22
  • So the problem is that when going backwards using "prev" button it doesn't loop in that direction it loops only when going forward using "next" button. It hits a dead end since it never gets an index lower than 0. – zer00ne Commented Dec 31, 2024 at 21:29
  • @derpirscher: Any solid approach that doesn't require lastIndexOf? – Mori Commented Jan 2 at 21:28
  • 1 I would also try to see how some open source text editors have implemented this if the code is on GitHub – cmgchess Commented Jan 5 at 14:59
 |  Show 7 more comments

8 Answers 8

Reset to default 8 +500

The issue is that according to mdn

'hello world hello'.lastIndexOf('hello', 0) and 'hello world hello'.lastIndexOf('hello', -5) both return 0 — because both cause the method to only look for hello at index 0.

so const startIndex = text.lastIndexOf(findTerm, -2); gives 0 for 'Love' and goes inside the if

you could handle with something like

const startIndex = (currentMatchIndex - 1 < 0) ? -1 : text.lastIndexOf(findTerm, currentMatchIndex - 1);

let currentMatchIndex = -1; // Tracks the current match index

function resetIndex() {
  currentMatchIndex = -1;
}

function highlightMatch(startIndex, endIndex) {
  const textarea = document.getElementById('myTextarea');
  textarea.setSelectionRange(startIndex, endIndex);
  textarea.focus();
}

function next() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    const startIndex = text.indexOf(findTerm, currentMatchIndex + 1);
    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.indexOf(findTerm); // Loop back to first occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}

function previous() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    //handling
    const startIndex = (currentMatchIndex - 1 < 0) ? -1 : text.lastIndexOf(findTerm, currentMatchIndex - 1);

    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.lastIndexOf(findTerm); // Loop to last occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}
<input type="text" id="findInput" placeholder="Find" onchange="resetIndex()">
<button onclick="next()">Next</button>
<button onclick="previous()">Previous</button>
<textarea id="myTextarea" rows="1" cols="50">Love never dies. Love never dies. Love never dies.</textarea>

The code can be shortened and made re-usable by applying the DRY principle. The following snippet shows it being applied on two <div>s with the same pattern search functionality:

function findpatterns(cont){
 const [[srch],[txt],btns]=["input[type=text]","textarea","button"].map(s=>cont.querySelectorAll(s));
 srch.addEventListener("input",_=>{
  cont.res=[...txt.value.matchAll(srch.value)].map(a=>a.index);
  cont.patlen=srch.value.length;
  cont.idx=undefined;
 });
 btns.forEach((b,i)=>b.addEventListener("click",_=>{
  // do nothing if there is no match
  if(!cont.res?.length) return;
  // increment or decrement idx within the available indices of cont.res:
  const rev=["Previous","<"].includes(b.textContent);
  cont.idx=(cont.idx==undefined
   ?rev?cont.res.length-1:0
   :cont.idx+(rev?cont.res.length-1:1)
   )%cont.res.length;
  hilite();
 }));
 function hilite(){
  let pos=cont.res[cont.idx];
  txt.setSelectionRange(pos,pos+cont.patlen);
  txt.focus();
 }
}

// Now, apply the function on all .findpat-divs:
document.querySelectorAll(".findpat").forEach(findpatterns);
First example:
<div class="findpat">
 <input type="text" placeholder="Find"><br>
 <button>Next</button>
 <button>Previous</button><br>
 <textarea rows="3" cols="20">Love never dies. Love never dies. Love never dies.</textarea>
</div>
<br>
And here is another example of the same thing:
<div class="findpat">
 <input type="text" placeholder="Find"><br>
 <button>&lt;</button>
 <button>&gt;</button><br>
 <textarea rows="3" cols="35">And here is something completely different. There are some repeated character groups, but not as obvious as in the first example</textarea>
</div>

The lines

const rev=["Previous","<"].includes(b.textContent);
cont.idx=(cont.idx==undefined
   ?rev?cont.res.length-1:0
   :cont.idx+(rev?cont.res.length-1:1)
   )%cont.res.length

deserve some.further explanation:

The constant rev is set to true if one of the reverse buttons ("Previous" or "≪") was clicked.

The index in cont.idx is ...

  • initialised (if cont.idx==undefined) to
    • cont.res.length-1 in case of res being true
    • or to 0 otherwise.

In case cont.idx already has a value then it is

  • decremented by -1 in case of rev being true or
  • incremented by 1 for all other clicked buttons within a .findpat div.

Actually, instead of decrementing cont.idx simply by -1 I add cont.res.length-1 to it and then calculate the modulus with respect to cont.res.length. This way I will always get a number pointing to a valid array index of cont.res.

First the solution, then the explanation.

Solution

function getIndicesOf(searchStr, str, caseSensitive) {
    var searchStrLen = searchStr.length;
    if (searchStrLen == 0) {
        return [];
    }
    var startIndex = 0, index, indices = [];
    if (!caseSensitive) {
        str = str.toLowerCase();
        searchStr = searchStr.toLowerCase();
    }
    while ((index = str.indexOf(searchStr, startIndex)) > -1) {
        indices.push(index);
        startIndex = index + searchStrLen;
    }
    return indices;
}

var myTextFinder = function(searchContext, sourceContext, nextButton, previousButton) {
    var currentIndex = undefined;
    var matchIndices = [];
    var search = searchContext.value;
    this.initialize = function(s) {
        search = s;
        currentIndex = undefined;
        matchIndices = getIndicesOf(search, sourceContext.value, true);
    };
    this.highlight = function() {
        if (matchIndices.length) {
            sourceContext.setSelectionRange(matchIndices[currentIndex], matchIndices[currentIndex] + search.length);
            sourceContext.focus();
        }
    };
    this.go = (direction) => {
        if (currentIndex === undefined) {
            currentIndex = ((direction > 0) ? 0 : (matchIndices.length - 1))
        } else {
            currentIndex = (currentIndex + matchIndices.length + direction) % matchIndices.length;
        }
        this.highlight();
    };
    this.next = () => {
        this.go(1);
    };
    this.previous = () => {
        this.go(-1);
    };
    nextButton.addEventListener("click", () => {this.next()});
    previousButton.addEventListener("click", () => {this.previous()});
    var init = () => {
        this.initialize(searchContext.value);
    };
    searchContext.addEventListener("input", init);
    sourceContext.addEventListener("input", init);
    
};

new myTextFinder(document.getElementById("findInput"), document.getElementById("myTextarea"), document.getElementById("next"), document.getElementById("previous"));
<input type="text" id="findInput" placeholder="Find">
<button id="next">Next</button>
<button id="previous">Previous</button>
<textarea id="myTextarea" rows="3" cols="16">Love never dies. Love never dies. Love never dies.</textarea>

Explanation

  • I have used getIndicesOf as a function that finds all indices from the update of the first answer here
  • it is basically loops str until it no longer finds a match and builds an array of indices
  • myTextFinder is an old school instantiable function where helper methods are being defined into
  • currentIndex is the current place of the index. It's undefined upon any initialization, because we want next to point to the first match and previous to point to the last one upon the first click, but on later clicks to modulo-increment or modulo-decrement, respectively the results
  • initialize resets currentIndex, builds the match array and sets the search text to the current value
  • highlight sets the selection range to the current one, starting from the current index up to the current index + search size
  • go computes the new currentIndex based on the rules I described earlier and calls highlight
  • next and previous are basically calling go with the correct direction
  • we define proper event handlers both for the buttons and for the contexts (search and source) so that upon next or previous click the event triggers as well as on search or context input we reinitialize

The main problem is your useage of lastIndexOf, because if the position parameter gets <=0 it will always find an occurrence at index 0

You can fix this in your code very easily by setting the optional position parameter to Number.MAX_SAFE_INTEGER if currentMatchIndex <= 0 (ie on the very first click on previous, or if the current match is at the start of the text)

const startIndex = text.lastIndexOf(findTerm, currentMatchIndex <= 0 ? Number.MAX_SAFE_INTEGER : currentMatchIndex - 1);

Another simple approach is, creating an index of the words first and then iterating over that index. This will also prevent to search the text over and over again. Of course, the index has to be reset on every change of the search field or the textare. For explanation see comments in the code

let 
  indexArray = [],
  currentMatchIndex = -1,  //current index in the index array
  search = "";

// recreates the indexarray and resets the index on changing
// either the textarea or the searchfield
function resetIndex() {
  let text = document.getElementById("myTextarea").value;
  search = document.getElementById("findInput").value;
  currentMatchIndex = -1;
  indexArray = [];

  // if either text or search is empty, do matches can be found
  if (!text || !search)
    return;
  
  // iterate searching over the text and push the found indexes
  // into the search array
  let i = text.indexOf(search);
  while (i >= 0) {
    indexArray.push(i);
    i = text.indexOf(search, i + 1);
  }
}


function highlightMatch(startIndex, endIndex) {
  const textarea = document.getElementById('myTextarea');
  textarea.setSelectionRange(startIndex, endIndex);
  textarea.focus();
}

// with each click on one of the search buttons iterate over the 
// index array in the given direction (+1 = next, -1 = previous
function find(direction) {
  if (!indexArray.length)
    return alert("input not found");
   
  if (!direction)
    return alert("invalid direction");
    
  direction = Math.sign(direction);  //ensure direction is -1 or +1
  
  // adapt the current match index if it's the very first search after resetIndex
  // and searching previous (so that the last occurence will be selected
  if (currentMatchIndex < 0 && direction < 0) currentMatchIndex = 0;
  
  // calculate the new index in the match array, ie add the direction (+1 or -1)
  // to the current index and loop over if the new index is outside the array
  currentMatchIndex = (currentMatchIndex + direction + indexArray.length) % indexArray.length;
  
  //get start and end position for highlighting
  let 
    start = indexArray[currentMatchIndex], 
    end = start + search.length;
    
  highlightMatch(start, end);
}

resetIndex();
<input type="text" id="findInput" placeholder="Find" onchange="resetIndex()">
<button onclick="find(1)">Next</button>
<button onclick="find(-1)">Previous</button>
<textarea id="myTextarea" rows="3" cols="16" onchange="resetIndex()">Love never dies. Love never dies. Love never dies.</textarea>

When the second argument passed to lastIndexOf is negative, it will act the same as if that argument were 0. So the search will still start at index 0 in that case, and the only possible return value is 0 (when it matches there) or -1 (otherwise).

A way to avoid this undesired behaviour when you would pass -1 as argument, and to support wrap-around is to duplicate the text to search in, and set the start index in the second half when looking for a previous occurrence (and in the first half when looking for a next occurrence). This way you never get into this situation of a negative argument, and you can apply the % operator on the result to get what you expect.

Some other remarks:

  • It is better practice to attach event handlers in code, not via HTML attributes.
  • You can avoid the global variable currentMatchIndex by looking at the current position of the caret. This has the advantage that if the user moves the caret, the next click on a button will search from that updated position.
  • You can avoid code repetition by passing a parameter to next to support both a next and previous search.
  • Instead of displaying an alert, it is more user-friendly to display a non-blocking message on the page itself.

Here is how that could look:

// Search for the elements just once
const btnPrev = document.getElementById("prev");
const btnNext = document.getElementById("next");
const textarea = document.getElementById('myTextarea');
const inpFind = document.getElementById('findInput');
const divMsg = document.getElementById('msg');
// Attach event handlers in code
btnPrev.addEventListener("click", () => next(true));
btnNext.addEventListener("click", () => next(false));
inpFind.addEventListener("change", resetIndex);

function resetIndex() {
    textarea.setSelectionRange(0, 0);
}

function highlightMatch(startIndex, endIndex) {
    textarea.setSelectionRange(startIndex, endIndex);
    textarea.focus();
}

function next(reverse) {
    const findTerm = inpFind.value;
    if (!findTerm) return;
    const text = textarea.value;
    const start = textarea.selectionStart;
    const end = textarea.selectionEnd;
    // Determine whether to call indexOf or lastIndexOf, and from where
    const method = reverse ? "lastIndexOf" : "indexOf";
    const offset = reverse ? text.length - 1 : +(text.slice(start, end) === findTerm);
    // Duplicate the text to deal with wrap-around
    const match = (text + text)[method](findTerm, start + offset) % text.length;
    if (match > -1) highlightMatch(match, match + findTerm.length);
    divMsg.style.display = match > -1 ? "none" : "block";
}
#msg { display: none; color: red }
<input type="text" id="findInput" placeholder="Find">
<button id="next">Next</button>
<button id="prev">Previous</button><br>
<textarea id="myTextarea" rows="3" cols="16">Love never dies. Love never dies. Love never dies.</textarea>
<div id="msg">Find term not found.</div>

Update

This example uses indexOf() by a function generator. The example below can use simple regex as well as strings. I have attempted to make this example more readable and less terse by changing the variables to be more descriptive. It's compact because I used certain properties that are not used very often although they are standard and really old. Located at the end of this answer is a list of links that explain some of the things I've used.

Expected Behavior

  • ✅ When the "Next" button is pressed, it finds the next occurrence.

  • ✅ When the "Previous" button is pressed, it loops to the last occurrence when it reaches the first match.

  • ✅ Has anyone encountered this issue before or knows how to fix it?

  • ✅ I'm looking for a robust solution that avoids handling edge cases and exceptions with multiple conditional statements.

The solution I wrote covers one edge case which is if the user keys Enter/Return while focused in the <input>. There are now only 4 ifs but that's to be expected when you implement event delegation. For the core part dealing with the search pattern there are 2 ifs. Conditions are a fundamental necessity of any programming language and that goes double if user interaction is involved wherein we need to anticipate the users needs and actions. As for being robust, I'm 99% certain it can't be broken by the user. Also, lastIndexOf() was avoided (I don't think I ever used it before).

Details are commented in example.

let index = 0; // Counter
let direction = 0; // Direction of counter
let matchArray; // On every search this is a 2D array.

// Reference <form>
const mainForm = document.forms.mainForm; 
/**
 * Reference all form controls in <form>
 * In this layout it is all:
 *   - <input>
 *   - <fieldset>
 *   - <button>
 *   - <output>
 *   - <textarea>
 */
const io = mainForm.elements;
const findText = io.findText; // Reference <input>
const showInfo = io.showInfo; // Reference <output>
const textArea = io.textArea; // Reference <textarea>
const btnGroup = io.btnGroup; // Reference <fieldset>
// An array of all 4 <button>s
const btnArray = [...btnGroup.children];

/**
 * Hide the 2 <button>s with the given [name] (grpName).
 * @paran {string} grpName - Either "seek" or "step"
 */
const hideBtns = (grpName) => {
  btnArray.forEach((btn) => {
    if (btn.name === grpName) {
      btn.classList.add("hide");
    } else {
      btn.classList.remove("hide");
    }
  });
};

/**
 * Highlight the match from start to end on the text of
 * #textArea indicated by the current index.
 * #showInfo informs user of current count and total of
 * matches.
 */
const markTerm = () => {
  showInfo.value = (index + 1) + " of " + matchArray.length
  " matching " + findText.value + ".";
  const startIdx = matchArray[index][0];
  const endIdx = matchArray[index][1];
  textArea.setSelectionRange(startIdx, endIdx);
  textArea.focus();
};

/**
 * Gets an iterator object from the function generator 
 * listTerm() and converts it into an array (matchArray).
 * If there's no matches then #showInfo informs the user 
 * and then the function terminates. If there are one or 
 * more matches, the highlight will start at the beginning 
 * of the matches if #miniBtn was clicked (direction = +1) 
 * or the highlight will start at the end of matches if 
 * #maxiBtn was clicked (direction = -1).
 * Finally, markTerm() is called and hideBtns("seek").
 */
const findTerm = () => {
  matchArray = [...listTerm(textArea.value, findText.value)];
  if (matchArray.length === 0) {
    showInfo.value = `No match for "${findText.value}".`;
    return;
  }
  index = direction === -1 ? matchArray.length - 1 : 0;
  markTerm();
  hideBtns("seek");
};

/**
 * Delegates the "click" event to the 4 <button>s.
 * direction is assigned either a -1 (#prevBtn, #maxiBtn) 
 * or a +1 (#nextBtn, #miniBtn).
 * If a <button ... name="seek"> was clicked, this function
 * terminates and findTerm() is called.
 * If a <button ... name="step" was clicked, index is 
 * modified by direction...
 * if index is less than 0 then index is at the end of
 * matchArray...
 * if index is equal to or greater than the index at the
 * end of matchArray the index is 0...
 * otherwise index is index.
 * Next, markTerm() is called.
 * @param {object} event - Event object
 */ 
const moveTerm = (event) => {
  const clicked = event.target.id;
  switch (clicked) {
    case "prevBtn": direction = -1; break;
    case "nextBtn": direction = +1; break;
    case "maxiBtn": direction = -1; return findTerm();
    case "miniBtn": direction = +1; return findTerm();
    default: return;
  }
  index = index + direction;
  index = index < 0 ? matchArray.length - 1 : 
  index >= matchArray.length ? 0 : 
  index;
  markTerm();
};

/**
 * Delegate the "input" event on either #findText or 
 * #textArea. If the user enters text in either #findText
 * or #textArea btnGroup will be enabled and the [name="seek"]
 * <button>s will be revealed. 
 * If either #findText or #textArea is empty, #btnGroup 
 * remains [disabled].
 * @param {object} event - Event object
 */
const typeTerm = (event) => {
  showInfo.value = "";
  btnGroup.disabled = !findText.value.trim() || 
  !textArea.value.trim();
  if (!btnGroup.disabled) {
    hideBtns("step");
  }
};

/**
 * Find each occurance of the given substring (term) in the
 * given text (text). On each match, yield a subarray 
 * consisting of it's starting and ending indices. before 
 * return, an iterator object has been yielded which 
 * findTerm() will convert into a 2D array (matchArray).
 * @param {string} text - Text to search through
 * @param {string} term - Search term to find
 * @yield {object} - An iterator
 */
const listTerm = function*(text, term) {
  let i = 0;
  while (true) {
    const startIdx = text.indexOf(term, i);
    if (startIdx !== -1) {
      const endIdx = startIdx + term.length;
      yield [startIdx, endIdx];
      i = startIdx + 1;
    } else return;
  }
};

/**
 * Register <fieldset id="btnGroup"> to listen an delegate
 * the "click" event for the 4 <button>s by calling 
 * moveTerm(event).
 */
btnGroup.addEventListener("click", moveTerm);

/**
 * Register <form id="mainForm"> to listen and delegate the
 * "input" event on either #findText or #textArea by 
 * calling typeTerm(event).
 */
mainForm.addEventListener("input", typeTerm);

/**
 * Register <form id="mainForm"> to listen for the "submit"
 * event and stop the default redirection of the page if the
 * user keys the Return/Enter key while focused in the 
 * <input>.
 */
mainForm.addEventListener("submit", (event) => {
  event.preventDefault();
  return;
});
:root {
  font: 2ch/1.5 "Segoe UI";
}

form {
  max-width: 23rem;
}

input,
button,
textarea {
  display: inline-block;
  font: inherit;
  padding: 5px;
}

button {
  width: 3rem;
  margin-bottom: 8px;
  cursor: pointer;
}

#findText {
  width: 13.25rem;
  margin-bottom: 8px;
}

#btnGroup {
  display: inline-block;
  margin: 0;
  margin-left: 3px;
  padding: 0;
  border: 0;
}

#btnGroup[disabled] button {
  opacity: 0.4;
  cursor: not-allowed;
}

[name="seek"] {
  color: lime;
}

[name="step"] {
  color: cyan;
}

.hide {
  display: none;
}

#showInfo {
  display: block;
  margin: 0 auto 5px;
  color: blue;
}

#showInfo::after {
  content: "\00feff";
}

#textArea {
  width: 19rem;
}
<!-- <search> is just for semantics -->
<search>

  <form id="mainForm">
  
    <fieldset>
      <legend>Find Text</legend>
      
      <!-- 
        [type="search"] is a [type="text] with a delete button: X
      -->
      <input id="findText" type="search" placeholder="Find">
      
      <!-- 
        This <fieldset> is has it's default styles removed.
        It's the parent of 4 <button>s.
        Access to these <button>s is blocked when it is
        [disabled].
      -->
      <fieldset id="btnGroup" disabled>
        
        <!--
          The 1 set of 2 <button>s are hidden (.hide) while
          the other set of 2 buttons are rendered in the DOM.
          Which are hidden and visible is determined by user
          actions: If user types anything in #findText or #textArea
          the <button ... name="seek"> are visible. If the user
          clicks a [name="seek"] and has one or more matches
          [name="step"] <button>s appear.
        <!--
          These 2 <button>s move the highlight on the
          text of #textArea forward and back.
          They both have [name="step"].
        -->
        <button id="prevBtn" name="step" class="hide" type="button">◀</button>
        <button id="nextBtn" name="step" class="hide" type="button">▶</button>
        
        <!--
          These 2 <button>s start the highlight the text on 
          either the beginning or end of the matches in the text
          of #textArea.
          They both have [name="seek"]
        -->
        <button id="maxiBtn" name="seek" type="button">◀</button>
        <button id="miniBtn" name="seek" type="button">▶</button>
        
      </fieldset>
      
      <!-- 
        This <output> displays messages ex:
          'No match for "term".'
          '3 of 6 matching "term".'
      -->
      <output id="showInfo"></output>
      
      <!--
        This <textarea> contains the text user will search.
      -->
      <textarea id="textArea" rows="3" cols="16">
Love never dies.
Love never dies. 
Love never dies.
      </textarea>
      
    </fieldset>
    
  </form>
  
</search>

Some Stuff Used

  • HTMLFormElement.elements

    • Terse and succinct syntax to access [form controls]:2. Assuming the layout has an <input>, a <textarea>, <select>, and multiple <button>s:

      const form = document.forms.formID;
      const fc = form.elements;
      const input = fc.inputID;
      const textarea = fc.textareaID;
      const select = fc.selectID;
      const btnArray = Array.from(fc.btnName);
      
    • Equivalent to:

      const form = document.getElementById("formID");
      const fc = form.querySelectorAll("input, textarea, select, button");
      const input = form.querySelector("#inputID");
      const textarea = form.querySelector("#textareaID");
      const select = form.querySelector("#selectID");
      const btnArray = [...form.querySelectorAll("[name=btnName]")];
      
  • Ternary Operator

    • Terse and succinct syntax alternative flow control. Assuming checkbox is already defined.

      let state = checkbox.checked ? 1 : 0;
      
    • Equivalent to:

      let state;
      if (checkbox.checked) state = 1;
      else state = 0; 
      

the issue arises because when your search term is at the very beginning of the textarea (index 0) the calculation for the lastIndexOf in the previous() function doesn't correctly loop back to the last occurrence, it happens because currentMatchIndex - 1 evaluates to -1, and text.lastIndexOf(findTerm, -1) will not find any valid index, resulting in currentMatchIndex remaining -1.

To resolve this issue, you can adjust your previous() function logic slightly by explicitly checking whether the lastIndexOf returned a valid index and ensuring proper looping. Here's an updated version of your code:

let currentMatchIndex = -1; // Tracks the current match index

function resetIndex() {
  currentMatchIndex = -1;
}

function highlightMatch(startIndex, endIndex) {
  const textarea = document.getElementById('myTextarea');
  textarea.setSelectionRange(startIndex, endIndex);
  textarea.focus();
}

function next() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    const startIndex = text.indexOf(findTerm, currentMatchIndex + 1);
    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.indexOf(findTerm); // Loop back to first occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}

function previous() {
  const findTerm = document.getElementById('findInput').value;
  const textarea = document.getElementById('myTextarea');
  const text = textarea.value;

  if (findTerm) {
    let startIndex;
    if (currentMatchIndex > 0) {
      startIndex = text.lastIndexOf(findTerm, currentMatchIndex - 1);
    } else {
      startIndex = text.lastIndexOf(findTerm); // Loop to last occurrence
    }

    if (startIndex !== -1) {
      currentMatchIndex = startIndex;
    } else {
      currentMatchIndex = text.lastIndexOf(findTerm); // Loop to last occurrence
    }

    if (currentMatchIndex !== -1) {
      highlightMatch(currentMatchIndex, currentMatchIndex + findTerm.length);
    } else {
      alert("Find term not found.");
    }
  }
}
<input type="text" id="findInput" placeholder="Find" onchange="resetIndex()">
<button onclick="next()">Next</button>
<button onclick="previous()">Previous</button>
<textarea id="myTextarea" rows="3" cols="16">Love never dies. Love never dies. Love never dies.</textarea>

When currentMatchIndex is 0 (the search term is the first word), subtracting 1 makes the starting point negative. JavaScript resets that to 0, so it just keeps finding the same match instead of looping back.

You can check if currentMatchIndex is 0 and, if so, start searching from the end of the text instead. Something like this:

if (currentMatchIndex === 0) {
    startIndex = text.length;  // wrap around to the end
} else {
    startIndex = currentMatchIndex - 1;
}

This way the search loops properly

本文标签: