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;
-}