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);
121 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.
1ncG1vNJzZmirpJawrLvVnqmfpJ%2Bse6S7zGiorp2jqbawutJobW9wZWmDeIWOoaawZaSkeq6typ5kmmWTqsC1u8xmq56wpJqxqsDOq2Swp6Kgeri106FkraCVYsKvsM5mmKecXaeypbuMrJ%2BoqqSYwrW%2FjJqdrZ2iYrqwsMg%3D