3
\$\begingroup\$

I have created a simple text editor with a "replace all" feature. It's meant to be part of a larger project, so it needs to be robust and work reliably across various scenarios. Please review the code for quality, error handling, and any potential edge cases I might have missed.

const editor = document.getElementById('editor');
const findInput = document.getElementById('findInput');
const replaceInput = document.getElementById('replaceInput');
const matchCaseToggle = document.getElementById('matchCaseToggle');
const wholeWordToggle = document.getElementById('wholeWordToggle');
const regexModeToggle = document.getElementById('regexModeToggle');
const replaceButton = document.getElementById('replaceButton');
const undoButton = document.getElementById('undoButton');
const replaceFeedback = document.getElementById('replaceFeedback');
let previousEditorValue = null;

// Interpret some common escape sequences in the replacement string
function interpretEscapeSequences(str) {
  const escapeMap = {
    '\\n': '\n', // Newline
    '\\r': '\r', // Carriage return
    '\\t': '\t', // Tab
    '\\\\': '\\', // Backslash
  };
  return str.replace(/\\[nrt\\]/g, match => escapeMap[match]);
}

function isActive(button) {
  return button.classList.contains('active');
}

function deactivate(button) {
  button.classList.remove('active');
}

function updatePlaceholder() {
  findInput.placeholder = isActive(regexModeToggle) ? 'pattern or /pattern/flags' : '';
}

function clearFeedback() {
  if (replaceFeedback.value) {
    replaceFeedback.value = '';
  }
}

function clearUndoState() {
  if (previousEditorValue !== null) {
    previousEditorValue = null;
    undoButton.disabled = true;
  }
}

for (const textField of [editor, findInput, replaceInput]) {
  textField.addEventListener('input', function() {
    clearFeedback();
    if (this === editor) {
      clearUndoState();
    }
  });
}

for (const button of document.querySelectorAll('.toggle')) {
  button.addEventListener('click', function() {
    this.classList.toggle('active');
    if (this === matchCaseToggle || this === wholeWordToggle) {
      deactivate(regexModeToggle);
    } else if (this === regexModeToggle) {
      deactivate(matchCaseToggle);
      deactivate(wholeWordToggle);
    }
    updatePlaceholder();
    clearFeedback();
  });
}

replaceButton.addEventListener('click', function() {
  const currentEditorValue = editor.value;
  const findInputValue = findInput.value;
  const replaceInputValue = replaceInput.value;
  const isCaseSensitive = isActive(matchCaseToggle);
  const isWholeWord = isActive(wholeWordToggle);
  const isRegex = isActive(regexModeToggle);

  if (!findInputValue) {
    replaceFeedback.value = 'No find term';
    return;
  }

  // Prepare pattern and flags for RegExp constructor based on find input and options
  let pattern;
  let flags = 'g'; // Global flag is always needed to replace all
  let regex;
  if (isRegex) {
    const regexLiteralMatch = findInputValue.match(/^\/(.*)\/(.*)$/);
    if (regexLiteralMatch) {
      [, pattern, flags] = regexLiteralMatch;
      if (!flags.includes('g')) {
        flags += 'g';
      }
    } else {
      pattern = findInputValue;
    }
  } else {
    pattern = findInputValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (isWholeWord) {
      // Unicode-aware word boundaries (mimics \b) with 'u' flag for non-English characters
      pattern = `(?<![\\p{L}\\p{N}_])${pattern}(?![\\p{L}\\p{N}_])`;
      flags += 'u';
    }
    if (!isCaseSensitive) {
      flags += 'i';
    }
  }
  try {
    regex = new RegExp(pattern, flags);
  } catch (error) {
    replaceFeedback.value = error.message;
    return;
  }

  const matches = currentEditorValue.match(regex);
  if (matches) {
    const matchCount = matches.length;
    previousEditorValue = currentEditorValue;
    // Use function replacement in non-regex mode to avoid interpreting $ patterns
    editor.value = currentEditorValue.replace(regex, isRegex ? interpretEscapeSequences(replaceInputValue) : () => replaceInputValue);
    replaceFeedback.innerHTML = `<span class="number">${matchCount}</span> ${matchCount === 1 ? 'replacement' : 'replacements'}`;
    undoButton.disabled = false;
  } else {
    replaceFeedback.value = 'No matches';
  }
});

undoButton.addEventListener('click', function() {
  editor.value = previousEditorValue;
  replaceFeedback.value = 'Replacement undone';
  clearUndoState();
});
#editor {
  width: 100%;
  height: 100px;
}

.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.5rem 0;
}

#replaceFeedback {
  color: #666;
  min-height: 1.2em;
}

.findOptions {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.toggle {
  padding: 0.25rem 0.75rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f0f0f0;
  cursor: pointer;
  transition: all 0.2s;
}

.toggle.active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

#replaceButton {
  background: #28a745;
  color: white;
}

.number {
  font-family: monospace;
}
<textarea id="editor"></textarea>
<div class="controls">
  <label for="findInput">Find</label>
  <input type="text" id="findInput">
  <label for="replaceInput">Replace</label>
  <input type="text" id="replaceInput">
  <div class="findOptions">
    <button type="button" class="toggle" id="matchCaseToggle">Match Case</button>
    <button type="button" class="toggle" id="wholeWordToggle">Whole Word</button>
    <button type="button" class="toggle" id="regexModeToggle">Regex</button>
  </div>
  <button type="button" id="replaceButton">Replace All</button>
  <button type="button" id="undoButton" disabled>Undo</button>
</div>
<output for="editor findInput matchCaseToggle wholeWordToggle regexModeToggle" id="replaceFeedback"></output>

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

automated tests

Adding some unit tests wouldn't hurt, as that would increase our confidence that correct values are being computed. Focus on regex details, rather than simulating button presses.

init

for (const textField of [editor, findInput, replaceInput]) {
  ...

for (const button of document.querySelectorAll('.toggle')) {

It would be convenient to bury such code in an "init" function. For one thing, that would let unit tests re-initialize as needed.

scope

The OP code accepts arbitrary regexes, which might be a little on the ambitious side for a "simple" editor. Consider making a weaker promise, like accepting just regexes which will only match within a line, rather than spanning newline boundaries. (See "rope" below.)

2nd copy

needs to be robust and work reliably across various scenarios.

In a scenario where the document being edited is 100 MiB, and the replace of s/unique1/unique2/ changes just a single word, we pretty much create another 100 MiB copy and discard the original. You might possibly find that less performant than desired.

Put another way, your document is modeled as a single string, without structure.

data structure

You might find it convenient to model the document as a rope or piece table, especially if your application grows and becomes less "simple". It is certainly cheaper to make a "unique2" replacement by copying a small fraction of a 100 MiB document than to copy the whole document. Journaling such edits to disk also becomes cheaper, if we want to prevent loss of work in the event of a crash.

Choice of data structure will be enormously important if you ever wish to support an "undo" feature.

state assumptions

If the design goal is to only support editing of "short" documents, then write down a comment in the source code, to make that explicit.

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.