RiffBlog

How to make a custom texteditor work with the undo and redo shortcuts after modifying the DOM?

I am building a small custom editor using the contenteditable attribute on a div.

By default, when the user pastes HTML content, the HTML is inserted in to the contenteditable div, which I do not want.

To solve this issue, I used a custom paste handler like this question suggests:

Stop pasting html style in a contenteditable div only paste the plain text

editor.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData('text/plain'); document.execCommand("insertText", false, text.replaceAll("\n", "<div><br></div>")); }) 

So far so good. Because the execCommand is deprecated, I used a replacement, which is to basically use ranges to insert the text (stripped of HTML) by hand.

execCommand() is now obsolete, what's the alternative?

replace selected text in contenteditable div

editor.addEventListener("paste", (e) => { e.preventDefault(); const text = (e.originalEvent || e).clipboardData.getData("text/plain"); const selection = window.getSelection(); selection.deleteFromDocument(); const range = selection.getRangeAt(0); range.insertNode(document.createTextNode(text)); range.collapse(); }); 

This worked well until I noticed, that the undo and redo commands were not working anymore after pasting something. This is because of the DOM modifications done in the custom paste event, which is a deal breaker.

In search of a solution I found the following question:

Allowing contenteditable to undo after dom modification

The first answer there suggests to use the execCommand, which is deprecated, so it's not a good solution.

The second answer suggests building a custom undo redo stack to handle all these events by hand. So I opted for the second solution.

I build a custom undo redo handler using a simple array to store the versions. For that I used the beforeinput event to listen for the undo and redo events.

editor.addEventListener("beforeinput", (e) => { if (e.inputType === "historyUndo") { e.preventDefault(); console.log("undo"); }; if (e.inputType === "historyRedo") { e.preventDefault(); console.log("redo"); }; }); 

This works very well for undos, however, due to me preventing the default, the browsers undo/redo stack is never in a state to redo, so the beforeinput listener is never fired when pressing cmd + z or ctrl + z on windows.

I've also tried this using the input event with the same result.

I've searched for other solutions to handle the undo/redo events using JavaScript, but building a custom handler using keydown is no option here, because every OS uses different shortcuts, especially mobile devices!

Question

Now, There's multiple possible solutions to solve my problem, so any of them that works is very welcome.

Is there a way to handle the paste event by hand and insert the pasted plaintext into the document while keeping the undo/redo functionality or implementing a replacement to such functionality by hand?

Example

Try to paste something and then undo, it wont work.

const editor = document.getElementById("editor"); editor.addEventListener("paste", (e) => { e.preventDefault(); const text = (e.originalEvent || e).clipboardData.getData("text/plain").replaceAll("\n", "<div><br></div>"); const selection = window.getSelection(); selection.deleteFromDocument(); const range = selection.getRangeAt(0); range.insertNode(document.createTextNode(text)); range.collapse(); });
#editor { border: 1px solid black; height: 100px; }
<div id="editor" contenteditable="true"></div>

Edit

Modifying the ClipboardEvent does not work because it's read only. Dispatching a new event also does not work because the event would be untrusted, so the browser does not paste the content.

const event = new ClipboardEvent("paste", { clipboardData: new DataTransfer(), }); event.clipboardData.setData("text/plain", "blah"); editor.dispatchEvent(event); 
12

1 Answer

I am facing the same problem and solved it using the approach discussed in this writeup, with his original code here. It's quite a clever hack. In the end, his approach still uses execCommand, but it is a very narrow and hidden use of execCommand just to push an integer index onto the native undo stack so that your "undo data" can be tracked on a separate stack. It also has the benefit of being able to intersperse normal undo operations (like reverting typed-in input) with the ones you push on to the separate undo stack.

I had to modify his undoer constructor to use an 'input' element rather than a contentEditable 'div'. And because I'm already listening for 'input' in my own contentEditable 'div', I needed to stopImmediatePropagation on the hidden input.

constructor(callback, zero=null) { this._duringUpdate = false; this._stack = [zero]; // Using an input element rather than contentEditable div because parent is already a // contentEditable div this._ctrl = document.createElement('input'); this._ctrl.setAttribute('aria-hidden', 'true'); this._ctrl.setAttribute('id', 'hiddenInput'); this._ctrl.style.opacity = 0; this._ctrl.style.position = 'fixed'; this._ctrl.style.top = '-1000px'; this._ctrl.style.pointerEvents = 'none'; this._ctrl.tabIndex = -1; this._ctrl.textContent = '0'; this._ctrl.style.visibility = 'hidden'; // hide element while not used this._ctrl.addEventListener('focus', (ev) => { window.setTimeout(() => void this._ctrl.blur(), 1); }); this._ctrl.addEventListener('input', (ev) => { ev.stopImmediatePropagation(); // We don't want this event to be seen by the parent if (!this._duringUpdate) { callback(this.data); this._ctrl.textContent = this._depth - 1; } else { this._ctrl.textContent = ev.data; } const s = window.getSelection(); if (s.containsNode(this._ctrl, true)) { s.removeAllRanges(); } }); } 

I create the undoer and a way to capture the undoerData I'm using for paste and other operations that need access to the range when the operation took place. To use it for paste, I listen for the paste event in my own contentEditable div (call it editor below).

const undoer = new Undoer(_undoOperation, null); const _undoerData = function(operation, data) { const undoData = { operation: operation, range: _rangeProxy(), // <- Capture document.currentSelection() as a range data: data } return undoData; }; editor.addEventListener('paste', function(e) { e.preventDefault(); var pastedText = undefined; if (e.clipboardData && e.clipboardData.getData) { pastedText = e.clipboardData.getData('text/plain'); } const undoerData = _undoerData('pasteText', pastedText); undoer.push(undoerData, editor); _doOperation(undoerData); }); 

Then the _doOperation and _undoOperation switch on the operation and have the information in undoerData to take the proper action:

const _doOperation = function(undoerData) { switch (undoerData.operation) { case 'pasteText': let pastedText = undoerData.data; let range = undoerData.range; // Do whatever is needed to paste the pastedText at the location // identified in range break; default: // Throw an error or do something reasonable }; }; const _undoOperation = function(undoerData) { switch (undoerData.operation) { case 'pasteText': let pastedText = undoerData.data; let range = undoerData.range; // Do whatever is needed to remove the pastedText at the location // identified in range break; default: // Throw an error or do something reasonable }; }; 

With this scheme and the pasteText operation implemented, you can now copy text, paste - type - delete text - type - reposition cursor - paste, and then undo 6 times with the _undoOperation being invoked on the 1st and 6th undo while the others just work like they're supposed to.

1

ncG1vNJzZmirpJawrLvVnqmfpJ%2Bse6S7zGiorp2jqbawutJobW9wZWmDeIWOoaawZaSkeq6typ5kmmWTqsC1u8xmq56wpJqxqsDOq2Swp6Kgeri106FkraCVYsKvsM5mmKecXaeypbuMrJ%2BoqqSYwrW%2FjJqdrZ2iYrqwsMg%3D

Lorie Orum

Update: 2024-05-21