Merge pull request #1475 from coralproject/rte-fixes

RTE fixes and improvements
This commit is contained in:
Wyatt Johnson
2018-03-26 13:06:56 -06:00
committed by GitHub
35 changed files with 1872 additions and 266 deletions
@@ -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 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : 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(<mark key={i}>{match.text}</mark>);
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 (
<span className={`${className}-text`} {...rest}>
{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 (
<span key={i} className={`${className}-line`}>
{content}
{i !== textbreaks.length - 1 && (
<br className={`${className}-linebreak`} />
)}
</span>
);
})}
</span>
);
};
CommentFormatter.propTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array,
suspectWords: PropTypes.array,
body: PropTypes.string,
};
export default CommentFormatter;
@@ -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);
@@ -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}
/>
<a
@@ -8,7 +8,7 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
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 ApproveButton from 'coral-admin/src/components/ApproveButton';
@@ -140,7 +140,7 @@ class Comment extends React.Component {
fill="adminCommentContent"
className={cn(styles.commentContent, 'talk-admin-comment')}
size={1}
defaultComponent={CommentFormatter}
defaultComponent={AdminCommentContent}
passthrough={{ ...slotPassthrough, ...formatterSettings }}
/>
<div className={styles.commentContentFooter}>
-9
View File
@@ -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) =>
@@ -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;
}
}
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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(<br key={`${keyPrefix}_${i}`} />);
}
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 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : 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(
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
{match.text}
</a>
);
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 ? `<mark>${escapeHTML(token)}</mark>` : 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 (
<div className={cn(className, styles.content)}>
{renderText(body, suspectWords, bannedWords)}
</div>
);
};
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 (
<div
className={cn(className, styles.content)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
/>
);
};
AdminCommentContentHTML.propTypes = commonPropTypes;
const AdminCommentContent = ({
className,
body,
suspectWords,
bannedWords,
html,
}) => {
const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
return (
<Component
className={className}
body={body}
suspectWords={suspectWords}
bannedWords={bannedWords}
/>
);
};
AdminCommentContent.propTypes = {
...commonPropTypes,
html: PropTypes.bool,
};
export default AdminCommentContent;
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
export { withSlotElements } from 'coral-framework/hocs';
@@ -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';
@@ -0,0 +1,4 @@
.content {
composes: content from "./CommentContent.css";
}
@@ -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 (
<Content
className={styles.content}
body={comment.richTextBody ? comment.richTextBody : comment.body}
suspectWords={suspectWords}
bannedWords={bannedWords}
html={!!comment.richTextBody}
/>
);
}
}
AdminCommentContent.propTypes = {
comment: PropTypes.object.isRequired,
suspectWords: PropTypes.array.isRequired,
bannedWords: PropTypes.array.isRequired,
};
export default AdminCommentContent;
@@ -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;
}
@@ -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 (
<BareButton
className={cn(className, styles.button)}
title={title}
onClick={onClick}
>
<Icon className={styles.icon} name={icon} />
</BareButton>
);
}
}
Button.propTypes = {
icon: PropTypes.string.isRequired,
className: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func,
};
export default Button;
@@ -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;
}
@@ -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 (
<div className={cn(styles.root, `${PLUGIN_NAME}-container`)}>
<label
@@ -96,32 +60,44 @@ class Editor extends React.Component {
className="screen-reader-text"
aria-hidden={true}
>
{this.props.label}
{label}
</label>
<Toolbar>
<Button icon="format_bold" title="bold" onClick={this.formatBold} />
<Button
icon="format_italic"
title="italic"
onClick={this.formatItalic}
/>
<Button
icon="format_quote"
title="quote"
onClick={this.formatBlockquote}
/>
</Toolbar>
{!this.props.input.body && (
<div className={styles.placeholder}>{this.props.placeholder}</div>
)}
<ContentEditable
id={inputId}
onKeyPress={this.outdentOnEnter}
className={styles.contentEditable}
ref={this.handleRef}
html={this.getHTML()}
disabled={false}
<RTE
inputId={inputId}
className={`${PLUGIN_NAME}-editor`}
classNameDisabled={`${PLUGIN_NAME}-editor-disabled`}
contentClassName={cn(`${PLUGIN_NAME}-content`, styles.commentContent)}
contentClassNameDisabled={`${PLUGIN_NAME}-content-disabled`}
toolbarClassName={`${PLUGIN_NAME}-toolbar`}
toolbarClassNameDisabled={`${PLUGIN_NAME}-toolbar-disabled`}
onChange={this.handleChange}
value={this.getHTML()}
disabled={disabled}
placeholder={placeholder}
ref={this.handleRef}
features={[
<Bold
key="bold"
title={t('talk-plugin-rich-text.format_bold')}
className={`${PLUGIN_NAME}-feature-bold`}
>
<Icon className={styles.icon} name="format_bold" />
</Bold>,
<Italic
key="italic"
title={t('talk-plugin-rich-text.format_italic')}
className={`${PLUGIN_NAME}-feature-italic`}
>
<Icon className={styles.icon} name="format_italic" />
</Italic>,
<Blockquote
key="blockquote"
title={t('talk-plugin-rich-text.format_blockquote')}
className={`${PLUGIN_NAME}-feature-blockquote`}
>
<Icon className={styles.icon} name="format_quote" />
</Blockquote>,
]}
/>
</div>
);
@@ -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,
@@ -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;
}
@@ -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 (
<div className={classNames.root}>
<Toolbar className={classNames.toolbar}>
{this.renderFeatures()}
</Toolbar>
{!value && <div className={classNames.placeholder}>{placeholder}</div>}
<ContentEditable
id={inputId}
onMouseUp={this.handleMouseUp}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
onKeyUp={this.handleKeyUp}
onPaste={this.handlePaste}
onCut={this.handleCut}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onSelect={this.handleSelectionChange}
className={classNames.content}
ref={this.handleRef}
html={value}
disabled={disabled}
onChange={this.handleChange}
/>
</div>
);
}
}
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;
@@ -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;
}
@@ -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
className={cn(className, styles.button, {
[cn(styles.active, activeClassName)]: active,
})}
title={title}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
}
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;
@@ -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 (
<Button
className={className}
title={title}
onClick={this.handleClick}
active={this.state.active}
disabled={disabled || this.state.disabled}
>
{children}
</Button>
);
}
}
Toggle.propTypes = {
api: PropTypes.object,
className: PropTypes.string,
title: PropTypes.string,
children: PropTypes.node,
disabled: PropTypes.bool,
};
return Toggle;
};
export default createToggle;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,3 @@
export { default as Bold } from './Bold';
export { default as Italic } from './Italic';
export { default as Blockquote } from './Blockquote';
@@ -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;
@@ -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 <br> 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 <br> 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;
}
@@ -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 = [];
}
}
@@ -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);
@@ -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`
@@ -0,0 +1,6 @@
en:
talk-plugin-rich-text:
format_bold: bold
format_italic: italic
format_blockquote: blockquote
@@ -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(/<p>/g, '<div>') // IE and old browsers outputs <p> instead of <div>s
.replace(/<\/p>/g, '</div>'); // IE and old browsers outputs <p> instead of <div>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 <b> tag.
str = str
@@ -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: [],
},