Use our own UNDO stack!!

This commit is contained in:
Chi Vinh Le
2018-03-24 21:43:32 +01:00
parent 595dda11bf
commit 3bd8d5627b
3 changed files with 205 additions and 19 deletions
@@ -4,15 +4,33 @@ import styles from './RTE.css';
import cn from 'classnames';
import ContentEditable from 'react-contenteditable';
import Toolbar from './components/Toolbar';
import { insertNewLine, insertText } from './lib/dom';
import {
insertNewLine,
insertText,
getSelectionRange,
replaceSelection,
cloneNodeAndRange,
replaceNodeChildren,
} from './lib/dom';
import API from './lib/api';
import Undo from './lib/undo';
import bowser from 'bowser';
import throttle from 'lodash/throttle';
class RTE extends React.Component {
ref = null;
api = null;
undo = new Undo();
buttonsRef = {};
saveCheckpoint = throttle((html, node, range) => {
const args = [html];
if (node && range) {
args.push(...cloneNodeAndRange(node, range));
}
this.undo.save(...args);
}, 1000);
createButtonRefHandler(key) {
return ref => {
if (ref) {
@@ -27,12 +45,30 @@ class RTE extends React.Component {
Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k]));
}
constructor(props) {
super(props);
this.saveCheckpoint(props.value);
}
componentWillReceiveProps(props) {
if (props.value !== this.ref.htmlEl.innerHTML) {
this.undo.clear();
this.saveCheckpoint(props.value);
}
}
handleChange = () => {
this.handleSelectionChange();
this.props.onChange({
text: this.ref.htmlEl.innerText,
html: this.ref.htmlEl.innerHTML,
});
this.ref.htmlEl.focus();
this.saveCheckpoint(
this.ref.htmlEl.innerHTML,
this.ref.htmlEl,
getSelectionRange()
);
};
handleRef = ref => (
@@ -40,7 +76,6 @@ class RTE extends React.Component {
);
handleSelectionChange = () => {
// console.log(window.getSelection().getRangeAt(0));
this.forEachButton(b => {
b.onSelectionChange && b.onSelectionChange();
});
@@ -88,27 +123,24 @@ class RTE extends React.Component {
setTimeout(() => this.handleSelectionChange());
};
handleKeyDown = () => {
handleKeyDown = e => {
// IE has issues not firing the onChange event.
if (bowser.msie) {
setTimeout(this.handleChange);
}
};
handleKeyUp = () => {
// IE has issues not firing the onChange event.
if (bowser.msie) {
setTimeout(this.handleChange);
// Undo Redo
if (e.key === 'z' && e.metaKey) {
if (e.shiftKey) {
this.handleRedo();
} else {
this.handleUndo();
}
e.preventDefault();
return false;
}
this.handleSelectionChange();
};
handleKeyPress = e => {
this.handleSelectionChange();
// IE has issues not firing the onChange event.
if (bowser.msie) {
setTimeout(this.handleChange);
}
// Newlines Or Special Enter Behaviors.
if (e.key === 'Enter') {
if (!e.shiftKey && this.handleSpecialEnter()) {
this.handleChange();
@@ -124,6 +156,69 @@ class RTE extends React.Component {
}
};
handleKeyUp = () => {
// IE has issues not firing the onChange event.
if (bowser.msie) {
setTimeout(this.handleChange);
}
this.handleSelectionChange();
};
restoreCheckpoint(html, node, range) {
if (node && range) {
// We need to clone it, otherwise we'll mutate
// that original one which can still be in the undo stack.
const [nodeCloned, rangeCloned] = cloneNodeAndRange(node, range);
// Remember range values, as `rangeCloned` can changed during
// DOM manipulation.
const startOffset = rangeCloned.startOffset;
const endOffset = rangeCloned.startOffset;
// Rewrite startContainer if it was pointing to `nodeCloned`.
const startContainer =
rangeCloned.startContainer === nodeCloned
? this.ref.htmlEl
: rangeCloned.startContainer;
// Rewrite endContainer if it was pointing to `nodeCloned`.
const endContainer =
rangeCloned.endContainer === nodeCloned
? this.ref.htmlEl
: rangeCloned.endContainer;
// Replace children with the ones from nodeCloned.
replaceNodeChildren(this.ref.htmlEl, nodeCloned);
// Now setup the selection range.
const finalRange = document.createRange();
finalRange.setStart(startContainer, startOffset);
finalRange.setEnd(endContainer, endOffset);
// SELECT!
replaceSelection(finalRange);
} else {
this.ref.htmlEl.innerHTML = html;
}
this.handleChange();
}
handleUndo() {
this.saveCheckpoint.flush();
if (this.undo.canUndo()) {
const [html, node, range] = this.undo.undo();
this.restoreCheckpoint(html, node, range);
}
}
handleRedo() {
this.saveCheckpoint.flush();
if (this.undo.canRedo()) {
const [html, node, range] = this.undo.redo();
this.restoreCheckpoint(html, node, range);
}
}
renderButtons() {
return this.props.buttons.map(b => {
return React.cloneElement(b, {
@@ -166,7 +261,6 @@ class RTE extends React.Component {
<ContentEditable
id={inputId}
onMouseUp={this.handleMouseUp}
onKeyPress={this.handleKeyPress}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onPaste={this.handlePaste}
@@ -337,8 +337,8 @@ export function getSelectedChildren(ancestor) {
export function outdentNode(node, changeSelection) {
const parentNode = node.parentNode;
const offset = indexOfChildNode(parentNode, node);
while (node.childNodes.length) {
parentNode.insertBefore(node.childNodes[0], node);
while (node.firstChild) {
parentNode.insertBefore(node.firstChild, node);
}
parentNode.removeChild(node);
@@ -349,3 +349,38 @@ export function outdentNode(node, changeSelection) {
replaceSelection(range);
}
}
function cloneNodeAndRangeHelper(node, range, rangeCloned) {
const nodeCloned = node.cloneNode(false);
node.childNodes.forEach(n =>
nodeCloned.appendChild(cloneNodeAndRangeHelper(n, range, rangeCloned))
);
if (range.startContainer === node) {
rangeCloned.setStart(nodeCloned, range.startOffset);
}
if (range.endContainer === node) {
rangeCloned.setEnd(nodeCloned, range.endOffset);
}
return nodeCloned;
}
export function cloneNodeAndRange(node, range) {
const rangeCloned = range.cloneRange();
const nodeCloned = cloneNodeAndRangeHelper(node, range, rangeCloned);
if (
rangeCloned.startContainer === range.startContainer ||
rangeCloned.endContainer === range.endContainer
) {
throw new Error('Range not inside node');
}
return [nodeCloned, rangeCloned];
}
export function replaceNodeChildren(node, node2) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
while (node2.firstChild) {
node.appendChild(node2.firstChild);
}
}
@@ -0,0 +1,57 @@
export default class Undo {
undoStack = [];
redoStack = [];
size;
constructor(size = 100) {
this.size = size;
}
clear() {
this.undoStack = [];
this.redoStack = [];
}
canUndo() {
return this.undoStack.length > 1;
}
canRedo() {
return this.redoStack.length;
}
undo() {
if (!this.canUndo()) {
throw new Error('Nothing to undo');
}
const cur = this.undoStack.pop();
this.redoStack.push(cur);
return this.undoStack[this.undoStack.length - 1];
}
redo() {
if (!this.canRedo()) {
throw new Error('Nothing to redo');
}
const x = this.redoStack.pop();
this.undoStack.push(x);
return x;
}
save(x, ...meta) {
// Ignore if we already have that saved.
if (
this.undoStack.length &&
this.undoStack[this.undoStack.length - 1][0] === x
) {
return;
}
this.undoStack.push([x, ...meta]);
// Adhere to maximum size.
if (this.undoStack.length > this.size) {
this.undoStack.splice(0, 1);
}
this.redoStack = [];
}
}