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 55ce25333..422109292 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -3,17 +3,15 @@ import PropTypes from 'prop-types'; import styles from './RTE.css'; import cn from 'classnames'; import ContentEditable from 'react-contenteditable'; -import Toolbar from './Toolbar'; +import Toolbar from './components/Toolbar'; +import { insertNewLine } from './lib/dom'; +import API from './lib/api'; -class Editor extends React.Component { +class RTE extends React.Component { ref = null; - handleRef = ref => (this.ref = ref); + api = null; buttonsRef = {}; - get contentEditable() { - return this.ref.htmlEl; - } - createButtonRefHandler(key) { return ref => { if (ref) { @@ -24,38 +22,46 @@ class Editor extends React.Component { }; } - hasAncestor(tag) { - const sel = window.getSelection(); - const range = sel.getRangeAt(0); - let cur = range.startContainer; - do { - if (cur.nodeName === tag) { - return true; - } - cur = cur.parentNode; - } while (cur); - return false; - } - forEachButton(callback) { Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); } - handleChange = evt => { + handleChange = () => { this.props.onChange({ text: this.ref.htmlEl.innerText, - html: evt.target.value, + html: this.ref.htmlEl.innerHTML, }); }; + handleRef = ref => ( + (this.ref = ref), (this.api = new API(this.ref.htmlEl, this.handleChange)) + ); + handleSelectionChange = () => { + // console.log(window.getSelection().getRangeAt(0)); this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); }; + handleSpecialEnter = () => { + let handled = false; + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + let container = range.startContainer; + while (!handled && container && container !== this.ref.htmlEl) { + this.forEachButton(b => { + if (!handled) { + handled = !!(b.onEnter && b.onEnter(container)); + } + }); + container = container.parentNode; + } + return handled; + }; + handleClick = () => { - this.handleSelectionChange(); + setTimeout(() => this.handleSelectionChange()); }; handleKeyDown = () => { @@ -68,17 +74,25 @@ class Editor extends React.Component { handleKeyPress = e => { this.handleSelectionChange(); - if (e.key === 'Enter' && !e.shiftKey) { - setTimeout(() => { - document.execCommand('outdent'); - }); + if (e.key === 'Enter') { + if (!e.shiftKey && this.handleSpecialEnter()) { + this.handleChange(); + e.preventDefault(); + return false; + } + + insertNewLine(true); + + this.handleChange(); + e.preventDefault(); + return false; } }; renderButtons() { return this.props.buttons.map(b => { return React.cloneElement(b, { - rte: this, + api: this.api, ref: this.createButtonRefHandler(b.key), }); }); @@ -115,11 +129,11 @@ class Editor extends React.Component { } } -Editor.defaultProps = { +RTE.defaultProps = { buttons: [], }; -Editor.propTypes = { +RTE.propTypes = { buttons: PropTypes.array, inputId: PropTypes.string, input: PropTypes.object, @@ -132,4 +146,4 @@ Editor.propTypes = { value: PropTypes.string, }; -export default Editor; +export default RTE; 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 1ba9d933f..7d3b76d6f 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 @@ -1,24 +1,58 @@ import createToggle from '../factories/createToggle'; -import { hasAncestor } from '../utils'; -import bowser from 'bowser'; +import { + findIntersectingTag, + insertNewLineAfterNode, + replaceSelection, + insertNodes, + getSelectedNodesExpanded, + outdentNode, +} from '../lib/dom'; -const execCommand = () => { - if (hasAncestor('BLOCKQUOTE')) { - document.execCommand('outdent'); +// 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 { - if (bowser.msie) { - document.execCommand('indent'); + const node = document.createElement('blockquote'); + const selectedNodes = getSelectedNodesExpanded(); + if (selectedNodes.length) { + const firstNode = selectedNodes[0]; + firstNode.parentNode.insertBefore(node, firstNode); + selectedNodes.forEach(n => { + node.appendChild(n); + }); + selectNode(node); } else { - document.execCommand('formatBlock', false, 'blockquote'); + node.appendChild(document.createElement('br')); + insertNodes(node); + selectNode(node); } + this.broadcastChange(); } +} + +function syncState() { + return !!findIntersectingTag('BLOCKQUOTE'); +} + +const onEnter = node => { + if (node.tagName !== 'BLOCKQUOTE') { + return; + } + insertNewLineAfterNode(node, true); + return true; }; -const syncState = () => { - return hasAncestor('BLOCKQUOTE'); -}; - -const Blockquote = createToggle(execCommand, syncState); +const Blockquote = createToggle(execCommand, syncState, { onEnter }); Blockquote.defaultProps = { children: 'Blockquote', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Button.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Button.css rename to plugins/talk-plugin-rich-text/client/components/rte/components/Button.css diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Button.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Button.js rename to plugins/talk-plugin-rich-text/client/components/rte/components/Button.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Toolbar.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.css similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Toolbar.css rename to plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.css diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Toolbar.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.js similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Toolbar.js rename to plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.js 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 61c7263a3..040dc6543 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 @@ -1,25 +1,29 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Button from '../Button'; +import Button from '../components/Button'; -const createToggle = (execCommand, getCurrentState) => { +const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => { class Toggle extends React.Component { state = { active: false, }; + execCommand = () => execCommand.apply(this.props.api); + getCurrentState = () => getCurrentState.apply(this.props.api); + onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args); + formatToggle = () => { - execCommand(); - this.props.rte.contentEditable.focus(); + this.execCommand(); }; handleClick = () => { + this.props.api.focus(); this.formatToggle(); - this.syncState(); + this.props.api.focus(); }; syncState = () => { - if (this.state.active !== getCurrentState()) { + if (this.state.active !== this.getCurrentState()) { this.setState(state => ({ active: !state.active, })); @@ -46,7 +50,7 @@ const createToggle = (execCommand, getCurrentState) => { } Toggle.propTypes = { - rte: PropTypes.object, + api: PropTypes.object, className: PropTypes.string, title: PropTypes.string, children: PropTypes.node, 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 new file mode 100644 index 000000000..5150753d2 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -0,0 +1,14 @@ +import { selectionIsInside } from './dom'; + +export default class API { + constructor(contentEditable, onChange) { + this.contentEditable = contentEditable; + this.broadcastChange = onChange; + } + isSelectionInside() { + return selectionIsInside(this.contentEditable); + } + focus() { + this.contentEditable.focus(); + } +} 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 new file mode 100644 index 000000000..24a22f317 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -0,0 +1,333 @@ +export function findAncestorWithTag(node, tag) { + do { + if (node.nodeName === tag) { + return node; + } + node = node.parentNode; + } while (node); + return false; +} + +export function findIntersectingTag(tag) { + const range = getSelectionRange(); + if (!range) { + return null; + } + + const ancestor = findAncestorWithTag(range.startContainer, tag); + if (ancestor) { + return ancestor; + } + + const nodes = getSelectedChildren(range.commonAncestorContainer); + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].tagName === tag) { + return nodes[i]; + } + const query = nodes[i].querySelector && nodes[i].querySelector(tag); + if (query) { + return query; + } + } + return null; +} + +export function indexOfChildNode(parent, child) { + for (let i = 0; i < parent.childNodes.length; i++) { + if (parent.childNodes[i] === child) { + return i; + } + } + return -1; +} + +export function insertText(text) { + const selection = window.getSelection(); + if (!selection.isCollapsed) { + document.execCommand('delete'); + } + const range = selection.getRangeAt(0); + const offset = range.startOffset; + const container = range.startContainer; + + if (container.nodeName === '#text') { + container.textContent = + container.textContent.slice(0, offset) + + text + + container.textContent.slice(offset); + const nextOffset = offset + text.length; + range.setStart(container, nextOffset); + range.setEnd(container, nextOffset); + } else { + const textNode = document.createTextNode(text); + container.insertBefore(textNode, container.childNodes[offset]); + } +} + +export function insertNodes(...nodes) { + const selection = window.getSelection(); + if (!selection.isCollapsed) { + document.execCommand('delete'); + } + const range = selection.getRangeAt(0); + const offset = range.startOffset; + const container = range.startContainer; + if (container.nodeName === '#text') { + const startSlice = container.textContent.slice(0, offset); + const endSlice = container.textContent.slice(offset); + if (startSlice) { + nodes.splice(0, 0, document.createTextNode(startSlice)); + } + if (endSlice) { + nodes.push(document.createTextNode(endSlice)); + } + const parentNode = container.parentNode; + nodes.forEach(n => parentNode.insertBefore(n, container)); + parentNode.removeChild(container); + } else { + let parentNode = container; + let nextSibling = container.childNodes[offset]; + nodes.forEach(n => parentNode.insertBefore(n, nextSibling)); + } +} + +export function replaceSelection(range) { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +export function isSelectionCollapsed() { + return window.getSelection().isCollapsed; +} + +export function getSelectionRange() { + const selection = window.getSelection(); + return selection.rangeCount ? selection.getRangeAt(0) : null; +} + +function ensureEndMarker(node) { + if ( + !node.lastChild || + node.lastChild.tagName !== 'BR' || + node.lastChild.className !== 'coral-rte-end-marker' + ) { + const br = document.createElement('br'); + br.className = 'coral-rte-end-marker'; + node.appendChild(br); + } +} + +export function selectionIsInside(node) { + const range = getSelectionRange(); + return ( + range && + (node.contains(range.startContainer) || node.contains(range.endContainer)) + ); +} + +export function appendNewLine(node, changeSelection) { + const el = document.createElement('br'); + node.appendChild(el); + + if (changeSelection) { + const offset = indexOfChildNode(node, el); + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset); + replaceSelection(range); + } +} + +export function insertNewLine(changeSelection) { + // Insert
node. + const el = document.createElement('br'); + insertNodes(el); + + // Calculate next selection. + const range = document.createRange(); + if (el.nextSibling) { + const offset = indexOfChildNode(el.parentNode, el.nextSibling); + range.setStart(el.parentNode, offset); + range.setEnd(el.parentNode, offset); + } else { + // We need to add a
marker at the end, because we can't + // select the last
. + ensureEndMarker(el.parentNode); + + const offset = el.parentNode.childNodes.length - 1; + range.setStart(el.parentNode, offset); + range.setEnd(el.parentNode, offset); + } + + if (changeSelection) { + replaceSelection(range); + } +} + +export function insertNewLineAfterNode(node, changeSelection) { + if (node.parentNode.lastChild === node) { + appendNewLine(node.parentNode, changeSelection); + } else { + if (changeSelection) { + const offset = indexOfChildNode(node.parentNode, node) + 1; + const range = document.createRange(); + range.setStart(node.parentNode, offset); + range.setEnd(node.parentNode, offset); + replaceSelection(range); + } + insertNewLine(); + } +} + +export function getSelectedNode(container, offset) { + if (container.nodeName === '#text') { + return container; + } + return container.childNodes[offset]; +} + +export function isBlockElement(node) { + if (node.nodeName === '#text') { + return false; + } + return !window + .getComputedStyle(node) + .getPropertyValue('display') + .startsWith('inline'); +} + +export function findParentBlock(node) { + if (!node.parentNode) { + return null; + } + if (isBlockElement(node.parentNode)) { + return node.parentNode; + } + return findParentBlock(node.parentNode); +} + +export function lastParentBeforeBlock(node) { + let child = node; + while (!isBlockElement(child.parentNode)) { + child = node.parentNode; + } + return child; +} + +export function getLeftOfNode(node) { + let result = []; + let leftMost = node; + while ( + leftMost.previousSibling && + leftMost.previousSibling.tagName !== 'BR' && + !isBlockElement(leftMost.previousSibling) + ) { + result.splice(0, 0, leftMost.previousSibling); + leftMost = leftMost.previousSibling; + } + return result; +} + +export function getRightOfNode(node) { + let result = []; + let cur = node; + while ( + cur.nextSibling && + cur.nextSibling.tagName !== 'BR' && + !isBlockElement(cur.nextSibling) + ) { + cur = cur.nextSibling; + result.push(cur); + } + if (cur.nextSibling && cur.nextSibling.tagName === 'BR') { + result.push(cur.nextSibling); + } + return result; +} + +export function getWholeLine(node) { + if (isBlockElement(node)) { + return [node]; + } + const child = lastParentBeforeBlock(node); + return [...getLeftOfNode(child), child, ...getRightOfNode(child)]; +} + +export function getSelectedLine() { + const range = getSelectionRange(); + if (!range) { + return []; + } + const start = getSelectedNode(range.startContainer, range.startOffset); + return start ? getWholeLine(start) : []; +} + +export function getSelectedNodesExpanded() { + const range = getSelectionRange(); + if (!range) { + return []; + } + + if (range.collapsed) { + return getSelectedLine(); + } + + let ancestor = range.commonAncestorContainer; + if (!isBlockElement(ancestor)) { + ancestor = findParentBlock(ancestor); + } + + const result = getSelectedChildren(ancestor); + return [ + ...getLeftOfNode(result[0]), + ...result, + ...getRightOfNode(result[result.length - 1]), + ]; +} + +export function getSelectedChildren(ancestor) { + const result = []; + const range = getSelectionRange(); + if (!range) { + return result; + } + if (!range) { + return result; + } + + const start = getSelectedNode(range.startContainer, range.startOffset); + const end = getSelectedNode(range.endContainer, range.endOffset); + let foundStart = false; + for (let i = 0; i < ancestor.childNodes.length; i++) { + const node = ancestor.childNodes[i]; + if (!foundStart) { + if (node.contains(start)) { + foundStart = true; + } + } + if (foundStart) { + result.push(node); + if (node.contains(end)) { + break; + } + } + } + return result; +} + +export function outdentNode(node, changeSelection) { + const parentNode = node.parentNode; + const offset = indexOfChildNode(parentNode, node); + while (node.childNodes.length) { + parentNode.insertBefore(node.childNodes[0], node); + } + parentNode.removeChild(node); + + if (changeSelection) { + const range = document.createRange(); + range.setStart(parentNode, offset); + range.setEnd(parentNode, offset); + replaceSelection(range); + } +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/utils.js b/plugins/talk-plugin-rich-text/client/components/rte/utils.js deleted file mode 100644 index 0d613df8a..000000000 --- a/plugins/talk-plugin-rich-text/client/components/rte/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -export function hasAncestor(tag) { - const sel = window.getSelection(); - const range = sel.getRangeAt(0); - let cur = range.startContainer; - do { - if (cur.nodeName === tag) { - return true; - } - cur = cur.parentNode; - } while (cur); - return false; -}