From 19920deeda32af804c805549bd8fcb21e4a43aca Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 18:03:13 +0100 Subject: [PATCH 01/40] Better detect tag ancestors --- .../client/components/Editor.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js index 5c1497f00..eefb978a8 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -46,13 +46,17 @@ class Editor extends React.Component { this.props.unregisterHook(this.normalizeHook); } - getCurrentTagName() { + hasAncestor(tag) { const sel = window.getSelection(); const range = sel.getRangeAt(0); - if (range.startContainer.nodeName !== '#text') { - return range.startContainer.nodeName; - } - return range.startContainer.parentNode.tagName; + let cur = range.startContainer; + do { + if (cur.nodeName === tag) { + return true; + } + cur = cur.parentNode; + } while (cur); + return false; } formatBold = () => { @@ -66,8 +70,7 @@ class Editor extends React.Component { }; formatBlockquote = () => { - const currentTag = this.getCurrentTagName(); - if (currentTag === 'BLOCKQUOTE') { + if (this.hasAncestor('BLOCKQUOTE')) { document.execCommand('outdent'); } else { if (bowser.msie) { From a1a867ec5501a8b78d91d2b52981234e2c0d3c89 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 19:44:53 +0100 Subject: [PATCH 02/40] Add margin to contentEditable --- plugins/talk-plugin-rich-text/client/components/Editor.css | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.css b/plugins/talk-plugin-rich-text/client/components/Editor.css index eabc1ee94..4e6a072a9 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.css +++ b/plugins/talk-plugin-rich-text/client/components/Editor.css @@ -9,6 +9,7 @@ width: 100%; padding: 10px; font-style: unset; + margin-bottom: 3px; } .placeholder { From 292e33c105d698b157f0555ad4e5e363c7acbb6d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 23:15:21 +0100 Subject: [PATCH 03/40] Implement AdminCommentContent for text and html --- .../coral-admin/src/components/IfHasLink.js | 2 +- .../src/components/UserDetailComment.js | 4 +- .../routes/Moderation/components/Comment.js | 4 +- .../contentRenderer.js} | 83 +++---- client/coral-admin/src/utils/index.js | 9 - .../components/AdminCommentContent.css | 12 + .../components/AdminCommentContent.js | 210 ++++++++++++++++++ client/coral-framework/utils/matchLinks.js | 8 + plugin-api/beta/client/components/index.js | 3 + .../client/components/AdminCommentContent.css | 4 + .../client/components/AdminCommentContent.js | 27 +++ .../client/containers/AdminCommentContent.js | 12 + plugins/talk-plugin-rich-text/client/index.js | 6 +- 13 files changed, 321 insertions(+), 63 deletions(-) rename client/coral-admin/src/{components/CommentFormatter.js => utils/contentRenderer.js} (59%) create mode 100644 client/coral-framework/components/AdminCommentContent.css create mode 100644 client/coral-framework/components/AdminCommentContent.js create mode 100644 client/coral-framework/utils/matchLinks.js create mode 100644 plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css create mode 100644 plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js create mode 100644 plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js 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/components/CommentFormatter.js b/client/coral-admin/src/utils/contentRenderer.js similarity index 59% rename from client/coral-admin/src/components/CommentFormatter.js rename to client/coral-admin/src/utils/contentRenderer.js index 8874d123f..9e5f4e1a4 100644 --- a/client/coral-admin/src/components/CommentFormatter.js +++ b/client/coral-admin/src/utils/contentRenderer.js @@ -1,6 +1,5 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { matchLinks } from '../utils'; +import { matchLinks } from './index'; import memoize from 'lodash/memoize'; function escapeRegExp(string) { @@ -35,6 +34,17 @@ function getPhrasesRegexp(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) { @@ -48,14 +58,20 @@ function markPhrases(body, suspectWords, bannedWords, keyPrefix) { // markLinks looks for links inside `body` and highlights them by returning // an array of React Elements. -function markLinks(body) { +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}); + content.push( + +
+ {match.text} + + + ); index = match.lastIndex; }); } @@ -63,47 +79,20 @@ function markLinks(body) { return content; } -const CommentFormatter = ({ - body, - suspectWords, - bannedWords, - className = 'comment', - ...rest -}) => { - // Breaking the body by line break - const textbreaks = body.split('\n'); +export 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; + } - 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; + // Highlight suspect and banned phrase inside this part of text. + return markPhrases(element, suspectWords, bannedWords, index); + }); + }); +} 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..4acd88a28 --- /dev/null +++ b/client/coral-framework/components/AdminCommentContent.css @@ -0,0 +1,12 @@ +.content { + a { + color: #063b9a; + text-decoration: underline; + font-weight: 300; + background-color: ##f4ff81; + } + + mark { + background-color: ##f4ff81; + } +} diff --git a/client/coral-framework/components/AdminCommentContent.js b/client/coral-framework/components/AdminCommentContent.js new file mode 100644 index 000000000..be86c26bb --- /dev/null +++ b/client/coral-framework/components/AdminCommentContent.js @@ -0,0 +1,210 @@ +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, +}) => { + const node = document.createElement('div'); + node.innerHTML = body; + markHTMLNode(node, suspectWords, bannedWords); + 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/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/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..eb768adb9 100644 --- a/plugins/talk-plugin-rich-text/client/index.js +++ b/plugins/talk-plugin-rich-text/client/index.js @@ -1,13 +1,15 @@ import Editor from './containers/Editor'; import CommentContent from './containers/CommentContent'; +import AdminCommentContent from './containers/AdminCommentContent'; + import { gql } from 'react-apollo'; export default { slots: { draftArea: [Editor], commentContent: [CommentContent], - adminCommentContent: [CommentContent], - userDetailCommentContent: [CommentContent], + adminCommentContent: [AdminCommentContent], + userDetailCommentContent: [AdminCommentContent], }, fragments: { CreateCommentResponse: gql` From d4e02b620e1eb0efca2409c8677ddf2fd259cc74 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 23:19:50 +0100 Subject: [PATCH 04/40] Typo --- client/coral-framework/components/AdminCommentContent.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/coral-framework/components/AdminCommentContent.css b/client/coral-framework/components/AdminCommentContent.css index 4acd88a28..b8f1e3eff 100644 --- a/client/coral-framework/components/AdminCommentContent.css +++ b/client/coral-framework/components/AdminCommentContent.css @@ -3,10 +3,10 @@ color: #063b9a; text-decoration: underline; font-weight: 300; - background-color: ##f4ff81; + background-color: #f4ff81; } mark { - background-color: ##f4ff81; + background-color: #f4ff81; } } From a1fcb6e1711e1060452d66bb3afd1fbbeb5d9fea Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 23:27:36 +0100 Subject: [PATCH 05/40] Make bold more bold --- client/coral-framework/components/AdminCommentContent.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/coral-framework/components/AdminCommentContent.css b/client/coral-framework/components/AdminCommentContent.css index b8f1e3eff..9b7a47b33 100644 --- a/client/coral-framework/components/AdminCommentContent.css +++ b/client/coral-framework/components/AdminCommentContent.css @@ -9,4 +9,8 @@ mark { background-color: #f4ff81; } + + b, strong { + font-weight: 600; + } } From 543dde12aa40ad21988eaf5dddfa5b99735da6c3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 23:59:35 +0100 Subject: [PATCH 06/40] Remove unused --- .../coral-admin/src/utils/contentRenderer.js | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 client/coral-admin/src/utils/contentRenderer.js diff --git a/client/coral-admin/src/utils/contentRenderer.js b/client/coral-admin/src/utils/contentRenderer.js deleted file mode 100644 index 9e5f4e1a4..000000000 --- a/client/coral-admin/src/utils/contentRenderer.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { matchLinks } from './index'; -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); - -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; -} - -export 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); - }); - }); -} From 1fb1fc6ba98ed70766154eaa8c015299c7b5c900 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 23 Mar 2018 00:02:27 +0100 Subject: [PATCH 07/40] Add comments --- client/coral-framework/components/AdminCommentContent.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/coral-framework/components/AdminCommentContent.js b/client/coral-framework/components/AdminCommentContent.js index be86c26bb..b3110e822 100644 --- a/client/coral-framework/components/AdminCommentContent.js +++ b/client/coral-framework/components/AdminCommentContent.js @@ -172,9 +172,16 @@ const AdminCommentContentHTML = ({ 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 (
Date: Fri, 23 Mar 2018 12:37:20 +0100 Subject: [PATCH 08/40] Refactor n active state --- .../client/components/Button.css | 20 --- .../client/components/Editor.css | 16 +-- .../client/components/Editor.js | 104 ++++---------- .../client/components/rte/Button.css | 37 +++++ .../client/components/{ => rte}/Button.js | 24 +++- .../client/components/rte/RTE.css | 18 +++ .../client/components/rte/RTE.js | 135 ++++++++++++++++++ .../client/components/{ => rte}/Toolbar.css | 0 .../client/components/{ => rte}/Toolbar.js | 0 .../components/rte/buttons/Blockquote.js | 27 ++++ .../client/components/rte/buttons/Bold.js | 12 ++ .../client/components/rte/buttons/Italic.js | 12 ++ .../client/components/rte/buttons/index.js | 3 + .../components/rte/factories/createToggle.js | 57 ++++++++ .../client/components/rte/utils.js | 12 ++ 15 files changed, 362 insertions(+), 115 deletions(-) delete mode 100644 plugins/talk-plugin-rich-text/client/components/Button.css create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/Button.css rename plugins/talk-plugin-rich-text/client/components/{ => rte}/Button.js (50%) create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/RTE.css create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/RTE.js rename plugins/talk-plugin-rich-text/client/components/{ => rte}/Toolbar.css (100%) rename plugins/talk-plugin-rich-text/client/components/{ => rte}/Toolbar.js (100%) create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/buttons/index.js create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/utils.js 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/Editor.css b/plugins/talk-plugin-rich-text/client/components/Editor.css index 4e6a072a9..2259d01a3 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.css +++ b/plugins/talk-plugin-rich-text/client/components/Editor.css @@ -1,15 +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; - margin-bottom: 3px; } .placeholder { @@ -17,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 eefb978a8..5962630b5 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -4,19 +4,18 @@ 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/buttons'; 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, }); }; @@ -46,52 +45,10 @@ class Editor extends React.Component { this.props.unregisterHook(this.normalizeHook); } - hasAncestor(tag) { - const sel = window.getSelection(); - const range = sel.getRangeAt(0); - let cur = range.startContainer; - do { - if (cur.nodeName === tag) { - return true; - } - cur = cur.parentNode; - } while (cur); - return false; - } - - formatBold = () => { - document.execCommand('bold'); - this.ref.htmlEl.focus(); - }; - - formatItalic = () => { - document.execCommand('italic'); - this.ref.htmlEl.focus(); - }; - - formatBlockquote = () => { - if (this.hasAncestor('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 (
- -
); @@ -137,7 +88,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/Button.css b/plugins/talk-plugin-rich-text/client/components/rte/Button.css new file mode 100644 index 000000000..daedd54ec --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/Button.css @@ -0,0 +1,37 @@ +.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; +} diff --git a/plugins/talk-plugin-rich-text/client/components/Button.js b/plugins/talk-plugin-rich-text/client/components/rte/Button.js similarity index 50% rename from plugins/talk-plugin-rich-text/client/components/Button.js rename to plugins/talk-plugin-rich-text/client/components/rte/Button.js index 330e3d993..f7bc75941 100644 --- a/plugins/talk-plugin-rich-text/client/components/Button.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/Button.js @@ -1,29 +1,39 @@ 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; + const { + className, + title, + onClick, + children, + active, + activeClassName, + } = this.props; return ( - - - + {children} + ); } } Button.propTypes = { - icon: PropTypes.string.isRequired, className: PropTypes.string, + activeClassName: PropTypes.string, title: PropTypes.string, onClick: PropTypes.func, + children: PropTypes.node, + active: PropTypes.bool, }; export default Button; 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..ab24828bd --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css @@ -0,0 +1,18 @@ +.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; +} 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..55ce25333 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -0,0 +1,135 @@ +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 './Toolbar'; + +class Editor extends React.Component { + ref = null; + handleRef = ref => (this.ref = ref); + buttonsRef = {}; + + get contentEditable() { + return this.ref.htmlEl; + } + + createButtonRefHandler(key) { + return ref => { + if (ref) { + this.buttonsRef[key] = ref; + } else { + delete this.buttonsRef[key]; + } + }; + } + + hasAncestor(tag) { + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + let cur = range.startContainer; + do { + if (cur.nodeName === tag) { + return true; + } + cur = cur.parentNode; + } while (cur); + return false; + } + + forEachButton(callback) { + Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); + } + + handleChange = evt => { + this.props.onChange({ + text: this.ref.htmlEl.innerText, + html: evt.target.value, + }); + }; + + handleSelectionChange = () => { + this.forEachButton(b => { + b.onSelectionChange && b.onSelectionChange(); + }); + }; + + handleClick = () => { + this.handleSelectionChange(); + }; + + handleKeyDown = () => { + this.handleSelectionChange(); + }; + + handleKeyUp = () => { + this.handleSelectionChange(); + }; + + handleKeyPress = e => { + this.handleSelectionChange(); + if (e.key === 'Enter' && !e.shiftKey) { + setTimeout(() => { + document.execCommand('outdent'); + }); + } + }; + + renderButtons() { + return this.props.buttons.map(b => { + return React.cloneElement(b, { + rte: this, + ref: this.createButtonRefHandler(b.key), + }); + }); + } + + render() { + const { + className, + contentClassName, + toolbarClassName, + value, + placeholder, + inputId, + } = this.props; + + return ( +
+ {this.renderButtons()} + {!value &&
{placeholder}
} + +
+ ); + } +} + +Editor.defaultProps = { + buttons: [], +}; + +Editor.propTypes = { + buttons: PropTypes.array, + inputId: PropTypes.string, + input: PropTypes.object, + onChange: PropTypes.func, + disabled: PropTypes.bool, + className: PropTypes.string, + contentClassName: PropTypes.string, + toolbarClassName: PropTypes.string, + placeholder: PropTypes.string, + value: PropTypes.string, +}; + +export default Editor; diff --git a/plugins/talk-plugin-rich-text/client/components/Toolbar.css b/plugins/talk-plugin-rich-text/client/components/rte/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/Toolbar.css diff --git a/plugins/talk-plugin-rich-text/client/components/Toolbar.js b/plugins/talk-plugin-rich-text/client/components/rte/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/Toolbar.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js new file mode 100644 index 000000000..1ba9d933f --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -0,0 +1,27 @@ +import createToggle from '../factories/createToggle'; +import { hasAncestor } from '../utils'; +import bowser from 'bowser'; + +const execCommand = () => { + if (hasAncestor('BLOCKQUOTE')) { + document.execCommand('outdent'); + } else { + if (bowser.msie) { + document.execCommand('indent'); + } else { + document.execCommand('formatBlock', false, 'blockquote'); + } + } +}; + +const syncState = () => { + return hasAncestor('BLOCKQUOTE'); +}; + +const Blockquote = createToggle(execCommand, syncState); + +Blockquote.defaultProps = { + children: 'Blockquote', +}; + +export default Blockquote; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js new file mode 100644 index 000000000..4988b0679 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js @@ -0,0 +1,12 @@ +import createToggle from '../factories/createToggle'; + +const execCommand = () => document.execCommand('bold'); +const syncState = () => document.queryCommandState('bold'); + +const Bold = createToggle(execCommand, syncState); + +Bold.defaultProps = { + children: 'Bold', +}; + +export default Bold; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js new file mode 100644 index 000000000..6387e4237 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js @@ -0,0 +1,12 @@ +import createToggle from '../factories/createToggle'; + +const execCommand = () => document.execCommand('italic'); +const syncState = () => document.queryCommandState('italic'); + +const Italic = createToggle(execCommand, syncState); + +Italic.defaultProps = { + children: 'Italic', +}; + +export default Italic; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/index.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/index.js new file mode 100644 index 000000000..5e985fe6a --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/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/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js new file mode 100644 index 000000000..61c7263a3 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from '../Button'; + +const createToggle = (execCommand, getCurrentState) => { + class Toggle extends React.Component { + state = { + active: false, + }; + + formatToggle = () => { + execCommand(); + this.props.rte.contentEditable.focus(); + }; + + handleClick = () => { + this.formatToggle(); + this.syncState(); + }; + + syncState = () => { + if (this.state.active !== getCurrentState()) { + this.setState(state => ({ + active: !state.active, + })); + } + }; + + onSelectionChange() { + this.syncState(); + } + + render() { + const { className, title, children } = this.props; + return ( + + ); + } + } + + Toggle.propTypes = { + rte: PropTypes.object, + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node, + }; + return Toggle; +}; + +export default createToggle; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/utils.js b/plugins/talk-plugin-rich-text/client/components/rte/utils.js new file mode 100644 index 000000000..0d613df8a --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/utils.js @@ -0,0 +1,12 @@ +export function hasAncestor(tag) { + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + let cur = range.startContainer; + do { + if (cur.nodeName === tag) { + return true; + } + cur = cur.parentNode; + } while (cur); + return false; +} From b504d2ad2bf64c2817aa646c3e7e015c6bf00934 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 23 Mar 2018 12:41:08 +0100 Subject: [PATCH 09/40] Translations --- .../client/components/Editor.js | 19 +++++++++++++------ .../client/translations.yml | 6 ++++++ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 plugins/talk-plugin-rich-text/client/translations.yml diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js index 5962630b5..9a5bf67a6 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -7,6 +7,7 @@ import { htmlNormalizer } from '../utils'; import RTE from './rte/RTE'; import { Icon } from 'plugin-api/beta/client/components/ui'; import { Bold, Italic, Blockquote } from './rte/buttons'; +import { t } from 'plugin-api/beta/client/services'; class Editor extends React.Component { ref = null; @@ -66,14 +67,20 @@ class Editor extends React.Component { disabled={disabled} placeholder={placeholder} buttons={[ - - + + , - - + + , -
- +
+
, ]} /> 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 + From 246e3efe7feafd7ab49f09438aced4d5549ba1bf Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 23 Mar 2018 12:49:08 +0100 Subject: [PATCH 10/40] Include translations --- plugins/talk-plugin-rich-text/client/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/talk-plugin-rich-text/client/index.js b/plugins/talk-plugin-rich-text/client/index.js index eb768adb9..2f1e9ac0d 100644 --- a/plugins/talk-plugin-rich-text/client/index.js +++ b/plugins/talk-plugin-rich-text/client/index.js @@ -1,10 +1,12 @@ 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], From cc66be033ae16cbdd42874c505046ebbbc5d42f4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 23 Mar 2018 17:03:57 +0100 Subject: [PATCH 11/40] More manual DOM manipulations --- .../client/components/rte/RTE.js | 76 ++-- .../components/rte/buttons/Blockquote.js | 60 +++- .../rte/{ => components}/Button.css | 0 .../components/rte/{ => components}/Button.js | 0 .../rte/{ => components}/Toolbar.css | 0 .../rte/{ => components}/Toolbar.js | 0 .../components/rte/factories/createToggle.js | 18 +- .../client/components/rte/lib/api.js | 14 + .../client/components/rte/lib/dom.js | 333 ++++++++++++++++++ .../client/components/rte/utils.js | 12 - 10 files changed, 450 insertions(+), 63 deletions(-) rename plugins/talk-plugin-rich-text/client/components/rte/{ => components}/Button.css (100%) rename plugins/talk-plugin-rich-text/client/components/rte/{ => components}/Button.js (100%) rename plugins/talk-plugin-rich-text/client/components/rte/{ => components}/Toolbar.css (100%) rename plugins/talk-plugin-rich-text/client/components/rte/{ => components}/Toolbar.js (100%) create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/lib/api.js create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js delete mode 100644 plugins/talk-plugin-rich-text/client/components/rte/utils.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 55ce25333..422109292 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -3,17 +3,15 @@ import PropTypes from 'prop-types'; import styles from './RTE.css'; import cn from 'classnames'; import ContentEditable from 'react-contenteditable'; -import Toolbar from './Toolbar'; +import Toolbar from './components/Toolbar'; +import { insertNewLine } from './lib/dom'; +import API from './lib/api'; -class Editor extends React.Component { +class RTE extends React.Component { ref = null; - handleRef = ref => (this.ref = ref); + api = null; buttonsRef = {}; - get contentEditable() { - return this.ref.htmlEl; - } - createButtonRefHandler(key) { return ref => { if (ref) { @@ -24,38 +22,46 @@ class Editor extends React.Component { }; } - hasAncestor(tag) { - const sel = window.getSelection(); - const range = sel.getRangeAt(0); - let cur = range.startContainer; - do { - if (cur.nodeName === tag) { - return true; - } - cur = cur.parentNode; - } while (cur); - return false; - } - forEachButton(callback) { Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); } - handleChange = evt => { + handleChange = () => { this.props.onChange({ text: this.ref.htmlEl.innerText, - html: evt.target.value, + html: this.ref.htmlEl.innerHTML, }); }; + handleRef = ref => ( + (this.ref = ref), (this.api = new API(this.ref.htmlEl, this.handleChange)) + ); + handleSelectionChange = () => { + // console.log(window.getSelection().getRangeAt(0)); this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); }; + handleSpecialEnter = () => { + let handled = false; + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + let container = range.startContainer; + while (!handled && container && container !== this.ref.htmlEl) { + this.forEachButton(b => { + if (!handled) { + handled = !!(b.onEnter && b.onEnter(container)); + } + }); + container = container.parentNode; + } + return handled; + }; + handleClick = () => { - this.handleSelectionChange(); + setTimeout(() => this.handleSelectionChange()); }; handleKeyDown = () => { @@ -68,17 +74,25 @@ class Editor extends React.Component { handleKeyPress = e => { this.handleSelectionChange(); - if (e.key === 'Enter' && !e.shiftKey) { - setTimeout(() => { - document.execCommand('outdent'); - }); + if (e.key === 'Enter') { + if (!e.shiftKey && this.handleSpecialEnter()) { + this.handleChange(); + e.preventDefault(); + return false; + } + + insertNewLine(true); + + this.handleChange(); + e.preventDefault(); + return false; } }; renderButtons() { return this.props.buttons.map(b => { return React.cloneElement(b, { - rte: this, + api: this.api, ref: this.createButtonRefHandler(b.key), }); }); @@ -115,11 +129,11 @@ class Editor extends React.Component { } } -Editor.defaultProps = { +RTE.defaultProps = { buttons: [], }; -Editor.propTypes = { +RTE.propTypes = { buttons: PropTypes.array, inputId: PropTypes.string, input: PropTypes.object, @@ -132,4 +146,4 @@ Editor.propTypes = { value: PropTypes.string, }; -export default Editor; +export default RTE; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index 1ba9d933f..7d3b76d6f 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -1,24 +1,58 @@ import createToggle from '../factories/createToggle'; -import { hasAncestor } from '../utils'; -import bowser from 'bowser'; +import { + findIntersectingTag, + insertNewLineAfterNode, + replaceSelection, + insertNodes, + getSelectedNodesExpanded, + outdentNode, +} from '../lib/dom'; -const execCommand = () => { - if (hasAncestor('BLOCKQUOTE')) { - document.execCommand('outdent'); +// TODO: select end of node. +function selectNode(node) { + const range = document.createRange(); + const container = node.childNodes.length ? node.childNodes[0] : node; + range.setStart(container, 0); + range.setEnd(container, 0); + replaceSelection(range); +} + +function execCommand() { + const bq = findIntersectingTag('BLOCKQUOTE'); + if (bq) { + outdentNode(bq, true); } else { - if (bowser.msie) { - document.execCommand('indent'); + const node = document.createElement('blockquote'); + const selectedNodes = getSelectedNodesExpanded(); + if (selectedNodes.length) { + const firstNode = selectedNodes[0]; + firstNode.parentNode.insertBefore(node, firstNode); + selectedNodes.forEach(n => { + node.appendChild(n); + }); + selectNode(node); } else { - document.execCommand('formatBlock', false, 'blockquote'); + node.appendChild(document.createElement('br')); + insertNodes(node); + selectNode(node); } + this.broadcastChange(); } +} + +function syncState() { + return !!findIntersectingTag('BLOCKQUOTE'); +} + +const onEnter = node => { + if (node.tagName !== 'BLOCKQUOTE') { + return; + } + insertNewLineAfterNode(node, true); + return true; }; -const syncState = () => { - return hasAncestor('BLOCKQUOTE'); -}; - -const Blockquote = createToggle(execCommand, syncState); +const Blockquote = createToggle(execCommand, syncState, { onEnter }); Blockquote.defaultProps = { children: 'Blockquote', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Button.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Button.css rename to plugins/talk-plugin-rich-text/client/components/rte/components/Button.css diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Button.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Button.js rename to plugins/talk-plugin-rich-text/client/components/rte/components/Button.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Toolbar.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.css similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Toolbar.css rename to plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.css diff --git a/plugins/talk-plugin-rich-text/client/components/rte/Toolbar.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.js similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/rte/Toolbar.js rename to plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index 61c7263a3..040dc6543 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -1,25 +1,29 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Button from '../Button'; +import Button from '../components/Button'; -const createToggle = (execCommand, getCurrentState) => { +const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => { class Toggle extends React.Component { state = { active: false, }; + execCommand = () => execCommand.apply(this.props.api); + getCurrentState = () => getCurrentState.apply(this.props.api); + onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args); + formatToggle = () => { - execCommand(); - this.props.rte.contentEditable.focus(); + this.execCommand(); }; handleClick = () => { + this.props.api.focus(); this.formatToggle(); - this.syncState(); + this.props.api.focus(); }; syncState = () => { - if (this.state.active !== getCurrentState()) { + if (this.state.active !== this.getCurrentState()) { this.setState(state => ({ active: !state.active, })); @@ -46,7 +50,7 @@ const createToggle = (execCommand, getCurrentState) => { } Toggle.propTypes = { - rte: PropTypes.object, + api: PropTypes.object, className: PropTypes.string, title: PropTypes.string, children: PropTypes.node, diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js new file mode 100644 index 000000000..5150753d2 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -0,0 +1,14 @@ +import { selectionIsInside } from './dom'; + +export default class API { + constructor(contentEditable, onChange) { + this.contentEditable = contentEditable; + this.broadcastChange = onChange; + } + isSelectionInside() { + return selectionIsInside(this.contentEditable); + } + focus() { + this.contentEditable.focus(); + } +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js new file mode 100644 index 000000000..24a22f317 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -0,0 +1,333 @@ +export function findAncestorWithTag(node, tag) { + do { + if (node.nodeName === tag) { + return node; + } + node = node.parentNode; + } while (node); + return false; +} + +export function findIntersectingTag(tag) { + const range = getSelectionRange(); + if (!range) { + return null; + } + + const ancestor = findAncestorWithTag(range.startContainer, tag); + if (ancestor) { + return ancestor; + } + + const nodes = getSelectedChildren(range.commonAncestorContainer); + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].tagName === tag) { + return nodes[i]; + } + const query = nodes[i].querySelector && nodes[i].querySelector(tag); + if (query) { + return query; + } + } + return null; +} + +export function indexOfChildNode(parent, child) { + for (let i = 0; i < parent.childNodes.length; i++) { + if (parent.childNodes[i] === child) { + return i; + } + } + return -1; +} + +export function insertText(text) { + const selection = window.getSelection(); + if (!selection.isCollapsed) { + document.execCommand('delete'); + } + const range = selection.getRangeAt(0); + const offset = range.startOffset; + const container = range.startContainer; + + if (container.nodeName === '#text') { + container.textContent = + container.textContent.slice(0, offset) + + text + + container.textContent.slice(offset); + const nextOffset = offset + text.length; + range.setStart(container, nextOffset); + range.setEnd(container, nextOffset); + } else { + const textNode = document.createTextNode(text); + container.insertBefore(textNode, container.childNodes[offset]); + } +} + +export function insertNodes(...nodes) { + const selection = window.getSelection(); + if (!selection.isCollapsed) { + document.execCommand('delete'); + } + const range = selection.getRangeAt(0); + const offset = range.startOffset; + const container = range.startContainer; + if (container.nodeName === '#text') { + const startSlice = container.textContent.slice(0, offset); + const endSlice = container.textContent.slice(offset); + if (startSlice) { + nodes.splice(0, 0, document.createTextNode(startSlice)); + } + if (endSlice) { + nodes.push(document.createTextNode(endSlice)); + } + const parentNode = container.parentNode; + nodes.forEach(n => parentNode.insertBefore(n, container)); + parentNode.removeChild(container); + } else { + let parentNode = container; + let nextSibling = container.childNodes[offset]; + nodes.forEach(n => parentNode.insertBefore(n, nextSibling)); + } +} + +export function replaceSelection(range) { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +export function isSelectionCollapsed() { + return window.getSelection().isCollapsed; +} + +export function getSelectionRange() { + const selection = window.getSelection(); + return selection.rangeCount ? selection.getRangeAt(0) : null; +} + +function ensureEndMarker(node) { + if ( + !node.lastChild || + node.lastChild.tagName !== 'BR' || + node.lastChild.className !== 'coral-rte-end-marker' + ) { + const br = document.createElement('br'); + br.className = 'coral-rte-end-marker'; + node.appendChild(br); + } +} + +export function selectionIsInside(node) { + const range = getSelectionRange(); + return ( + range && + (node.contains(range.startContainer) || node.contains(range.endContainer)) + ); +} + +export function appendNewLine(node, changeSelection) { + const el = document.createElement('br'); + node.appendChild(el); + + if (changeSelection) { + const offset = indexOfChildNode(node, el); + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset); + replaceSelection(range); + } +} + +export function insertNewLine(changeSelection) { + // Insert
node. + const el = document.createElement('br'); + insertNodes(el); + + // Calculate next selection. + const range = document.createRange(); + if (el.nextSibling) { + const offset = indexOfChildNode(el.parentNode, el.nextSibling); + range.setStart(el.parentNode, offset); + range.setEnd(el.parentNode, offset); + } else { + // We need to add a
marker at the end, because we can't + // select the last
. + ensureEndMarker(el.parentNode); + + const offset = el.parentNode.childNodes.length - 1; + range.setStart(el.parentNode, offset); + range.setEnd(el.parentNode, offset); + } + + if (changeSelection) { + replaceSelection(range); + } +} + +export function insertNewLineAfterNode(node, changeSelection) { + if (node.parentNode.lastChild === node) { + appendNewLine(node.parentNode, changeSelection); + } else { + if (changeSelection) { + const offset = indexOfChildNode(node.parentNode, node) + 1; + const range = document.createRange(); + range.setStart(node.parentNode, offset); + range.setEnd(node.parentNode, offset); + replaceSelection(range); + } + insertNewLine(); + } +} + +export function getSelectedNode(container, offset) { + if (container.nodeName === '#text') { + return container; + } + return container.childNodes[offset]; +} + +export function isBlockElement(node) { + if (node.nodeName === '#text') { + return false; + } + return !window + .getComputedStyle(node) + .getPropertyValue('display') + .startsWith('inline'); +} + +export function findParentBlock(node) { + if (!node.parentNode) { + return null; + } + if (isBlockElement(node.parentNode)) { + return node.parentNode; + } + return findParentBlock(node.parentNode); +} + +export function lastParentBeforeBlock(node) { + let child = node; + while (!isBlockElement(child.parentNode)) { + child = node.parentNode; + } + return child; +} + +export function getLeftOfNode(node) { + let result = []; + let leftMost = node; + while ( + leftMost.previousSibling && + leftMost.previousSibling.tagName !== 'BR' && + !isBlockElement(leftMost.previousSibling) + ) { + result.splice(0, 0, leftMost.previousSibling); + leftMost = leftMost.previousSibling; + } + return result; +} + +export function getRightOfNode(node) { + let result = []; + let cur = node; + while ( + cur.nextSibling && + cur.nextSibling.tagName !== 'BR' && + !isBlockElement(cur.nextSibling) + ) { + cur = cur.nextSibling; + result.push(cur); + } + if (cur.nextSibling && cur.nextSibling.tagName === 'BR') { + result.push(cur.nextSibling); + } + return result; +} + +export function getWholeLine(node) { + if (isBlockElement(node)) { + return [node]; + } + const child = lastParentBeforeBlock(node); + return [...getLeftOfNode(child), child, ...getRightOfNode(child)]; +} + +export function getSelectedLine() { + const range = getSelectionRange(); + if (!range) { + return []; + } + const start = getSelectedNode(range.startContainer, range.startOffset); + return start ? getWholeLine(start) : []; +} + +export function getSelectedNodesExpanded() { + const range = getSelectionRange(); + if (!range) { + return []; + } + + if (range.collapsed) { + return getSelectedLine(); + } + + let ancestor = range.commonAncestorContainer; + if (!isBlockElement(ancestor)) { + ancestor = findParentBlock(ancestor); + } + + const result = getSelectedChildren(ancestor); + return [ + ...getLeftOfNode(result[0]), + ...result, + ...getRightOfNode(result[result.length - 1]), + ]; +} + +export function getSelectedChildren(ancestor) { + const result = []; + const range = getSelectionRange(); + if (!range) { + return result; + } + if (!range) { + return result; + } + + const start = getSelectedNode(range.startContainer, range.startOffset); + const end = getSelectedNode(range.endContainer, range.endOffset); + let foundStart = false; + for (let i = 0; i < ancestor.childNodes.length; i++) { + const node = ancestor.childNodes[i]; + if (!foundStart) { + if (node.contains(start)) { + foundStart = true; + } + } + if (foundStart) { + result.push(node); + if (node.contains(end)) { + break; + } + } + } + return result; +} + +export function outdentNode(node, changeSelection) { + const parentNode = node.parentNode; + const offset = indexOfChildNode(parentNode, node); + while (node.childNodes.length) { + parentNode.insertBefore(node.childNodes[0], node); + } + parentNode.removeChild(node); + + if (changeSelection) { + const range = document.createRange(); + range.setStart(parentNode, offset); + range.setEnd(parentNode, offset); + replaceSelection(range); + } +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/utils.js b/plugins/talk-plugin-rich-text/client/components/rte/utils.js deleted file mode 100644 index 0d613df8a..000000000 --- a/plugins/talk-plugin-rich-text/client/components/rte/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -export function hasAncestor(tag) { - const sel = window.getSelection(); - const range = sel.getRangeAt(0); - let cur = range.startContainer; - do { - if (cur.nodeName === tag) { - return true; - } - cur = cur.parentNode; - } while (cur); - return false; -} From a154ccfc605952188f7ec1c49910abf1e1f22cd4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sat, 24 Mar 2018 15:36:49 +0100 Subject: [PATCH 12/40] Better dom handling, disable state --- .../client/components/rte/RTE.css | 11 ++++ .../client/components/rte/RTE.js | 63 +++++++++++-------- .../components/rte/buttons/Blockquote.js | 6 +- .../client/components/rte/buttons/Bold.js | 4 +- .../client/components/rte/buttons/Italic.js | 4 +- .../components/rte/components/Button.css | 6 ++ .../components/rte/components/Button.js | 3 + .../components/rte/factories/createToggle.js | 20 ++++-- .../client/components/rte/lib/dom.js | 22 ++++++- 9 files changed, 100 insertions(+), 39 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.css b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css index ab24828bd..2bf1feaf5 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.css +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css @@ -16,3 +16,14 @@ 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 index 422109292..2694c3a26 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -27,6 +27,7 @@ class RTE extends React.Component { } handleChange = () => { + this.handleSelectionChange(); this.props.onChange({ text: this.ref.htmlEl.innerText, html: this.ref.htmlEl.innerHTML, @@ -60,18 +61,10 @@ class RTE extends React.Component { return handled; }; - handleClick = () => { + handleMouseUp = () => { setTimeout(() => this.handleSelectionChange()); }; - handleKeyDown = () => { - this.handleSelectionChange(); - }; - - handleKeyUp = () => { - this.handleSelectionChange(); - }; - handleKeyPress = e => { this.handleSelectionChange(); if (e.key === 'Enter') { @@ -92,36 +85,51 @@ class RTE extends React.Component { renderButtons() { return this.props.buttons.map(b => { return React.cloneElement(b, { + disabled: this.props.disabled, api: this.api, ref: this.createButtonRefHandler(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 { - className, - contentClassName, - toolbarClassName, - value, - placeholder, - inputId, - } = this.props; + const { value, placeholder, inputId, disabled } = this.props; + + const classNames = this.getClassNames(); return ( -
- {this.renderButtons()} - {!value &&
{placeholder}
} +
+ {this.renderButtons()} + {!value &&
{placeholder}
}
@@ -140,8 +148,13 @@ RTE.propTypes = { 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, }; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index 7d3b76d6f..d12429339 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -36,11 +36,11 @@ function execCommand() { insertNodes(node); selectNode(node); } - this.broadcastChange(); } + this.broadcastChange(); } -function syncState() { +function isActive() { return !!findIntersectingTag('BLOCKQUOTE'); } @@ -52,7 +52,7 @@ const onEnter = node => { return true; }; -const Blockquote = createToggle(execCommand, syncState, { onEnter }); +const Blockquote = createToggle(execCommand, { onEnter, isActive }); Blockquote.defaultProps = { children: 'Blockquote', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js index 4988b0679..7f6259c77 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js @@ -1,9 +1,9 @@ import createToggle from '../factories/createToggle'; const execCommand = () => document.execCommand('bold'); -const syncState = () => document.queryCommandState('bold'); +const isActive = () => document.queryCommandState('bold'); -const Bold = createToggle(execCommand, syncState); +const Bold = createToggle(execCommand, { isActive }); Bold.defaultProps = { children: 'Bold', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js index 6387e4237..f7fc6a4f4 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js @@ -1,9 +1,9 @@ import createToggle from '../factories/createToggle'; const execCommand = () => document.execCommand('italic'); -const syncState = () => document.queryCommandState('italic'); +const isActive = () => document.queryCommandState('italic'); -const Italic = createToggle(execCommand, syncState); +const Italic = createToggle(execCommand, { isActive }); Italic.defaultProps = { children: 'Italic', 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 index daedd54ec..070906205 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css +++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css @@ -35,3 +35,9 @@ border-radius: 3px; background-color: #ddd; } + +.button:disabled{ + color: #bbb; + cursor: default; + background-color: inherit; +} 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 index f7bc75941..f99648cb7 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js @@ -12,6 +12,7 @@ class Button extends React.Component { children, active, activeClassName, + disabled, } = this.props; return ( @@ -34,6 +36,7 @@ Button.propTypes = { 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/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index 040dc6543..b307ae199 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -2,14 +2,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import Button from '../components/Button'; -const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => { +const createToggle = ( + execCommand, + { onEnter, isActive = () => false, isDisabled = () => false } = {} +) => { class Toggle extends React.Component { state = { active: false, + disabled: false, }; execCommand = () => execCommand.apply(this.props.api); - getCurrentState = () => getCurrentState.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); formatToggle = () => { @@ -23,11 +28,16 @@ const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => { }; syncState = () => { - if (this.state.active !== this.getCurrentState()) { + 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() { @@ -35,13 +45,14 @@ const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => { } render() { - const { className, title, children } = this.props; + const { className, title, children, disabled } = this.props; return ( @@ -54,6 +65,7 @@ const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => { className: PropTypes.string, title: PropTypes.string, children: PropTypes.node, + disabled: PropTypes.bool, }; return Toggle; }; 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 index 24a22f317..e611de234 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -118,11 +118,24 @@ function ensureEndMarker(node) { } } +export function nodeContains(node, lookFor) { + if (node.isSameNode(lookFor)) { + return true; + } + for (let i = 0; i < node.childNodes.length; i++) { + if (nodeContains(node.childNodes[i], lookFor)) { + return true; + } + } + return false; +} + export function selectionIsInside(node) { const range = getSelectionRange(); return ( range && - (node.contains(range.startContainer) || node.contains(range.endContainer)) + (nodeContains(node, range.startContainer) || + nodeContains(node, range.endContainer)) ); } @@ -251,6 +264,9 @@ export function getWholeLine(node) { return [node]; } const child = lastParentBeforeBlock(node); + if (child.tagName === 'BR') { + return [...getLeftOfNode(child), child]; + } return [...getLeftOfNode(child), child, ...getRightOfNode(child)]; } @@ -302,13 +318,13 @@ export function getSelectedChildren(ancestor) { for (let i = 0; i < ancestor.childNodes.length; i++) { const node = ancestor.childNodes[i]; if (!foundStart) { - if (node.contains(start)) { + if (nodeContains(node, start)) { foundStart = true; } } if (foundStart) { result.push(node); - if (node.contains(end)) { + if (nodeContains(node, end)) { break; } } From 595dda11bffacc302d0aeed3598f265c2568543f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sat, 24 Mar 2018 19:14:06 +0100 Subject: [PATCH 13/40] Control pasting, better IE support --- .../client/components/rte/RTE.js | 49 ++++++++++++++++++- .../components/rte/factories/createToggle.js | 1 + .../client/components/rte/lib/dom.js | 2 + 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 2694c3a26..074809b1d 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -4,8 +4,9 @@ import styles from './RTE.css'; import cn from 'classnames'; import ContentEditable from 'react-contenteditable'; import Toolbar from './components/Toolbar'; -import { insertNewLine } from './lib/dom'; +import { insertNewLine, insertText } from './lib/dom'; import API from './lib/api'; +import bowser from 'bowser'; class RTE extends React.Component { ref = null; @@ -61,12 +62,53 @@ class RTE extends React.Component { return handled; }; + handleCut = () => { + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(this.handleChange); + } + }; + + handlePaste = e => { + // Get text representation of clipboard + // This works cross browser. + const text = ( + (e.originalEvent || e).clipboardData || window.clipboardData + ).getData('Text'); + + // insert text manually + insertText(text); + this.handleChange(); + + e.preventDefault(); + return false; + }; + handleMouseUp = () => { setTimeout(() => this.handleSelectionChange()); }; + handleKeyDown = () => { + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(this.handleChange); + } + }; + + handleKeyUp = () => { + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(this.handleChange); + } + this.handleSelectionChange(); + }; + handleKeyPress = e => { this.handleSelectionChange(); + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(this.handleChange); + } if (e.key === 'Enter') { if (!e.shiftKey && this.handleSpecialEnter()) { this.handleChange(); @@ -124,8 +166,11 @@ class RTE extends React.Component { { 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 index e611de234..3531b4333 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -61,6 +61,8 @@ export function insertText(text) { } else { const textNode = document.createTextNode(text); container.insertBefore(textNode, container.childNodes[offset]); + range.setStart(textNode, text.length); + range.setEnd(textNode, text.length); } } From 3bd8d5627b72dacc957cd31af2c552d82a2dc291 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sat, 24 Mar 2018 21:43:32 +0100 Subject: [PATCH 14/40] Use our own UNDO stack!! --- .../client/components/rte/RTE.js | 128 +++++++++++++++--- .../client/components/rte/lib/dom.js | 39 +++++- .../client/components/rte/lib/undo.js | 57 ++++++++ 3 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 074809b1d..2e1722ec1 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -4,15 +4,33 @@ import styles from './RTE.css'; import cn from 'classnames'; import ContentEditable from 'react-contenteditable'; import Toolbar from './components/Toolbar'; -import { insertNewLine, insertText } from './lib/dom'; +import { + insertNewLine, + insertText, + getSelectionRange, + replaceSelection, + cloneNodeAndRange, + replaceNodeChildren, +} from './lib/dom'; import API from './lib/api'; +import Undo from './lib/undo'; import bowser from 'bowser'; +import throttle from 'lodash/throttle'; class RTE extends React.Component { ref = null; api = null; + undo = new Undo(); buttonsRef = {}; + saveCheckpoint = throttle((html, node, range) => { + const args = [html]; + if (node && range) { + args.push(...cloneNodeAndRange(node, range)); + } + this.undo.save(...args); + }, 1000); + createButtonRefHandler(key) { return ref => { if (ref) { @@ -27,12 +45,30 @@ class RTE extends React.Component { Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); } + constructor(props) { + super(props); + this.saveCheckpoint(props.value); + } + + componentWillReceiveProps(props) { + if (props.value !== this.ref.htmlEl.innerHTML) { + this.undo.clear(); + this.saveCheckpoint(props.value); + } + } + handleChange = () => { this.handleSelectionChange(); 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() + ); }; handleRef = ref => ( @@ -40,7 +76,6 @@ class RTE extends React.Component { ); handleSelectionChange = () => { - // console.log(window.getSelection().getRangeAt(0)); this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); @@ -88,27 +123,24 @@ class RTE extends React.Component { setTimeout(() => this.handleSelectionChange()); }; - handleKeyDown = () => { + handleKeyDown = e => { // IE has issues not firing the onChange event. if (bowser.msie) { setTimeout(this.handleChange); } - }; - handleKeyUp = () => { - // IE has issues not firing the onChange event. - if (bowser.msie) { - setTimeout(this.handleChange); + // Undo Redo + if (e.key === 'z' && e.metaKey) { + if (e.shiftKey) { + this.handleRedo(); + } else { + this.handleUndo(); + } + e.preventDefault(); + return false; } - this.handleSelectionChange(); - }; - handleKeyPress = e => { - this.handleSelectionChange(); - // IE has issues not firing the onChange event. - if (bowser.msie) { - setTimeout(this.handleChange); - } + // Newlines Or Special Enter Behaviors. if (e.key === 'Enter') { if (!e.shiftKey && this.handleSpecialEnter()) { this.handleChange(); @@ -124,6 +156,69 @@ class RTE extends React.Component { } }; + handleKeyUp = () => { + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(this.handleChange); + } + this.handleSelectionChange(); + }; + + 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; + } + 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); + } + } + renderButtons() { return this.props.buttons.map(b => { return React.cloneElement(b, { @@ -166,7 +261,6 @@ class RTE extends React.Component { + 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; +} + +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]; +} + +export function replaceNodeChildren(node, node2) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } + while (node2.firstChild) { + node.appendChild(node2.firstChild); + } +} 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..c43131958 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js @@ -0,0 +1,57 @@ +export default class Undo { + 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 = []; + } +} From 06ed7920053301fc04a9b0346f384c116e2994e3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 00:02:42 +0100 Subject: [PATCH 15/40] Add comments --- .../client/components/rte/RTE.js | 48 +++++++++++++++---- .../components/rte/buttons/Blockquote.js | 17 ++----- .../components/rte/factories/createToggle.js | 4 ++ .../client/components/rte/lib/api.js | 10 +++- .../client/components/rte/lib/dom.js | 33 +++++++++++++ .../client/components/rte/lib/undo.js | 8 ++++ 6 files changed, 98 insertions(+), 22 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 2e1722ec1..358228d01 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -11,6 +11,7 @@ import { replaceSelection, cloneNodeAndRange, replaceNodeChildren, + selectEndOfNode, } from './lib/dom'; import API from './lib/api'; import Undo from './lib/undo'; @@ -18,11 +19,21 @@ import bowser from 'bowser'; import throttle from 'lodash/throttle'; class RTE extends React.Component { + /// Ref to react-contenteditable ref = null; + + // Our "plugins" api. api = null; + + // Instance of undo stack. undo = new Undo(); + + // Refs to the buttons. buttonsRef = {}; + // 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) { @@ -31,6 +42,12 @@ class RTE extends React.Component { this.undo.save(...args); }, 1000); + constructor(props) { + super(props); + this.saveCheckpoint(props.value); + } + + // Returns a handler that fills our `buttonsRef`. createButtonRefHandler(key) { return ref => { if (ref) { @@ -41,16 +58,25 @@ class RTE extends React.Component { }; } + // Ref to react-contenteditable. + handleRef = ref => ( + (this.ref = ref), + (this.api = new API( + this.ref.htmlEl, + this.handleChange, + () => this.undo.canUndo(), + () => this.undo.canRedo(), + this.handleUndo, + this.handleRedo + )) + ); + forEachButton(callback) { Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); } - constructor(props) { - super(props); - this.saveCheckpoint(props.value); - } - 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); @@ -71,16 +97,17 @@ class RTE extends React.Component { ); }; - handleRef = ref => ( - (this.ref = ref), (this.api = new API(this.ref.htmlEl, this.handleChange)) - ); - handleSelectionChange = () => { + // Let buttons know selection has changeed, so they + // can update. this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); }; + // Called when Enter was pressed without shift. + // Traverses from bottom to top and calling + // button handlers and stops when one has handled this event. handleSpecialEnter = () => { let handled = false; const sel = window.getSelection(); @@ -104,6 +131,8 @@ class RTE extends React.Component { } }; + // We intercept pasting, so that we + // force text/plain content. handlePaste = e => { // Get text representation of clipboard // This works cross browser. @@ -199,6 +228,7 @@ class RTE extends React.Component { replaceSelection(finalRange); } else { this.ref.htmlEl.innerHTML = html; + selectEndOfNode(this.ref.htmlEl); } this.handleChange(); } diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index d12429339..7b5b21b95 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -2,27 +2,20 @@ import createToggle from '../factories/createToggle'; import { findIntersectingTag, insertNewLineAfterNode, - replaceSelection, insertNodes, getSelectedNodesExpanded, outdentNode, + selectEndOfNode, } from '../lib/dom'; -// TODO: select end of node. -function selectNode(node) { - const range = document.createRange(); - const container = node.childNodes.length ? node.childNodes[0] : node; - range.setStart(container, 0); - range.setEnd(container, 0); - replaceSelection(range); -} - function execCommand() { const bq = findIntersectingTag('BLOCKQUOTE'); if (bq) { outdentNode(bq, true); } else { const node = document.createElement('blockquote'); + + // Expanded selection means we always select whole lines. const selectedNodes = getSelectedNodesExpanded(); if (selectedNodes.length) { const firstNode = selectedNodes[0]; @@ -30,11 +23,11 @@ function execCommand() { selectedNodes.forEach(n => { node.appendChild(n); }); - selectNode(node); + selectEndOfNode(node); } else { node.appendChild(document.createElement('br')); insertNodes(node); - selectNode(node); + selectEndOfNode(node); } } this.broadcastChange(); diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index 32b702df4..c14fc73a1 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -2,6 +2,10 @@ 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, isActive = () => false, isDisabled = () => false } = {} 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 index 5150753d2..ce52afeec 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -1,9 +1,17 @@ import { selectionIsInside } from './dom'; +/** + * An instance of API is passed to all the buttons to + * interact with RTE, which servers as a clean abstraction. + */ export default class API { - constructor(contentEditable, onChange) { + constructor(contentEditable, onChange, canUndo, canRedo, undo, redo) { this.contentEditable = contentEditable; this.broadcastChange = onChange; + this.canUndo = canUndo; + this.canRedo = canRedo; + this.undo = undo; + this.redo = redo; } isSelectionInside() { return selectionIsInside(this.contentEditable); 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 index 9c4b4ff82..8ce10a31a 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -384,3 +384,36 @@ export function replaceNodeChildren(node, node2) { node.appendChild(node2.firstChild); } } + +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 index c43131958..eef076567 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js @@ -1,4 +1,12 @@ +/** + * 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; From 2757791672ba7b34488f1216acab7672fbcc5857 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 01:20:46 +0100 Subject: [PATCH 16/40] Refactor --- .../components/rte/buttons/Blockquote.js | 6 +- .../components/rte/factories/createToggle.js | 2 +- .../client/components/rte/lib/dom.js | 233 +++++++++++++----- 3 files changed, 180 insertions(+), 61 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index 7b5b21b95..ad829c2fd 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -1,6 +1,6 @@ import createToggle from '../factories/createToggle'; import { - findIntersectingTag, + findIntersecting, insertNewLineAfterNode, insertNodes, getSelectedNodesExpanded, @@ -9,7 +9,7 @@ import { } from '../lib/dom'; function execCommand() { - const bq = findIntersectingTag('BLOCKQUOTE'); + const bq = findIntersecting('BLOCKQUOTE'); if (bq) { outdentNode(bq, true); } else { @@ -34,7 +34,7 @@ function execCommand() { } function isActive() { - return !!findIntersectingTag('BLOCKQUOTE'); + return !!findIntersecting('BLOCKQUOTE'); } const onEnter = node => { diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index c14fc73a1..a12f9f4d9 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -29,7 +29,7 @@ const createToggle = ( this.props.api.focus(); this.formatToggle(); this.props.api.focus(); - this.syncState(); + setTimeout(this.syncState); }; syncState = () => { 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 index 8ce10a31a..69bff32f4 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -1,37 +1,121 @@ -export function findAncestorWithTag(node, tag) { - do { - if (node.nodeName === tag) { +/** + * Find ancestor with given tag or whith callback returning true. + */ +export function findAncestor(node, tagOrCallback) { + const callback = + typeof tagOrCallback === 'function' + ? tagOrCallback + : n => n.tagName === tagOrCallback; + while (node.parentNode) { + node = node.parentNode; + if (callback(node)) { return node; } - node = node.parentNode; - } while (node); - return false; + } + return null; } -export function findIntersectingTag(tag) { +/** + * 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; + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + if (callback(child)) { + return child; + } + const found = findChild(child, tagOrCallback); + if (found) { + return found; + } + } + return null; +} + +/** + * Find an node intersecting with the selection with given tag or + * with callback returning true. + */ +export function findIntersecting(tagOrCallback) { + const callback = + typeof tagOrCallback === 'function' + ? tagOrCallback + : n => n.tagName === tagOrCallback; + const range = getSelectionRange(); if (!range) { return null; } - const ancestor = findAncestorWithTag(range.startContainer, tag); + if (callback(range.startContainer)) { + return range.startContainer; + } + + const ancestor = findAncestor(range.startContainer, callback); if (ancestor) { return ancestor; } const nodes = getSelectedChildren(range.commonAncestorContainer); for (let i = 0; i < nodes.length; i++) { - if (nodes[i].tagName === tag) { + if (callback(nodes[i])) { return nodes[i]; } - const query = nodes[i].querySelector && nodes[i].querySelector(tag); - if (query) { - return query; + 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) { @@ -41,6 +125,10 @@ export function indexOfChildNode(parent, child) { return -1; } +/** + * Same as `document.execCommand('insertText', false, text)` but also + * works for IE. Changes Selection. + */ export function insertText(text) { const selection = window.getSelection(); if (!selection.isCollapsed) { @@ -66,6 +154,10 @@ export function insertText(text) { } } +/** + * Insert nodes to current selection, + * does not change selection. + */ export function insertNodes(...nodes) { const selection = window.getSelection(); if (!selection.isCollapsed) { @@ -93,21 +185,31 @@ export function insertNodes(...nodes) { } } +/** + * 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 'br' marker at the end of the node. function ensureEndMarker(node) { if ( !node.lastChild || @@ -120,18 +222,10 @@ function ensureEndMarker(node) { } } -export function nodeContains(node, lookFor) { - if (node.isSameNode(lookFor)) { - return true; - } - for (let i = 0; i < node.childNodes.length; i++) { - if (nodeContains(node.childNodes[i], lookFor)) { - return true; - } - } - return false; -} - +/** + * Returns true if selection is completely inside + * given node. + */ export function selectionIsInside(node) { const range = getSelectionRange(); return ( @@ -141,6 +235,9 @@ export function selectionIsInside(node) { ); } +/** + * Append a newline to the node. + */ export function appendNewLine(node, changeSelection) { const el = document.createElement('br'); node.appendChild(el); @@ -154,6 +251,11 @@ export function appendNewLine(node, changeSelection) { } } +/** + * 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'); @@ -180,6 +282,9 @@ export function insertNewLine(changeSelection) { } } +/** + * Inserts a new line after given node. + */ export function insertNewLineAfterNode(node, changeSelection) { if (node.parentNode.lastChild === node) { appendNewLine(node.parentNode, changeSelection); @@ -195,41 +300,20 @@ export function insertNewLineAfterNode(node, changeSelection) { } } -export function getSelectedNode(container, offset) { +/** + * 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]; } -export function isBlockElement(node) { - if (node.nodeName === '#text') { - return false; - } - return !window - .getComputedStyle(node) - .getPropertyValue('display') - .startsWith('inline'); -} - -export function findParentBlock(node) { - if (!node.parentNode) { - return null; - } - if (isBlockElement(node.parentNode)) { - return node.parentNode; - } - return findParentBlock(node.parentNode); -} - -export function lastParentBeforeBlock(node) { - let child = node; - while (!isBlockElement(child.parentNode)) { - child = node.parentNode; - } - return child; -} - +/** + * Returns an array of all nodes before `node`. + */ export function getLeftOfNode(node) { let result = []; let leftMost = node; @@ -244,6 +328,9 @@ export function getLeftOfNode(node) { return result; } +/** + * Returns an array of all nodes after `node`. + */ export function getRightOfNode(node) { let result = []; let cur = node; @@ -261,26 +348,40 @@ export function getRightOfNode(node) { 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 = lastParentBeforeBlock(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 = getSelectedNode(range.startContainer, range.startOffset); + 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) { @@ -304,6 +405,10 @@ export function getSelectedNodesExpanded() { ]; } +/** + * Returns array of children that intersects with + * the selection. + */ export function getSelectedChildren(ancestor) { const result = []; const range = getSelectionRange(); @@ -314,8 +419,8 @@ export function getSelectedChildren(ancestor) { return result; } - const start = getSelectedNode(range.startContainer, range.startOffset); - const end = getSelectedNode(range.endContainer, range.endOffset); + 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]; @@ -334,6 +439,9 @@ export function getSelectedChildren(ancestor) { return result; } +/** + * Removes node and assimilate its children with the parent. + */ export function outdentNode(node, changeSelection) { const parentNode = node.parentNode; const offset = indexOfChildNode(parentNode, node); @@ -364,6 +472,9 @@ function cloneNodeAndRangeHelper(node, range, rangeCloned) { 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); @@ -376,6 +487,9 @@ export function cloneNodeAndRange(node, range) { 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); @@ -385,6 +499,11 @@ export function replaceNodeChildren(node, node2) { } } +/** + * 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]; From 8586da97fb30b027ff8777772eba4ed5fd934809 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 01:32:21 +0100 Subject: [PATCH 17/40] Add limitTo --- .../client/components/rte/lib/api.js | 11 +++-------- .../client/components/rte/lib/dom.js | 15 ++++++++++----- 2 files changed, 13 insertions(+), 13 deletions(-) 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 index ce52afeec..c7ba4d51a 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -1,22 +1,17 @@ -import { selectionIsInside } from './dom'; - /** * An instance of API is passed to all the buttons to * interact with RTE, which servers as a clean abstraction. */ export default class API { - constructor(contentEditable, onChange, canUndo, canRedo, undo, redo) { - this.contentEditable = contentEditable; + constructor(container, onChange, canUndo, canRedo, undo, redo) { + this.container = container; this.broadcastChange = onChange; this.canUndo = canUndo; this.canRedo = canRedo; this.undo = undo; this.redo = redo; } - isSelectionInside() { - return selectionIsInside(this.contentEditable); - } focus() { - this.contentEditable.focus(); + this.container.focus(); } } diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js index 69bff32f4..6c25d6052 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -1,7 +1,8 @@ /** * 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) { +export function findAncestor(node, tagOrCallback, limitTo) { const callback = typeof tagOrCallback === 'function' ? tagOrCallback @@ -11,6 +12,9 @@ export function findAncestor(node, tagOrCallback) { if (callback(node)) { return node; } + if (limitTo && node.isSameNode(limitTo)) { + return null; + } } return null; } @@ -38,9 +42,10 @@ export function findChild(node, tagOrCallback) { /** * Find an node intersecting with the selection with given tag or - * with callback returning true. + * with callback returning true. If `limitTo` is passed, the search + * is limited to this container. */ -export function findIntersecting(tagOrCallback) { +export function findIntersecting(tagOrCallback, limitTo) { const callback = typeof tagOrCallback === 'function' ? tagOrCallback @@ -55,7 +60,7 @@ export function findIntersecting(tagOrCallback) { return range.startContainer; } - const ancestor = findAncestor(range.startContainer, callback); + const ancestor = findAncestor(range.startContainer, callback, limitTo); if (ancestor) { return ancestor; } @@ -226,7 +231,7 @@ function ensureEndMarker(node) { * Returns true if selection is completely inside * given node. */ -export function selectionIsInside(node) { +export function isSelectionInside(node) { const range = getSelectionRange(); return ( range && From 939df89842470ed4a6df2692dda6a80219c4913f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 01:37:45 +0100 Subject: [PATCH 18/40] Simplify normalizer, adjust server config --- plugins/talk-plugin-rich-text/client/utils.js | 9 ++------- plugins/talk-plugin-rich-text/server/config.js | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) 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: [], }, From 69e194f6480cc5eabba5bf544c9eaa17d1f9411f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 01:58:35 +0100 Subject: [PATCH 19/40] Unmount cleanly --- .../client/components/Editor.js | 4 ++ .../client/components/rte/RTE.js | 41 +++++++++++++------ .../components/rte/factories/createToggle.js | 7 +++- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js index 9a5bf67a6..9dbca33e1 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -40,6 +40,9 @@ class Editor extends React.Component { } }); } + if (this.props.isReply) { + this.ref.focus(); + } } componentWillUnmount() { @@ -66,6 +69,7 @@ class Editor extends React.Component { value={this.getHTML()} disabled={disabled} placeholder={placeholder} + ref={this.handleRef} buttons={[ diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 358228d01..a6da5783a 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -12,6 +12,7 @@ import { cloneNodeAndRange, replaceNodeChildren, selectEndOfNode, + isSelectionInside, } from './lib/dom'; import API from './lib/api'; import Undo from './lib/undo'; @@ -31,6 +32,11 @@ class RTE extends React.Component { // Refs to the buttons. buttonsRef = {}; + // Export this for parent components. + focus = () => this.ref.htmlEl.focus(); + + unmounted = 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. @@ -61,14 +67,16 @@ class RTE extends React.Component { // Ref to react-contenteditable. handleRef = ref => ( (this.ref = ref), - (this.api = new API( - this.ref.htmlEl, - this.handleChange, - () => this.undo.canUndo(), - () => this.undo.canRedo(), - this.handleUndo, - this.handleRedo - )) + (this.api = + ref && + new API( + this.ref.htmlEl, + this.handleChange, + () => this.undo.canUndo(), + () => this.undo.canRedo(), + this.handleUndo, + this.handleRedo + )) ); forEachButton(callback) { @@ -80,9 +88,18 @@ class RTE extends React.Component { 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 = () => { this.handleSelectionChange(); this.props.onChange({ @@ -127,7 +144,7 @@ class RTE extends React.Component { handleCut = () => { // IE has issues not firing the onChange event. if (bowser.msie) { - setTimeout(this.handleChange); + setTimeout(() => !this.unmounted && this.handleChange()); } }; @@ -149,13 +166,13 @@ class RTE extends React.Component { }; handleMouseUp = () => { - setTimeout(() => this.handleSelectionChange()); + setTimeout(() => !this.unmounted && this.handleSelectionChange()); }; handleKeyDown = e => { // IE has issues not firing the onChange event. if (bowser.msie) { - setTimeout(this.handleChange); + setTimeout(() => !this.unmounted && this.handleChange); } // Undo Redo @@ -188,7 +205,7 @@ class RTE extends React.Component { handleKeyUp = () => { // IE has issues not firing the onChange event. if (bowser.msie) { - setTimeout(this.handleChange); + setTimeout(() => !this.unmounted && this.handleChange); } this.handleSelectionChange(); }; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index a12f9f4d9..6e1ca5b71 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -20,6 +20,11 @@ const createToggle = ( isActive = () => isActive.apply(this.props.api); isDisabled = () => isDisabled.apply(this.props.api); onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args); + unmounted = false; + + componentWillUnmount() { + this.unmounted = true; + } formatToggle = () => { this.execCommand(); @@ -29,7 +34,7 @@ const createToggle = ( this.props.api.focus(); this.formatToggle(); this.props.api.focus(); - setTimeout(this.syncState); + setTimeout(() => !this.unmounted && this.syncState()); }; syncState = () => { From a55cee5cc163cbdd1c58fbaef9ae6fb3ef58acf5 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 17:50:29 +0200 Subject: [PATCH 20/40] IE Fixes --- .../client/components/rte/RTE.js | 59 +++++++++---------- .../components/rte/buttons/Blockquote.js | 8 +-- .../client/components/rte/buttons/Bold.js | 29 ++++++++- .../client/components/rte/buttons/Italic.js | 27 ++++++++- .../components/rte/components/Button.css | 2 +- .../client/components/rte/lib/api.js | 44 ++++++++++---- .../client/components/rte/lib/dom.js | 13 +++- 7 files changed, 125 insertions(+), 57 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index a6da5783a..76557cd02 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -14,7 +14,7 @@ import { selectEndOfNode, isSelectionInside, } from './lib/dom'; -import API from './lib/api'; +import createAPI from './lib/api'; import Undo from './lib/undo'; import bowser from 'bowser'; import throttle from 'lodash/throttle'; @@ -24,7 +24,15 @@ class RTE extends React.Component { ref = null; // Our "plugins" api. - api = null; + 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(); @@ -36,6 +44,7 @@ class RTE extends React.Component { 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 @@ -65,19 +74,7 @@ class RTE extends React.Component { } // Ref to react-contenteditable. - handleRef = ref => ( - (this.ref = ref), - (this.api = - ref && - new API( - this.ref.htmlEl, - this.handleChange, - () => this.undo.canUndo(), - () => this.undo.canRedo(), - this.handleUndo, - this.handleRedo - )) - ); + handleRef = ref => (this.ref = ref); forEachButton(callback) { Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); @@ -101,7 +98,6 @@ class RTE extends React.Component { } handleChange = () => { - this.handleSelectionChange(); this.props.onChange({ text: this.ref.htmlEl.innerText, html: this.ref.htmlEl.innerHTML, @@ -148,6 +144,16 @@ class RTE extends React.Component { } }; + 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 => { @@ -165,18 +171,14 @@ class RTE extends React.Component { return false; }; - handleMouseUp = () => { - setTimeout(() => !this.unmounted && this.handleSelectionChange()); - }; - handleKeyDown = e => { // IE has issues not firing the onChange event. if (bowser.msie) { - setTimeout(() => !this.unmounted && this.handleChange); + setTimeout(() => !this.unmounted && this.handleChange()); } - // Undo Redo - if (e.key === 'z' && e.metaKey) { + // Undo Redo 'Z' + if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { if (e.shiftKey) { this.handleRedo(); } else { @@ -202,14 +204,6 @@ class RTE extends React.Component { } }; - handleKeyUp = () => { - // IE has issues not firing the onChange event. - if (bowser.msie) { - setTimeout(() => !this.unmounted && this.handleChange); - } - this.handleSelectionChange(); - }; - restoreCheckpoint(html, node, range) { if (node && range) { // We need to clone it, otherwise we'll mutate @@ -312,6 +306,9 @@ class RTE extends React.Component { 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} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index ad829c2fd..40addc014 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -9,7 +9,7 @@ import { } from '../lib/dom'; function execCommand() { - const bq = findIntersecting('BLOCKQUOTE'); + const bq = findIntersecting('BLOCKQUOTE', this.container); if (bq) { outdentNode(bq, true); } else { @@ -34,16 +34,16 @@ function execCommand() { } function isActive() { - return !!findIntersecting('BLOCKQUOTE'); + return this.focused && !!findIntersecting('BLOCKQUOTE', this.container); } -const onEnter = node => { +function onEnter(node) { if (node.tagName !== 'BLOCKQUOTE') { return; } insertNewLineAfterNode(node, true); return true; -}; +} const Blockquote = createToggle(execCommand, { onEnter, isActive }); diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js index 7f6259c77..53bdfe71b 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js @@ -1,9 +1,32 @@ import createToggle from '../factories/createToggle'; +import { findIntersecting } from '../lib/dom'; -const execCommand = () => document.execCommand('bold'); -const isActive = () => document.queryCommandState('bold'); +const boldTags = ['B', 'STRONG']; -const Bold = createToggle(execCommand, { isActive }); +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 + ); +} + +const Bold = createToggle(execCommand, { isActive, isDisabled }); Bold.defaultProps = { children: 'Bold', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js index f7fc6a4f4..64786d5b9 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js @@ -1,9 +1,30 @@ import createToggle from '../factories/createToggle'; +import { findIntersecting } from '../lib/dom'; -const execCommand = () => document.execCommand('italic'); -const isActive = () => document.queryCommandState('italic'); +const italicTags = ['I', 'EM']; -const Italic = createToggle(execCommand, { isActive }); +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 + ); +} + +const Italic = createToggle(execCommand, { isActive, isDisabled }); Italic.defaultProps = { children: 'Italic', 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 index 070906205..39b14fb7e 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css +++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css @@ -39,5 +39,5 @@ .button:disabled{ color: #bbb; cursor: default; - background-color: inherit; + background: none; } 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 index c7ba4d51a..9e018f093 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -1,17 +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. */ -export default class API { - constructor(container, onChange, canUndo, canRedo, undo, redo) { - this.container = container; - this.broadcastChange = onChange; - this.canUndo = canUndo; - this.canRedo = canRedo; - this.undo = undo; - this.redo = redo; - } - focus() { - this.container.focus(); - } +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 index 6c25d6052..dc822bb89 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -7,6 +7,9 @@ export function findAncestor(node, tagOrCallback, limitTo) { typeof tagOrCallback === 'function' ? tagOrCallback : n => n.tagName === tagOrCallback; + if (node.isSameNode(limitTo)) { + return null; + } while (node.parentNode) { node = node.parentNode; if (callback(node)) { @@ -216,6 +219,9 @@ export function getSelectionRange() { // Adds a 'br' marker at the end of the node. function ensureEndMarker(node) { + if (!isBlockElement(node)) { + return; + } if ( !node.lastChild || node.lastChild.tagName !== 'BR' || @@ -465,9 +471,10 @@ export function outdentNode(node, changeSelection) { function cloneNodeAndRangeHelper(node, range, rangeCloned) { const nodeCloned = node.cloneNode(false); - node.childNodes.forEach(n => - nodeCloned.appendChild(cloneNodeAndRangeHelper(n, range, rangeCloned)) - ); + 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); } From a7e580fab3b2228aca9d52f05e9cd064d059c059 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 18:38:49 +0200 Subject: [PATCH 21/40] Fix paste for ie --- .../client/components/rte/RTE.js | 20 +++++++++++++--- .../client/components/rte/lib/dom.js | 23 +++++++++++-------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 76557cd02..403d1ed02 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -116,6 +116,7 @@ class RTE extends React.Component { this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); + console.log('sel', getSelectionRange()); }; // Called when Enter was pressed without shift. @@ -163,9 +164,22 @@ class RTE extends React.Component { (e.originalEvent || e).clipboardData || window.clipboardData ).getData('Text'); - // insert text manually - insertText(text); - this.handleChange(); + // 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; 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 index dc822bb89..200354cd6 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -139,10 +139,11 @@ export function indexOfChildNode(parent, child) { */ export function insertText(text) { const selection = window.getSelection(); - if (!selection.isCollapsed) { - document.execCommand('delete'); - } const range = selection.getRangeAt(0); + if (!range.collapsed) { + range.deleteContents(); + } + const newRange = document.createRange(); const offset = range.startOffset; const container = range.startContainer; @@ -152,14 +153,16 @@ export function insertText(text) { text + container.textContent.slice(offset); const nextOffset = offset + text.length; - range.setStart(container, nextOffset); - range.setEnd(container, nextOffset); + + newRange.setStart(container, nextOffset); + newRange.setEnd(container, nextOffset); } else { const textNode = document.createTextNode(text); container.insertBefore(textNode, container.childNodes[offset]); - range.setStart(textNode, text.length); - range.setEnd(textNode, text.length); + newRange.setStart(textNode, text.length); + newRange.setEnd(textNode, text.length); } + replaceSelection(newRange); } /** @@ -168,10 +171,10 @@ export function insertText(text) { */ export function insertNodes(...nodes) { const selection = window.getSelection(); - if (!selection.isCollapsed) { - document.execCommand('delete'); - } const range = selection.getRangeAt(0); + if (!range.collapsed) { + range.deleteContents(); + } const offset = range.startOffset; const container = range.startContainer; if (container.nodeName === '#text') { From 81b98187f158a960828c6d17b8328d50a1aba851 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 18:44:46 +0200 Subject: [PATCH 22/40] Ensure end in new line blockquote --- .../client/components/rte/buttons/Blockquote.js | 2 ++ .../talk-plugin-rich-text/client/components/rte/lib/dom.js | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index 40addc014..c3b6bfc5a 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -6,6 +6,7 @@ import { getSelectedNodesExpanded, outdentNode, selectEndOfNode, + endWithNewLine, } from '../lib/dom'; function execCommand() { @@ -23,6 +24,7 @@ function execCommand() { selectedNodes.forEach(n => { node.appendChild(n); }); + endWithNewLine(node); selectEndOfNode(node); } else { node.appendChild(document.createElement('br')); 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 index 200354cd6..df78ac37c 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -221,7 +221,7 @@ export function getSelectionRange() { } // Adds a 'br' marker at the end of the node. -function ensureEndMarker(node) { +export function endWithNewLine(node) { if (!isBlockElement(node)) { return; } @@ -284,7 +284,7 @@ export function insertNewLine(changeSelection) { } else { // We need to add a
marker at the end, because we can't // select the last
. - ensureEndMarker(el.parentNode); + endWithNewLine(el.parentNode); const offset = el.parentNode.childNodes.length - 1; range.setStart(el.parentNode, offset); From 7d4217d2d7c5494b3b8f39de6ee21e9d71e42a62 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 21:41:45 +0200 Subject: [PATCH 23/40] Remove all unwanted style attr --- .../client/components/rte/RTE.js | 20 ++- .../components/rte/buttons/Blockquote.js | 12 +- .../client/components/rte/lib/dom.js | 170 ++++++++++++++---- 3 files changed, 153 insertions(+), 49 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 403d1ed02..655ebfc1d 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -13,6 +13,7 @@ import { replaceNodeChildren, selectEndOfNode, isSelectionInside, + traverse, } from './lib/dom'; import createAPI from './lib/api'; import Undo from './lib/undo'; @@ -98,6 +99,13 @@ class RTE extends React.Component { } 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, @@ -116,7 +124,6 @@ class RTE extends React.Component { this.forEachButton(b => { b.onSelectionChange && b.onSelectionChange(); }); - console.log('sel', getSelectionRange()); }; // Called when Enter was pressed without shift. @@ -218,6 +225,16 @@ class RTE extends React.Component { } }; + handleKeyPress = e => { + const character = String.fromCharCode(e.charCode); + if (character) { + insertText(character); + this.handleChange(); + e.preventDefault(); + return false; + } + }; + restoreCheckpoint(html, node, range) { if (node && range) { // We need to clone it, otherwise we'll mutate @@ -317,6 +334,7 @@ class RTE extends React.Component { id={inputId} onMouseUp={this.handleMouseUp} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} onKeyUp={this.handleKeyUp} onPaste={this.handlePaste} onCut={this.handleCut} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index c3b6bfc5a..d8a409b44 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -6,7 +6,7 @@ import { getSelectedNodesExpanded, outdentNode, selectEndOfNode, - endWithNewLine, + indentNodes, } from '../lib/dom'; function execCommand() { @@ -14,19 +14,13 @@ function execCommand() { if (bq) { outdentNode(bq, true); } else { - const node = document.createElement('blockquote'); - // Expanded selection means we always select whole lines. const selectedNodes = getSelectedNodesExpanded(); if (selectedNodes.length) { - const firstNode = selectedNodes[0]; - firstNode.parentNode.insertBefore(node, firstNode); - selectedNodes.forEach(n => { - node.appendChild(n); - }); - endWithNewLine(node); + const node = indentNodes(selectedNodes, 'blockquote'); selectEndOfNode(node); } else { + const node = document.createElement('blockquote'); node.appendChild(document.createElement('br')); insertNodes(node); selectEndOfNode(node); 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 index df78ac37c..66ac35662 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -1,3 +1,41 @@ +/** + * 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. @@ -7,19 +45,17 @@ export function findAncestor(node, tagOrCallback, limitTo) { typeof tagOrCallback === 'function' ? tagOrCallback : n => n.tagName === tagOrCallback; - if (node.isSameNode(limitTo)) { - return null; - } - while (node.parentNode) { - node = node.parentNode; - if (callback(node)) { - return node; - } - if (limitTo && node.isSameNode(limitTo)) { - return null; - } - } - return null; + return ( + traverseUp( + node, + n => { + if (callback(n)) { + return n; + } + }, + limitTo + ) || null + ); } /** @@ -30,17 +66,13 @@ export function findChild(node, tagOrCallback) { typeof tagOrCallback === 'function' ? tagOrCallback : n => n.tagName === tagOrCallback; - for (let i = 0; i < node.childNodes.length; i++) { - const child = node.childNodes[i]; - if (callback(child)) { - return child; - } - const found = findChild(child, tagOrCallback); - if (found) { - return found; - } - } - return null; + return ( + traverse(node, n => { + if (callback(n)) { + return n; + } + }) || null + ); } /** @@ -220,19 +252,13 @@ export function getSelectionRange() { return selection.rangeCount ? selection.getRangeAt(0) : null; } -// Adds a 'br' marker at the end of the node. -export function endWithNewLine(node) { +// Adds a bogus 'br' at the end of the node if not existant. +export function addBogusBR(node) { if (!isBlockElement(node)) { return; } - if ( - !node.lastChild || - node.lastChild.tagName !== 'BR' || - node.lastChild.className !== 'coral-rte-end-marker' - ) { - const br = document.createElement('br'); - br.className = 'coral-rte-end-marker'; - node.appendChild(br); + if (!node.lastChild || !isBogusBR(node.lastChild)) { + node.appendChild(document.createElement('br')); } } @@ -275,6 +301,17 @@ export function insertNewLine(changeSelection) { 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) { @@ -282,10 +319,6 @@ export function insertNewLine(changeSelection) { range.setStart(el.parentNode, offset); range.setEnd(el.parentNode, offset); } else { - // We need to add a
marker at the end, because we can't - // select the last
. - endWithNewLine(el.parentNode); - const offset = el.parentNode.childNodes.length - 1; range.setStart(el.parentNode, offset); range.setEnd(el.parentNode, offset); @@ -342,6 +375,14 @@ export function getLeftOfNode(node) { 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`. */ @@ -356,7 +397,11 @@ export function getRightOfNode(node) { cur = cur.nextSibling; result.push(cur); } - if (cur.nextSibling && cur.nextSibling.tagName === 'BR') { + if ( + cur.nextSibling && + cur.nextSibling.tagName === 'BR' && + !isBogusBR(cur.nextSibling) + ) { result.push(cur.nextSibling); } return result; @@ -457,11 +502,28 @@ export function getSelectedChildren(ancestor) { * Removes node and assimilate its children with the parent. */ export function outdentNode(node, changeSelection) { + /// Remove bogus br + if (node.lastChild && node.lastChild.tagName === 'BR') { + node.removeChild(node.lastChild); + } + + // A new line to substitute the missing block element. + const needExtraLine = + node.nextSibling && + !isBlockElement(node.nextSibling) && + !isBlockElement(node.lastChild); + const parentNode = node.parentNode; const offset = indexOfChildNode(parentNode, node); + while (node.firstChild) { parentNode.insertBefore(node.firstChild, node); } + + if (needExtraLine) { + parentNode.insertBefore(document.createElement('BR'), node); + } + parentNode.removeChild(node); if (changeSelection) { @@ -472,6 +534,36 @@ export function outdentNode(node, changeSelection) { } } +/** + * Indent children. + */ +export function indentNodes(nodes, tagName) { + 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 === lastNode.parentNode.lastChild && + isBogusBR(lastNode.parentNode.lastChild) + ) { + lastNode.parentNode.removeChild(lastNode.parentNode.lastChild); + } + + const firstNode = nodes[0]; + // Remove previous br as it is not needed + if (firstNode.previousSibling && firstNode.previousSibling.tagName === 'BR') { + firstNode.parentNode.removeChild(firstNode.previousSibling); + } + + // Finally indent. + firstNode.parentNode.insertBefore(node, firstNode); + nodes.forEach(n => { + node.appendChild(n); + }); + return node; +} + function cloneNodeAndRangeHelper(node, range, rangeCloned) { const nodeCloned = node.cloneNode(false); for (let i = 0; i < node.childNodes.length; i++) { From 022699152125c79674b129f7a6973cf714fec3a3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 22:07:32 +0200 Subject: [PATCH 24/40] Better selection behavior --- .../components/rte/buttons/Blockquote.js | 3 +- .../client/components/rte/lib/dom.js | 89 ++++++++++++++----- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index d8a409b44..5fb6dbf63 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -17,8 +17,7 @@ function execCommand() { // Expanded selection means we always select whole lines. const selectedNodes = getSelectedNodesExpanded(); if (selectedNodes.length) { - const node = indentNodes(selectedNodes, 'blockquote'); - selectEndOfNode(node); + indentNodes(selectedNodes, 'blockquote', true); } else { const node = document.createElement('blockquote'); node.appendChild(document.createElement('br')); 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 index 66ac35662..c1200c931 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -264,15 +264,26 @@ export function addBogusBR(node) { /** * Returns true if selection is completely inside - * given node. + * given nodes. */ -export function isSelectionInside(node) { +export function isSelectionInside(...nodes) { + let foundStart = false; const range = getSelectionRange(); - return ( - range && - (nodeContains(node, range.startContainer) || - nodeContains(node, range.endContainer)) - ); + 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; } /** @@ -502,25 +513,43 @@ export function getSelectedChildren(ancestor) { * Removes node and assimilate its children with the parent. */ export function outdentNode(node, changeSelection) { - /// Remove bogus br + // 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 line to substitute the missing block element. - const needExtraLine = + // A new lines to substitute the missing block element. + const needLineAfter = node.nextSibling && !isBlockElement(node.nextSibling) && !isBlockElement(node.lastChild); + const needLineBefore = + node.previousSibling && + !isBlockElement(node.previousSibling) && + node.previousSibling.tageName !== 'BR'; const parentNode = node.parentNode; - const offset = indexOfChildNode(parentNode, node); + + if (needLineBefore) { + parentNode.insertBefore(document.createElement('BR'), node); + } + + const previousOffset = indexOfChildNode(parentNode, node); while (node.firstChild) { parentNode.insertBefore(node.firstChild, node); } - if (needExtraLine) { + if (needLineAfter) { parentNode.insertBefore(document.createElement('BR'), node); } @@ -528,8 +557,22 @@ export function outdentNode(node, changeSelection) { if (changeSelection) { const range = document.createRange(); - range.setStart(parentNode, offset); - range.setEnd(parentNode, offset); + + 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); } } @@ -537,30 +580,36 @@ export function outdentNode(node, changeSelection) { /** * Indent children. */ -export function indentNodes(nodes, tagName) { +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 === lastNode.parentNode.lastChild && - isBogusBR(lastNode.parentNode.lastChild) + lastNode.nextSibling === parentNode.lastChild && + isBogusBR(parentNode.lastChild) ) { - lastNode.parentNode.removeChild(lastNode.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') { - firstNode.parentNode.removeChild(firstNode.previousSibling); + parentNode.removeChild(firstNode.previousSibling); } // Finally indent. - firstNode.parentNode.insertBefore(node, firstNode); + parentNode.insertBefore(node, firstNode); nodes.forEach(n => { node.appendChild(n); }); + + if (changeSelection) { + selectEndOfNode(node); + } + return node; } From ee9786de027b228486c3440075bca3ab9ea7e2a1 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 22:18:57 +0200 Subject: [PATCH 25/40] Fix append new line after node.. --- .../client/components/rte/lib/dom.js | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) 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 index c1200c931..c2bfb4372 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -286,22 +286,6 @@ export function isSelectionInside(...nodes) { return false; } -/** - * Append a newline to the node. - */ -export function appendNewLine(node, changeSelection) { - const el = document.createElement('br'); - node.appendChild(el); - - if (changeSelection) { - const offset = indexOfChildNode(node, el); - const range = document.createRange(); - range.setStart(node, offset); - range.setEnd(node, offset); - replaceSelection(range); - } -} - /** * Insert new line. This is what happens * when adding new lines through pressing Enter. @@ -344,17 +328,19 @@ export function insertNewLine(changeSelection) { * Inserts a new line after given node. */ export function insertNewLineAfterNode(node, changeSelection) { - if (node.parentNode.lastChild === node) { - appendNewLine(node.parentNode, changeSelection); + const el = document.createElement('br'); + if (node.nextSibling) { + node.parentNode.insertBefore(el, node.nextSibling); } else { - if (changeSelection) { - const offset = indexOfChildNode(node.parentNode, node) + 1; - const range = document.createRange(); - range.setStart(node.parentNode, offset); - range.setEnd(node.parentNode, offset); - replaceSelection(range); - } - insertNewLine(); + 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); } } From 11051fbdb9338c4ee4619f33c2f9b1bf14aad906 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 22:43:30 +0200 Subject: [PATCH 26/40] Shortcut support --- .../client/components/rte/RTE.js | 18 ++++++++++++++++++ .../components/rte/buttons/Blockquote.js | 4 ++-- .../client/components/rte/buttons/Bold.js | 10 +++++++++- .../client/components/rte/buttons/Italic.js | 10 +++++++++- .../components/rte/factories/createToggle.js | 4 +++- .../client/components/rte/lib/dom.js | 3 ++- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index 655ebfc1d..e919a86ef 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -126,6 +126,17 @@ class RTE extends React.Component { }); }; + // Allow buttons to handle shortcuts. + handleShortcut = e => { + let handled = false; + this.forEachButton(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 // button handlers and stops when one has handled this event. @@ -209,6 +220,13 @@ class RTE extends React.Component { 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()) { diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js index 5fb6dbf63..201260fcd 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Blockquote.js @@ -4,7 +4,7 @@ import { insertNewLineAfterNode, insertNodes, getSelectedNodesExpanded, - outdentNode, + outdentBlock, selectEndOfNode, indentNodes, } from '../lib/dom'; @@ -12,7 +12,7 @@ import { function execCommand() { const bq = findIntersecting('BLOCKQUOTE', this.container); if (bq) { - outdentNode(bq, true); + outdentBlock(bq, true); } else { // Expanded selection means we always select whole lines. const selectedNodes = getSelectedNodesExpanded(); diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js index 53bdfe71b..8f5b55de5 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Bold.js @@ -25,8 +25,16 @@ function isDisabled() { this.container ); } +function onShortcut(e) { + if (e.key === 'b') { + if (!isDisabled.apply(this)) { + execCommand.apply(this); + } + return true; + } +} -const Bold = createToggle(execCommand, { isActive, isDisabled }); +const Bold = createToggle(execCommand, { isActive, isDisabled, onShortcut }); Bold.defaultProps = { children: 'Bold', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js index 64786d5b9..d35f08da0 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/buttons/Italic.js @@ -23,8 +23,16 @@ function isDisabled() { this.container ); } +function onShortcut(e) { + if (e.key === 'i') { + if (!isDisabled.apply(this)) { + execCommand.apply(this); + } + return true; + } +} -const Italic = createToggle(execCommand, { isActive, isDisabled }); +const Italic = createToggle(execCommand, { isActive, isDisabled, onShortcut }); Italic.defaultProps = { children: 'Italic', diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js index 6e1ca5b71..028757222 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -8,7 +8,7 @@ import Button from '../components/Button'; */ const createToggle = ( execCommand, - { onEnter, isActive = () => false, isDisabled = () => false } = {} + { onEnter, onShortcut, isActive = () => false, isDisabled = () => false } = {} ) => { class Toggle extends React.Component { state = { @@ -20,6 +20,8 @@ const createToggle = ( 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() { 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 index c2bfb4372..a5fbec19e 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -498,7 +498,7 @@ export function getSelectedChildren(ancestor) { /** * Removes node and assimilate its children with the parent. */ -export function outdentNode(node, changeSelection) { +export function outdentBlock(node, changeSelection) { // Save previous range. const selectionWasInside = isSelectionInside(node); const { @@ -517,6 +517,7 @@ export function outdentNode(node, changeSelection) { const needLineAfter = node.nextSibling && !isBlockElement(node.nextSibling) && + node.lastChild && !isBlockElement(node.lastChild); const needLineBefore = node.previousSibling && From 17d50c6ebb986842471e6325669604bf3f2d58c7 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 22:48:51 +0200 Subject: [PATCH 27/40] Add classNames for styling --- .../client/components/Editor.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js index 9dbca33e1..b273799b5 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -64,25 +64,36 @@ class Editor extends React.Component { + , ,
, From 7867b0113eded2bf76ddaa7983496d9ac889f1a9 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 22:52:48 +0200 Subject: [PATCH 28/40] Rename buttons to feature --- .../client/components/Editor.js | 10 ++--- .../client/components/rte/RTE.js | 42 ++++++++++--------- .../rte/{buttons => features}/Blockquote.js | 0 .../rte/{buttons => features}/Bold.js | 0 .../rte/{buttons => features}/Italic.js | 0 .../rte/{buttons => features}/index.js | 0 6 files changed, 27 insertions(+), 25 deletions(-) rename plugins/talk-plugin-rich-text/client/components/rte/{buttons => features}/Blockquote.js (100%) rename plugins/talk-plugin-rich-text/client/components/rte/{buttons => features}/Bold.js (100%) rename plugins/talk-plugin-rich-text/client/components/rte/{buttons => features}/Italic.js (100%) rename plugins/talk-plugin-rich-text/client/components/rte/{buttons => features}/index.js (100%) diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js index b273799b5..e09931d38 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -6,7 +6,7 @@ import { PLUGIN_NAME } from '../constants'; import { htmlNormalizer } from '../utils'; import RTE from './rte/RTE'; import { Icon } from 'plugin-api/beta/client/components/ui'; -import { Bold, Italic, Blockquote } from './rte/buttons'; +import { Bold, Italic, Blockquote } from './rte/features'; import { t } from 'plugin-api/beta/client/services'; class Editor extends React.Component { @@ -75,25 +75,25 @@ class Editor extends React.Component { disabled={disabled} placeholder={placeholder} ref={this.handleRef} - buttons={[ + features={[ , ,
, diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index e919a86ef..ba4bf98bd 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -38,8 +38,8 @@ class RTE extends React.Component { // Instance of undo stack. undo = new Undo(); - // Refs to the buttons. - buttonsRef = {}; + // Refs to the features. + featuresRef = {}; // Export this for parent components. focus = () => this.ref.htmlEl.focus(); @@ -63,13 +63,13 @@ class RTE extends React.Component { this.saveCheckpoint(props.value); } - // Returns a handler that fills our `buttonsRef`. - createButtonRefHandler(key) { + // Returns a handler that fills our `featuresRef`. + createFeatureRefHandler(key) { return ref => { if (ref) { - this.buttonsRef[key] = ref; + this.featuresRef[key] = ref; } else { - delete this.buttonsRef[key]; + delete this.featuresRef[key]; } }; } @@ -77,8 +77,8 @@ class RTE extends React.Component { // Ref to react-contenteditable. handleRef = ref => (this.ref = ref); - forEachButton(callback) { - Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k])); + forEachFeature(callback) { + Object.keys(this.featuresRef).map(k => callback(this.featuresRef[k])); } componentWillReceiveProps(props) { @@ -119,17 +119,17 @@ class RTE extends React.Component { }; handleSelectionChange = () => { - // Let buttons know selection has changeed, so they + // Let features know selection has changeed, so they // can update. - this.forEachButton(b => { + this.forEachFeature(b => { b.onSelectionChange && b.onSelectionChange(); }); }; - // Allow buttons to handle shortcuts. + // Allow features to handle shortcuts. handleShortcut = e => { let handled = false; - this.forEachButton(b => { + this.forEachFeature(b => { if (!handled) { handled = !!(b.onShortcut && b.onShortcut(e)); } @@ -139,14 +139,14 @@ class RTE extends React.Component { // Called when Enter was pressed without shift. // Traverses from bottom to top and calling - // button handlers and stops when one has handled this event. + // 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.forEachButton(b => { + this.forEachFeature(b => { if (!handled) { handled = !!(b.onEnter && b.onEnter(container)); } @@ -309,12 +309,12 @@ class RTE extends React.Component { } } - renderButtons() { - return this.props.buttons.map(b => { + renderFeatures() { + return this.props.features.map(b => { return React.cloneElement(b, { disabled: this.props.disabled, api: this.api, - ref: this.createButtonRefHandler(b.key), + ref: this.createFeatureRefHandler(b.key), }); }); } @@ -346,7 +346,9 @@ class RTE extends React.Component { return (
- {this.renderButtons()} + + {this.renderFeatures()} + {!value &&
{placeholder}
} Date: Sun, 25 Mar 2018 23:01:43 +0200 Subject: [PATCH 29/40] Support nested feature instance --- plugins/talk-plugin-rich-text/client/components/rte/RTE.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index ba4bf98bd..a33889ea6 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -78,7 +78,12 @@ class RTE extends React.Component { handleRef = ref => (this.ref = ref); forEachFeature(callback) { - Object.keys(this.featuresRef).map(k => callback(this.featuresRef[k])); + Object.keys(this.featuresRef).map(k => { + const instance = this.featureRef[k].getFeatureInstance + ? this.featureRef[k].getFeatureInstance() + : this.featureRef[k]; + callback(instance); + }); } componentWillReceiveProps(props) { From 45ad7eeff745f0c6f460e3377ab1ff9aaacc6d69 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sun, 25 Mar 2018 23:17:47 +0200 Subject: [PATCH 30/40] Let browser insert text --- plugin-api/alpha/client/hocs/index.js | 1 + .../client/components/rte/RTE.js | 16 +++------------- 2 files changed, 4 insertions(+), 13 deletions(-) create mode 100644 plugin-api/alpha/client/hocs/index.js 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/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js index a33889ea6..3726fb7c1 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -79,9 +79,9 @@ class RTE extends React.Component { forEachFeature(callback) { Object.keys(this.featuresRef).map(k => { - const instance = this.featureRef[k].getFeatureInstance - ? this.featureRef[k].getFeatureInstance() - : this.featureRef[k]; + const instance = this.featuresRef[k].getFeatureInstance + ? this.featuresRef[k].getFeatureInstance() + : this.featuresRef[k]; callback(instance); }); } @@ -248,16 +248,6 @@ class RTE extends React.Component { } }; - handleKeyPress = e => { - const character = String.fromCharCode(e.charCode); - if (character) { - insertText(character); - this.handleChange(); - e.preventDefault(); - return false; - } - }; - restoreCheckpoint(html, node, range) { if (node && range) { // We need to clone it, otherwise we'll mutate From e86cfaaa5b2dcc74303fb8c1c267cd197d7eef61 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 26 Mar 2018 12:52:04 -0600 Subject: [PATCH 31/40] docs update --- docs/_config.yml | 2 +- docs/source/01-01-talk-quickstart.md | 6 +- docs/source/01-02-installation-from-docker.md | 6 +- docs/source/01-03-installation-from-source.md | 4 +- docs/source/02-01-required-configuration.md | 4 +- docs/source/02-02-advanced-configuration.md | 67 ++++++++++--------- .../03-01-product-guide-how-talk-works.md | 6 +- .../03-02-product-guide-commenter-features.md | 4 +- .../03-03-product-guide-moderator-features.md | 8 +-- docs/source/03-04-product-guide-trust.md | 2 +- .../03-05-product-guide-toxic-comments.md | 2 +- docs/source/plugins/overview.md | 2 +- docs/themes/coral/layout/partial/sidebar.swig | 2 +- plugins/talk-plugin-toxic-comments/README.md | 2 +- 14 files changed, 60 insertions(+), 57 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index f269d8695..deb14ced4 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -171,7 +171,7 @@ marked: breaks: false smartLists: true smartypants: true - modifyAnchors: '' + modifyAnchors: 1 autolink: true node_sass: diff --git a/docs/source/01-01-talk-quickstart.md b/docs/source/01-01-talk-quickstart.md index 9ab7a50af..c18aea638 100644 --- a/docs/source/01-01-talk-quickstart.md +++ b/docs/source/01-01-talk-quickstart.md @@ -24,7 +24,7 @@ to persist data. The following versions are supported: An optional dependency for Talk is [Docker](https://www.docker.com/community-edition#/download). -It is used during [development](#development) to set up the database and can be +It is used during development to set up the database and can be used to [install via Docker](#installation-from-docker). We have tested Talk and this documentation with versions 17.06.2+. @@ -80,7 +80,7 @@ volumes: ``` This is the bare minimum needed to run the demo, for more configuration -variables, check out the [Configuration](./configuration/) section. +variables, check out the [Configuration](/talk/configuration/) section. And you can then start it with: @@ -172,7 +172,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret ``` This is only the bare minimum needed to run the demo, for more configuration -variables, check out the [Configuration](./configuration/) section. Facebook login above +variables, check out the [Configuration](/talk/configuration/) section. Facebook login above will definitely not work unless you change those values as well. diff --git a/docs/source/01-02-installation-from-docker.md b/docs/source/01-02-installation-from-docker.md index 13e253737..dbf43fb54 100644 --- a/docs/source/01-02-installation-from-docker.md +++ b/docs/source/01-02-installation-from-docker.md @@ -75,7 +75,7 @@ volumes: ``` This is the bare minimum needed to start Talk, for more configuration -variables, check out the [Configuration](./configuration/) section. +variables, check out the [Configuration](/talk/configuration/) section. And you can then start it with: @@ -111,7 +111,7 @@ talk_1 yarn start Up 0.0.0.0:3000->3000/tcp ``` -At this stage, you should refer to the [configuration](./configuration/) for +At this stage, you should refer to the [configuration](/talk/configuration/) for configuration variables that are specific to your installation. ## Onbuild @@ -142,7 +142,7 @@ This accomplishes a lot: 2. Installs any new dependencies that were required by any new plugins. 3. Builds the new static bundles so that they are ready to serve when the image is running. -4. Specifies a build time variable [TALK_DEFAULT_LANG](./advanced-configuration/#talk_default_lang). Refer +4. Specifies a build time variable [TALK_DEFAULT_LANG](/talk/advanced-configuration/#talk-default-lang). Refer to [Dockerfile.onbuild](https://github.com/coralproject/talk/blob/master/Dockerfile.onbuild) for the available build variables. diff --git a/docs/source/01-03-installation-from-source.md b/docs/source/01-03-installation-from-source.md index 8b6f52228..a460751f7 100644 --- a/docs/source/01-03-installation-from-source.md +++ b/docs/source/01-03-installation-from-source.md @@ -62,7 +62,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret ``` This is the bare minimum needed to start Talk, for more configuration -variables, check out the [Configuration](./configuration/) +variables, check out the [Configuration](/talk/configuration/) section. Facebook login above will definitely not work unless you change those values as well. @@ -73,5 +73,5 @@ You can now start the application by running: yarn watch:server ``` -At this stage, you should refer to the [configuration](./configuration/) for +At this stage, you should refer to the [configuration](/talk/configuration/) for configuration variables that are specific to your installation. diff --git a/docs/source/02-01-required-configuration.md b/docs/source/02-01-required-configuration.md index 1bfaa361c..be1e1d0b2 100644 --- a/docs/source/02-01-required-configuration.md +++ b/docs/source/02-01-required-configuration.md @@ -16,7 +16,7 @@ instance of Talk. If you've already configured your application with the required configuration, you can further customize it's behavior by applying -[Advanced Configuration](./advanced-configuration/). +[Advanced Configuration](/talk/advanced-configuration/). ## TALK_MONGO_URL @@ -81,4 +81,4 @@ TALK_JWT_SECRET=jX9y8G2ApcVLwyL{$6s3 Be default, we sign our tokens with HMAC using a SHA-256 hash algorithm. If you want to change the signing algorithm, or use multiple signing/verifying keys, -refer to our [Advanced Configuration](./advanced-configuration/) documentation. +refer to our [Advanced Configuration](/talk/advanced-configuration/) documentation. diff --git a/docs/source/02-02-advanced-configuration.md b/docs/source/02-02-advanced-configuration.md index 458efe8d7..30bc5c69b 100644 --- a/docs/source/02-02-advanced-configuration.md +++ b/docs/source/02-02-advanced-configuration.md @@ -15,7 +15,7 @@ The variables above have defaults, and are _optional_ to start your instance of Talk. If this is your first time configuring Talk, ensure you've also added the -[Required Configuration](./configuration) as well, +[Required Configuration](/talk/configuration) as well, otherwise the application will fail to start. ## TALK_CACHE_EXPIRY_COMMENT_COUNT @@ -26,7 +26,7 @@ Configure the duration for which comment counts are cached for, parsed by ## TALK_DEFAULT_LANG This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild](./installation-from-docker/#onbuild) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_DEFAULT_LANG=en`. Specify the default translation language. (Default `en`) @@ -34,7 +34,7 @@ Specify the default translation language. (Default `en`) ## TALK_DEFAULT_STREAM_TAB This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild](./installation-from-docker/#onbuild) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_DEFAULT_STREAM_TAB=all`. Specify the default stream tab in the admin. (Default `all`) @@ -53,7 +53,7 @@ in the embed.js target that is loaded on the page that loads the embed. (Default ## TALK_DISABLE_STATIC_SERVER When `TRUE`, it will not mount the static asset serving routes on the router. -This is used primarily in conjunction with [TALK_STATIC_URI](#talk_static_uri) +This is used primarily in conjunction with [TALK_STATIC_URI](#talk-static-uri) when the static assets are being hosted on an external domain. (Default `FALSE`) ## TALK_HELMET_CONFIGURATION @@ -149,7 +149,7 @@ claim for login JWT tokens. (Default `talk`) ## TALK_JWT_CLEAR_COOKIE_LOGOUT When `FALSE`, Talk will not clear the cookie with name -[TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) when logging out +[TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) when logging out but will still blacklist the token. (Default `TRUE`) ## TALK_JWT_COOKIE_NAME @@ -160,8 +160,8 @@ user. (Default `authorization`) ## TALK_JWT_COOKIE_NAMES The different cookie names to check for a JWT token in, separated by a `,`. By -default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name) -and [TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) for this +default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name) +and [TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) for this value. Any additional cookie names specified here will be appended to the list of cookie names to inspect. @@ -183,13 +183,13 @@ Would mean we would check the following cookies (in order) for a valid token: When `TRUE`, Talk will not verify or sign JWT’s with an audience [aud](https://tools.ietf.org/html/rfc7519#section-4.1.3) -claim, even if [TALK_JWT_AUDIENCE](#talk_jwt_audience) is set. (Default `FALSE`) +claim, even if [TALK_JWT_AUDIENCE](#talk-jwt-audience) is set. (Default `FALSE`) ## TALK_JWT_DISABLE_ISSUER When `TRUE`, Talk will not verify or sign JWT’s with an issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1) -claim, even if [TALK_JWT_ISSUER](#talk_jwt_issuer) is set. (Default `FALSE`) +claim, even if [TALK_JWT_ISSUER](#talk-jwt-issuer) is set. (Default `FALSE`) ## TALK_JWT_EXPIRY @@ -205,7 +205,7 @@ reason to create reasonable expiry lengths as to minimize the storage overhead. ## TALK_JWT_ISSUER The issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1) -claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](./configuration/#talk_root_url)) +claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url)) ## TALK_JWT_SECRET @@ -223,22 +223,25 @@ You can also express this secret in the JSON syntax: TALK_JWT_SECRET={"secret": "jX9y8G2ApcVLwyL{$6s3"} ``` -Refer to the documentation for [TALK_JWT_ALG](#talk_jwt_alg) for other signing +Refer to the documentation for [TALK_JWT_ALG](#talk-jwt-alg) for other signing methods and other forms of the `TALK_JWT_SECRET`. If you are interested in using -multiple keys, then refer to [TALK_JWT_SECRETS](#talk_jwt_secrets). +multiple keys, then refer to [TALK_JWT_SECRETS](#talk-jwt-secrets). ## TALK_JWT_SECRETS Used when specifying multiple secrets used for key rotations. This is a JSON encoded array, where each element matches the JWT Secret pattern. When this is -used, you do not need to specify a [TALK_JWT_SECRET](#talk_jwt_secret) as this +used, you do not need to specify a [TALK_JWT_SECRET](#talk-jwt-secret) as this will take precedence. **The first secret in `TALK_JWT_SECRETS` will be used for signing, and must contain a private key if used with an asymmetric algorithm.** All secrets should specify a `kid` field which uniquely identifies a given key -and will sign all tokens with that `kid` for later identification. +and will sign all tokens with that `kid` for later identification. _If a token +is not signed with the `kid` field in the header, and multiple secrets are used, +the token will fail to be verified. This field must match what's provided to +Talk in the form of the `kid` field in the secret._ -When the value of [TALK_JWT_ALG](#talk_jwt_alg) is a `HS*` value, then the value +When the value of [TALK_JWT_ALG](#talk-jwt-alg) is a `HS*` value, then the value of the `TALK_JWT_SECRETS` should take the form: ```plain @@ -247,24 +250,24 @@ TALK_JWT_SECRETS=[{"kid": "1", "secret": "my-super-secret"}, {"kid": "2", "secre Note that the secret is stored in a JSON object, keyed by `secret`. This is only needed when specifying in the multiple secrets for `TALK_JWT_SECRETS`, but may -be used to specify the single [TALK_JWT_SECRET](#talk_jwt_secret). +be used to specify the single [TALK_JWT_SECRET](#talk-jwt-secret). -When the value of [TALK_JWT_ALG](#talk_jwt_alg) is **not** a `HS*` value, then +When the value of [TALK_JWT_ALG](#talk-jwt-alg) is **not** a `HS*` value, then the value of the `TALK_JWT_SECRETS` should take the form: ```plain TALK_JWT_SECRETS=[{"kid": "1", "private": "", "public": ""}, ...] ``` -Refer to the documentation on the [TALK_JWT_ALG](#talk_jwt_alg) for more +Refer to the documentation on the [TALK_JWT_ALG](#talk-jwt-alg) for more information on what to store in these parameters. ## TALK_JWT_SIGNING_COOKIE_NAME The default cookie name that is use to set a cookie containing a JWT that was -issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name)) +issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name)) ## TALK_JWT_USER_ID_CLAIM @@ -298,8 +301,8 @@ the websocket to keep the socket alive, parsed by Setting a reCAPTCHA Public and Secret key will enable and require reCAPTCHA upon multiple failed login attempts. Client secret used for enabling reCAPTCHA powered logins. If -[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and -[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead +[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and +[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead default to providing only a time based lockout. Refer to [reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information on getting an account setup. @@ -307,8 +310,8 @@ on getting an account setup. ## TALK_RECAPTCHA_SECRET Server secret used for enabling reCAPTCHA powered logins. If -[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and -[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead +[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and +[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead default to providing only a time based lockout. Refer to [reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information on getting an account setup. @@ -347,7 +350,7 @@ by [ms](https://www.npmjs.com/package/ms). (Default `1 sec`) ## TALK_ROOT_URL_MOUNT_PATH When set to `TRUE`, the routes will be mounted onto the `` component -of the [TALK_ROOT_URL](./configuration/#talk_root_url). +of the [TALK_ROOT_URL](/talk/configuration/#talk-root-url). You would use this when your upstream proxy cannot strip the prefix from the url. (Default `FALSE`) @@ -395,12 +398,12 @@ Used to set the uri where the static assets should be served from. This is used when you want to upload the static assets through your build process to a service like Google Cloud Storage or Amazon S3 and you would then specify the CDN/Storage url. (Defaults to value of -[TALK_ROOT_URL](./configuration/#talk_root_url)) +[TALK_ROOT_URL](/talk/configuration/#talk-root-url)) ## TALK_THREADING_LEVEL This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild](./installation-from-docker/#onbuild) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_THREADING_LEVEL=3`. Specify the maximum depth of the comment thread. (Default `3`) @@ -414,13 +417,13 @@ Used to override the location to connect to the websocket endpoint to potentially another host. This should be used when you need to route websocket requests out of your CDN in order to serve traffic more efficiently. -If the value of [TALK_ROOT_URL](./configuration/#talk_root_url) +If the value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url) is a https url, then this defaults to `wss://${location.host}${MOUNT_PATH}api/v1/live`. Otherwise, it defaults to `ws://${location.host}${MOUNT_PATH}api/v1/live`. -Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk_root_url_mount_path) +Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk-root-url-mount-path) is `FALSE`, or the path component of -[TALK_ROOT_URL](./configuration/#talk_root_url) if it's `TRUE`. +[TALK_ROOT_URL](/talk/configuration/#talk-root-url) if it's `TRUE`. **Warning: if used without managing the auth state manually, auth cannot be persisted due to browser restrictions.** @@ -491,7 +494,7 @@ be used with caution. (Default `FALSE`) ## TALK_ADDTL_COMMENTS_ON_LOAD_MORE This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10`. Specifies the number of additional comments to load when a user clicks `Load More`. (Default `10`) @@ -499,7 +502,7 @@ Specifies the number of additional comments to load when a user clicks `Load Mor ## TALK_ASSET_COMMENTS_LOAD_DEPTH This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_ASSET_COMMENTS_LOAD_DEPTH=10`. Specifies the initial number of comments to load for an asset. (Default `10`) @@ -507,7 +510,7 @@ Specifies the initial number of comments to load for an asset. (Default `10`) ## TALK_REPLY_COMMENTS_LOAD_DEPTH This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`. Specifies the initial replies to load for a comment. (Default `3`) diff --git a/docs/source/03-01-product-guide-how-talk-works.md b/docs/source/03-01-product-guide-how-talk-works.md index fa27e5ed0..9463f74fe 100644 --- a/docs/source/03-01-product-guide-how-talk-works.md +++ b/docs/source/03-01-product-guide-how-talk-works.md @@ -28,13 +28,13 @@ Plugins are additional functionality which are optional to use with Talk. You can turn these on or off, depending on your specific needs. Plugins are either part of our core plugins, which ship with Talk, or they are developed by 3rd parties and either used privately and internally, or are open sourced for use -across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](./default-plugins/) -and [Additional Plugins](./additional-plugins/) pages. +across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](/talk/default-plugins/) +and [Additional Plugins](/talk/additional-plugins/) pages. ## Recipes Recipes are plugin templates that are created by the Talk team and 3rd party developers, in order to help contributors and newsrooms build plugins easily. -You can explore the recipes we offer by visiting our [Plugin Recipes](./plugin-recipes/) +You can explore the recipes we offer by visiting our [Plugin Recipes](/talk/plugin-recipes/) page. diff --git a/docs/source/03-02-product-guide-commenter-features.md b/docs/source/03-02-product-guide-commenter-features.md index 1c432303a..390fdce50 100644 --- a/docs/source/03-02-product-guide-commenter-features.md +++ b/docs/source/03-02-product-guide-commenter-features.md @@ -31,7 +31,7 @@ https://?commentId= Talk supports by default 3 levels of threading, meaning each top-level comment has a depth of 3 replies; replies beyond that are not nested below the 3rd level. You can adjust this using the -[TALK_THREADING_LEVEL](./advanced-configuration/#talk_threading_level) +[TALK_THREADING_LEVEL](/talk/advanced-configuration/#talk-threading-level) configuration variable. We don’t recommend deep threading because it can cause issues with styling, especially on mobile. @@ -162,7 +162,7 @@ Staff role. The Featured comment badge shows when a comment has been featured. Another optional badge is the Subscriber badge (which is available as a -[Recipe](./plugin-recipes/#recipe-subscriber). +[Recipe](/talk/plugin-recipes/#recipe-subscriber). Badges are another easy part of Talk to customize by creating a new `tag`, then setting some rules for when it should show, and how the badge should be styled. diff --git a/docs/source/03-03-product-guide-moderator-features.md b/docs/source/03-03-product-guide-moderator-features.md index 44f5605b4..9fbda6a18 100644 --- a/docs/source/03-03-product-guide-moderator-features.md +++ b/docs/source/03-03-product-guide-moderator-features.md @@ -56,8 +56,8 @@ history. **Toxic** The Toxic badge signifies comments that are above the set Toxicity Probability -Threshold. Note you must have [talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments) enabled. -[Read more about Toxic Comments here](./toxic-comments/). +Threshold. Note you must have [talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) enabled. +[Read more about Toxic Comments here](/talk/toxic-comments/). **Suspect** @@ -122,7 +122,7 @@ automatically. **Reports** This shows if a commenter is a reliable flagger, an unreliable flagger, or a -neutral flagger. [Read more about reliable and unreliable flaggers here](./trust/#reliable-and-unreliable-flaggers). +neutral flagger. [Read more about reliable and unreliable flaggers here](/talk/trust/#reliable-and-unreliable-flaggers). **Moderating from this View** @@ -173,7 +173,7 @@ manage your team members’ roles (Admins, Moderators, Staff), as well as search for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ### Configure -See [Configuring Talk](./configuring-talk/). +See [Configuring Talk](/talk/configuring-talk/). ## Moderating via the Comment Stream diff --git a/docs/source/03-04-product-guide-trust.md b/docs/source/03-04-product-guide-trust.md index bea080716..c27c59dcc 100644 --- a/docs/source/03-04-product-guide-trust.md +++ b/docs/source/03-04-product-guide-trust.md @@ -28,7 +28,7 @@ Here are the default thresholds: +3 and higher: Reliable ``` -You can configure your own Trust thresholds by using [TRUST_THRESHOLD](./advanced-configuration/#trust_thresholds) in your +You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your configuration. diff --git a/docs/source/03-05-product-guide-toxic-comments.md b/docs/source/03-05-product-guide-toxic-comments.md index 3d52bfe22..ddbb4f9ff 100644 --- a/docs/source/03-05-product-guide-toxic-comments.md +++ b/docs/source/03-05-product-guide-toxic-comments.md @@ -50,7 +50,7 @@ trying to improve a broken part of the internet. ## How do I add the Toxic Comments plugin? To enable this behavior, visit the -[talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments) +[talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) plugin documentation. diff --git a/docs/source/plugins/overview.md b/docs/source/plugins/overview.md index 03bb8dcee..26230dc00 100644 --- a/docs/source/plugins/overview.md +++ b/docs/source/plugins/overview.md @@ -116,4 +116,4 @@ configuration and will ensure that the image is ready to use by building all assets inside the image as well. For more information on the onbuild image, refer to the -[Installation from Docker](./installation-from-docker/) documentation. +[Installation from Docker](/talk/installation-from-docker/) documentation. diff --git a/docs/themes/coral/layout/partial/sidebar.swig b/docs/themes/coral/layout/partial/sidebar.swig index d6d4fbab1..9d0a335d6 100644 --- a/docs/themes/coral/layout/partial/sidebar.swig +++ b/docs/themes/coral/layout/partial/sidebar.swig @@ -17,7 +17,7 @@ diff --git a/plugins/talk-plugin-toxic-comments/README.md b/plugins/talk-plugin-toxic-comments/README.md index efd5f550a..60c05ad9f 100644 --- a/plugins/talk-plugin-toxic-comments/README.md +++ b/plugins/talk-plugin-toxic-comments/README.md @@ -12,7 +12,7 @@ plugin: Using the [Perspective API](http://perspectiveapi.com/), this plugin will warn users and reject comments that exceed the predefined toxicity threshold. For more information on what Toxic Comments are, check out the -[Toxic Comments](./toxic-comments/) documentation. +[Toxic Comments](/talk/toxic-comments/) documentation. Configuration: From 5b9d24a0293e3a83239f27dd39ab2c7596ca7329 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 26 Mar 2018 22:35:21 +0200 Subject: [PATCH 32/40] Disable hover on touch devices --- .../client/components/rte/components/Button.css | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 index 39b14fb7e..9f30022a0 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css +++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css @@ -23,16 +23,15 @@ border: none; color: #4e4e4e; margin-right: 3px; + border-radius: 3px; } -.button:hover{ +.button:hover { cursor: pointer; - border-radius: 3px; background-color: #eae8e8; } -.active { - border-radius: 3px; +.button.active { background-color: #ddd; } @@ -41,3 +40,12 @@ cursor: default; background: none; } + +@media (-moz-touch-enabled: 1), (pointer:coarse) { + .button:hover{ + background-color: transparent; + } + .button.active { + background-color: #ddd; + } +} From dca9b192404353736f9e28d2eec188731a0014b0 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 26 Mar 2018 22:44:24 +0200 Subject: [PATCH 33/40] Better disabled detection --- .../client/components/rte/features/Bold.js | 5 +++-- .../client/components/rte/features/Italic.js | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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 index 8f5b55de5..fe072e2b1 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js @@ -1,5 +1,5 @@ import createToggle from '../factories/createToggle'; -import { findIntersecting } from '../lib/dom'; +import { findIntersecting, findAncestor } from '../lib/dom'; const boldTags = ['B', 'STRONG']; @@ -21,7 +21,8 @@ function isDisabled() { n => n.nodeName !== '#text' && window.getComputedStyle(n).getPropertyValue('font-weight') === 'bold' && - !boldTags.includes(n.tagName), + !boldTags.includes(n.tagName) && + !findAncestor(n, n => boldTags.includes(n.tagName), this.container), this.container ); } 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 index d35f08da0..80703f47c 100644 --- a/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js +++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js @@ -1,5 +1,5 @@ import createToggle from '../factories/createToggle'; -import { findIntersecting } from '../lib/dom'; +import { findIntersecting, findAncestor } from '../lib/dom'; const italicTags = ['I', 'EM']; @@ -19,7 +19,8 @@ function isDisabled() { n => n.nodeName !== '#text' && window.getComputedStyle(n).getPropertyValue('font-style') === 'italic' && - !italicTags.includes(n.tagName), + !italicTags.includes(n.tagName) && + !findAncestor(n, n => italicTags.includes(n.tagName), this.container), this.container ); } From 0865c4d5ad19c717064ea8548663c88469166a8f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 26 Mar 2018 22:56:26 +0200 Subject: [PATCH 34/40] Remove italic styling from blockquote --- .../talk-plugin-rich-text/client/components/CommentContent.css | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/talk-plugin-rich-text/client/components/CommentContent.css b/plugins/talk-plugin-rich-text/client/components/CommentContent.css index c473ba310..4f81058f4 100644 --- a/plugins/talk-plugin-rich-text/client/components/CommentContent.css +++ b/plugins/talk-plugin-rich-text/client/components/CommentContent.css @@ -3,7 +3,6 @@ background-color: #F6F6F6; padding: 10px; margin: 20px 0px 20px 10px; - font-style: italic; border-radius: 2px; &::after { content: none; From cd412056ad33732ba795d80f23de984c9268017f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 26 Mar 2018 15:01:55 -0600 Subject: [PATCH 35/40] Notification fixes - When a comment reply is not visible, it will not trigger a notification email. - When a comment is approved/accepted, we'll check to see if the comment was visible before, if not, we'll delegate to the new reply notification handler to possibly send a notification email. --- .../index.js | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/plugins/talk-plugin-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js index 0b6fe62a0..e214975ac 100644 --- a/plugins/talk-plugin-notifications-category-reply/index.js +++ b/plugins/talk-plugin-notifications-category-reply/index.js @@ -2,6 +2,12 @@ const { get } = require('lodash'); const path = require('path'); const handle = async (ctx, comment) => { + // Check to see if this reply is visible. + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + // Check to see if this is a reply to an existing comment. const parentID = get(comment, 'parent_id', null); if (parentID === null) { @@ -90,12 +96,31 @@ const hydrate = async (ctx, category, context) => { return [headline, replier, permalink]; }; -const handler = { - handle, - category: 'reply', - event: 'commentAdded', - hydrate, - digestOrder: 30, +// commentAcceptedHandleAdapter will check to see if we need to send a +// notification for this comment if the comment has been recently approved but +// has not been approved before. +const commentAcceptedHandleAdapter = (ctx, comment) => { + // Don't send a notification for a non-visible comment. + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + + // Don't send a notification if the comment was previously visible. + if ( + // TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check. + comment.status_history + .slice(0, comment.status_history.length - 1) + .some(({ type }) => ['ACCEPTED', 'NONE'].includes(type)) + ) { + ctx.log.info( + 'comment was previously already visible, not sending another notification' + ); + return; + } + + // Delegate to the handle function. + return handle(ctx, comment); }; module.exports = { @@ -115,5 +140,20 @@ module.exports = { }, }, translations: path.join(__dirname, 'translations.yml'), - notifications: [handler], + notifications: [ + { + handle, + category: 'reply', + event: 'commentAdded', + hydrate, + digestOrder: 30, + }, + { + handle: commentAcceptedHandleAdapter, + category: 'reply', + event: 'commentAccepted', + hydrate, + digestOrder: 30, + }, + ], }; From 969d2a15885116eadf12f576d2531ebf181c3f1f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 26 Mar 2018 23:07:05 +0200 Subject: [PATCH 36/40] Profile my comments responsive fixes --- .../coral-embed-stream/src/tabs/profile/components/Comment.css | 1 + 1 file changed, 1 insertion(+) diff --git a/client/coral-embed-stream/src/tabs/profile/components/Comment.css b/client/coral-embed-stream/src/tabs/profile/components/Comment.css index 40a003de1..9004a1e6c 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Comment.css +++ b/client/coral-embed-stream/src/tabs/profile/components/Comment.css @@ -15,6 +15,7 @@ .main { min-width: 70%; + max-width: 100%; } .sidebar { From bc11eaa9c8662e7f72c30f1070fc7d5649db2179 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 26 Mar 2018 23:11:14 +0200 Subject: [PATCH 37/40] Fix notification responsiveness --- .../talk-plugin-notifications/client/components/Settings.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/talk-plugin-notifications/client/components/Settings.css b/plugins/talk-plugin-notifications/client/components/Settings.css index f9a65c88d..0aaaa8ab3 100644 --- a/plugins/talk-plugin-notifications/client/components/Settings.css +++ b/plugins/talk-plugin-notifications/client/components/Settings.css @@ -1,6 +1,6 @@ .root { margin-bottom: 20px; - width: 350px; + max-width: 350px; } .innerSettings { From 34e3266a6fda79459bb35668f70cc96927c505f7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 26 Mar 2018 16:06:40 -0600 Subject: [PATCH 38/40] expanded fix to staff replies --- .../index.js | 56 ++++++++++++++++--- .../server/messages.js | 2 - 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/plugins/talk-plugin-notifications-category-staff/index.js b/plugins/talk-plugin-notifications-category-staff/index.js index a5d12c175..bf7da0776 100644 --- a/plugins/talk-plugin-notifications-category-staff/index.js +++ b/plugins/talk-plugin-notifications-category-staff/index.js @@ -2,6 +2,11 @@ const { get } = require('lodash'); const path = require('path'); const handle = async (ctx, comment) => { + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + // Check to see if this is a reply to an existing comment. const parentID = get(comment, 'parent_id', null); if (parentID === null) { @@ -111,13 +116,31 @@ const hydrate = async (ctx, category, context) => { return [headline, replier, organizationName, permalink]; }; -const handler = { - handle, - category: 'staff', - event: 'commentAdded', - hydrate, - supersedesCategories: ['reply'], - digestOrder: 20, +// commentAcceptedHandleAdapter will check to see if we need to send a +// notification for this comment if the comment has been recently approved but +// has not been approved before. +const commentAcceptedHandleAdapter = (ctx, comment) => { + // Don't send a notification for a non-visible comment. + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + + // Don't send a notification if the comment was previously visible. + if ( + // TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check. + comment.status_history + .slice(0, comment.status_history.length - 1) + .some(({ type }) => ['ACCEPTED', 'NONE'].includes(type)) + ) { + ctx.log.info( + 'comment was previously already visible, not sending another notification' + ); + return; + } + + // Delegate to the handle function. + return handle(ctx, comment); }; module.exports = { @@ -137,5 +160,22 @@ module.exports = { }, }, translations: path.join(__dirname, 'translations.yml'), - notifications: [handler], + notifications: [ + { + handle, + category: 'staff', + event: 'commentAdded', + hydrate, + supersedesCategories: ['reply'], + digestOrder: 20, + }, + { + handle: commentAcceptedHandleAdapter, + category: 'staff', + event: 'commentAccepted', + hydrate, + supersedesCategories: ['reply'], + digestOrder: 20, + }, + ], }; diff --git a/plugins/talk-plugin-notifications/server/messages.js b/plugins/talk-plugin-notifications/server/messages.js index 67c4b9d6b..0ba9ee70b 100644 --- a/plugins/talk-plugin-notifications/server/messages.js +++ b/plugins/talk-plugin-notifications/server/messages.js @@ -63,8 +63,6 @@ const sendNotificationsBatch = async (ctx, notifications) => { return; } - console.log(notifications); - return Promise.all( map( notifications, From c2133eb009eded93a0dc6c1f17888c0d274c5110 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 27 Mar 2018 12:55:43 -0600 Subject: [PATCH 39/40] removed unused plugin from directory --- docs/source/_data/plugins.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/source/_data/plugins.yml b/docs/source/_data/plugins.yml index 1b8dcc1c5..4c3621fcb 100644 --- a/docs/source/_data/plugins.yml +++ b/docs/source/_data/plugins.yml @@ -90,8 +90,6 @@ - reaction - name: talk-plugin-rich-text description: Enables rich text plugins that save data as HTML. -- name: talk-plugin-rich-text-pell - description: Enables the pell rich text editor. - name: talk-plugin-slack-notifications description: Sends all comments as notifications to a slack channel - name: talk-plugin-sort-most-liked @@ -135,4 +133,4 @@ description: Enables the dropdown used to display sorting options on the embed stream. tags: - default - - sorting \ No newline at end of file + - sorting From 217c2f1bfae6a4ca2e4f807da533253654e3e31a Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Wed, 28 Mar 2018 08:55:55 -0400 Subject: [PATCH 40/40] Add back missing tutorial --- docs/_config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_config.yml b/docs/_config.yml index deb14ced4..c61fdeb21 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -154,6 +154,8 @@ sidebar: url: /building-basic-plugin/ - title: Customizing Plugins with Coral UI url: /customizing-plugins-coral-ui/ + - title: When You've Installed Talk + url: /when-youve-installed-talk/ - title: Migrating children: - title: Migrating to v4.0.0