diff --git a/client/coral-admin/src/components/CommentFormatter.js b/client/coral-admin/src/components/CommentFormatter.js
deleted file mode 100644
index 8874d123f..000000000
--- a/client/coral-admin/src/components/CommentFormatter.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { matchLinks } from '../utils';
-import memoize from 'lodash/memoize';
-
-function escapeRegExp(string) {
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
-}
-
-// generate a regulare expression that catches the `phrases`.
-function generateRegExp(phrases) {
- const inner = phrases
- .map(phrase =>
- phrase
- .split(/\s+/)
- .map(word => escapeRegExp(word))
- .join('[\\s"?!.]+')
- )
- .join('|');
-
- const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
- try {
- return new RegExp(pattern, 'iu');
- } catch (_err) {
- // IE does not support unicode support, so we'll create one without.
- return new RegExp(pattern, 'i');
- }
-}
-
-// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
-function getPhrasesRegexp(suspectWords, bannedWords) {
- return generateRegExp([...suspectWords, ...bannedWords]);
-}
-
-// Memoized version as arguments rarely change.
-const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
-
-// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
-// an array of React Elements.
-function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
- const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
- const tokens = body.split(regexp);
- return tokens.map(
- (token, i) =>
- i % 3 === 2 ? {token} : token
- );
-}
-
-// markLinks looks for links inside `body` and highlights them by returning
-// an array of React Elements.
-function markLinks(body) {
- const matches = matchLinks(body);
- const content = [];
- let index = 0;
- if (matches) {
- matches.forEach((match, i) => {
- content.push(body.substring(index, match.index));
- content.push({match.text});
- index = match.lastIndex;
- });
- }
- content.push(body.substring(index));
- return content;
-}
-
-const CommentFormatter = ({
- body,
- suspectWords,
- bannedWords,
- className = 'comment',
- ...rest
-}) => {
- // Breaking the body by line break
- const textbreaks = body.split('\n');
-
- return (
-
- {textbreaks.map((line, i) => {
- const content = markLinks(line).map((element, index) => {
- // Keep highlighted links.
- if (typeof element !== 'string') {
- return element;
- }
-
- // Highlight suspect and banned phrase inside this part of text.
- return markPhrases(element, suspectWords, bannedWords, index);
- });
-
- return (
-
- {content}
- {i !== textbreaks.length - 1 && (
-
- )}
-
- );
- })}
-
- );
-};
-
-CommentFormatter.propTypes = {
- className: PropTypes.string,
- bannedWords: PropTypes.array,
- suspectWords: PropTypes.array,
- body: PropTypes.string,
-};
-
-export default CommentFormatter;
diff --git a/client/coral-admin/src/components/IfHasLink.js b/client/coral-admin/src/components/IfHasLink.js
index 8209a28e5..35be8c967 100644
--- a/client/coral-admin/src/components/IfHasLink.js
+++ b/client/coral-admin/src/components/IfHasLink.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { matchLinks } from '../utils';
+import matchLinks from 'coral-framework/utils/matchLinks';
export default ({ text, children }) => {
const hasLinks = !!matchLinks(text);
diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js
index 72c85c960..f2ff2e932 100644
--- a/client/coral-admin/src/components/UserDetailComment.js
+++ b/client/coral-admin/src/components/UserDetailComment.js
@@ -5,7 +5,7 @@ import { Link } from 'react-router';
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
-import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
+import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import CommentAnimatedEdit from './CommentAnimatedEdit';
@@ -93,7 +93,7 @@ class UserDetailComment extends React.Component {
'talk-admin-user-detail-comment'
)}
size={1}
- defaultComponent={CommentFormatter}
+ defaultComponent={AdminCommentContent}
passthrough={slotPassthrough}
/>
diff --git a/client/coral-admin/src/utils/index.js b/client/coral-admin/src/utils/index.js
index 41ff3db3c..180615a87 100644
--- a/client/coral-admin/src/utils/index.js
+++ b/client/coral-admin/src/utils/index.js
@@ -1,12 +1,3 @@
-import LinkifyIt from 'linkify-it';
-import tlds from 'tlds';
-const linkify = new LinkifyIt();
-linkify.tlds(tlds);
-
-export function matchLinks(text) {
- return linkify.match(text);
-}
-
export const isPremod = mod => mod === 'PRE';
export const getModPath = (type = 'all', assetId) =>
diff --git a/client/coral-framework/components/AdminCommentContent.css b/client/coral-framework/components/AdminCommentContent.css
new file mode 100644
index 000000000..9b7a47b33
--- /dev/null
+++ b/client/coral-framework/components/AdminCommentContent.css
@@ -0,0 +1,16 @@
+.content {
+ a {
+ color: #063b9a;
+ text-decoration: underline;
+ font-weight: 300;
+ background-color: #f4ff81;
+ }
+
+ mark {
+ background-color: #f4ff81;
+ }
+
+ b, strong {
+ font-weight: 600;
+ }
+}
diff --git a/client/coral-framework/components/AdminCommentContent.js b/client/coral-framework/components/AdminCommentContent.js
new file mode 100644
index 000000000..b3110e822
--- /dev/null
+++ b/client/coral-framework/components/AdminCommentContent.js
@@ -0,0 +1,217 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import matchLinks from '../utils/matchLinks';
+import memoize from 'lodash/memoize';
+import cn from 'classnames';
+import styles from './AdminCommentContent.css';
+
+function escapeHTML(unsafe) {
+ return unsafe
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+}
+
+// generate a regulare expression that catches the `phrases`.
+function generateRegExp(phrases) {
+ const inner = phrases
+ .map(phrase =>
+ phrase
+ .split(/\s+/)
+ .map(word => escapeRegExp(word))
+ .join('[\\s"?!.]+')
+ )
+ .join('|');
+
+ const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
+ try {
+ return new RegExp(pattern, 'iu');
+ } catch (_err) {
+ // IE does not support unicode support, so we'll create one without.
+ return new RegExp(pattern, 'i');
+ }
+}
+
+// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
+function getPhrasesRegexp(suspectWords, bannedWords) {
+ return generateRegExp([...suspectWords, ...bannedWords]);
+}
+
+// Memoized version as arguments rarely change.
+const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
+
+function nl2br(body, keyPrefix) {
+ const tokens = body.split('\n').reduce((tokens, t, i) => {
+ if (i !== 0) {
+ tokens.push( );
+ }
+ tokens.push(t);
+ return tokens;
+ }, []);
+ return tokens;
+}
+
+// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
+// an array of React Elements.
+function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
+ const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
+ const tokens = body.split(regexp);
+ return tokens.map(
+ (token, i) =>
+ i % 3 === 2 ? {token} : token
+ );
+}
+
+// markLinks looks for links inside `body` and highlights them by returning
+// an array of React Elements.
+function markLinks(body, keyPrefix) {
+ const matches = matchLinks(body);
+ const content = [];
+ let index = 0;
+ if (matches) {
+ matches.forEach((match, i) => {
+ content.push(body.substring(index, match.index));
+ content.push(
+
+ {match.text}
+
+ );
+ index = match.lastIndex;
+ });
+ }
+ content.push(body.substring(index));
+ return content;
+}
+
+// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning
+// a HTML string.
+function markPhrasesHTML(text, suspectWords, bannedWords) {
+ const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
+ const tokens = text.split(regexp);
+ if (tokens.length === 1) {
+ return text;
+ }
+ return tokens
+ .map(
+ (token, i) =>
+ i % 3 === 2 ? `${escapeHTML(token)}` : escapeHTML(token)
+ )
+ .join('');
+}
+
+// markHTMLNode manipulates the node by looking for #text nodes and adding markers
+// for `supsectWords` and `bannedWords`.
+function markHTMLNode(parentNode, suspectWords, bannedWords) {
+ parentNode.childNodes.forEach(node => {
+ if (node.nodeName === '#text') {
+ const newContent = markPhrasesHTML(
+ node.textContent,
+ suspectWords,
+ bannedWords
+ );
+ if (newContent !== node.textContent) {
+ const newNode = document.createElement('span');
+ newNode.innerHTML = newContent;
+ parentNode.replaceChild(newNode, node);
+ }
+ } else {
+ markHTMLNode(node, suspectWords, bannedWords);
+ }
+ });
+}
+
+// renderText performs all the marking of a text body and returns an array of React Elements.
+function renderText(body, suspectWords, bannedWords) {
+ return nl2br(body).map((element, index) => {
+ // Skip br tags.
+ if (typeof element !== 'string') {
+ return element;
+ }
+ return markLinks(element, index).map((element, index) => {
+ // Keep highlighted links.
+ if (typeof element !== 'string') {
+ return element;
+ }
+
+ // Highlight suspect and banned phrase inside this part of text.
+ return markPhrases(element, suspectWords, bannedWords, index);
+ });
+ });
+}
+
+const commonPropTypes = {
+ className: PropTypes.string,
+ bannedWords: PropTypes.array.isRequired,
+ suspectWords: PropTypes.array.isRequired,
+ body: PropTypes.string.isRequired,
+};
+
+const AdminCommentContentText = ({
+ body,
+ className,
+ suspectWords,
+ bannedWords,
+}) => {
+ return (
+
+ {renderText(body, suspectWords, bannedWords)}
+
+ );
+};
+AdminCommentContentText.propTypes = commonPropTypes;
+
+const AdminCommentContentHTML = ({
+ body,
+ className,
+ suspectWords,
+ bannedWords,
+}) => {
+ // We create a Shadow DOM Tree with the HTML body content and
+ // use it as a parser.
+ const node = document.createElement('div');
+ node.innerHTML = body;
+
+ // Then we traverse it recursively and manipulate it to highlight suspect words
+ // and banned words.
+ markHTMLNode(node, suspectWords, bannedWords);
+
+ // Finally we render the content of the Shadow DOM Tree
+ return (
+
+ );
+};
+AdminCommentContentHTML.propTypes = commonPropTypes;
+
+const AdminCommentContent = ({
+ className,
+ body,
+ suspectWords,
+ bannedWords,
+ html,
+}) => {
+ const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
+ return (
+
+ );
+};
+
+AdminCommentContent.propTypes = {
+ ...commonPropTypes,
+ html: PropTypes.bool,
+};
+
+export default AdminCommentContent;
diff --git a/client/coral-framework/utils/matchLinks.js b/client/coral-framework/utils/matchLinks.js
new file mode 100644
index 000000000..8387d64cf
--- /dev/null
+++ b/client/coral-framework/utils/matchLinks.js
@@ -0,0 +1,8 @@
+import LinkifyIt from 'linkify-it';
+import tlds from 'tlds';
+const linkify = new LinkifyIt();
+linkify.tlds(tlds);
+
+export default function matchLinks(text) {
+ return linkify.match(text);
+}
diff --git a/plugin-api/alpha/client/hocs/index.js b/plugin-api/alpha/client/hocs/index.js
new file mode 100644
index 000000000..3c860c7f5
--- /dev/null
+++ b/plugin-api/alpha/client/hocs/index.js
@@ -0,0 +1 @@
+export { withSlotElements } from 'coral-framework/hocs';
diff --git a/plugin-api/beta/client/components/index.js b/plugin-api/beta/client/components/index.js
index fc6159544..77b1a5517 100644
--- a/plugin-api/beta/client/components/index.js
+++ b/plugin-api/beta/client/components/index.js
@@ -20,6 +20,9 @@ export {
export {
default as CommentContent,
} from 'coral-framework/components/CommentContent';
+export {
+ default as AdminCommentContent,
+} from 'coral-framework/components/AdminCommentContent';
export {
default as ConfigureCard,
} from 'coral-framework/components/ConfigureCard';
diff --git a/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css
new file mode 100644
index 000000000..046eadfe7
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css
@@ -0,0 +1,4 @@
+.content {
+ composes: content from "./CommentContent.css";
+}
+
diff --git a/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js
new file mode 100644
index 000000000..1b7e53ebf
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from './AdminCommentContent.css';
+import { AdminCommentContent as Content } from 'plugin-api/beta/client/components';
+
+class AdminCommentContent extends React.Component {
+ render() {
+ const { comment, suspectWords, bannedWords } = this.props;
+ return (
+
+ );
+ }
+}
+
+AdminCommentContent.propTypes = {
+ comment: PropTypes.object.isRequired,
+ suspectWords: PropTypes.array.isRequired,
+ bannedWords: PropTypes.array.isRequired,
+};
+
+export default AdminCommentContent;
diff --git a/plugins/talk-plugin-rich-text/client/components/Button.css b/plugins/talk-plugin-rich-text/client/components/Button.css
deleted file mode 100644
index f9f1ba186..000000000
--- a/plugins/talk-plugin-rich-text/client/components/Button.css
+++ /dev/null
@@ -1,20 +0,0 @@
-.button > i {
- vertical-align: middle;
-}
-
-.button {
- background-color: transparent;
- padding: 3px;
- border: none;
- color: #4e4e4e;
- margin-right: 3px;
-}
-
-.button:hover{
- cursor: pointer;
- border-radius: 3px;
- background-color: #eae8e8;
-}
-.icon {
- font-size: 20px;
-}
diff --git a/plugins/talk-plugin-rich-text/client/components/Button.js b/plugins/talk-plugin-rich-text/client/components/Button.js
deleted file mode 100644
index 330e3d993..000000000
--- a/plugins/talk-plugin-rich-text/client/components/Button.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styles from './Button.css';
-import { Icon, BareButton } from 'plugin-api/beta/client/components/ui';
-import cn from 'classnames';
-
-class Button extends React.Component {
- render() {
- const { className, icon, title, onClick } = this.props;
- return (
-
-
-
- );
- }
-}
-
-Button.propTypes = {
- icon: PropTypes.string.isRequired,
- className: PropTypes.string,
- title: PropTypes.string,
- onClick: PropTypes.func,
-};
-
-export default Button;
diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.css b/plugins/talk-plugin-rich-text/client/components/Editor.css
index eabc1ee94..2259d01a3 100644
--- a/plugins/talk-plugin-rich-text/client/components/Editor.css
+++ b/plugins/talk-plugin-rich-text/client/components/Editor.css
@@ -1,14 +1,5 @@
-.contentEditable {
+.commentContent {
composes: content from "./CommentContent.css";
- background: #fff;
- border: solid 1px #bbb;
- min-height: 120px;
- box-sizing: border-box;
- outline: 0;
- overflow-y: auto;
- width: 100%;
- padding: 10px;
- font-style: unset;
}
.placeholder {
@@ -16,3 +7,7 @@
margin: 12px 0 0 12px;
color: #bbb;
}
+
+.icon {
+ font-size: 20px;
+}
diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js
index 5c1497f00..e09931d38 100644
--- a/plugins/talk-plugin-rich-text/client/components/Editor.js
+++ b/plugins/talk-plugin-rich-text/client/components/Editor.js
@@ -4,19 +4,19 @@ import styles from './Editor.css';
import cn from 'classnames';
import { PLUGIN_NAME } from '../constants';
import { htmlNormalizer } from '../utils';
-import ContentEditable from 'react-contenteditable';
-import Toolbar from './Toolbar';
-import Button from './Button';
-import bowser from 'bowser';
+import RTE from './rte/RTE';
+import { Icon } from 'plugin-api/beta/client/components/ui';
+import { Bold, Italic, Blockquote } from './rte/features';
+import { t } from 'plugin-api/beta/client/services';
class Editor extends React.Component {
ref = null;
handleRef = ref => (this.ref = ref);
- handleChange = evt => {
+ handleChange = c => {
this.props.onInputChange({
- body: this.ref.htmlEl.innerText,
- richTextBody: evt.target.value,
+ body: c.text,
+ richTextBody: c.html,
});
};
@@ -40,55 +40,19 @@ class Editor extends React.Component {
}
});
}
+ if (this.props.isReply) {
+ this.ref.focus();
+ }
}
componentWillUnmount() {
this.props.unregisterHook(this.normalizeHook);
}
- getCurrentTagName() {
- const sel = window.getSelection();
- const range = sel.getRangeAt(0);
- if (range.startContainer.nodeName !== '#text') {
- return range.startContainer.nodeName;
- }
- return range.startContainer.parentNode.tagName;
- }
-
- formatBold = () => {
- document.execCommand('bold');
- this.ref.htmlEl.focus();
- };
-
- formatItalic = () => {
- document.execCommand('italic');
- this.ref.htmlEl.focus();
- };
-
- formatBlockquote = () => {
- const currentTag = this.getCurrentTagName();
- if (currentTag === 'BLOCKQUOTE') {
- document.execCommand('outdent');
- } else {
- if (bowser.msie) {
- document.execCommand('indent');
- } else {
- document.execCommand('formatBlock', false, 'blockquote');
- }
- }
- this.ref.htmlEl.focus();
- };
-
- outdentOnEnter = e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- setTimeout(() => {
- document.execCommand('outdent');
- });
- }
- };
-
render() {
- const inputId = `${this.props.id}-rte`;
+ const { id, placeholder, label, disabled } = this.props;
+
+ const inputId = `${id}-rte`;
return (
-
-
-
-
-
- {!this.props.input.body && (
-
{this.props.placeholder}
- )}
-
+
+ ,
+
+
+ ,
+
+
+
,
+ ]}
/>
);
@@ -134,7 +110,6 @@ Editor.propTypes = {
onInputChange: PropTypes.func,
disabled: PropTypes.bool,
comment: PropTypes.object,
- classNames: PropTypes.object,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.css b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css
new file mode 100644
index 000000000..2bf1feaf5
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css
@@ -0,0 +1,29 @@
+.contentEditable {
+ background: #fff;
+ border: solid 1px #bbb;
+ min-height: 120px;
+ box-sizing: border-box;
+ outline: 0;
+ overflow-y: auto;
+ width: 100%;
+ padding: 10px;
+ font-style: unset;
+ margin-bottom: 3px;
+}
+
+.placeholder {
+ position: absolute;
+ margin: 12px 0 0 12px;
+ color: #bbb;
+}
+
+.toolbarDisabled {
+ background: #f8f8f8;
+ cursor: default;
+}
+
+.contentEditableDisabled {
+ background: #fafafa;
+ color: #888;
+ cursor: default;
+}
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js
new file mode 100644
index 000000000..3726fb7c1
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js
@@ -0,0 +1,392 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from './RTE.css';
+import cn from 'classnames';
+import ContentEditable from 'react-contenteditable';
+import Toolbar from './components/Toolbar';
+import {
+ insertNewLine,
+ insertText,
+ getSelectionRange,
+ replaceSelection,
+ cloneNodeAndRange,
+ replaceNodeChildren,
+ selectEndOfNode,
+ isSelectionInside,
+ traverse,
+} from './lib/dom';
+import createAPI from './lib/api';
+import Undo from './lib/undo';
+import bowser from 'bowser';
+import throttle from 'lodash/throttle';
+
+class RTE extends React.Component {
+ /// Ref to react-contenteditable
+ ref = null;
+
+ // Our "plugins" api.
+ api = createAPI(
+ () => this.ref.htmlEl,
+ () => this.handleChange(),
+ () => this.undo.canUndo(),
+ () => this.undo.canRedo(),
+ () => this.handleUndo(),
+ () => this.handleRedo(),
+ () => this.focused
+ );
+
+ // Instance of undo stack.
+ undo = new Undo();
+
+ // Refs to the features.
+ featuresRef = {};
+
+ // Export this for parent components.
+ focus = () => this.ref.htmlEl.focus();
+
+ unmounted = false;
+ focused = false;
+
+ // 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) {
+ args.push(...cloneNodeAndRange(node, range));
+ }
+ this.undo.save(...args);
+ }, 1000);
+
+ constructor(props) {
+ super(props);
+ this.saveCheckpoint(props.value);
+ }
+
+ // Returns a handler that fills our `featuresRef`.
+ createFeatureRefHandler(key) {
+ return ref => {
+ if (ref) {
+ this.featuresRef[key] = ref;
+ } else {
+ delete this.featuresRef[key];
+ }
+ };
+ }
+
+ // Ref to react-contenteditable.
+ handleRef = ref => (this.ref = ref);
+
+ forEachFeature(callback) {
+ Object.keys(this.featuresRef).map(k => {
+ const instance = this.featuresRef[k].getFeatureInstance
+ ? this.featuresRef[k].getFeatureInstance()
+ : this.featuresRef[k];
+ callback(instance);
+ });
+ }
+
+ 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);
+ if (isSelectionInside(this.ref.htmlEl)) {
+ setTimeout(() => !this.unmounted && selectEndOfNode(this.ref.htmlEl));
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ // Cancel pending stuff.
+ this.saveCheckpoint.cancel();
+ this.unmounted = true;
+ }
+
+ handleChange = () => {
+ // TODO: don't rely on this hack.
+ // It removes all `style` attr that
+ // remaining execCommand still add.
+ traverse(this.ref.htmlEl, n => {
+ n.removeAttribute && n.removeAttribute('style');
+ });
+
+ 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()
+ );
+ };
+
+ handleSelectionChange = () => {
+ // Let features know selection has changeed, so they
+ // can update.
+ this.forEachFeature(b => {
+ b.onSelectionChange && b.onSelectionChange();
+ });
+ };
+
+ // Allow features to handle shortcuts.
+ handleShortcut = e => {
+ let handled = false;
+ this.forEachFeature(b => {
+ if (!handled) {
+ handled = !!(b.onShortcut && b.onShortcut(e));
+ }
+ });
+ return handled;
+ };
+
+ // Called when Enter was pressed without shift.
+ // Traverses from bottom to top and calling
+ // feature handlers and stops when one has handled this event.
+ 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.forEachFeature(b => {
+ if (!handled) {
+ handled = !!(b.onEnter && b.onEnter(container));
+ }
+ });
+ container = container.parentNode;
+ }
+ return handled;
+ };
+
+ handleCut = () => {
+ // IE has issues not firing the onChange event.
+ if (bowser.msie) {
+ setTimeout(() => !this.unmounted && this.handleChange());
+ }
+ };
+
+ handleFocus = () => {
+ this.focused = true;
+ };
+
+ handleBlur = () => {
+ this.focused = false;
+ // Sometimes the onselect event doesn't fire on blur.
+ this.handleSelectionChange();
+ };
+
+ // We intercept pasting, so that we
+ // force text/plain content.
+ handlePaste = e => {
+ // Get text representation of clipboard
+ // This works cross browser.
+ const text = (
+ (e.originalEvent || e).clipboardData || window.clipboardData
+ ).getData('Text');
+
+ // IE does this funny thing to change the selection after the paste
+ // event, remember the range for now.
+ const range = getSelectionRange().cloneRange();
+
+ // Run outside of event loop to fix
+ // selection issues with IE.
+ setTimeout(() => {
+ // Manually delete range, cope with IE.
+ if (!range.collapsed) {
+ range.deleteContents();
+ }
+
+ // insert text manually
+ insertText(text);
+ this.handleChange();
+ });
+
+ e.preventDefault();
+ return false;
+ };
+
+ handleKeyDown = e => {
+ // IE has issues not firing the onChange event.
+ if (bowser.msie) {
+ setTimeout(() => !this.unmounted && this.handleChange());
+ }
+
+ // Undo Redo 'Z'
+ if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
+ if (e.shiftKey) {
+ this.handleRedo();
+ } else {
+ this.handleUndo();
+ }
+ e.preventDefault();
+ return false;
+ }
+
+ if (e.metaKey || e.ctrlKey) {
+ if (this.handleShortcut(e)) {
+ e.preventDefault();
+ return false;
+ }
+ }
+
+ // Newlines Or Special Enter Behaviors.
+ if (e.key === 'Enter') {
+ if (!e.shiftKey && this.handleSpecialEnter()) {
+ this.handleChange();
+ e.preventDefault();
+ return false;
+ }
+
+ insertNewLine(true);
+
+ this.handleChange();
+ e.preventDefault();
+ return false;
+ }
+ };
+
+ 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;
+ selectEndOfNode(this.ref.htmlEl);
+ }
+ 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);
+ }
+ }
+
+ renderFeatures() {
+ return this.props.features.map(b => {
+ return React.cloneElement(b, {
+ disabled: this.props.disabled,
+ api: this.api,
+ ref: this.createFeatureRefHandler(b.key),
+ });
+ });
+ }
+
+ getClassNames() {
+ const { disabled } = this.props;
+ return {
+ toolbar: cn(this.props.toolbarClassName, {
+ [this.props.toolbarClassNameDisabled]: disabled,
+ [styles.toolbarDisabled]: disabled,
+ }),
+ content: cn(styles.contentEditable, this.props.contentClassName, {
+ [this.props.contentClassNameDisabled]: disabled,
+ [styles.contentEditableDisabled]: disabled,
+ }),
+ root: cn(this.props.className, {
+ [this.props.classNameDisabled]: disabled,
+ }),
+ placeholder: cn(styles.placeholder, this.props.placeholderClassName, {
+ [this.props.placeholderClassNameDisabled]: disabled,
+ }),
+ };
+ }
+
+ render() {
+ const { value, placeholder, inputId, disabled } = this.props;
+
+ const classNames = this.getClassNames();
+
+ return (
+
+
+ {this.renderFeatures()}
+
+ {!value &&
{placeholder}
}
+
+
+ );
+ }
+}
+
+RTE.defaultProps = {
+ features: [],
+};
+
+RTE.propTypes = {
+ features: PropTypes.array,
+ inputId: PropTypes.string,
+ input: PropTypes.object,
+ onChange: PropTypes.func,
+ disabled: PropTypes.bool,
+ className: PropTypes.string,
+ classNameDisabled: PropTypes.string,
+ contentClassName: PropTypes.string,
+ contentClassNameDisabled: PropTypes.string,
+ toolbarClassName: PropTypes.string,
+ toolbarClassNameDisabled: PropTypes.string,
+ placeholderClassName: PropTypes.string,
+ placeholderClassNameDisabled: PropTypes.string,
+ placeholder: PropTypes.string,
+ value: PropTypes.string,
+};
+
+export default RTE;
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css
new file mode 100644
index 000000000..39b14fb7e
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css
@@ -0,0 +1,43 @@
+.buttonReset {
+ user-select: none;
+ outline: invert none medium;
+ border: none;
+ touch-action: manipulation;
+ padding: 0;
+ overflow: hidden;
+
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
+ &::-moz-focus-inner: {
+ border: 0;
+ }
+}
+
+.button > i {
+ vertical-align: middle;
+}
+
+.button {
+ composes: buttonReset;
+ background-color: transparent;
+ padding: 3px;
+ border: none;
+ color: #4e4e4e;
+ margin-right: 3px;
+}
+
+.button:hover{
+ cursor: pointer;
+ border-radius: 3px;
+ background-color: #eae8e8;
+}
+
+.active {
+ border-radius: 3px;
+ background-color: #ddd;
+}
+
+.button:disabled{
+ color: #bbb;
+ cursor: default;
+ background: none;
+}
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js
new file mode 100644
index 000000000..f99648cb7
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from './Button.css';
+import cn from 'classnames';
+
+class Button extends React.Component {
+ render() {
+ const {
+ className,
+ title,
+ onClick,
+ children,
+ active,
+ activeClassName,
+ disabled,
+ } = this.props;
+ return (
+
+ );
+ }
+}
+
+Button.propTypes = {
+ className: PropTypes.string,
+ activeClassName: PropTypes.string,
+ title: PropTypes.string,
+ onClick: PropTypes.func,
+ children: PropTypes.node,
+ active: PropTypes.bool,
+ disabled: PropTypes.bool,
+};
+
+export default Button;
diff --git a/plugins/talk-plugin-rich-text/client/components/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/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/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/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
new file mode 100644
index 000000000..028757222
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js
@@ -0,0 +1,85 @@
+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, onShortcut, isActive = () => false, isDisabled = () => false } = {}
+) => {
+ class Toggle extends React.Component {
+ state = {
+ active: false,
+ disabled: false,
+ };
+
+ execCommand = () => execCommand.apply(this.props.api);
+ isActive = () => isActive.apply(this.props.api);
+ isDisabled = () => isDisabled.apply(this.props.api);
+ onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args);
+ onShortcut = (...args) =>
+ onShortcut && onShortcut.apply(this.props.api, args);
+ unmounted = false;
+
+ componentWillUnmount() {
+ this.unmounted = true;
+ }
+
+ formatToggle = () => {
+ this.execCommand();
+ };
+
+ handleClick = () => {
+ this.props.api.focus();
+ this.formatToggle();
+ this.props.api.focus();
+ setTimeout(() => !this.unmounted && this.syncState());
+ };
+
+ syncState = () => {
+ if (this.state.active !== this.isActive()) {
+ this.setState(state => ({
+ active: !state.active,
+ }));
+ }
+ if (this.state.disabled !== this.isDisabled()) {
+ this.setState(state => ({
+ disabled: !state.disabled,
+ }));
+ }
+ };
+
+ onSelectionChange() {
+ this.syncState();
+ }
+
+ render() {
+ const { className, title, children, disabled } = this.props;
+ return (
+
+ );
+ }
+ }
+
+ Toggle.propTypes = {
+ api: PropTypes.object,
+ className: PropTypes.string,
+ title: PropTypes.string,
+ children: PropTypes.node,
+ disabled: PropTypes.bool,
+ };
+ return Toggle;
+};
+
+export default createToggle;
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/features/Blockquote.js
new file mode 100644
index 000000000..201260fcd
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Blockquote.js
@@ -0,0 +1,49 @@
+import createToggle from '../factories/createToggle';
+import {
+ findIntersecting,
+ insertNewLineAfterNode,
+ insertNodes,
+ getSelectedNodesExpanded,
+ outdentBlock,
+ selectEndOfNode,
+ indentNodes,
+} from '../lib/dom';
+
+function execCommand() {
+ const bq = findIntersecting('BLOCKQUOTE', this.container);
+ if (bq) {
+ outdentBlock(bq, true);
+ } else {
+ // Expanded selection means we always select whole lines.
+ const selectedNodes = getSelectedNodesExpanded();
+ if (selectedNodes.length) {
+ indentNodes(selectedNodes, 'blockquote', true);
+ } else {
+ const node = document.createElement('blockquote');
+ node.appendChild(document.createElement('br'));
+ insertNodes(node);
+ selectEndOfNode(node);
+ }
+ }
+ this.broadcastChange();
+}
+
+function isActive() {
+ return this.focused && !!findIntersecting('BLOCKQUOTE', this.container);
+}
+
+function onEnter(node) {
+ if (node.tagName !== 'BLOCKQUOTE') {
+ return;
+ }
+ insertNewLineAfterNode(node, true);
+ return true;
+}
+
+const Blockquote = createToggle(execCommand, { onEnter, isActive });
+
+Blockquote.defaultProps = {
+ children: 'Blockquote',
+};
+
+export default Blockquote;
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js b/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js
new file mode 100644
index 000000000..8f5b55de5
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js
@@ -0,0 +1,43 @@
+import createToggle from '../factories/createToggle';
+import { findIntersecting } from '../lib/dom';
+
+const boldTags = ['B', 'STRONG'];
+
+function execCommand() {
+ return document.execCommand('bold');
+}
+
+function isActive() {
+ return this.focused && document.queryCommandState('bold');
+}
+function isDisabled() {
+ if (!this.focused) {
+ return false;
+ }
+
+ // Disable whenever the bold styling came from a different
+ // tag than those we control.
+ return !!findIntersecting(
+ n =>
+ n.nodeName !== '#text' &&
+ window.getComputedStyle(n).getPropertyValue('font-weight') === 'bold' &&
+ !boldTags.includes(n.tagName),
+ this.container
+ );
+}
+function onShortcut(e) {
+ if (e.key === 'b') {
+ if (!isDisabled.apply(this)) {
+ execCommand.apply(this);
+ }
+ return true;
+ }
+}
+
+const Bold = createToggle(execCommand, { isActive, isDisabled, onShortcut });
+
+Bold.defaultProps = {
+ children: 'Bold',
+};
+
+export default Bold;
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js b/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js
new file mode 100644
index 000000000..d35f08da0
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js
@@ -0,0 +1,41 @@
+import createToggle from '../factories/createToggle';
+import { findIntersecting } from '../lib/dom';
+
+const italicTags = ['I', 'EM'];
+
+function execCommand() {
+ return document.execCommand('italic');
+}
+function isActive() {
+ return this.focused && document.queryCommandState('italic');
+}
+function isDisabled() {
+ if (!this.focused) {
+ return false;
+ }
+ // Disable whenever the italic styling came from a different
+ // tag than those we control.
+ return !!findIntersecting(
+ n =>
+ n.nodeName !== '#text' &&
+ window.getComputedStyle(n).getPropertyValue('font-style') === 'italic' &&
+ !italicTags.includes(n.tagName),
+ this.container
+ );
+}
+function onShortcut(e) {
+ if (e.key === 'i') {
+ if (!isDisabled.apply(this)) {
+ execCommand.apply(this);
+ }
+ return true;
+ }
+}
+
+const Italic = createToggle(execCommand, { isActive, isDisabled, onShortcut });
+
+Italic.defaultProps = {
+ children: 'Italic',
+};
+
+export default Italic;
diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/index.js b/plugins/talk-plugin-rich-text/client/components/rte/features/index.js
new file mode 100644
index 000000000..5e985fe6a
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/features/index.js
@@ -0,0 +1,3 @@
+export { default as Bold } from './Bold';
+export { default as Italic } from './Italic';
+export { default as Blockquote } from './Blockquote';
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..9e018f093
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js
@@ -0,0 +1,37 @@
+import { isSelectionInside } from './dom';
+
+/**
+ * An instance of API is passed to all the buttons to
+ * interact with RTE, which servers as a clean abstraction.
+ */
+function createAPI(
+ getContainer,
+ broadcastChange,
+ canUndo,
+ canRedo,
+ undo,
+ redo,
+ getFocused
+) {
+ return {
+ broadcastChange,
+ canUndo,
+ canRedo,
+ undo,
+ redo,
+ get focused() {
+ return getFocused();
+ },
+ get container() {
+ return getContainer();
+ },
+ focus() {
+ this.container.focus();
+ },
+ isSelectionInside() {
+ return isSelectionInside(getContainer());
+ },
+ };
+}
+
+export default createAPI;
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..a5fbec19e
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js
@@ -0,0 +1,681 @@
+/**
+ * Traverse DOM tree until callback returns anything.
+ */
+export function traverse(node, callback) {
+ let result;
+ for (let i = 0; i < node.childNodes.length; i++) {
+ const child = node.childNodes[i];
+ result = callback(child);
+ if (result === undefined) {
+ result = traverse(child, callback);
+ }
+ if (result !== undefined) {
+ return result;
+ }
+ }
+}
+
+/**
+ * Traverse DOM tree backwards until callback returns anything.
+ * If hits limitTo returns null.
+ */
+export function traverseUp(node, callback, limitTo) {
+ let result;
+ if (node.isSameNode(limitTo)) {
+ return null;
+ }
+ while (node.parentNode) {
+ node = node.parentNode;
+ result = callback(node);
+ if (result !== undefined) {
+ return result;
+ }
+ if (limitTo && node.isSameNode(limitTo)) {
+ return null;
+ }
+ }
+}
+
+/**
+ * Find ancestor with given tag or whith callback returning true.
+ * If `limitTo` is passed, the search is limited to this container.
+ */
+export function findAncestor(node, tagOrCallback, limitTo) {
+ const callback =
+ typeof tagOrCallback === 'function'
+ ? tagOrCallback
+ : n => n.tagName === tagOrCallback;
+ return (
+ traverseUp(
+ node,
+ n => {
+ if (callback(n)) {
+ return n;
+ }
+ },
+ limitTo
+ ) || null
+ );
+}
+
+/**
+ * Find child with given tag or when callback return true.
+ */
+export function findChild(node, tagOrCallback) {
+ const callback =
+ typeof tagOrCallback === 'function'
+ ? tagOrCallback
+ : n => n.tagName === tagOrCallback;
+ return (
+ traverse(node, n => {
+ if (callback(n)) {
+ return n;
+ }
+ }) || null
+ );
+}
+
+/**
+ * Find an node intersecting with the selection with given tag or
+ * with callback returning true. If `limitTo` is passed, the search
+ * is limited to this container.
+ */
+export function findIntersecting(tagOrCallback, limitTo) {
+ const callback =
+ typeof tagOrCallback === 'function'
+ ? tagOrCallback
+ : n => n.tagName === tagOrCallback;
+
+ const range = getSelectionRange();
+ if (!range) {
+ return null;
+ }
+
+ if (callback(range.startContainer)) {
+ return range.startContainer;
+ }
+
+ const ancestor = findAncestor(range.startContainer, callback, limitTo);
+ if (ancestor) {
+ return ancestor;
+ }
+
+ const nodes = getSelectedChildren(range.commonAncestorContainer);
+ for (let i = 0; i < nodes.length; i++) {
+ if (callback(nodes[i])) {
+ return nodes[i];
+ }
+ const found = findChild(nodes[i], callback);
+ if (found) {
+ return found;
+ }
+ }
+ return null;
+}
+
+/**
+ * Same as node.contains but works in IE.
+ * In addition lookFor can also be a callback.
+ */
+export function nodeContains(node, lookFor) {
+ const callback =
+ typeof lookFor === 'function' ? lookFor : n => n.isSameNode(lookFor);
+ if (callback(node)) {
+ return true;
+ }
+ return !!findChild(node, callback);
+}
+
+/**
+ * Returns true if node is not `inline` nor `inline-block`.
+ */
+export function isBlockElement(node) {
+ if (node.nodeName === '#text') {
+ return false;
+ }
+ return !window
+ .getComputedStyle(node)
+ .getPropertyValue('display')
+ .startsWith('inline');
+}
+
+/**
+ * Find parent that is a block element.
+ */
+export function findParentBlock(node) {
+ return findAncestor(node, isBlockElement);
+}
+
+/**
+ * Find last parent before a block element.
+ */
+export function lastParentBeforeBlock(node) {
+ return findAncestor(node, n => !n.parentNode || isBlockElement(n.parentNode));
+}
+
+/**
+ * Like `Array.indexOf` but works on `childNodes`.
+ */
+export function indexOfChildNode(parent, child) {
+ for (let i = 0; i < parent.childNodes.length; i++) {
+ if (parent.childNodes[i] === child) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Same as `document.execCommand('insertText', false, text)` but also
+ * works for IE. Changes Selection.
+ */
+export function insertText(text) {
+ const selection = window.getSelection();
+ const range = selection.getRangeAt(0);
+ if (!range.collapsed) {
+ range.deleteContents();
+ }
+ const newRange = document.createRange();
+ 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;
+
+ newRange.setStart(container, nextOffset);
+ newRange.setEnd(container, nextOffset);
+ } else {
+ const textNode = document.createTextNode(text);
+ container.insertBefore(textNode, container.childNodes[offset]);
+ newRange.setStart(textNode, text.length);
+ newRange.setEnd(textNode, text.length);
+ }
+ replaceSelection(newRange);
+}
+
+/**
+ * Insert nodes to current selection,
+ * does not change selection.
+ */
+export function insertNodes(...nodes) {
+ const selection = window.getSelection();
+ const range = selection.getRangeAt(0);
+ if (!range.collapsed) {
+ range.deleteContents();
+ }
+ 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));
+ }
+}
+
+/**
+ * Helper to replace current selection with range.
+ */
+export function replaceSelection(range) {
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+/**
+ * Helper to to know if selection is collapsed.
+ */
+export function isSelectionCollapsed() {
+ return window.getSelection().isCollapsed;
+}
+
+/**
+ * Helper to get current selection range.
+ */
+export function getSelectionRange() {
+ const selection = window.getSelection();
+ return selection.rangeCount ? selection.getRangeAt(0) : null;
+}
+
+// Adds a bogus 'br' at the end of the node if not existant.
+export function addBogusBR(node) {
+ if (!isBlockElement(node)) {
+ return;
+ }
+ if (!node.lastChild || !isBogusBR(node.lastChild)) {
+ node.appendChild(document.createElement('br'));
+ }
+}
+
+/**
+ * Returns true if selection is completely inside
+ * given nodes.
+ */
+export function isSelectionInside(...nodes) {
+ let foundStart = false;
+ const range = getSelectionRange();
+ if (!range) {
+ return false;
+ }
+ for (let i = 0; i < nodes.length; i++) {
+ if (!foundStart) {
+ foundStart = nodeContains(nodes[i], range.startContainer);
+ }
+ if (foundStart) {
+ const foundEnd = nodeContains(nodes[i], range.endContainer);
+ if (foundEnd) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * Insert new line. This is what happens
+ * when adding new lines through pressing Enter.
+ * Deals with browers quirks.
+ */
+export function insertNewLine(changeSelection) {
+ // Insert node.
+ const el = document.createElement('br');
+ insertNodes(el);
+
+ // If we are adding to the end of the node, we also need
+ // to add a bogus br.
+ if (!el.nextSibling) {
+ el.parentNode.appendChild(document.createElement('br'));
+ }
+
+ // Adding directly before a block element needs also a bogus br.
+ if (el.nextSibling && isBlockElement(el.nextSibling)) {
+ el.parentNode.insertBefore(document.createElement('br'), el.nextSibling);
+ }
+
+ // 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 {
+ const offset = el.parentNode.childNodes.length - 1;
+ range.setStart(el.parentNode, offset);
+ range.setEnd(el.parentNode, offset);
+ }
+
+ if (changeSelection) {
+ replaceSelection(range);
+ }
+}
+
+/**
+ * Inserts a new line after given node.
+ */
+export function insertNewLineAfterNode(node, changeSelection) {
+ const el = document.createElement('br');
+ if (node.nextSibling) {
+ node.parentNode.insertBefore(el, node.nextSibling);
+ } else {
+ node.parentNode.appendChild(el);
+ }
+
+ if (changeSelection) {
+ const offset = indexOfChildNode(node.parentNode, el);
+ const range = document.createRange();
+ range.setStart(node.parentNode, offset);
+ range.setEnd(node.parentNode, offset);
+ replaceSelection(range);
+ }
+}
+
+/**
+ * Given a container and a offset, return the selected
+ * node. Usually to resolve the start or end of a range.
+ */
+export function getRangeNode(container, offset) {
+ if (container.nodeName === '#text') {
+ return container;
+ }
+ return container.childNodes[offset];
+}
+
+/**
+ * Returns an array of all nodes before `node`.
+ */
+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 isBogusBR(node) {
+ return (
+ (!node.previousSibling || !isBlockElement(node.previousSibling)) &&
+ node.tagName === 'BR' &&
+ (!node.nextSibling || isBlockElement(node.previousSibling))
+ );
+}
+
+/**
+ * Returns an array of all nodes after `node`.
+ */
+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' &&
+ !isBogusBR(cur.nextSibling)
+ ) {
+ result.push(cur.nextSibling);
+ }
+ return result;
+}
+
+/**
+ * Given `node` find the line it belongs too
+ * and return the whole line as an array.
+ */
+export function getWholeLine(node) {
+ if (isBlockElement(node)) {
+ return [node];
+ }
+ const child = isBlockElement(node.parentNode)
+ ? node
+ : lastParentBeforeBlock(node);
+ if (child.tagName === 'BR') {
+ return [...getLeftOfNode(child), child];
+ }
+ return [...getLeftOfNode(child), child, ...getRightOfNode(child)];
+}
+
+/**
+ * Get selected line at the start of the selection.
+ * Returns an array of nodes.
+ */
+export function getSelectedLine() {
+ const range = getSelectionRange();
+ if (!range) {
+ return [];
+ }
+ const start = getRangeNode(range.startContainer, range.startOffset);
+ return start ? getWholeLine(start) : [];
+}
+
+/**
+ * Finds a commen block ancestor in the selection
+ * and return "whole" lines as an array of nodes.
+ */
+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]),
+ ];
+}
+
+/**
+ * Returns array of children that intersects with
+ * the selection.
+ */
+export function getSelectedChildren(ancestor) {
+ const result = [];
+ const range = getSelectionRange();
+ if (!range) {
+ return result;
+ }
+ if (!range) {
+ return result;
+ }
+
+ const start = getRangeNode(range.startContainer, range.startOffset);
+ const end = getRangeNode(range.endContainer, range.endOffset);
+ let foundStart = false;
+ for (let i = 0; i < ancestor.childNodes.length; i++) {
+ const node = ancestor.childNodes[i];
+ if (!foundStart) {
+ if (nodeContains(node, start)) {
+ foundStart = true;
+ }
+ }
+ if (foundStart) {
+ result.push(node);
+ if (nodeContains(node, end)) {
+ break;
+ }
+ }
+ }
+ return result;
+}
+
+/**
+ * Removes node and assimilate its children with the parent.
+ */
+export function outdentBlock(node, changeSelection) {
+ // Save previous range.
+ const selectionWasInside = isSelectionInside(node);
+ const {
+ startContainer,
+ startOffset,
+ endContainer,
+ endOffset,
+ } = getSelectionRange();
+
+ // Remove bogus br
+ if (node.lastChild && node.lastChild.tagName === 'BR') {
+ node.removeChild(node.lastChild);
+ }
+
+ // A new lines to substitute the missing block element.
+ const needLineAfter =
+ node.nextSibling &&
+ !isBlockElement(node.nextSibling) &&
+ node.lastChild &&
+ !isBlockElement(node.lastChild);
+ const needLineBefore =
+ node.previousSibling &&
+ !isBlockElement(node.previousSibling) &&
+ node.previousSibling.tageName !== 'BR';
+
+ const parentNode = node.parentNode;
+
+ if (needLineBefore) {
+ parentNode.insertBefore(document.createElement('BR'), node);
+ }
+
+ const previousOffset = indexOfChildNode(parentNode, node);
+
+ while (node.firstChild) {
+ parentNode.insertBefore(node.firstChild, node);
+ }
+
+ if (needLineAfter) {
+ parentNode.insertBefore(document.createElement('BR'), node);
+ }
+
+ parentNode.removeChild(node);
+
+ if (changeSelection) {
+ const range = document.createRange();
+
+ if (selectionWasInside) {
+ if (startContainer === node) {
+ range.setStart(parentNode, startOffset + previousOffset);
+ } else {
+ range.setStart(startContainer, startOffset);
+ }
+ if (endContainer === node) {
+ range.setEnd(parentNode, endOffset + previousOffset);
+ } else {
+ range.setEnd(endContainer, endOffset);
+ }
+ } else {
+ range.setStart(parentNode, previousOffset);
+ range.setEnd(parentNode, previousOffset);
+ }
+ replaceSelection(range);
+ }
+}
+
+/**
+ * Indent children.
+ */
+export function indentNodes(nodes, tagName, changeSelection) {
+ const parentNode = nodes[0].parentNode;
+ const node = document.createElement(tagName);
+
+ // Remove bogus BR if the blockquote is the last element.
+ // Otherwise there will be an unwanted empty line.
+ const lastNode = nodes[nodes.length - 1];
+ if (
+ lastNode.nextSibling === parentNode.lastChild &&
+ isBogusBR(parentNode.lastChild)
+ ) {
+ parentNode.removeChild(parentNode.lastChild);
+ }
+
+ const firstNode = nodes[0];
+ // Remove previous br as it is not needed
+ if (firstNode.previousSibling && firstNode.previousSibling.tagName === 'BR') {
+ parentNode.removeChild(firstNode.previousSibling);
+ }
+
+ // Finally indent.
+ parentNode.insertBefore(node, firstNode);
+ nodes.forEach(n => {
+ node.appendChild(n);
+ });
+
+ if (changeSelection) {
+ selectEndOfNode(node);
+ }
+
+ return node;
+}
+
+function cloneNodeAndRangeHelper(node, range, rangeCloned) {
+ const nodeCloned = node.cloneNode(false);
+ for (let i = 0; i < node.childNodes.length; i++) {
+ const n = node.childNodes[i];
+ 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;
+}
+
+/**
+ * Clones node and returns both the cloned node and an equivalent Range.
+ */
+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];
+}
+
+/**
+ * Take children of the second node and replace children of first node.
+ */
+export function replaceNodeChildren(node, node2) {
+ while (node.firstChild) {
+ node.removeChild(node.firstChild);
+ }
+ while (node2.firstChild) {
+ node.appendChild(node2.firstChild);
+ }
+}
+
+/**
+ * Tries to select the end of node.
+ * Currently looks for and text nodes to find a suitable
+ * candidate for a selection.
+ */
+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
new file mode 100644
index 000000000..eef076567
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js
@@ -0,0 +1,65 @@
+/**
+ * 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;
+
+ 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 = [];
+ }
+}
diff --git a/plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js b/plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js
new file mode 100644
index 000000000..bfc708f7b
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js
@@ -0,0 +1,12 @@
+import { gql } from 'react-apollo';
+import { withFragments } from 'plugin-api/beta/client/hocs';
+import AdminCommentContent from '../components/AdminCommentContent';
+
+export default withFragments({
+ comment: gql`
+ fragment TalkPluginRichText_AdminCommentContent_comment on Comment {
+ body
+ richTextBody
+ }
+ `,
+})(AdminCommentContent);
diff --git a/plugins/talk-plugin-rich-text/client/index.js b/plugins/talk-plugin-rich-text/client/index.js
index 6eb7a8c95..2f1e9ac0d 100644
--- a/plugins/talk-plugin-rich-text/client/index.js
+++ b/plugins/talk-plugin-rich-text/client/index.js
@@ -1,13 +1,17 @@
import Editor from './containers/Editor';
import CommentContent from './containers/CommentContent';
+import AdminCommentContent from './containers/AdminCommentContent';
+import translations from './translations.yml';
+
import { gql } from 'react-apollo';
export default {
+ translations,
slots: {
draftArea: [Editor],
commentContent: [CommentContent],
- adminCommentContent: [CommentContent],
- userDetailCommentContent: [CommentContent],
+ adminCommentContent: [AdminCommentContent],
+ userDetailCommentContent: [AdminCommentContent],
},
fragments: {
CreateCommentResponse: gql`
diff --git a/plugins/talk-plugin-rich-text/client/translations.yml b/plugins/talk-plugin-rich-text/client/translations.yml
new file mode 100644
index 000000000..0f230ace5
--- /dev/null
+++ b/plugins/talk-plugin-rich-text/client/translations.yml
@@ -0,0 +1,6 @@
+en:
+ talk-plugin-rich-text:
+ format_bold: bold
+ format_italic: italic
+ format_blockquote: blockquote
+
diff --git a/plugins/talk-plugin-rich-text/client/utils.js b/plugins/talk-plugin-rich-text/client/utils.js
index 4f49b3654..80132d646 100644
--- a/plugins/talk-plugin-rich-text/client/utils.js
+++ b/plugins/talk-plugin-rich-text/client/utils.js
@@ -1,12 +1,7 @@
export function htmlNormalizer(htmlInput) {
let str = htmlInput;
- // We are normalizing the input from contenteditable of each browser, also removing unnecesary html tags
- // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content#Differences_in_markup_generation
-
- // Old browsers uses `p` normalize to `div` instead.
- str = str
- .replace(/
/g, '
') // IE and old browsers outputs
instead of
s
- .replace(/<\/p>/g, '
'); // IE and old browsers outputs
instead of
s
+ // Some tags have not been normalized across browsers in `Coral RTE` yet.
+ // So we'll do this manual step here for now.
// Harmonize all to tag.
str = str
diff --git a/plugins/talk-plugin-rich-text/server/config.js b/plugins/talk-plugin-rich-text/server/config.js
index 347502d08..a9614a8f0 100644
--- a/plugins/talk-plugin-rich-text/server/config.js
+++ b/plugins/talk-plugin-rich-text/server/config.js
@@ -14,7 +14,7 @@ const config = {
// TODO: move to admin eventually
// Super strict rules to make sure users only submit the tags they are allowed
dompurify: {
- ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br', 'div'],
+ ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br', 'div', 'span'],
ALLOWED_ATTR: [],
},