From 3bd8d5627b72dacc957cd31af2c552d82a2dc291 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sat, 24 Mar 2018 21:43:32 +0100 Subject: [PATCH] Use our own UNDO stack!! --- .../client/components/rte/RTE.js | 128 +++++++++++++++--- .../client/components/rte/lib/dom.js | 39 +++++- .../client/components/rte/lib/undo.js | 57 ++++++++ 3 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 074809b1d..2e1722ec1 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -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 { + 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); + } +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js new file mode 100644 index 000000000..c43131958 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js @@ -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 = []; + } +}