Merge branch 'master' into scraper

This commit is contained in:
Kim Gardner
2018-03-28 09:18:11 -04:00
committed by GitHub
56 changed files with 2042 additions and 345 deletions
@@ -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}>
-9
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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
View File
@@ -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:
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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.
+35 -32
View File
@@ -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 JWTs 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 JWTs 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 dont 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
+1 -1
View File
@@ -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.
+1 -3
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -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: [],
},
+1 -1
View File
@@ -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: