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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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