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 (
- -
); @@ -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: [], },