mirror of
https://github.com/wassname/talk.git
synced 2026-07-16 11:22:16 +08:00
Implement Coral RTE
This commit is contained in:
@@ -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/*
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk/client"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<BareButton
|
||||
className={cn(className, styles.button)}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className={styles.icon} name={icon} />
|
||||
</BareButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -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 ? (
|
||||
<div
|
||||
className={`${PLUGIN_NAME}-text`}
|
||||
dangerouslySetInnerHTML={{ __html: comment.richTextBody }}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${PLUGIN_NAME}-text`}>{comment.body}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentContent.propTypes = {
|
||||
comment: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn(styles.root, `${PLUGIN_NAME}-container`)}>
|
||||
<Toolbar>
|
||||
<Button icon="format_bold" title="bold" onClick={this.formatBold} />
|
||||
<Button
|
||||
icon="format_italic"
|
||||
title="italic"
|
||||
onClick={this.formatItalic}
|
||||
/>
|
||||
<Button
|
||||
icon="format_quote"
|
||||
title="quote"
|
||||
onClick={this.formatBlockquote}
|
||||
/>
|
||||
</Toolbar>
|
||||
<ContentEditable
|
||||
onKeyPress={this.outdentOnEnter}
|
||||
className={styles.contentEditable}
|
||||
id={id}
|
||||
ref={this.handleRef}
|
||||
html={this.state.html} // innerHTML of the editable div
|
||||
disabled={false} // use true to disable edition
|
||||
onChange={this.handleChange} // handle innerHTML change
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <div className={cn(className, styles.toolbar)} {...rest} />;
|
||||
}
|
||||
}
|
||||
|
||||
Toolbar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
@@ -0,0 +1 @@
|
||||
export const PLUGIN_NAME = 'talk-plugin-rich-text-coral';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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(/<p>/g, '<div>') // IE and old browsers outputs <p> instead of <div>s
|
||||
.replace(/<\/p>/g, '</div>'); // IE and old browsers outputs <p> instead of <div>s
|
||||
|
||||
// Harmonize all to <b> tag.
|
||||
str = str
|
||||
.replace(/<strong>/g, '<b>') // IE
|
||||
.replace(/<\/strong>/g, '</b>'); // IE
|
||||
|
||||
// Harmonize all to <i> tag.
|
||||
str = str
|
||||
.replace(/<em>/g, '<i>') // IE
|
||||
.replace(/<\/em>/g, '</i>'); // IE
|
||||
|
||||
// Remove first opening tag, otherwise
|
||||
// with the following transformation below
|
||||
// we might add an unintended first empty line.
|
||||
if (str.startsWith('<div>')) {
|
||||
str = str.replace('<div>', '');
|
||||
}
|
||||
|
||||
// Normalize <div>s to <br>.
|
||||
// return str.replace(/<div>/g, '<br>').replace(/<\/div>/g, '');
|
||||
return str;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
@@ -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 <coral@mozillafoundation.org>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"react-contenteditable": "^2.0.7"
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user