mirror of
https://github.com/wassname/talk.git
synced 2026-08-12 12:30:39 +08:00
Merge branch 'master' into scraper
This commit is contained in:
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { matchLinks } from '../utils';
|
||||
import memoize from 'lodash/memoize';
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
// generate a regulare expression that catches the `phrases`.
|
||||
function generateRegExp(phrases) {
|
||||
const inner = phrases
|
||||
.map(phrase =>
|
||||
phrase
|
||||
.split(/\s+/)
|
||||
.map(word => escapeRegExp(word))
|
||||
.join('[\\s"?!.]+')
|
||||
)
|
||||
.join('|');
|
||||
|
||||
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
|
||||
try {
|
||||
return new RegExp(pattern, 'iu');
|
||||
} catch (_err) {
|
||||
// IE does not support unicode support, so we'll create one without.
|
||||
return new RegExp(pattern, 'i');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
|
||||
function getPhrasesRegexp(suspectWords, bannedWords) {
|
||||
return generateRegExp([...suspectWords, ...bannedWords]);
|
||||
}
|
||||
|
||||
// Memoized version as arguments rarely change.
|
||||
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
|
||||
|
||||
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
|
||||
// an array of React Elements.
|
||||
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
|
||||
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
|
||||
const tokens = body.split(regexp);
|
||||
return tokens.map(
|
||||
(token, i) =>
|
||||
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
|
||||
);
|
||||
}
|
||||
|
||||
// markLinks looks for links inside `body` and highlights them by returning
|
||||
// an array of React Elements.
|
||||
function markLinks(body) {
|
||||
const matches = matchLinks(body);
|
||||
const content = [];
|
||||
let index = 0;
|
||||
if (matches) {
|
||||
matches.forEach((match, i) => {
|
||||
content.push(body.substring(index, match.index));
|
||||
content.push(<mark key={i}>{match.text}</mark>);
|
||||
index = match.lastIndex;
|
||||
});
|
||||
}
|
||||
content.push(body.substring(index));
|
||||
return content;
|
||||
}
|
||||
|
||||
const CommentFormatter = ({
|
||||
body,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
className = 'comment',
|
||||
...rest
|
||||
}) => {
|
||||
// Breaking the body by line break
|
||||
const textbreaks = body.split('\n');
|
||||
|
||||
return (
|
||||
<span className={`${className}-text`} {...rest}>
|
||||
{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 (
|
||||
<span key={i} className={`${className}-line`}>
|
||||
{content}
|
||||
{i !== textbreaks.length - 1 && (
|
||||
<br className={`${className}-linebreak`} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
CommentFormatter.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bannedWords: PropTypes.array,
|
||||
suspectWords: PropTypes.array,
|
||||
body: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CommentFormatter;
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<a
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './Comment.css';
|
||||
import CommentLabels from 'coral-admin/src/components/CommentLabels';
|
||||
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
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 ApproveButton from 'coral-admin/src/components/ApproveButton';
|
||||
@@ -140,7 +140,7 @@ class Comment extends React.Component {
|
||||
fill="adminCommentContent"
|
||||
className={cn(styles.commentContent, 'talk-admin-comment')}
|
||||
size={1}
|
||||
defaultComponent={CommentFormatter}
|
||||
defaultComponent={AdminCommentContent}
|
||||
passthrough={{ ...slotPassthrough, ...formatterSettings }}
|
||||
/>
|
||||
<div className={styles.commentContentFooter}>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
.main {
|
||||
min-width: 70%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.content {
|
||||
a {
|
||||
color: #063b9a;
|
||||
text-decoration: underline;
|
||||
font-weight: 300;
|
||||
background-color: #f4ff81;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: #f4ff81;
|
||||
}
|
||||
|
||||
b, strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import matchLinks from '../utils/matchLinks';
|
||||
import memoize from 'lodash/memoize';
|
||||
import cn from 'classnames';
|
||||
import styles from './AdminCommentContent.css';
|
||||
|
||||
function escapeHTML(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.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(<br key={`${keyPrefix}_${i}`} />);
|
||||
}
|
||||
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 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : 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(
|
||||
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
|
||||
{match.text}
|
||||
</a>
|
||||
);
|
||||
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 ? `<mark>${escapeHTML(token)}</mark>` : 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 (
|
||||
<div className={cn(className, styles.content)}>
|
||||
{renderText(body, suspectWords, bannedWords)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
AdminCommentContentText.propTypes = commonPropTypes;
|
||||
|
||||
const AdminCommentContentHTML = ({
|
||||
body,
|
||||
className,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
}) => {
|
||||
// We create a Shadow DOM Tree with the HTML body content and
|
||||
// use it as a parser.
|
||||
const node = document.createElement('div');
|
||||
node.innerHTML = body;
|
||||
|
||||
// Then we traverse it recursively and manipulate it to highlight suspect words
|
||||
// and banned words.
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
|
||||
// Finally we render the content of the Shadow DOM Tree
|
||||
return (
|
||||
<div
|
||||
className={cn(className, styles.content)}
|
||||
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
AdminCommentContentHTML.propTypes = commonPropTypes;
|
||||
|
||||
const AdminCommentContent = ({
|
||||
className,
|
||||
body,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
html,
|
||||
}) => {
|
||||
const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
body={body}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
AdminCommentContent.propTypes = {
|
||||
...commonPropTypes,
|
||||
html: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default AdminCommentContent;
|
||||
@@ -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);
|
||||
}
|
||||
+3
-1
@@ -154,6 +154,8 @@ sidebar:
|
||||
url: /building-basic-plugin/
|
||||
- title: Customizing Plugins with Coral UI
|
||||
url: /customizing-plugins-coral-ui/
|
||||
- title: When You've Installed Talk
|
||||
url: /when-youve-installed-talk/
|
||||
- title: Migrating
|
||||
children:
|
||||
- title: Migrating to v4.0.0
|
||||
@@ -171,7 +173,7 @@ marked:
|
||||
breaks: false
|
||||
smartLists: true
|
||||
smartypants: true
|
||||
modifyAnchors: ''
|
||||
modifyAnchors: 1
|
||||
autolink: true
|
||||
|
||||
node_sass:
|
||||
|
||||
@@ -24,7 +24,7 @@ to persist data. The following versions are supported:
|
||||
|
||||
An optional dependency for Talk is
|
||||
[Docker](https://www.docker.com/community-edition#/download).
|
||||
It is used during [development](#development) to set up the database and can be
|
||||
It is used during development to set up the database and can be
|
||||
used to [install via Docker](#installation-from-docker). We have tested Talk
|
||||
and this documentation with versions 17.06.2+.
|
||||
|
||||
@@ -80,7 +80,7 @@ volumes:
|
||||
```
|
||||
|
||||
This is the bare minimum needed to run the demo, for more configuration
|
||||
variables, check out the [Configuration](./configuration/) section.
|
||||
variables, check out the [Configuration](/talk/configuration/) section.
|
||||
|
||||
|
||||
And you can then start it with:
|
||||
@@ -172,7 +172,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret
|
||||
```
|
||||
|
||||
This is only the bare minimum needed to run the demo, for more configuration
|
||||
variables, check out the [Configuration](./configuration/) section. Facebook login above
|
||||
variables, check out the [Configuration](/talk/configuration/) section. Facebook login above
|
||||
will definitely not work unless you change those values as well.
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ volumes:
|
||||
```
|
||||
|
||||
This is the bare minimum needed to start Talk, for more configuration
|
||||
variables, check out the [Configuration](./configuration/) section.
|
||||
variables, check out the [Configuration](/talk/configuration/) section.
|
||||
|
||||
|
||||
And you can then start it with:
|
||||
@@ -111,7 +111,7 @@ talk_1 yarn start Up 0.0.0.0:3000->3000/tcp
|
||||
```
|
||||
|
||||
|
||||
At this stage, you should refer to the [configuration](./configuration/) for
|
||||
At this stage, you should refer to the [configuration](/talk/configuration/) for
|
||||
configuration variables that are specific to your installation.
|
||||
|
||||
## Onbuild
|
||||
@@ -142,7 +142,7 @@ This accomplishes a lot:
|
||||
2. Installs any new dependencies that were required by any new plugins.
|
||||
3. Builds the new static bundles so that they are ready to serve when the image
|
||||
is running.
|
||||
4. Specifies a build time variable [TALK_DEFAULT_LANG](./advanced-configuration/#talk_default_lang). Refer
|
||||
4. Specifies a build time variable [TALK_DEFAULT_LANG](/talk/advanced-configuration/#talk-default-lang). Refer
|
||||
to [Dockerfile.onbuild](https://github.com/coralproject/talk/blob/master/Dockerfile.onbuild) for the
|
||||
available build variables.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret
|
||||
```
|
||||
|
||||
This is the bare minimum needed to start Talk, for more configuration
|
||||
variables, check out the [Configuration](./configuration/)
|
||||
variables, check out the [Configuration](/talk/configuration/)
|
||||
section. Facebook login above will definitely not work unless you change those
|
||||
values as well.
|
||||
|
||||
@@ -73,5 +73,5 @@ You can now start the application by running:
|
||||
yarn watch:server
|
||||
```
|
||||
|
||||
At this stage, you should refer to the [configuration](./configuration/) for
|
||||
At this stage, you should refer to the [configuration](/talk/configuration/) for
|
||||
configuration variables that are specific to your installation.
|
||||
|
||||
@@ -16,7 +16,7 @@ instance of Talk.
|
||||
|
||||
If you've already configured your application with the required configuration,
|
||||
you can further customize it's behavior by applying
|
||||
[Advanced Configuration](./advanced-configuration/).
|
||||
[Advanced Configuration](/talk/advanced-configuration/).
|
||||
|
||||
## TALK_MONGO_URL
|
||||
|
||||
@@ -81,4 +81,4 @@ TALK_JWT_SECRET=jX9y8G2ApcVLwyL{$6s3
|
||||
|
||||
Be default, we sign our tokens with HMAC using a SHA-256 hash algorithm. If you
|
||||
want to change the signing algorithm, or use multiple signing/verifying keys,
|
||||
refer to our [Advanced Configuration](./advanced-configuration/) documentation.
|
||||
refer to our [Advanced Configuration](/talk/advanced-configuration/) documentation.
|
||||
|
||||
@@ -15,7 +15,7 @@ The variables above have defaults, and are _optional_ to start your
|
||||
instance of Talk.
|
||||
|
||||
If this is your first time configuring Talk, ensure you've also added the
|
||||
[Required Configuration](./configuration) as well,
|
||||
[Required Configuration](/talk/configuration) as well,
|
||||
otherwise the application will fail to start.
|
||||
|
||||
## TALK_CACHE_EXPIRY_COMMENT_COUNT
|
||||
@@ -26,7 +26,7 @@ Configure the duration for which comment counts are cached for, parsed by
|
||||
## TALK_DEFAULT_LANG
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild](./installation-from-docker/#onbuild)
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_DEFAULT_LANG=en`.
|
||||
|
||||
Specify the default translation language. (Default `en`)
|
||||
@@ -34,7 +34,7 @@ Specify the default translation language. (Default `en`)
|
||||
## TALK_DEFAULT_STREAM_TAB
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild](./installation-from-docker/#onbuild)
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_DEFAULT_STREAM_TAB=all`.
|
||||
|
||||
Specify the default stream tab in the admin. (Default `all`)
|
||||
@@ -53,7 +53,7 @@ in the embed.js target that is loaded on the page that loads the embed. (Default
|
||||
## TALK_DISABLE_STATIC_SERVER
|
||||
|
||||
When `TRUE`, it will not mount the static asset serving routes on the router.
|
||||
This is used primarily in conjunction with [TALK_STATIC_URI](#talk_static_uri)
|
||||
This is used primarily in conjunction with [TALK_STATIC_URI](#talk-static-uri)
|
||||
when the static assets are being hosted on an external domain. (Default `FALSE`)
|
||||
|
||||
## TALK_HELMET_CONFIGURATION
|
||||
@@ -149,7 +149,7 @@ claim for login JWT tokens. (Default `talk`)
|
||||
## TALK_JWT_CLEAR_COOKIE_LOGOUT
|
||||
|
||||
When `FALSE`, Talk will not clear the cookie with name
|
||||
[TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) when logging out
|
||||
[TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) when logging out
|
||||
but will still blacklist the token. (Default `TRUE`)
|
||||
|
||||
## TALK_JWT_COOKIE_NAME
|
||||
@@ -160,8 +160,8 @@ user. (Default `authorization`)
|
||||
## TALK_JWT_COOKIE_NAMES
|
||||
|
||||
The different cookie names to check for a JWT token in, separated by a `,`. By
|
||||
default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name)
|
||||
and [TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) for this
|
||||
default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name)
|
||||
and [TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) for this
|
||||
value. Any additional cookie names specified here will be appended to the list
|
||||
of cookie names to inspect.
|
||||
|
||||
@@ -183,13 +183,13 @@ Would mean we would check the following cookies (in order) for a valid token:
|
||||
|
||||
When `TRUE`, Talk will not verify or sign JWT’s with an audience
|
||||
[aud](https://tools.ietf.org/html/rfc7519#section-4.1.3)
|
||||
claim, even if [TALK_JWT_AUDIENCE](#talk_jwt_audience) is set. (Default `FALSE`)
|
||||
claim, even if [TALK_JWT_AUDIENCE](#talk-jwt-audience) is set. (Default `FALSE`)
|
||||
|
||||
## TALK_JWT_DISABLE_ISSUER
|
||||
|
||||
When `TRUE`, Talk will not verify or sign JWT’s with an issuer
|
||||
[iss](https://tools.ietf.org/html/rfc7519#section-4.1.1)
|
||||
claim, even if [TALK_JWT_ISSUER](#talk_jwt_issuer) is set. (Default `FALSE`)
|
||||
claim, even if [TALK_JWT_ISSUER](#talk-jwt-issuer) is set. (Default `FALSE`)
|
||||
|
||||
## TALK_JWT_EXPIRY
|
||||
|
||||
@@ -205,7 +205,7 @@ reason to create reasonable expiry lengths as to minimize the storage overhead.
|
||||
## TALK_JWT_ISSUER
|
||||
|
||||
The issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1)
|
||||
claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](./configuration/#talk_root_url))
|
||||
claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url))
|
||||
|
||||
## TALK_JWT_SECRET
|
||||
|
||||
@@ -223,22 +223,25 @@ You can also express this secret in the JSON syntax:
|
||||
TALK_JWT_SECRET={"secret": "jX9y8G2ApcVLwyL{$6s3"}
|
||||
```
|
||||
|
||||
Refer to the documentation for [TALK_JWT_ALG](#talk_jwt_alg) for other signing
|
||||
Refer to the documentation for [TALK_JWT_ALG](#talk-jwt-alg) for other signing
|
||||
methods and other forms of the `TALK_JWT_SECRET`. If you are interested in using
|
||||
multiple keys, then refer to [TALK_JWT_SECRETS](#talk_jwt_secrets).
|
||||
multiple keys, then refer to [TALK_JWT_SECRETS](#talk-jwt-secrets).
|
||||
|
||||
## TALK_JWT_SECRETS
|
||||
|
||||
Used when specifying multiple secrets used for key rotations. This is a JSON
|
||||
encoded array, where each element matches the JWT Secret pattern. When this is
|
||||
used, you do not need to specify a [TALK_JWT_SECRET](#talk_jwt_secret) as this
|
||||
used, you do not need to specify a [TALK_JWT_SECRET](#talk-jwt-secret) as this
|
||||
will take precedence. **The first secret in `TALK_JWT_SECRETS` will be used for
|
||||
signing, and must contain a private key if used with an asymmetric algorithm.**
|
||||
|
||||
All secrets should specify a `kid` field which uniquely identifies a given key
|
||||
and will sign all tokens with that `kid` for later identification.
|
||||
and will sign all tokens with that `kid` for later identification. _If a token
|
||||
is not signed with the `kid` field in the header, and multiple secrets are used,
|
||||
the token will fail to be verified. This field must match what's provided to
|
||||
Talk in the form of the `kid` field in the secret._
|
||||
|
||||
When the value of [TALK_JWT_ALG](#talk_jwt_alg) is a `HS*` value, then the value
|
||||
When the value of [TALK_JWT_ALG](#talk-jwt-alg) is a `HS*` value, then the value
|
||||
of the `TALK_JWT_SECRETS` should take the form:
|
||||
|
||||
```plain
|
||||
@@ -247,24 +250,24 @@ TALK_JWT_SECRETS=[{"kid": "1", "secret": "my-super-secret"}, {"kid": "2", "secre
|
||||
|
||||
Note that the secret is stored in a JSON object, keyed by `secret`. This is only
|
||||
needed when specifying in the multiple secrets for `TALK_JWT_SECRETS`, but may
|
||||
be used to specify the single [TALK_JWT_SECRET](#talk_jwt_secret).
|
||||
be used to specify the single [TALK_JWT_SECRET](#talk-jwt-secret).
|
||||
|
||||
|
||||
When the value of [TALK_JWT_ALG](#talk_jwt_alg) is **not** a `HS*` value, then
|
||||
When the value of [TALK_JWT_ALG](#talk-jwt-alg) is **not** a `HS*` value, then
|
||||
the value of the `TALK_JWT_SECRETS` should take the form:
|
||||
|
||||
```plain
|
||||
TALK_JWT_SECRETS=[{"kid": "1", "private": "<my private key>", "public": "<my public key>"}, ...]
|
||||
```
|
||||
|
||||
Refer to the documentation on the [TALK_JWT_ALG](#talk_jwt_alg) for more
|
||||
Refer to the documentation on the [TALK_JWT_ALG](#talk-jwt-alg) for more
|
||||
information on what to store in these parameters.
|
||||
|
||||
|
||||
## TALK_JWT_SIGNING_COOKIE_NAME
|
||||
|
||||
The default cookie name that is use to set a cookie containing a JWT that was
|
||||
issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name))
|
||||
issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name))
|
||||
|
||||
## TALK_JWT_USER_ID_CLAIM
|
||||
|
||||
@@ -298,8 +301,8 @@ the websocket to keep the socket alive, parsed by
|
||||
Setting a reCAPTCHA Public and Secret key will enable and require reCAPTCHA upon multiple failed login attempts.
|
||||
|
||||
Client secret used for enabling reCAPTCHA powered logins. If
|
||||
[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and
|
||||
[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead
|
||||
[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and
|
||||
[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead
|
||||
default to providing only a time based lockout. Refer to
|
||||
[reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information
|
||||
on getting an account setup.
|
||||
@@ -307,8 +310,8 @@ on getting an account setup.
|
||||
## TALK_RECAPTCHA_SECRET
|
||||
|
||||
Server secret used for enabling reCAPTCHA powered logins. If
|
||||
[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and
|
||||
[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead
|
||||
[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and
|
||||
[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead
|
||||
default to providing only a time based lockout. Refer to
|
||||
[reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information
|
||||
on getting an account setup.
|
||||
@@ -347,7 +350,7 @@ by [ms](https://www.npmjs.com/package/ms). (Default `1 sec`)
|
||||
## TALK_ROOT_URL_MOUNT_PATH
|
||||
|
||||
When set to `TRUE`, the routes will be mounted onto the `<PATHNAME>` component
|
||||
of the [TALK_ROOT_URL](./configuration/#talk_root_url).
|
||||
of the [TALK_ROOT_URL](/talk/configuration/#talk-root-url).
|
||||
You would use this when your upstream proxy cannot strip the prefix from the
|
||||
url. (Default `FALSE`)
|
||||
|
||||
@@ -395,12 +398,12 @@ Used to set the uri where the static assets should be served from. This is used
|
||||
when you want to upload the static assets through your build process to a
|
||||
service like Google Cloud Storage or Amazon S3 and you would then specify the
|
||||
CDN/Storage url. (Defaults to value of
|
||||
[TALK_ROOT_URL](./configuration/#talk_root_url))
|
||||
[TALK_ROOT_URL](/talk/configuration/#talk-root-url))
|
||||
|
||||
## TALK_THREADING_LEVEL
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild](./installation-from-docker/#onbuild)
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_THREADING_LEVEL=3`.
|
||||
|
||||
Specify the maximum depth of the comment thread. (Default `3`)
|
||||
@@ -414,13 +417,13 @@ Used to override the location to connect to the websocket endpoint to
|
||||
potentially another host. This should be used when you need to route websocket
|
||||
requests out of your CDN in order to serve traffic more efficiently.
|
||||
|
||||
If the value of [TALK_ROOT_URL](./configuration/#talk_root_url)
|
||||
If the value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url)
|
||||
is a https url, then this defaults to `wss://${location.host}${MOUNT_PATH}api/v1/live`.
|
||||
Otherwise, it defaults to `ws://${location.host}${MOUNT_PATH}api/v1/live`.
|
||||
|
||||
Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk_root_url_mount_path)
|
||||
Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk-root-url-mount-path)
|
||||
is `FALSE`, or the path component of
|
||||
[TALK_ROOT_URL](./configuration/#talk_root_url) if it's `TRUE`.
|
||||
[TALK_ROOT_URL](/talk/configuration/#talk-root-url) if it's `TRUE`.
|
||||
|
||||
**Warning: if used without managing the auth state manually, auth
|
||||
cannot be persisted due to browser restrictions.**
|
||||
@@ -491,7 +494,7 @@ be used with caution. (Default `FALSE`)
|
||||
## TALK_ADDTL_COMMENTS_ON_LOAD_MORE
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10`.
|
||||
|
||||
Specifies the number of additional comments to load when a user clicks `Load More`. (Default `10`)
|
||||
@@ -499,7 +502,7 @@ Specifies the number of additional comments to load when a user clicks `Load Mor
|
||||
## TALK_ASSET_COMMENTS_LOAD_DEPTH
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_ASSET_COMMENTS_LOAD_DEPTH=10`.
|
||||
|
||||
Specifies the initial number of comments to load for an asset. (Default `10`)
|
||||
@@ -507,7 +510,7 @@ Specifies the initial number of comments to load for an asset. (Default `10`)
|
||||
## TALK_REPLY_COMMENTS_LOAD_DEPTH
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
|
||||
|
||||
Specifies the initial replies to load for a comment. (Default `3`)
|
||||
|
||||
@@ -28,13 +28,13 @@ Plugins are additional functionality which are optional to use with Talk. You
|
||||
can turn these on or off, depending on your specific needs. Plugins are either
|
||||
part of our core plugins, which ship with Talk, or they are developed by 3rd
|
||||
parties and either used privately and internally, or are open sourced for use
|
||||
across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](./default-plugins/)
|
||||
and [Additional Plugins](./additional-plugins/) pages.
|
||||
across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](/talk/default-plugins/)
|
||||
and [Additional Plugins](/talk/additional-plugins/) pages.
|
||||
|
||||
## Recipes
|
||||
|
||||
Recipes are plugin templates that are created by the Talk team and 3rd party
|
||||
developers, in order to help contributors and newsrooms build plugins easily.
|
||||
You can explore the recipes we offer by visiting our [Plugin Recipes](./plugin-recipes/)
|
||||
You can explore the recipes we offer by visiting our [Plugin Recipes](/talk/plugin-recipes/)
|
||||
page.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ https://<your asset url>?commentId=<the comment id>
|
||||
Talk supports by default 3 levels of threading, meaning each top-level comment
|
||||
has a depth of 3 replies; replies beyond that are not nested below the 3rd
|
||||
level. You can adjust this using the
|
||||
[TALK_THREADING_LEVEL](./advanced-configuration/#talk_threading_level)
|
||||
[TALK_THREADING_LEVEL](/talk/advanced-configuration/#talk-threading-level)
|
||||
configuration variable. We don’t recommend deep threading because it can cause
|
||||
issues with styling, especially on mobile.
|
||||
|
||||
@@ -162,7 +162,7 @@ Staff role.
|
||||
The Featured comment badge shows when a comment has been featured.
|
||||
|
||||
Another optional badge is the Subscriber badge (which is available as a
|
||||
[Recipe](./plugin-recipes/#recipe-subscriber).
|
||||
[Recipe](/talk/plugin-recipes/#recipe-subscriber).
|
||||
|
||||
Badges are another easy part of Talk to customize by creating a new `tag`, then
|
||||
setting some rules for when it should show, and how the badge should be styled.
|
||||
|
||||
@@ -56,8 +56,8 @@ history.
|
||||
**Toxic**
|
||||
|
||||
The Toxic badge signifies comments that are above the set Toxicity Probability
|
||||
Threshold. Note you must have [talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments) enabled.
|
||||
[Read more about Toxic Comments here](./toxic-comments/).
|
||||
Threshold. Note you must have [talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) enabled.
|
||||
[Read more about Toxic Comments here](/talk/toxic-comments/).
|
||||
|
||||
**Suspect**
|
||||
|
||||
@@ -122,7 +122,7 @@ automatically.
|
||||
**Reports**
|
||||
|
||||
This shows if a commenter is a reliable flagger, an unreliable flagger, or a
|
||||
neutral flagger. [Read more about reliable and unreliable flaggers here](./trust/#reliable-and-unreliable-flaggers).
|
||||
neutral flagger. [Read more about reliable and unreliable flaggers here](/talk/trust/#reliable-and-unreliable-flaggers).
|
||||
|
||||
**Moderating from this View**
|
||||
|
||||
@@ -173,7 +173,7 @@ manage your team members’ roles (Admins, Moderators, Staff), as well as search
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ###
|
||||
Configure
|
||||
|
||||
See [Configuring Talk](./configuring-talk/).
|
||||
See [Configuring Talk](/talk/configuring-talk/).
|
||||
|
||||
## Moderating via the Comment Stream
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Here are the default thresholds:
|
||||
+3 and higher: Reliable
|
||||
```
|
||||
|
||||
You can configure your own Trust thresholds by using [TRUST_THRESHOLD](./advanced-configuration/#trust_thresholds) in your
|
||||
You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your
|
||||
configuration.
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ trying to improve a broken part of the internet.
|
||||
|
||||
## How do I add the Toxic Comments plugin?
|
||||
To enable this behavior, visit the
|
||||
[talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments)
|
||||
[talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments)
|
||||
plugin documentation.
|
||||
|
||||
|
||||
|
||||
@@ -90,8 +90,6 @@
|
||||
- reaction
|
||||
- name: talk-plugin-rich-text
|
||||
description: Enables rich text plugins that save data as HTML.
|
||||
- name: talk-plugin-rich-text-pell
|
||||
description: Enables the pell rich text editor.
|
||||
- name: talk-plugin-slack-notifications
|
||||
description: Sends all comments as notifications to a slack channel
|
||||
- name: talk-plugin-sort-most-liked
|
||||
@@ -135,4 +133,4 @@
|
||||
description: Enables the dropdown used to display sorting options on the embed stream.
|
||||
tags:
|
||||
- default
|
||||
- sorting
|
||||
- sorting
|
||||
|
||||
@@ -116,4 +116,4 @@ configuration and will ensure that the image is ready to use by building all
|
||||
assets inside the image as well.
|
||||
|
||||
For more information on the onbuild image, refer to the
|
||||
[Installation from Docker](./installation-from-docker/) documentation.
|
||||
[Installation from Docker](/talk/installation-from-docker/) documentation.
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<ul class="sidebar__links">
|
||||
{% for item in item.children %}
|
||||
<li class="{% if is_current(item.url) %}active{% endif %}">
|
||||
<a href="{% if !is_current(item.url) %}{{ url_for(item.url) }}{% else %}#{% endif %}">{{ item.title }}</a>
|
||||
<a href="{{ url_for(item.url) }}">{{ item.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { withSlotElements } from 'coral-framework/hocs';
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,12 @@ const { get } = require('lodash');
|
||||
const path = require('path');
|
||||
|
||||
const handle = async (ctx, comment) => {
|
||||
// Check to see if this reply is visible.
|
||||
if (!comment.visible) {
|
||||
ctx.log.info('comment was not visible, not sending notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
const parentID = get(comment, 'parent_id', null);
|
||||
if (parentID === null) {
|
||||
@@ -90,12 +96,31 @@ const hydrate = async (ctx, category, context) => {
|
||||
return [headline, replier, permalink];
|
||||
};
|
||||
|
||||
const handler = {
|
||||
handle,
|
||||
category: 'reply',
|
||||
event: 'commentAdded',
|
||||
hydrate,
|
||||
digestOrder: 30,
|
||||
// commentAcceptedHandleAdapter will check to see if we need to send a
|
||||
// notification for this comment if the comment has been recently approved but
|
||||
// has not been approved before.
|
||||
const commentAcceptedHandleAdapter = (ctx, comment) => {
|
||||
// Don't send a notification for a non-visible comment.
|
||||
if (!comment.visible) {
|
||||
ctx.log.info('comment was not visible, not sending notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't send a notification if the comment was previously visible.
|
||||
if (
|
||||
// TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check.
|
||||
comment.status_history
|
||||
.slice(0, comment.status_history.length - 1)
|
||||
.some(({ type }) => ['ACCEPTED', 'NONE'].includes(type))
|
||||
) {
|
||||
ctx.log.info(
|
||||
'comment was previously already visible, not sending another notification'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the handle function.
|
||||
return handle(ctx, comment);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -115,5 +140,20 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
translations: path.join(__dirname, 'translations.yml'),
|
||||
notifications: [handler],
|
||||
notifications: [
|
||||
{
|
||||
handle,
|
||||
category: 'reply',
|
||||
event: 'commentAdded',
|
||||
hydrate,
|
||||
digestOrder: 30,
|
||||
},
|
||||
{
|
||||
handle: commentAcceptedHandleAdapter,
|
||||
category: 'reply',
|
||||
event: 'commentAccepted',
|
||||
hydrate,
|
||||
digestOrder: 30,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ const { get } = require('lodash');
|
||||
const path = require('path');
|
||||
|
||||
const handle = async (ctx, comment) => {
|
||||
if (!comment.visible) {
|
||||
ctx.log.info('comment was not visible, not sending notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
const parentID = get(comment, 'parent_id', null);
|
||||
if (parentID === null) {
|
||||
@@ -111,13 +116,31 @@ const hydrate = async (ctx, category, context) => {
|
||||
return [headline, replier, organizationName, permalink];
|
||||
};
|
||||
|
||||
const handler = {
|
||||
handle,
|
||||
category: 'staff',
|
||||
event: 'commentAdded',
|
||||
hydrate,
|
||||
supersedesCategories: ['reply'],
|
||||
digestOrder: 20,
|
||||
// commentAcceptedHandleAdapter will check to see if we need to send a
|
||||
// notification for this comment if the comment has been recently approved but
|
||||
// has not been approved before.
|
||||
const commentAcceptedHandleAdapter = (ctx, comment) => {
|
||||
// Don't send a notification for a non-visible comment.
|
||||
if (!comment.visible) {
|
||||
ctx.log.info('comment was not visible, not sending notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't send a notification if the comment was previously visible.
|
||||
if (
|
||||
// TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check.
|
||||
comment.status_history
|
||||
.slice(0, comment.status_history.length - 1)
|
||||
.some(({ type }) => ['ACCEPTED', 'NONE'].includes(type))
|
||||
) {
|
||||
ctx.log.info(
|
||||
'comment was previously already visible, not sending another notification'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the handle function.
|
||||
return handle(ctx, comment);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -137,5 +160,22 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
translations: path.join(__dirname, 'translations.yml'),
|
||||
notifications: [handler],
|
||||
notifications: [
|
||||
{
|
||||
handle,
|
||||
category: 'staff',
|
||||
event: 'commentAdded',
|
||||
hydrate,
|
||||
supersedesCategories: ['reply'],
|
||||
digestOrder: 20,
|
||||
},
|
||||
{
|
||||
handle: commentAcceptedHandleAdapter,
|
||||
category: 'staff',
|
||||
event: 'commentAccepted',
|
||||
hydrate,
|
||||
supersedesCategories: ['reply'],
|
||||
digestOrder: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.root {
|
||||
margin-bottom: 20px;
|
||||
width: 350px;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.innerSettings {
|
||||
|
||||
@@ -63,8 +63,6 @@ const sendNotificationsBatch = async (ctx, notifications) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(notifications);
|
||||
|
||||
return Promise.all(
|
||||
map(
|
||||
notifications,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.content {
|
||||
composes: content from "./CommentContent.css";
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Content
|
||||
className={styles.content}
|
||||
body={comment.richTextBody ? comment.richTextBody : comment.body}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
html={!!comment.richTextBody}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AdminCommentContent.propTypes = {
|
||||
comment: PropTypes.object.isRequired,
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default AdminCommentContent;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Button.css';
|
||||
import { Icon, BareButton } from 'plugin-api/beta/client/components/ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
class Button extends React.Component {
|
||||
render() {
|
||||
const { className, icon, title, onClick } = this.props;
|
||||
return (
|
||||
<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;
|
||||
@@ -3,7 +3,6 @@
|
||||
background-color: #F6F6F6;
|
||||
padding: 10px;
|
||||
margin: 20px 0px 20px 10px;
|
||||
font-style: italic;
|
||||
border-radius: 2px;
|
||||
&::after {
|
||||
content: none;
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
.contentEditable {
|
||||
.commentContent {
|
||||
composes: content from "./CommentContent.css";
|
||||
background: #fff;
|
||||
border: solid 1px #bbb;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-style: unset;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
@@ -16,3 +7,7 @@
|
||||
margin: 12px 0 0 12px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ import styles from './Editor.css';
|
||||
import cn from 'classnames';
|
||||
import { PLUGIN_NAME } from '../constants';
|
||||
import { htmlNormalizer } from '../utils';
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
import Toolbar from './Toolbar';
|
||||
import Button from './Button';
|
||||
import bowser from 'bowser';
|
||||
import RTE from './rte/RTE';
|
||||
import { Icon } from 'plugin-api/beta/client/components/ui';
|
||||
import { Bold, Italic, Blockquote } from './rte/features';
|
||||
import { t } from 'plugin-api/beta/client/services';
|
||||
|
||||
class Editor extends React.Component {
|
||||
ref = null;
|
||||
handleRef = ref => (this.ref = ref);
|
||||
|
||||
handleChange = evt => {
|
||||
handleChange = c => {
|
||||
this.props.onInputChange({
|
||||
body: this.ref.htmlEl.innerText,
|
||||
richTextBody: evt.target.value,
|
||||
body: c.text,
|
||||
richTextBody: c.html,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -40,55 +40,19 @@ class Editor extends React.Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.props.isReply) {
|
||||
this.ref.focus();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.unregisterHook(this.normalizeHook);
|
||||
}
|
||||
|
||||
getCurrentTagName() {
|
||||
const sel = window.getSelection();
|
||||
const range = sel.getRangeAt(0);
|
||||
if (range.startContainer.nodeName !== '#text') {
|
||||
return range.startContainer.nodeName;
|
||||
}
|
||||
return range.startContainer.parentNode.tagName;
|
||||
}
|
||||
|
||||
formatBold = () => {
|
||||
document.execCommand('bold');
|
||||
this.ref.htmlEl.focus();
|
||||
};
|
||||
|
||||
formatItalic = () => {
|
||||
document.execCommand('italic');
|
||||
this.ref.htmlEl.focus();
|
||||
};
|
||||
|
||||
formatBlockquote = () => {
|
||||
const currentTag = this.getCurrentTagName();
|
||||
if (currentTag === 'BLOCKQUOTE') {
|
||||
document.execCommand('outdent');
|
||||
} else {
|
||||
if (bowser.msie) {
|
||||
document.execCommand('indent');
|
||||
} else {
|
||||
document.execCommand('formatBlock', false, 'blockquote');
|
||||
}
|
||||
}
|
||||
this.ref.htmlEl.focus();
|
||||
};
|
||||
|
||||
outdentOnEnter = e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
setTimeout(() => {
|
||||
document.execCommand('outdent');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const inputId = `${this.props.id}-rte`;
|
||||
const { id, placeholder, label, disabled } = this.props;
|
||||
|
||||
const inputId = `${id}-rte`;
|
||||
return (
|
||||
<div className={cn(styles.root, `${PLUGIN_NAME}-container`)}>
|
||||
<label
|
||||
@@ -96,32 +60,44 @@ class Editor extends React.Component {
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}
|
||||
>
|
||||
{this.props.label}
|
||||
{label}
|
||||
</label>
|
||||
<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>
|
||||
{!this.props.input.body && (
|
||||
<div className={styles.placeholder}>{this.props.placeholder}</div>
|
||||
)}
|
||||
<ContentEditable
|
||||
id={inputId}
|
||||
onKeyPress={this.outdentOnEnter}
|
||||
className={styles.contentEditable}
|
||||
ref={this.handleRef}
|
||||
html={this.getHTML()}
|
||||
disabled={false}
|
||||
<RTE
|
||||
inputId={inputId}
|
||||
className={`${PLUGIN_NAME}-editor`}
|
||||
classNameDisabled={`${PLUGIN_NAME}-editor-disabled`}
|
||||
contentClassName={cn(`${PLUGIN_NAME}-content`, styles.commentContent)}
|
||||
contentClassNameDisabled={`${PLUGIN_NAME}-content-disabled`}
|
||||
toolbarClassName={`${PLUGIN_NAME}-toolbar`}
|
||||
toolbarClassNameDisabled={`${PLUGIN_NAME}-toolbar-disabled`}
|
||||
onChange={this.handleChange}
|
||||
value={this.getHTML()}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
ref={this.handleRef}
|
||||
features={[
|
||||
<Bold
|
||||
key="bold"
|
||||
title={t('talk-plugin-rich-text.format_bold')}
|
||||
className={`${PLUGIN_NAME}-feature-bold`}
|
||||
>
|
||||
<Icon className={styles.icon} name="format_bold" />
|
||||
</Bold>,
|
||||
<Italic
|
||||
key="italic"
|
||||
title={t('talk-plugin-rich-text.format_italic')}
|
||||
className={`${PLUGIN_NAME}-feature-italic`}
|
||||
>
|
||||
<Icon className={styles.icon} name="format_italic" />
|
||||
</Italic>,
|
||||
<Blockquote
|
||||
key="blockquote"
|
||||
title={t('talk-plugin-rich-text.format_blockquote')}
|
||||
className={`${PLUGIN_NAME}-feature-blockquote`}
|
||||
>
|
||||
<Icon className={styles.icon} name="format_quote" />
|
||||
</Blockquote>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -134,7 +110,6 @@ Editor.propTypes = {
|
||||
onInputChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
comment: PropTypes.object,
|
||||
classNames: PropTypes.object,
|
||||
registerHook: PropTypes.func,
|
||||
unregisterHook: PropTypes.func,
|
||||
isReply: PropTypes.bool,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.contentEditable {
|
||||
background: #fff;
|
||||
border: solid 1px #bbb;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-style: unset;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
margin: 12px 0 0 12px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.toolbarDisabled {
|
||||
background: #f8f8f8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.contentEditableDisabled {
|
||||
background: #fafafa;
|
||||
color: #888;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './RTE.css';
|
||||
import cn from 'classnames';
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import {
|
||||
insertNewLine,
|
||||
insertText,
|
||||
getSelectionRange,
|
||||
replaceSelection,
|
||||
cloneNodeAndRange,
|
||||
replaceNodeChildren,
|
||||
selectEndOfNode,
|
||||
isSelectionInside,
|
||||
traverse,
|
||||
} from './lib/dom';
|
||||
import createAPI from './lib/api';
|
||||
import Undo from './lib/undo';
|
||||
import bowser from 'bowser';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
class RTE extends React.Component {
|
||||
/// Ref to react-contenteditable
|
||||
ref = null;
|
||||
|
||||
// Our "plugins" api.
|
||||
api = createAPI(
|
||||
() => this.ref.htmlEl,
|
||||
() => this.handleChange(),
|
||||
() => this.undo.canUndo(),
|
||||
() => this.undo.canRedo(),
|
||||
() => this.handleUndo(),
|
||||
() => this.handleRedo(),
|
||||
() => this.focused
|
||||
);
|
||||
|
||||
// Instance of undo stack.
|
||||
undo = new Undo();
|
||||
|
||||
// Refs to the features.
|
||||
featuresRef = {};
|
||||
|
||||
// Export this for parent components.
|
||||
focus = () => this.ref.htmlEl.focus();
|
||||
|
||||
unmounted = false;
|
||||
focused = false;
|
||||
|
||||
// Should be called on every change to feed
|
||||
// our Undo stack. We save the innerHTML and if available
|
||||
// a copy of the contentEditable node and a copy of the range.
|
||||
saveCheckpoint = throttle((html, node, range) => {
|
||||
const args = [html];
|
||||
if (node && range) {
|
||||
args.push(...cloneNodeAndRange(node, range));
|
||||
}
|
||||
this.undo.save(...args);
|
||||
}, 1000);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.saveCheckpoint(props.value);
|
||||
}
|
||||
|
||||
// Returns a handler that fills our `featuresRef`.
|
||||
createFeatureRefHandler(key) {
|
||||
return ref => {
|
||||
if (ref) {
|
||||
this.featuresRef[key] = ref;
|
||||
} else {
|
||||
delete this.featuresRef[key];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Ref to react-contenteditable.
|
||||
handleRef = ref => (this.ref = ref);
|
||||
|
||||
forEachFeature(callback) {
|
||||
Object.keys(this.featuresRef).map(k => {
|
||||
const instance = this.featuresRef[k].getFeatureInstance
|
||||
? this.featuresRef[k].getFeatureInstance()
|
||||
: this.featuresRef[k];
|
||||
callback(instance);
|
||||
});
|
||||
}
|
||||
|
||||
componentWillReceiveProps(props) {
|
||||
// Clear undo stack if content was set to sth different.
|
||||
if (props.value !== this.ref.htmlEl.innerHTML) {
|
||||
this.undo.clear();
|
||||
this.saveCheckpoint(props.value);
|
||||
if (isSelectionInside(this.ref.htmlEl)) {
|
||||
setTimeout(() => !this.unmounted && selectEndOfNode(this.ref.htmlEl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cancel pending stuff.
|
||||
this.saveCheckpoint.cancel();
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
handleChange = () => {
|
||||
// TODO: don't rely on this hack.
|
||||
// It removes all `style` attr that
|
||||
// remaining execCommand still add.
|
||||
traverse(this.ref.htmlEl, n => {
|
||||
n.removeAttribute && n.removeAttribute('style');
|
||||
});
|
||||
|
||||
this.props.onChange({
|
||||
text: this.ref.htmlEl.innerText,
|
||||
html: this.ref.htmlEl.innerHTML,
|
||||
});
|
||||
this.ref.htmlEl.focus();
|
||||
this.saveCheckpoint(
|
||||
this.ref.htmlEl.innerHTML,
|
||||
this.ref.htmlEl,
|
||||
getSelectionRange()
|
||||
);
|
||||
};
|
||||
|
||||
handleSelectionChange = () => {
|
||||
// Let features know selection has changeed, so they
|
||||
// can update.
|
||||
this.forEachFeature(b => {
|
||||
b.onSelectionChange && b.onSelectionChange();
|
||||
});
|
||||
};
|
||||
|
||||
// Allow features to handle shortcuts.
|
||||
handleShortcut = e => {
|
||||
let handled = false;
|
||||
this.forEachFeature(b => {
|
||||
if (!handled) {
|
||||
handled = !!(b.onShortcut && b.onShortcut(e));
|
||||
}
|
||||
});
|
||||
return handled;
|
||||
};
|
||||
|
||||
// Called when Enter was pressed without shift.
|
||||
// Traverses from bottom to top and calling
|
||||
// feature handlers and stops when one has handled this event.
|
||||
handleSpecialEnter = () => {
|
||||
let handled = false;
|
||||
const sel = window.getSelection();
|
||||
const range = sel.getRangeAt(0);
|
||||
let container = range.startContainer;
|
||||
while (!handled && container && container !== this.ref.htmlEl) {
|
||||
this.forEachFeature(b => {
|
||||
if (!handled) {
|
||||
handled = !!(b.onEnter && b.onEnter(container));
|
||||
}
|
||||
});
|
||||
container = container.parentNode;
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
handleCut = () => {
|
||||
// IE has issues not firing the onChange event.
|
||||
if (bowser.msie) {
|
||||
setTimeout(() => !this.unmounted && this.handleChange());
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
// Sometimes the onselect event doesn't fire on blur.
|
||||
this.handleSelectionChange();
|
||||
};
|
||||
|
||||
// We intercept pasting, so that we
|
||||
// force text/plain content.
|
||||
handlePaste = e => {
|
||||
// Get text representation of clipboard
|
||||
// This works cross browser.
|
||||
const text = (
|
||||
(e.originalEvent || e).clipboardData || window.clipboardData
|
||||
).getData('Text');
|
||||
|
||||
// IE does this funny thing to change the selection after the paste
|
||||
// event, remember the range for now.
|
||||
const range = getSelectionRange().cloneRange();
|
||||
|
||||
// Run outside of event loop to fix
|
||||
// selection issues with IE.
|
||||
setTimeout(() => {
|
||||
// Manually delete range, cope with IE.
|
||||
if (!range.collapsed) {
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
// insert text manually
|
||||
insertText(text);
|
||||
this.handleChange();
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
// IE has issues not firing the onChange event.
|
||||
if (bowser.msie) {
|
||||
setTimeout(() => !this.unmounted && this.handleChange());
|
||||
}
|
||||
|
||||
// Undo Redo 'Z'
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.shiftKey) {
|
||||
this.handleRedo();
|
||||
} else {
|
||||
this.handleUndo();
|
||||
}
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (this.handleShortcut(e)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Newlines Or Special Enter Behaviors.
|
||||
if (e.key === 'Enter') {
|
||||
if (!e.shiftKey && this.handleSpecialEnter()) {
|
||||
this.handleChange();
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
insertNewLine(true);
|
||||
|
||||
this.handleChange();
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
restoreCheckpoint(html, node, range) {
|
||||
if (node && range) {
|
||||
// We need to clone it, otherwise we'll mutate
|
||||
// that original one which can still be in the undo stack.
|
||||
const [nodeCloned, rangeCloned] = cloneNodeAndRange(node, range);
|
||||
|
||||
// Remember range values, as `rangeCloned` can changed during
|
||||
// DOM manipulation.
|
||||
const startOffset = rangeCloned.startOffset;
|
||||
const endOffset = rangeCloned.startOffset;
|
||||
|
||||
// Rewrite startContainer if it was pointing to `nodeCloned`.
|
||||
const startContainer =
|
||||
rangeCloned.startContainer === nodeCloned
|
||||
? this.ref.htmlEl
|
||||
: rangeCloned.startContainer;
|
||||
|
||||
// Rewrite endContainer if it was pointing to `nodeCloned`.
|
||||
const endContainer =
|
||||
rangeCloned.endContainer === nodeCloned
|
||||
? this.ref.htmlEl
|
||||
: rangeCloned.endContainer;
|
||||
|
||||
// Replace children with the ones from nodeCloned.
|
||||
replaceNodeChildren(this.ref.htmlEl, nodeCloned);
|
||||
|
||||
// Now setup the selection range.
|
||||
const finalRange = document.createRange();
|
||||
finalRange.setStart(startContainer, startOffset);
|
||||
finalRange.setEnd(endContainer, endOffset);
|
||||
|
||||
// SELECT!
|
||||
replaceSelection(finalRange);
|
||||
} else {
|
||||
this.ref.htmlEl.innerHTML = html;
|
||||
selectEndOfNode(this.ref.htmlEl);
|
||||
}
|
||||
this.handleChange();
|
||||
}
|
||||
|
||||
handleUndo() {
|
||||
this.saveCheckpoint.flush();
|
||||
if (this.undo.canUndo()) {
|
||||
const [html, node, range] = this.undo.undo();
|
||||
this.restoreCheckpoint(html, node, range);
|
||||
}
|
||||
}
|
||||
|
||||
handleRedo() {
|
||||
this.saveCheckpoint.flush();
|
||||
if (this.undo.canRedo()) {
|
||||
const [html, node, range] = this.undo.redo();
|
||||
this.restoreCheckpoint(html, node, range);
|
||||
}
|
||||
}
|
||||
|
||||
renderFeatures() {
|
||||
return this.props.features.map(b => {
|
||||
return React.cloneElement(b, {
|
||||
disabled: this.props.disabled,
|
||||
api: this.api,
|
||||
ref: this.createFeatureRefHandler(b.key),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getClassNames() {
|
||||
const { disabled } = this.props;
|
||||
return {
|
||||
toolbar: cn(this.props.toolbarClassName, {
|
||||
[this.props.toolbarClassNameDisabled]: disabled,
|
||||
[styles.toolbarDisabled]: disabled,
|
||||
}),
|
||||
content: cn(styles.contentEditable, this.props.contentClassName, {
|
||||
[this.props.contentClassNameDisabled]: disabled,
|
||||
[styles.contentEditableDisabled]: disabled,
|
||||
}),
|
||||
root: cn(this.props.className, {
|
||||
[this.props.classNameDisabled]: disabled,
|
||||
}),
|
||||
placeholder: cn(styles.placeholder, this.props.placeholderClassName, {
|
||||
[this.props.placeholderClassNameDisabled]: disabled,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, placeholder, inputId, disabled } = this.props;
|
||||
|
||||
const classNames = this.getClassNames();
|
||||
|
||||
return (
|
||||
<div className={classNames.root}>
|
||||
<Toolbar className={classNames.toolbar}>
|
||||
{this.renderFeatures()}
|
||||
</Toolbar>
|
||||
{!value && <div className={classNames.placeholder}>{placeholder}</div>}
|
||||
<ContentEditable
|
||||
id={inputId}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
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}
|
||||
disabled={disabled}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RTE.defaultProps = {
|
||||
features: [],
|
||||
};
|
||||
|
||||
RTE.propTypes = {
|
||||
features: PropTypes.array,
|
||||
inputId: PropTypes.string,
|
||||
input: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
classNameDisabled: PropTypes.string,
|
||||
contentClassName: PropTypes.string,
|
||||
contentClassNameDisabled: PropTypes.string,
|
||||
toolbarClassName: PropTypes.string,
|
||||
toolbarClassNameDisabled: PropTypes.string,
|
||||
placeholderClassName: PropTypes.string,
|
||||
placeholderClassNameDisabled: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
export default RTE;
|
||||
@@ -0,0 +1,51 @@
|
||||
.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;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
cursor: pointer;
|
||||
background-color: #eae8e8;
|
||||
}
|
||||
|
||||
.button.active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.button:disabled{
|
||||
color: #bbb;
|
||||
cursor: default;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@media (-moz-touch-enabled: 1), (pointer:coarse) {
|
||||
.button:hover{
|
||||
background-color: transparent;
|
||||
}
|
||||
.button.active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Button.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
class Button extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
active,
|
||||
activeClassName,
|
||||
disabled,
|
||||
} = this.props;
|
||||
return (
|
||||
<button
|
||||
className={cn(className, styles.button, {
|
||||
[cn(styles.active, activeClassName)]: active,
|
||||
})}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
className: PropTypes.string,
|
||||
activeClassName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
active: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from '../components/Button';
|
||||
|
||||
/**
|
||||
* createToggle creates a button that can be active, inactive or disabled
|
||||
* and reacts on clicks. All callbacks are bound to the API instance.
|
||||
*/
|
||||
const createToggle = (
|
||||
execCommand,
|
||||
{ onEnter, onShortcut, isActive = () => false, isDisabled = () => false } = {}
|
||||
) => {
|
||||
class Toggle extends React.Component {
|
||||
state = {
|
||||
active: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
execCommand = () => execCommand.apply(this.props.api);
|
||||
isActive = () => isActive.apply(this.props.api);
|
||||
isDisabled = () => isDisabled.apply(this.props.api);
|
||||
onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args);
|
||||
onShortcut = (...args) =>
|
||||
onShortcut && onShortcut.apply(this.props.api, args);
|
||||
unmounted = false;
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
formatToggle = () => {
|
||||
this.execCommand();
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.api.focus();
|
||||
this.formatToggle();
|
||||
this.props.api.focus();
|
||||
setTimeout(() => !this.unmounted && this.syncState());
|
||||
};
|
||||
|
||||
syncState = () => {
|
||||
if (this.state.active !== this.isActive()) {
|
||||
this.setState(state => ({
|
||||
active: !state.active,
|
||||
}));
|
||||
}
|
||||
if (this.state.disabled !== this.isDisabled()) {
|
||||
this.setState(state => ({
|
||||
disabled: !state.disabled,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
onSelectionChange() {
|
||||
this.syncState();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, title, children, disabled } = this.props;
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
title={title}
|
||||
onClick={this.handleClick}
|
||||
active={this.state.active}
|
||||
disabled={disabled || this.state.disabled}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Toggle.propTypes = {
|
||||
api: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
return Toggle;
|
||||
};
|
||||
|
||||
export default createToggle;
|
||||
@@ -0,0 +1,49 @@
|
||||
import createToggle from '../factories/createToggle';
|
||||
import {
|
||||
findIntersecting,
|
||||
insertNewLineAfterNode,
|
||||
insertNodes,
|
||||
getSelectedNodesExpanded,
|
||||
outdentBlock,
|
||||
selectEndOfNode,
|
||||
indentNodes,
|
||||
} from '../lib/dom';
|
||||
|
||||
function execCommand() {
|
||||
const bq = findIntersecting('BLOCKQUOTE', this.container);
|
||||
if (bq) {
|
||||
outdentBlock(bq, true);
|
||||
} else {
|
||||
// Expanded selection means we always select whole lines.
|
||||
const selectedNodes = getSelectedNodesExpanded();
|
||||
if (selectedNodes.length) {
|
||||
indentNodes(selectedNodes, 'blockquote', true);
|
||||
} else {
|
||||
const node = document.createElement('blockquote');
|
||||
node.appendChild(document.createElement('br'));
|
||||
insertNodes(node);
|
||||
selectEndOfNode(node);
|
||||
}
|
||||
}
|
||||
this.broadcastChange();
|
||||
}
|
||||
|
||||
function isActive() {
|
||||
return this.focused && !!findIntersecting('BLOCKQUOTE', this.container);
|
||||
}
|
||||
|
||||
function onEnter(node) {
|
||||
if (node.tagName !== 'BLOCKQUOTE') {
|
||||
return;
|
||||
}
|
||||
insertNewLineAfterNode(node, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const Blockquote = createToggle(execCommand, { onEnter, isActive });
|
||||
|
||||
Blockquote.defaultProps = {
|
||||
children: 'Blockquote',
|
||||
};
|
||||
|
||||
export default Blockquote;
|
||||
@@ -0,0 +1,44 @@
|
||||
import createToggle from '../factories/createToggle';
|
||||
import { findIntersecting, findAncestor } from '../lib/dom';
|
||||
|
||||
const boldTags = ['B', 'STRONG'];
|
||||
|
||||
function execCommand() {
|
||||
return document.execCommand('bold');
|
||||
}
|
||||
|
||||
function isActive() {
|
||||
return this.focused && document.queryCommandState('bold');
|
||||
}
|
||||
function isDisabled() {
|
||||
if (!this.focused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disable whenever the bold styling came from a different
|
||||
// tag than those we control.
|
||||
return !!findIntersecting(
|
||||
n =>
|
||||
n.nodeName !== '#text' &&
|
||||
window.getComputedStyle(n).getPropertyValue('font-weight') === 'bold' &&
|
||||
!boldTags.includes(n.tagName) &&
|
||||
!findAncestor(n, n => boldTags.includes(n.tagName), this.container),
|
||||
this.container
|
||||
);
|
||||
}
|
||||
function onShortcut(e) {
|
||||
if (e.key === 'b') {
|
||||
if (!isDisabled.apply(this)) {
|
||||
execCommand.apply(this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const Bold = createToggle(execCommand, { isActive, isDisabled, onShortcut });
|
||||
|
||||
Bold.defaultProps = {
|
||||
children: 'Bold',
|
||||
};
|
||||
|
||||
export default Bold;
|
||||
@@ -0,0 +1,42 @@
|
||||
import createToggle from '../factories/createToggle';
|
||||
import { findIntersecting, findAncestor } from '../lib/dom';
|
||||
|
||||
const italicTags = ['I', 'EM'];
|
||||
|
||||
function execCommand() {
|
||||
return document.execCommand('italic');
|
||||
}
|
||||
function isActive() {
|
||||
return this.focused && document.queryCommandState('italic');
|
||||
}
|
||||
function isDisabled() {
|
||||
if (!this.focused) {
|
||||
return false;
|
||||
}
|
||||
// Disable whenever the italic styling came from a different
|
||||
// tag than those we control.
|
||||
return !!findIntersecting(
|
||||
n =>
|
||||
n.nodeName !== '#text' &&
|
||||
window.getComputedStyle(n).getPropertyValue('font-style') === 'italic' &&
|
||||
!italicTags.includes(n.tagName) &&
|
||||
!findAncestor(n, n => italicTags.includes(n.tagName), this.container),
|
||||
this.container
|
||||
);
|
||||
}
|
||||
function onShortcut(e) {
|
||||
if (e.key === 'i') {
|
||||
if (!isDisabled.apply(this)) {
|
||||
execCommand.apply(this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const Italic = createToggle(execCommand, { isActive, isDisabled, onShortcut });
|
||||
|
||||
Italic.defaultProps = {
|
||||
children: 'Italic',
|
||||
};
|
||||
|
||||
export default Italic;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Bold } from './Bold';
|
||||
export { default as Italic } from './Italic';
|
||||
export { default as Blockquote } from './Blockquote';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isSelectionInside } from './dom';
|
||||
|
||||
/**
|
||||
* An instance of API is passed to all the buttons to
|
||||
* interact with RTE, which servers as a clean abstraction.
|
||||
*/
|
||||
function createAPI(
|
||||
getContainer,
|
||||
broadcastChange,
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
getFocused
|
||||
) {
|
||||
return {
|
||||
broadcastChange,
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
get focused() {
|
||||
return getFocused();
|
||||
},
|
||||
get container() {
|
||||
return getContainer();
|
||||
},
|
||||
focus() {
|
||||
this.container.focus();
|
||||
},
|
||||
isSelectionInside() {
|
||||
return isSelectionInside(getContainer());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default createAPI;
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* Traverse DOM tree until callback returns anything.
|
||||
*/
|
||||
export function traverse(node, callback) {
|
||||
let result;
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const child = node.childNodes[i];
|
||||
result = callback(child);
|
||||
if (result === undefined) {
|
||||
result = traverse(child, callback);
|
||||
}
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse DOM tree backwards until callback returns anything.
|
||||
* If hits limitTo returns null.
|
||||
*/
|
||||
export function traverseUp(node, callback, limitTo) {
|
||||
let result;
|
||||
if (node.isSameNode(limitTo)) {
|
||||
return null;
|
||||
}
|
||||
while (node.parentNode) {
|
||||
node = node.parentNode;
|
||||
result = callback(node);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
if (limitTo && node.isSameNode(limitTo)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ancestor with given tag or whith callback returning true.
|
||||
* If `limitTo` is passed, the search is limited to this container.
|
||||
*/
|
||||
export function findAncestor(node, tagOrCallback, limitTo) {
|
||||
const callback =
|
||||
typeof tagOrCallback === 'function'
|
||||
? tagOrCallback
|
||||
: n => n.tagName === tagOrCallback;
|
||||
return (
|
||||
traverseUp(
|
||||
node,
|
||||
n => {
|
||||
if (callback(n)) {
|
||||
return n;
|
||||
}
|
||||
},
|
||||
limitTo
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find child with given tag or when callback return true.
|
||||
*/
|
||||
export function findChild(node, tagOrCallback) {
|
||||
const callback =
|
||||
typeof tagOrCallback === 'function'
|
||||
? tagOrCallback
|
||||
: n => n.tagName === tagOrCallback;
|
||||
return (
|
||||
traverse(node, n => {
|
||||
if (callback(n)) {
|
||||
return n;
|
||||
}
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an node intersecting with the selection with given tag or
|
||||
* with callback returning true. If `limitTo` is passed, the search
|
||||
* is limited to this container.
|
||||
*/
|
||||
export function findIntersecting(tagOrCallback, limitTo) {
|
||||
const callback =
|
||||
typeof tagOrCallback === 'function'
|
||||
? tagOrCallback
|
||||
: n => n.tagName === tagOrCallback;
|
||||
|
||||
const range = getSelectionRange();
|
||||
if (!range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (callback(range.startContainer)) {
|
||||
return range.startContainer;
|
||||
}
|
||||
|
||||
const ancestor = findAncestor(range.startContainer, callback, limitTo);
|
||||
if (ancestor) {
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
const nodes = getSelectedChildren(range.commonAncestorContainer);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (callback(nodes[i])) {
|
||||
return nodes[i];
|
||||
}
|
||||
const found = findChild(nodes[i], callback);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as node.contains but works in IE.
|
||||
* In addition lookFor can also be a callback.
|
||||
*/
|
||||
export function nodeContains(node, lookFor) {
|
||||
const callback =
|
||||
typeof lookFor === 'function' ? lookFor : n => n.isSameNode(lookFor);
|
||||
if (callback(node)) {
|
||||
return true;
|
||||
}
|
||||
return !!findChild(node, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if node is not `inline` nor `inline-block`.
|
||||
*/
|
||||
export function isBlockElement(node) {
|
||||
if (node.nodeName === '#text') {
|
||||
return false;
|
||||
}
|
||||
return !window
|
||||
.getComputedStyle(node)
|
||||
.getPropertyValue('display')
|
||||
.startsWith('inline');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find parent that is a block element.
|
||||
*/
|
||||
export function findParentBlock(node) {
|
||||
return findAncestor(node, isBlockElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find last parent before a block element.
|
||||
*/
|
||||
export function lastParentBeforeBlock(node) {
|
||||
return findAncestor(node, n => !n.parentNode || isBlockElement(n.parentNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `Array.indexOf` but works on `childNodes`.
|
||||
*/
|
||||
export function indexOfChildNode(parent, child) {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
if (parent.childNodes[i] === child) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `document.execCommand('insertText', false, text)` but also
|
||||
* works for IE. Changes Selection.
|
||||
*/
|
||||
export function insertText(text) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.collapsed) {
|
||||
range.deleteContents();
|
||||
}
|
||||
const newRange = document.createRange();
|
||||
const offset = range.startOffset;
|
||||
const container = range.startContainer;
|
||||
|
||||
if (container.nodeName === '#text') {
|
||||
container.textContent =
|
||||
container.textContent.slice(0, offset) +
|
||||
text +
|
||||
container.textContent.slice(offset);
|
||||
const nextOffset = offset + text.length;
|
||||
|
||||
newRange.setStart(container, nextOffset);
|
||||
newRange.setEnd(container, nextOffset);
|
||||
} else {
|
||||
const textNode = document.createTextNode(text);
|
||||
container.insertBefore(textNode, container.childNodes[offset]);
|
||||
newRange.setStart(textNode, text.length);
|
||||
newRange.setEnd(textNode, text.length);
|
||||
}
|
||||
replaceSelection(newRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert nodes to current selection,
|
||||
* does not change selection.
|
||||
*/
|
||||
export function insertNodes(...nodes) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.collapsed) {
|
||||
range.deleteContents();
|
||||
}
|
||||
const offset = range.startOffset;
|
||||
const container = range.startContainer;
|
||||
if (container.nodeName === '#text') {
|
||||
const startSlice = container.textContent.slice(0, offset);
|
||||
const endSlice = container.textContent.slice(offset);
|
||||
if (startSlice) {
|
||||
nodes.splice(0, 0, document.createTextNode(startSlice));
|
||||
}
|
||||
if (endSlice) {
|
||||
nodes.push(document.createTextNode(endSlice));
|
||||
}
|
||||
const parentNode = container.parentNode;
|
||||
nodes.forEach(n => parentNode.insertBefore(n, container));
|
||||
parentNode.removeChild(container);
|
||||
} else {
|
||||
let parentNode = container;
|
||||
let nextSibling = container.childNodes[offset];
|
||||
nodes.forEach(n => parentNode.insertBefore(n, nextSibling));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to replace current selection with range.
|
||||
*/
|
||||
export function replaceSelection(range) {
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to to know if selection is collapsed.
|
||||
*/
|
||||
export function isSelectionCollapsed() {
|
||||
return window.getSelection().isCollapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get current selection range.
|
||||
*/
|
||||
export function getSelectionRange() {
|
||||
const selection = window.getSelection();
|
||||
return selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
}
|
||||
|
||||
// Adds a bogus 'br' at the end of the node if not existant.
|
||||
export function addBogusBR(node) {
|
||||
if (!isBlockElement(node)) {
|
||||
return;
|
||||
}
|
||||
if (!node.lastChild || !isBogusBR(node.lastChild)) {
|
||||
node.appendChild(document.createElement('br'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if selection is completely inside
|
||||
* given nodes.
|
||||
*/
|
||||
export function isSelectionInside(...nodes) {
|
||||
let foundStart = false;
|
||||
const range = getSelectionRange();
|
||||
if (!range) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (!foundStart) {
|
||||
foundStart = nodeContains(nodes[i], range.startContainer);
|
||||
}
|
||||
if (foundStart) {
|
||||
const foundEnd = nodeContains(nodes[i], range.endContainer);
|
||||
if (foundEnd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert new line. This is what happens
|
||||
* when adding new lines through pressing Enter.
|
||||
* Deals with browers quirks.
|
||||
*/
|
||||
export function insertNewLine(changeSelection) {
|
||||
// Insert <br> node.
|
||||
const el = document.createElement('br');
|
||||
insertNodes(el);
|
||||
|
||||
// If we are adding to the end of the node, we also need
|
||||
// to add a bogus br.
|
||||
if (!el.nextSibling) {
|
||||
el.parentNode.appendChild(document.createElement('br'));
|
||||
}
|
||||
|
||||
// Adding directly before a block element needs also a bogus br.
|
||||
if (el.nextSibling && isBlockElement(el.nextSibling)) {
|
||||
el.parentNode.insertBefore(document.createElement('br'), el.nextSibling);
|
||||
}
|
||||
|
||||
// Calculate next selection.
|
||||
const range = document.createRange();
|
||||
if (el.nextSibling) {
|
||||
const offset = indexOfChildNode(el.parentNode, el.nextSibling);
|
||||
range.setStart(el.parentNode, offset);
|
||||
range.setEnd(el.parentNode, offset);
|
||||
} else {
|
||||
const offset = el.parentNode.childNodes.length - 1;
|
||||
range.setStart(el.parentNode, offset);
|
||||
range.setEnd(el.parentNode, offset);
|
||||
}
|
||||
|
||||
if (changeSelection) {
|
||||
replaceSelection(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new line after given node.
|
||||
*/
|
||||
export function insertNewLineAfterNode(node, changeSelection) {
|
||||
const el = document.createElement('br');
|
||||
if (node.nextSibling) {
|
||||
node.parentNode.insertBefore(el, node.nextSibling);
|
||||
} else {
|
||||
node.parentNode.appendChild(el);
|
||||
}
|
||||
|
||||
if (changeSelection) {
|
||||
const offset = indexOfChildNode(node.parentNode, el);
|
||||
const range = document.createRange();
|
||||
range.setStart(node.parentNode, offset);
|
||||
range.setEnd(node.parentNode, offset);
|
||||
replaceSelection(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a container and a offset, return the selected
|
||||
* node. Usually to resolve the start or end of a range.
|
||||
*/
|
||||
export function getRangeNode(container, offset) {
|
||||
if (container.nodeName === '#text') {
|
||||
return container;
|
||||
}
|
||||
return container.childNodes[offset];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all nodes before `node`.
|
||||
*/
|
||||
export function getLeftOfNode(node) {
|
||||
let result = [];
|
||||
let leftMost = node;
|
||||
while (
|
||||
leftMost.previousSibling &&
|
||||
leftMost.previousSibling.tagName !== 'BR' &&
|
||||
!isBlockElement(leftMost.previousSibling)
|
||||
) {
|
||||
result.splice(0, 0, leftMost.previousSibling);
|
||||
leftMost = leftMost.previousSibling;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isBogusBR(node) {
|
||||
return (
|
||||
(!node.previousSibling || !isBlockElement(node.previousSibling)) &&
|
||||
node.tagName === 'BR' &&
|
||||
(!node.nextSibling || isBlockElement(node.previousSibling))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all nodes after `node`.
|
||||
*/
|
||||
export function getRightOfNode(node) {
|
||||
let result = [];
|
||||
let cur = node;
|
||||
while (
|
||||
cur.nextSibling &&
|
||||
cur.nextSibling.tagName !== 'BR' &&
|
||||
!isBlockElement(cur.nextSibling)
|
||||
) {
|
||||
cur = cur.nextSibling;
|
||||
result.push(cur);
|
||||
}
|
||||
if (
|
||||
cur.nextSibling &&
|
||||
cur.nextSibling.tagName === 'BR' &&
|
||||
!isBogusBR(cur.nextSibling)
|
||||
) {
|
||||
result.push(cur.nextSibling);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given `node` find the line it belongs too
|
||||
* and return the whole line as an array.
|
||||
*/
|
||||
export function getWholeLine(node) {
|
||||
if (isBlockElement(node)) {
|
||||
return [node];
|
||||
}
|
||||
const child = isBlockElement(node.parentNode)
|
||||
? node
|
||||
: lastParentBeforeBlock(node);
|
||||
if (child.tagName === 'BR') {
|
||||
return [...getLeftOfNode(child), child];
|
||||
}
|
||||
return [...getLeftOfNode(child), child, ...getRightOfNode(child)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected line at the start of the selection.
|
||||
* Returns an array of nodes.
|
||||
*/
|
||||
export function getSelectedLine() {
|
||||
const range = getSelectionRange();
|
||||
if (!range) {
|
||||
return [];
|
||||
}
|
||||
const start = getRangeNode(range.startContainer, range.startOffset);
|
||||
return start ? getWholeLine(start) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a commen block ancestor in the selection
|
||||
* and return "whole" lines as an array of nodes.
|
||||
*/
|
||||
export function getSelectedNodesExpanded() {
|
||||
const range = getSelectionRange();
|
||||
if (!range) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (range.collapsed) {
|
||||
return getSelectedLine();
|
||||
}
|
||||
|
||||
let ancestor = range.commonAncestorContainer;
|
||||
if (!isBlockElement(ancestor)) {
|
||||
ancestor = findParentBlock(ancestor);
|
||||
}
|
||||
|
||||
const result = getSelectedChildren(ancestor);
|
||||
return [
|
||||
...getLeftOfNode(result[0]),
|
||||
...result,
|
||||
...getRightOfNode(result[result.length - 1]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of children that intersects with
|
||||
* the selection.
|
||||
*/
|
||||
export function getSelectedChildren(ancestor) {
|
||||
const result = [];
|
||||
const range = getSelectionRange();
|
||||
if (!range) {
|
||||
return result;
|
||||
}
|
||||
if (!range) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const start = getRangeNode(range.startContainer, range.startOffset);
|
||||
const end = getRangeNode(range.endContainer, range.endOffset);
|
||||
let foundStart = false;
|
||||
for (let i = 0; i < ancestor.childNodes.length; i++) {
|
||||
const node = ancestor.childNodes[i];
|
||||
if (!foundStart) {
|
||||
if (nodeContains(node, start)) {
|
||||
foundStart = true;
|
||||
}
|
||||
}
|
||||
if (foundStart) {
|
||||
result.push(node);
|
||||
if (nodeContains(node, end)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes node and assimilate its children with the parent.
|
||||
*/
|
||||
export function outdentBlock(node, changeSelection) {
|
||||
// Save previous range.
|
||||
const selectionWasInside = isSelectionInside(node);
|
||||
const {
|
||||
startContainer,
|
||||
startOffset,
|
||||
endContainer,
|
||||
endOffset,
|
||||
} = getSelectionRange();
|
||||
|
||||
// Remove bogus br
|
||||
if (node.lastChild && node.lastChild.tagName === 'BR') {
|
||||
node.removeChild(node.lastChild);
|
||||
}
|
||||
|
||||
// A new lines to substitute the missing block element.
|
||||
const needLineAfter =
|
||||
node.nextSibling &&
|
||||
!isBlockElement(node.nextSibling) &&
|
||||
node.lastChild &&
|
||||
!isBlockElement(node.lastChild);
|
||||
const needLineBefore =
|
||||
node.previousSibling &&
|
||||
!isBlockElement(node.previousSibling) &&
|
||||
node.previousSibling.tageName !== 'BR';
|
||||
|
||||
const parentNode = node.parentNode;
|
||||
|
||||
if (needLineBefore) {
|
||||
parentNode.insertBefore(document.createElement('BR'), node);
|
||||
}
|
||||
|
||||
const previousOffset = indexOfChildNode(parentNode, node);
|
||||
|
||||
while (node.firstChild) {
|
||||
parentNode.insertBefore(node.firstChild, node);
|
||||
}
|
||||
|
||||
if (needLineAfter) {
|
||||
parentNode.insertBefore(document.createElement('BR'), node);
|
||||
}
|
||||
|
||||
parentNode.removeChild(node);
|
||||
|
||||
if (changeSelection) {
|
||||
const range = document.createRange();
|
||||
|
||||
if (selectionWasInside) {
|
||||
if (startContainer === node) {
|
||||
range.setStart(parentNode, startOffset + previousOffset);
|
||||
} else {
|
||||
range.setStart(startContainer, startOffset);
|
||||
}
|
||||
if (endContainer === node) {
|
||||
range.setEnd(parentNode, endOffset + previousOffset);
|
||||
} else {
|
||||
range.setEnd(endContainer, endOffset);
|
||||
}
|
||||
} else {
|
||||
range.setStart(parentNode, previousOffset);
|
||||
range.setEnd(parentNode, previousOffset);
|
||||
}
|
||||
replaceSelection(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent children.
|
||||
*/
|
||||
export function indentNodes(nodes, tagName, changeSelection) {
|
||||
const parentNode = nodes[0].parentNode;
|
||||
const node = document.createElement(tagName);
|
||||
|
||||
// Remove bogus BR if the blockquote is the last element.
|
||||
// Otherwise there will be an unwanted empty line.
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
if (
|
||||
lastNode.nextSibling === parentNode.lastChild &&
|
||||
isBogusBR(parentNode.lastChild)
|
||||
) {
|
||||
parentNode.removeChild(parentNode.lastChild);
|
||||
}
|
||||
|
||||
const firstNode = nodes[0];
|
||||
// Remove previous br as it is not needed
|
||||
if (firstNode.previousSibling && firstNode.previousSibling.tagName === 'BR') {
|
||||
parentNode.removeChild(firstNode.previousSibling);
|
||||
}
|
||||
|
||||
// Finally indent.
|
||||
parentNode.insertBefore(node, firstNode);
|
||||
nodes.forEach(n => {
|
||||
node.appendChild(n);
|
||||
});
|
||||
|
||||
if (changeSelection) {
|
||||
selectEndOfNode(node);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function cloneNodeAndRangeHelper(node, range, rangeCloned) {
|
||||
const nodeCloned = node.cloneNode(false);
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const n = node.childNodes[i];
|
||||
nodeCloned.appendChild(cloneNodeAndRangeHelper(n, range, rangeCloned));
|
||||
}
|
||||
if (range.startContainer === node) {
|
||||
rangeCloned.setStart(nodeCloned, range.startOffset);
|
||||
}
|
||||
if (range.endContainer === node) {
|
||||
rangeCloned.setEnd(nodeCloned, range.endOffset);
|
||||
}
|
||||
return nodeCloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones node and returns both the cloned node and an equivalent Range.
|
||||
*/
|
||||
export function cloneNodeAndRange(node, range) {
|
||||
const rangeCloned = range.cloneRange();
|
||||
const nodeCloned = cloneNodeAndRangeHelper(node, range, rangeCloned);
|
||||
if (
|
||||
rangeCloned.startContainer === range.startContainer ||
|
||||
rangeCloned.endContainer === range.endContainer
|
||||
) {
|
||||
throw new Error('Range not inside node');
|
||||
}
|
||||
return [nodeCloned, rangeCloned];
|
||||
}
|
||||
|
||||
/**
|
||||
* Take children of the second node and replace children of first node.
|
||||
*/
|
||||
export function replaceNodeChildren(node, node2) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
}
|
||||
while (node2.firstChild) {
|
||||
node.appendChild(node2.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to select the end of node.
|
||||
* Currently looks for <br> and text nodes to find a suitable
|
||||
* candidate for a selection.
|
||||
*/
|
||||
export function selectEndOfNode(node) {
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
let child = node.childNodes[i];
|
||||
const s = selectEndOfNode(child);
|
||||
if (s) {
|
||||
return true;
|
||||
}
|
||||
if (child.tagName === 'BR') {
|
||||
if (
|
||||
child.previousSibling &&
|
||||
child.previousSibling.childName === '#text'
|
||||
) {
|
||||
child = child.previousSibling;
|
||||
} else {
|
||||
const offset = indexOfChildNode(node, child);
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset);
|
||||
replaceSelection(range);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (child.nodeName === '#text') {
|
||||
const range = document.createRange();
|
||||
range.setStart(child, child.textContent.length);
|
||||
range.setEnd(child, child.textContent.length);
|
||||
replaceSelection(range);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Simple size limited Undo stack.
|
||||
*/
|
||||
export default class Undo {
|
||||
/**
|
||||
* undoStack contains all known values.
|
||||
* The last value of this stack represent
|
||||
* the most recent change.
|
||||
*/
|
||||
undoStack = [];
|
||||
redoStack = [];
|
||||
size;
|
||||
|
||||
constructor(size = 100) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
canUndo() {
|
||||
return this.undoStack.length > 1;
|
||||
}
|
||||
|
||||
canRedo() {
|
||||
return this.redoStack.length;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.canUndo()) {
|
||||
throw new Error('Nothing to undo');
|
||||
}
|
||||
const cur = this.undoStack.pop();
|
||||
this.redoStack.push(cur);
|
||||
return this.undoStack[this.undoStack.length - 1];
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (!this.canRedo()) {
|
||||
throw new Error('Nothing to redo');
|
||||
}
|
||||
const x = this.redoStack.pop();
|
||||
this.undoStack.push(x);
|
||||
return x;
|
||||
}
|
||||
|
||||
save(x, ...meta) {
|
||||
// Ignore if we already have that saved.
|
||||
if (
|
||||
this.undoStack.length &&
|
||||
this.undoStack[this.undoStack.length - 1][0] === x
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.undoStack.push([x, ...meta]);
|
||||
|
||||
// Adhere to maximum size.
|
||||
if (this.undoStack.length > this.size) {
|
||||
this.undoStack.splice(0, 1);
|
||||
}
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,13 +1,17 @@
|
||||
import Editor from './containers/Editor';
|
||||
import CommentContent from './containers/CommentContent';
|
||||
import AdminCommentContent from './containers/AdminCommentContent';
|
||||
import translations from './translations.yml';
|
||||
|
||||
import { gql } from 'react-apollo';
|
||||
|
||||
export default {
|
||||
translations,
|
||||
slots: {
|
||||
draftArea: [Editor],
|
||||
commentContent: [CommentContent],
|
||||
adminCommentContent: [CommentContent],
|
||||
userDetailCommentContent: [CommentContent],
|
||||
adminCommentContent: [AdminCommentContent],
|
||||
userDetailCommentContent: [AdminCommentContent],
|
||||
},
|
||||
fragments: {
|
||||
CreateCommentResponse: gql`
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
en:
|
||||
talk-plugin-rich-text:
|
||||
format_bold: bold
|
||||
format_italic: italic
|
||||
format_blockquote: blockquote
|
||||
|
||||
@@ -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(/<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
|
||||
// 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 <b> tag.
|
||||
str = str
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ plugin:
|
||||
Using the [Perspective API](http://perspectiveapi.com/), this
|
||||
plugin will warn users and reject comments that exceed the predefined toxicity
|
||||
threshold. For more information on what Toxic Comments are, check out the
|
||||
[Toxic Comments](./toxic-comments/) documentation.
|
||||
[Toxic Comments](/talk/toxic-comments/) documentation.
|
||||
|
||||
Configuration:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user