JSX
JSX

Reputation: 85

Undo / Redo isn't working when update selected text

I was trying to create some buttons that will add markdown characters when click on it, so I used Selection API to get selected text and add characters then back caret to the end position of selected text and everything is done correctly.

When I try to press on undo Ctrl+z it's not back to the last text before adding the markdown characters I know that this because I changed the text node value.

Is there's a way to do that without effecting the node text and apply undo, redo?

let text = document.getElementById('test'),
  btn = document.getElementById('btn')
//function to replace text by index
String.prototype.replaceAt = function(start, end, replacement) {
  let text = '';
  for (let i = 0; i < this.length; i++) {
    if (i >= start && i < end) {
      text += ''
      if (i === end - 1) {
        text += replacement
      }
    } else {
      text += this[i]
    }
  }
  return text
}

function addNewStr(callback) {
  var sel = window.getSelection()
  try {
    var range = sel.getRangeAt(0),
      r = document.createRange()
    //check if there's text is selected
    if (!sel.isCollapsed) {
      let startPos = sel.anchorOffset,
        endPos = sel.focusOffset,
        node = sel.anchorNode,
        value = sel.anchorNode.nodeValue,
        selectedText = value.substring(startPos, endPos),
        parent = node.parentNode
      //function to determine if selection start from left to right or right to left
      function checkPos(callback) {
        if (startPos > endPos) {
          return callback(startPos, endPos)
        } else {
          return callback(endPos, startPos)
        }
      }
      if (typeof callback === 'function') {
        //getting the new str from the callback
        var replacement = callback(selectedText),
          caretIndex;
        //apply changes
        node.nodeValue = checkPos(function(end, start) {
          return node.nodeValue.replaceAt(start, end, replacement)
        })
        //check if the returned text from the callback is less or bigger than selected text to move caret to the end of selected text
        if (replacement.length > selectedText.length) {
          caretIndex = checkPos(function(pos) {
            return pos + (replacement.length - selectedText.length);
          })
        } else if (selectedText.length > replacement.length) {
          caretIndex = checkPos(function(pos) {
            return (pos - selectedText.length) + (replacement.length);
          })
        }
        //back caret to the end of the new position
        r.setStart(parent.childNodes[0], caretIndex)
        r.collapse(true)
        sel.removeAllRanges()
        sel.addRange(r)
      }
    }
  } catch (err) {
    console.log("Nothing is selected")
  }
}
btn.addEventListener("click", function() {
  addNewStr(function(str) {
    return '__' + str + '__'
  })
})
<div contenteditable="true" id="test" placeholder="insertText">
  try to select me
</div>
<button id="btn">to strong</button>

Upvotes: 4

Views: 985

Answers (1)

ray
ray

Reputation: 27275

Because you're changing the content programmatically, you're going to have to undo it programmatically.

In your button click handler:

  • capture the existing state of the content before you modify it
  • create a function that resets it to that state.
  • push that function into an array (the "undo stack")
const undoStack = [];

function onButtonClick (e) {
  // capture the existing state
  const textContent = text.innerText;

  // create a function to set the div content to its current value
  const undoFn = () => text.innerText = textContent;

  // push the undo function into the array
  undoStack.push(undoFn);

  // ...then do whatever the button does...
}

With that in place, you can listen for ctrl-z and invoke the most recent undo function:

// convenience to determine whether a keyboard event should trigger an undo
const isUndo = ({ctrlKey, metaKey, key}) => key === 'z' && (ctrlKey || metaKey);

// keydown event handler for undo
const keydown = (e) => {
  if(isUndo(e) && undos.length) {
    e.preventDefault();
    undos.pop()();
  }
}

// listen for keydowns
document.body.addEventListener('keydown', keydown);

There are some other considerations involved, e.g. whether certain user actions should clear the undo stack, but this is the basic idea.


Proof-of-concept demo

In the interest of clarity I've replaced your content modification code to just add a number on each click.

const div = document.getElementById('test');
const button = document.querySelector('button');

const undos = [];

button.addEventListener('click', e => {
  const text = div.innerText;
  undos.push(() => div.innerText = text);
  div.innerText += ` ${undos.length} `;
});

const isUndo = ({ctrlKey, metaKey, key}) => key === 'z' && (ctrlKey || metaKey);

const keydown = (e) => {
    if(isUndo(e) && undos.length) {
    e.preventDefault();
    undos.pop()();
  }
}

document.body.addEventListener('keydown', keydown);
<div contenteditable="true" id="test" placeholder="insertText">
  this is some text
</div>
<button id="btn">to strong</button>

Upvotes: 2

Related Questions