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 2e1722ec1..358228d01 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -11,6 +11,7 @@ import { replaceSelection, cloneNodeAndRange, replaceNodeChildren, + selectEndOfNode, } from './lib/dom'; import API from './lib/api'; import Undo from './lib/undo'; @@ -18,11 +19,21 @@ import bowser from 'bowser'; import throttle from 'lodash/throttle'; class RTE extends React.Component { + /// Ref to react-contenteditable ref = null; + + // Our "plugins" api. api = null; + + // Instance of undo stack. undo = new Undo(); + + // Refs to the buttons. buttonsRef = {}; + // Should be called on every change to feed + // our Undo stack. We save the innerHTML and if available + // a copy of the contentEditable node and a copy of the range. saveCheckpoint = throttle((html, node, range) => { const args = [html]; if (node && range) { @@ -31,6 +42,12 @@ class RTE extends React.Component { this.undo.save(...args); }, 1000); + constructor(props) { + super(props); + this.saveCheckpoint(props.value); + } + + // Returns a handler that fills our `buttonsRef`. createButtonRefHandler(key) { return ref => { if (ref) { @@ -41,16 +58,25 @@ class RTE extends React.Component { }; } + // Ref to react-contenteditable. + handleRef = ref => ( + (this.ref = ref), + (this.api = new API( + this.ref.htmlEl, + this.handleChange, + () => this.undo.canUndo(), + () => this.undo.canRedo(), + this.handleUndo, + this.handleRedo + )) + ); + forEachButton(callback) { Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); } - constructor(props) { - super(props); - this.saveCheckpoint(props.value); - } - componentWillReceiveProps(props) { + // Clear undo stack if content was set to sth different. if (props.value !== this.ref.htmlEl.innerHTML) { this.undo.clear(); this.saveCheckpoint(props.value); @@ -71,16 +97,17 @@ class RTE extends React.Component { ); }; - handleRef = ref => ( - (this.ref = ref), (this.api = new API(this.ref.htmlEl, this.handleChange)) - ); - handleSelectionChange = () => { + // Let buttons know selection has changeed, so they + // can update. this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); }; + // Called when Enter was pressed without shift. + // Traverses from bottom to top and calling + // button handlers and stops when one has handled this event. handleSpecialEnter = () => { let handled = false; const sel = window.getSelection(); @@ -104,6 +131,8 @@ class RTE extends React.Component { } }; + // We intercept pasting, so that we + // force text/plain content. handlePaste = e => { // Get text representation of clipboard // This works cross browser. @@ -199,6 +228,7 @@ class RTE extends React.Component { replaceSelection(finalRange); } else { this.ref.htmlEl.innerHTML = html; + selectEndOfNode(this.ref.htmlEl); } this.handleChange(); } diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index d12429339..7b5b21b95 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -2,27 +2,20 @@ import createToggle from '../factories/createToggle'; import { findIntersectingTag, insertNewLineAfterNode, - replaceSelection, insertNodes, getSelectedNodesExpanded, outdentNode, + selectEndOfNode, } from '../lib/dom'; -// TODO: select end of node. -function selectNode(node) { - const range = document.createRange(); - const container = node.childNodes.length ? node.childNodes[0] : node; - range.setStart(container, 0); - range.setEnd(container, 0); - replaceSelection(range); -} - function execCommand() { const bq = findIntersectingTag('BLOCKQUOTE'); if (bq) { outdentNode(bq, true); } else { const node = document.createElement('blockquote'); + + // Expanded selection means we always select whole lines. const selectedNodes = getSelectedNodesExpanded(); if (selectedNodes.length) { const firstNode = selectedNodes[0]; @@ -30,11 +23,11 @@ function execCommand() { selectedNodes.forEach(n => { node.appendChild(n); }); - selectNode(node); + selectEndOfNode(node); } else { node.appendChild(document.createElement('br')); insertNodes(node); - selectNode(node); + selectEndOfNode(node); } } this.broadcastChange(); diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index 32b702df4..c14fc73a1 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -2,6 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import Button from '../components/Button'; +/** + * createToggle creates a button that can be active, inactive or disabled + * and reacts on clicks. All callbacks are bound to the API instance. + */ const createToggle = ( execCommand, { onEnter, isActive = () => false, isDisabled = () => false } = {} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js index 5150753d2..ce52afeec 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -1,9 +1,17 @@ import { selectionIsInside } from './dom'; +/** + * An instance of API is passed to all the buttons to + * interact with RTE, which servers as a clean abstraction. + */ export default class API { - constructor(contentEditable, onChange) { + constructor(contentEditable, onChange, canUndo, canRedo, undo, redo) { this.contentEditable = contentEditable; this.broadcastChange = onChange; + this.canUndo = canUndo; + this.canRedo = canRedo; + this.undo = undo; + this.redo = redo; } isSelectionInside() { return selectionIsInside(this.contentEditable); diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js index 9c4b4ff82..8ce10a31a 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -384,3 +384,36 @@ export function replaceNodeChildren(node, node2) { node.appendChild(node2.firstChild); } } + +export function selectEndOfNode(node) { + for (let i = node.childNodes.length - 1; i >= 0; i--) { + let child = node.childNodes[i]; + const s = selectEndOfNode(child); + if (s) { + return true; + } + if (child.tagName === 'BR') { + if ( + child.previousSibling && + child.previousSibling.childName === '#text' + ) { + child = child.previousSibling; + } else { + const offset = indexOfChildNode(node, child); + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset); + replaceSelection(range); + return true; + } + } + if (child.nodeName === '#text') { + const range = document.createRange(); + range.setStart(child, child.textContent.length); + range.setEnd(child, child.textContent.length); + replaceSelection(range); + return true; + } + } + return false; +} 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 index c43131958..eef076567 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js @@ -1,4 +1,12 @@ +/** + * Simple size limited Undo stack. + */ export default class Undo { + /** + * undoStack contains all known values. + * The last value of this stack represent + * the most recent change. + */ undoStack = []; redoStack = []; size;