diff --git a/.gitignore b/.gitignore index 7c0007dc9..230acae6e 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ plugins/* !plugins/talk-plugin-toxic-comments !plugins/talk-plugin-viewing-options !plugins/talk-plugin-rich-text +!plugins/talk-plugin-rich-text-coral !plugins/talk-plugin-rich-text-pell **/node_modules/* diff --git a/plugins/talk-plugin-rich-text-coral/README.md b/plugins/talk-plugin-rich-text-coral/README.md new file mode 100644 index 000000000..f953a65cd --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/README.md @@ -0,0 +1,47 @@ +--- +title: talk-plugin-rich-text-coral +permalink: /plugin/talk-plugin-rich-text-coral/ +layout: plugin +plugin: + name: talk-plugin-rich-text-coral + depends: + - name: talk-plugin-rich-text + provides: + - Client +--- + +Enables rich text support client-side by using a simple RTE. + +## Installation + +Add `"talk-plugin-rich-text-coral"` to the `plugins.json` in your Talk +installation. Remember to add this in the `client` property since this plugin +only covers the client side. To add server support, please use +[talk-plugin-rich-text](/talk/plugin/talk-plugin-rich-text). + +_Note: Ensure that you don't have any other plugins utilizing the +`commentContent` slot, as it would result in duplicate comments._ + +## How does this work? + +This plugin contains 2 important components: + +- The Editor (`./components/Editor.js`) +- The Comment Content Renderer (`./components/CommentContent.js`) + +The editor component uses [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content). + +If you check our `index.js` you will notice that we inject this editor in the +`commentBox` slot. We do this to replace the core comment box with this one. + +Now, in order to render the new styled comments we need a comment renderer. For +this task we will have to replace our core comment renderer by using the +`commentContent` slot. + +If you are not familiar with GraphQL `client/index.js` will look complicated, +but fear not! With those functions we specify what to expect from the server +schema, how to perform optimistic updates and how keep the client store updated +with the latest changes. + +We encourage you to see the files and check how easy is to build plugins! If you +have any feedback, please let us know. diff --git a/plugins/talk-plugin-rich-text-coral/client/.eslintrc.json b/plugins/talk-plugin-rich-text-coral/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-rich-text-coral/client/components/Button.css b/plugins/talk-plugin-rich-text-coral/client/components/Button.css new file mode 100644 index 000000000..f9f1ba186 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/Button.css @@ -0,0 +1,20 @@ +.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-coral/client/components/Button.js b/plugins/talk-plugin-rich-text-coral/client/components/Button.js new file mode 100644 index 000000000..330e3d993 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/Button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './Button.css'; +import { Icon, BareButton } from 'plugin-api/beta/client/components/ui'; +import cn from 'classnames'; + +class Button extends React.Component { + render() { + const { className, icon, title, onClick } = this.props; + return ( + + + + ); + } +} + +Button.propTypes = { + icon: PropTypes.string.isRequired, + className: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, +}; + +export default Button; diff --git a/plugins/talk-plugin-rich-text-coral/client/components/CommentContent.js b/plugins/talk-plugin-rich-text-coral/client/components/CommentContent.js new file mode 100644 index 000000000..a0e045c6f --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/CommentContent.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { PLUGIN_NAME } from '../constants'; + +class CommentContent extends React.Component { + render() { + const { comment } = this.props; + return comment.richTextBody ? ( +
+ ) : ( +
{comment.body}
+ ); + } +} + +CommentContent.propTypes = { + comment: PropTypes.object.isRequired, +}; + +export default CommentContent; diff --git a/plugins/talk-plugin-rich-text-coral/client/components/Editor.css b/plugins/talk-plugin-rich-text-coral/client/components/Editor.css new file mode 100644 index 000000000..f102067a5 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/Editor.css @@ -0,0 +1,12 @@ +.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; +} + diff --git a/plugins/talk-plugin-rich-text-coral/client/components/Editor.js b/plugins/talk-plugin-rich-text-coral/client/components/Editor.js new file mode 100644 index 000000000..f6491572b --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/Editor.js @@ -0,0 +1,137 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +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'; + +class Editor extends React.Component { + ref = null; + handleRef = ref => (this.ref = ref); + state = { + html: + !this.props.isReply && this.props.comment + ? this.props.comment.richTextBody || this.props.comment.body || '' + : '', + }; + + handleChange = evt => { + const html = evt.target.value; + this.setState({ html }); + this.props.onChange(this.ref.htmlEl.innerText, { + richTextBody: htmlNormalizer(html), + }); + }; + + componentDidMount() { + if (this.props.registerHook) { + this.clearInputHook = this.props.registerHook( + 'postSubmit', + (res, handleBodyChange) => { + this.setState({ html: '' }); + handleBodyChange('', { richTextBody: '' }); + } + ); + } + } + + shouldComponentUpdate(nextProps) { + if (this.props.value !== nextProps.value) { + return false; + } + return true; + } + + componentWillUnmount() { + this.props.unregisterHook(this.clearInputHook); + } + + getCurrentTagName() { + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + if (range.startContainer.nodeName !== '#text') { + return range.startContainer.nodeName; + } + return range.startContainer.parentNode.tagName; + } + + formatBold = () => { + document.execCommand('bold'); + this.ref.htmlEl.focus(); + }; + + formatItalic = () => { + document.execCommand('italic'); + this.ref.htmlEl.focus(); + }; + + formatBlockquote = () => { + const currentTag = this.getCurrentTagName(); + if (currentTag === 'BLOCKQUOTE') { + document.execCommand('outdent'); + } else { + if (bowser.msie) { + document.execCommand('indent'); + } else { + document.execCommand('formatBlock', false, 'blockquote'); + } + } + this.ref.htmlEl.focus(); + }; + + outdentOnEnter = e => { + if (e.key === 'Enter' && !e.shiftKey) { + setTimeout(() => document.execCommand('outdent')); + } + }; + + render() { + const { id } = this.props; + return ( +
+ +
+ ); + } +} + +Editor.propTypes = { + rows: PropTypes.number, // TODO: should not be passed. + id: PropTypes.string, // TODO: should not be passed. + value: PropTypes.string, + placeholder: PropTypes.string, + onChange: PropTypes.func, + disabled: PropTypes.bool, + comment: PropTypes.object, + classNames: PropTypes.object, + registerHook: PropTypes.func, + unregisterHook: PropTypes.func, + isReply: PropTypes.bool, +}; + +export default Editor; diff --git a/plugins/talk-plugin-rich-text-coral/client/components/Toolbar.css b/plugins/talk-plugin-rich-text-coral/client/components/Toolbar.css new file mode 100644 index 000000000..f4880f432 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/Toolbar.css @@ -0,0 +1,7 @@ +.toolbar { + user-select: none; + padding: 5px 10px; + border-top: 1px solid #bbb; + border-left: 1px solid #bbb; + border-right: 1px solid #bbb; +} diff --git a/plugins/talk-plugin-rich-text-coral/client/components/Toolbar.js b/plugins/talk-plugin-rich-text-coral/client/components/Toolbar.js new file mode 100644 index 000000000..f3112e813 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/components/Toolbar.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './Toolbar.css'; +import cn from 'classnames'; + +class Toolbar extends React.Component { + render() { + const { className, ...rest } = this.props; + return
; + } +} + +Toolbar.propTypes = { + className: PropTypes.string, +}; + +export default Toolbar; diff --git a/plugins/talk-plugin-rich-text-coral/client/constants.js b/plugins/talk-plugin-rich-text-coral/client/constants.js new file mode 100644 index 000000000..40ea81053 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/constants.js @@ -0,0 +1 @@ +export const PLUGIN_NAME = 'talk-plugin-rich-text-coral'; diff --git a/plugins/talk-plugin-rich-text-coral/client/containers/CommentContent.js b/plugins/talk-plugin-rich-text-coral/client/containers/CommentContent.js new file mode 100644 index 000000000..99b65c22c --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/containers/CommentContent.js @@ -0,0 +1,12 @@ +import { gql } from 'react-apollo'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import CommentContent from '../components/CommentContent'; + +export default withFragments({ + comment: gql` + fragment TalkPluginRichTextCoral_CommentContent_comment on Comment { + body + richTextBody + } + `, +})(CommentContent); diff --git a/plugins/talk-plugin-rich-text-coral/client/containers/Editor.js b/plugins/talk-plugin-rich-text-coral/client/containers/Editor.js new file mode 100644 index 000000000..f748e7344 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/containers/Editor.js @@ -0,0 +1,12 @@ +import { gql } from 'react-apollo'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import Editor from '../components/Editor'; + +export default withFragments({ + comment: gql` + fragment TalkPluginRichTextCoral_Editor_comment on Comment { + body + richTextBody + } + `, +})(Editor); diff --git a/plugins/talk-plugin-rich-text-coral/client/index.js b/plugins/talk-plugin-rich-text-coral/client/index.js new file mode 100644 index 000000000..8cab68f8d --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/index.js @@ -0,0 +1,70 @@ +import Editor from './containers/Editor'; +import CommentContent from './containers/CommentContent'; +import { gql } from 'react-apollo'; + +export default { + slots: { + draftArea: [Editor], + commentContent: [CommentContent], + adminCommentContent: [CommentContent], + userDetailCommentContent: [CommentContent], + }, + fragments: { + CreateCommentResponse: gql` + fragment TalkRichTextCoral_CreateCommentResponse on CreateCommentResponse { + comment { + richTextBody + } + } + `, + EditCommentResponse: gql` + fragment TalkRichTextCoral_EditCommentResponse on EditCommentResponse { + comment { + richTextBody + } + } + `, + }, + mutations: { + PostComment: ({ variables: { input } }) => { + return { + optimisticResponse: { + createComment: { + comment: { + richTextBody: input.richTextBody, + }, + }, + }, + }; + }, + EditComment: ({ variables: { id, edit } }) => { + return { + optimisticResponse: { + editComment: { + comment: { + richTextBody: edit.richTextBody, + }, + }, + }, + update: proxy => { + const editCommentFragment = gql` + fragment TalkRichTextCoral_EditComment on Comment { + richTextBody + } + `; + + const fragmentId = `Comment_${id}`; + + proxy.writeFragment({ + fragment: editCommentFragment, + id: fragmentId, + data: { + __typename: 'Comment', + richTextBody: edit.richTextBody, + }, + }); + }, + }; + }, + }, +}; diff --git a/plugins/talk-plugin-rich-text-coral/client/utils.js b/plugins/talk-plugin-rich-text-coral/client/utils.js new file mode 100644 index 000000000..a617d618e --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/client/utils.js @@ -0,0 +1,31 @@ +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 + + // Harmonize all to tag. + str = str + .replace(//g, '') // IE + .replace(/<\/strong>/g, ''); // IE + + // Harmonize all to tag. + str = str + .replace(//g, '') // IE + .replace(/<\/em>/g, ''); // IE + + // Remove first opening tag, otherwise + // with the following transformation below + // we might add an unintended first empty line. + if (str.startsWith('
')) { + str = str.replace('
', ''); + } + + // Normalize
s to
. + // return str.replace(/
/g, '
').replace(/<\/div>/g, ''); + return str; +} diff --git a/plugins/talk-plugin-rich-text-coral/index.js b/plugins/talk-plugin-rich-text-coral/index.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/plugins/talk-plugin-rich-text-coral/package.json b/plugins/talk-plugin-rich-text-coral/package.json new file mode 100644 index 000000000..f15b565e7 --- /dev/null +++ b/plugins/talk-plugin-rich-text-coral/package.json @@ -0,0 +1,12 @@ +{ + "name": "@coralproject/talk-plugin-rich-text-coral", + "pluginName": "talk-plugin-rich-text-coral", + "version": "0.0.1", + "description": "Simple Rich Text Editor for Talk", + "main": "index.js", + "author": "The Coral Project Team ", + "license": "Apache-2.0", + "dependencies": { + "react-contenteditable": "^2.0.7" + } +} diff --git a/plugins/talk-plugin-rich-text/server/config.js b/plugins/talk-plugin-rich-text/server/config.js index 8d0fc87d3..347502d08 100644 --- a/plugins/talk-plugin-rich-text/server/config.js +++ b/plugins/talk-plugin-rich-text/server/config.js @@ -13,7 +13,10 @@ 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'] }, + dompurify: { + ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br', 'div'], + ALLOWED_ATTR: [], + }, // Secure config for jsdom even when DOMPurify creates a document without a browsing context jsdom: { diff --git a/yarn.lock b/yarn.lock index 135719b51..e8093adf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9142,6 +9142,10 @@ react-apollo@^1.4.12: object-assign "^4.0.1" prop-types "^15.5.8" +react-contenteditable@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/react-contenteditable/-/react-contenteditable-2.0.7.tgz#a8d1c1d7b9a393f336c5ecdb74e5e336d786676b" + react-dom@>=0.14.0: version "16.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"