[next] Implement configuration panes (#2173)

* feat: moderation config

* feat: configure banned and suspect words

* chore: upgrade react and test libs to the newest version <3

* chore: upgrade typescript + some refactor

* feat: general, organization and advanced configuration panes

* fix: translation

* feat: speedup fetching markdown editor

* feat: localize markdown editor

* chore: refactor container names

* chore: rename infobox to communityGuidelines

* feat: closing comment streams duration config

* test: add feature tests for configurations

* fix: mock only console.error

* chore: upgrade node

* chore: require node >= 10

* fix: better validation and default values

* feat: Make DurationField a general purpose component and reuse for Edit Comment Timeframe

* test: add unit test for duration field

* fix: patch for bug when built in production

* chore: bump npm version to latest

* fix: adapted Dockerfile to new version of node

* refactor: harmonized seconds/milliseconds to seconds

* fix: resolve bug from merge conflict
This commit is contained in:
Kiwi
2019-02-07 01:10:51 +01:00
committed by GitHub
parent 9fa5900acc
commit 53e168ae93
228 changed files with 10582 additions and 2774 deletions
@@ -0,0 +1,8 @@
.value {
width: calc(6 * var(--spacing-unit));
}
.unit {
height: 100%;
min-width: calc(17 * var(--spacing-unit));
}
@@ -0,0 +1,71 @@
import { noop } from "lodash";
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import { PropTypesOf } from "talk-framework/types";
import DurationField, { DURATION_UNIT } from "./DurationField";
it("renders correctly with default units", () => {
const props: PropTypesOf<typeof DurationField> = {
name: "duration",
value: "",
disabled: false,
onChange: noop,
};
const renderer = createRenderer();
renderer.render(<DurationField {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("renders correctly with specified units", () => {
const props: PropTypesOf<typeof DurationField> = {
name: "duration",
value: "",
disabled: false,
onChange: noop,
units: [DURATION_UNIT.SECONDS, DURATION_UNIT.HOURS],
};
const renderer = createRenderer();
renderer.render(<DurationField {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("use best matching unit", () => {
const props: PropTypesOf<typeof DurationField> = {
name: "duration",
value: "3600",
disabled: false,
onChange: noop,
units: [DURATION_UNIT.SECONDS, DURATION_UNIT.MINUTES, DURATION_UNIT.HOURS],
};
const renderer = createRenderer();
renderer.render(<DurationField {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("use initial unit if 0", () => {
const props: PropTypesOf<typeof DurationField> = {
name: "duration",
value: "0",
disabled: false,
onChange: noop,
units: [DURATION_UNIT.SECONDS, DURATION_UNIT.MINUTES, DURATION_UNIT.HOURS],
};
const renderer = createRenderer();
renderer.render(<DurationField {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("accepts invalid input", () => {
const props: PropTypesOf<typeof DurationField> = {
name: "duration",
value: "this is so invalid",
disabled: false,
onChange: noop,
units: [DURATION_UNIT.SECONDS, DURATION_UNIT.MINUTES, DURATION_UNIT.HOURS],
};
const renderer = createRenderer();
renderer.render(<DurationField {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -0,0 +1,223 @@
import { Localized } from "fluent-react/compat";
import React, { ChangeEvent, Component } from "react";
import { Flex, Option, SelectField, TextField } from "talk-ui/components";
import styles from "./DurationField.css";
/**
* DURATION_UNIT are units that can be used in the
* DurationField components.
*/
export enum DURATION_UNIT {
SECONDS = 1,
MINUTES = 60,
HOURS = 3600,
DAYS = 86400,
WEEKS = 604800,
}
type UnitElementCallback = (
currentValue: DURATION_UNIT,
unitValue: string
) => React.ReactElement<any>;
// This is used to render the Option elements to inlcude in the select field.
const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
[DURATION_UNIT.SECONDS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-seconds"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Seconds</Option>
</Localized>
),
[DURATION_UNIT.MINUTES]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-minutes"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Minutes</Option>
</Localized>
),
[DURATION_UNIT.HOURS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-hours"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Hours</Option>
</Localized>
),
[DURATION_UNIT.DAYS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-days"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Days</Option>
</Localized>
),
[DURATION_UNIT.WEEKS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-weeks"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Weeks</Option>
</Localized>
),
};
interface Props {
name: string;
value: string;
disabled: boolean;
onChange: (v: string) => void;
/** Specifiy units to include */
units?: ReadonlyArray<DURATION_UNIT>;
}
interface State {
/** Current value */
value: string;
/** Current unit */
unit?: DURATION_UNIT;
/** All available units */
units: ReadonlyArray<DURATION_UNIT>;
/**
* Element callbacks to generate the rendered
* Option element for the select field
*/
elementCallbacks: ReadonlyArray<UnitElementCallback>;
}
/**
* valueToState converts the value we receive from props to a new state.
* @param value The value that was passed through props.
* @param units The units that we use.
* @param unit The current value if any otherwise the best matching unit will be used.
*/
function valueToState(
value: string,
units: ReadonlyArray<DURATION_UNIT>,
unit?: DURATION_UNIT
) {
const parsed = parseInt(value, 10);
// If value was a valid number..
if (!isNaN(parsed)) {
// If unit is not set, we'll find the best matching unit.
if (!unit) {
// Start from the first unit,
// keep first unit if value is set to 0,
// otherwise use better matching unit if the value is fully dividable by the unit.
unit = units.reduce(
(x, cur) => (parsed % cur === 0 && parsed !== 0 ? cur : x)
);
}
// Compute new value relative to the selected unit.
value = (parsed / unit).toString();
}
return {
unit,
value,
units,
elementCallbacks: units.map(k => unitElementMap[k]),
};
}
/**
* stateToValue converts current state to the value we pass to onChange.
* @param state
*/
function stateToValue(state: State) {
const parsed = parseInt(state.value, 10);
// If state.value was a number, return computed result, otherwise return the string.
return (isNaN(parsed) ? state.value : parsed * state.unit!).toString();
}
/**
* Duration Field renders a TextField that accepts a number and a SelectField with a unit.
* If the entered value is a valid number, it'll propagate the computed value in seconds via
* onChange otherwise it'll just propogate whatever was entered as the value TextField.
*/
class DurationField extends Component<Props, State> {
public static defaultProps: Partial<Props> = {
units: [DURATION_UNIT.HOURS, DURATION_UNIT.DAYS, DURATION_UNIT.WEEKS],
};
public state: State = valueToState(this.props.value, this.props.units!);
public componentWillReceiveProps(nextProps: Props) {
this.setState(
valueToState(nextProps.value, this.props.units!, this.state.unit)
);
}
private handleValueChange = (e: ChangeEvent<HTMLInputElement>) => {
if (this.props.onChange) {
const newState: State = {
...this.state,
value: e.target.value,
};
// Assume we have a controlled component and propage the value up,
// it then should come back in the props.
this.props.onChange(stateToValue(newState));
}
};
private handleUnitChange = (e: ChangeEvent<HTMLSelectElement>) => {
// First set new unit before propagting new value.
this.setState(
{
unit: parseInt(e.target.value, 10),
},
() => {
if (this.props.onChange) {
this.props.onChange(stateToValue(this.state));
}
}
);
};
public render() {
const { disabled, name } = this.props;
return (
<Flex itemGutter>
<TextField
className={styles.value}
name={`${name}-value`}
onChange={this.handleValueChange}
value={this.state.value}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
textAlignCenter
aria-label="value"
/>
<SelectField
name={`${name}-unit`}
onChange={this.handleUnitChange}
disabled={disabled}
aria-label="unit"
classes={{
select: styles.unit,
}}
value={(this.state.unit || this.state.units[0]).toString()}
>
{this.state.elementCallbacks!.map((cb, i) =>
cb(parseInt(this.state.value, 10), this.state.units[i].toString())
)}
</SelectField>
</Flex>
);
}
}
export default DurationField;
@@ -0,0 +1,306 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`accepts invalid input 1`] = `
<ForwardRef(forwardRef)
itemGutter={true}
>
<withPropsOnChange(TextField)
aria-label="value"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="DurationField-value"
disabled={false}
name="duration-value"
onChange={[Function]}
spellCheck={false}
textAlignCenter={true}
value="this is so invalid"
/>
<withPropsOnChange(WithKeyboardFocus)
aria-label="unit"
classes={
Object {
"select": "DurationField-unit",
}
}
disabled={false}
name="duration-unit"
onChange={[Function]}
value="1"
>
<Localized
$value={NaN}
id="framework-durationField-seconds"
>
<Option
value="1"
>
Seconds
</Option>
</Localized>
<Localized
$value={NaN}
id="framework-durationField-minutes"
>
<Option
value="60"
>
Minutes
</Option>
</Localized>
<Localized
$value={NaN}
id="framework-durationField-hours"
>
<Option
value="3600"
>
Hours
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
</ForwardRef(forwardRef)>
`;
exports[`renders correctly with default units 1`] = `
<ForwardRef(forwardRef)
itemGutter={true}
>
<withPropsOnChange(TextField)
aria-label="value"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="DurationField-value"
disabled={false}
name="duration-value"
onChange={[Function]}
spellCheck={false}
textAlignCenter={true}
value=""
/>
<withPropsOnChange(WithKeyboardFocus)
aria-label="unit"
classes={
Object {
"select": "DurationField-unit",
}
}
disabled={false}
name="duration-unit"
onChange={[Function]}
value="3600"
>
<Localized
$value={NaN}
id="framework-durationField-hours"
>
<Option
value="3600"
>
Hours
</Option>
</Localized>
<Localized
$value={NaN}
id="framework-durationField-days"
>
<Option
value="86400"
>
Days
</Option>
</Localized>
<Localized
$value={NaN}
id="framework-durationField-weeks"
>
<Option
value="604800"
>
Weeks
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
</ForwardRef(forwardRef)>
`;
exports[`renders correctly with specified units 1`] = `
<ForwardRef(forwardRef)
itemGutter={true}
>
<withPropsOnChange(TextField)
aria-label="value"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="DurationField-value"
disabled={false}
name="duration-value"
onChange={[Function]}
spellCheck={false}
textAlignCenter={true}
value=""
/>
<withPropsOnChange(WithKeyboardFocus)
aria-label="unit"
classes={
Object {
"select": "DurationField-unit",
}
}
disabled={false}
name="duration-unit"
onChange={[Function]}
value="1"
>
<Localized
$value={NaN}
id="framework-durationField-seconds"
>
<Option
value="1"
>
Seconds
</Option>
</Localized>
<Localized
$value={NaN}
id="framework-durationField-hours"
>
<Option
value="3600"
>
Hours
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
</ForwardRef(forwardRef)>
`;
exports[`use best matching unit 1`] = `
<ForwardRef(forwardRef)
itemGutter={true}
>
<withPropsOnChange(TextField)
aria-label="value"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="DurationField-value"
disabled={false}
name="duration-value"
onChange={[Function]}
spellCheck={false}
textAlignCenter={true}
value="1"
/>
<withPropsOnChange(WithKeyboardFocus)
aria-label="unit"
classes={
Object {
"select": "DurationField-unit",
}
}
disabled={false}
name="duration-unit"
onChange={[Function]}
value="3600"
>
<Localized
$value={1}
id="framework-durationField-seconds"
>
<Option
value="1"
>
Seconds
</Option>
</Localized>
<Localized
$value={1}
id="framework-durationField-minutes"
>
<Option
value="60"
>
Minutes
</Option>
</Localized>
<Localized
$value={1}
id="framework-durationField-hours"
>
<Option
value="3600"
>
Hours
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
</ForwardRef(forwardRef)>
`;
exports[`use initial unit if 0 1`] = `
<ForwardRef(forwardRef)
itemGutter={true}
>
<withPropsOnChange(TextField)
aria-label="value"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="DurationField-value"
disabled={false}
name="duration-value"
onChange={[Function]}
spellCheck={false}
textAlignCenter={true}
value="0"
/>
<withPropsOnChange(WithKeyboardFocus)
aria-label="unit"
classes={
Object {
"select": "DurationField-unit",
}
}
disabled={false}
name="duration-unit"
onChange={[Function]}
value="1"
>
<Localized
$value={0}
id="framework-durationField-seconds"
>
<Option
value="1"
>
Seconds
</Option>
</Localized>
<Localized
$value={0}
id="framework-durationField-minutes"
>
<Option
value="60"
>
Minutes
</Option>
</Localized>
<Localized
$value={0}
id="framework-durationField-hours"
>
<Option
value="3600"
>
Hours
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
</ForwardRef(forwardRef)>
`;
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Button)
<ForwardRef(forwardRef)
classes={
Object {
"active": "FacebookButton-active",
@@ -31,5 +31,5 @@ exports[`renders correctly 1`] = `
<span>
Login with Facebook
</span>
</withPropsOnChange(Button)>
</ForwardRef(forwardRef)>
`;
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Button)
<ForwardRef(forwardRef)
classes={
Object {
"active": "GoogleButton-active",
@@ -31,5 +31,5 @@ exports[`renders correctly 1`] = `
<span>
Login with Google
</span>
</withPropsOnChange(Button)>
</ForwardRef(forwardRef)>
`;
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Button)
<ForwardRef(forwardRef)
classes={
Object {
"active": "OIDCButton-active",
@@ -19,5 +19,5 @@ exports[`renders correctly 1`] = `
<span>
Login with OIDC
</span>
</withPropsOnChange(Button)>
</ForwardRef(forwardRef)>
`;
@@ -3,3 +3,4 @@ export { default as PasswordField } from "./PasswordField";
export { default as FacebookButton } from "./FacebookButton";
export { default as GoogleButton } from "./GoogleButton";
export { default as OIDCButton } from "./OIDCButton";
export { default as DurationField, DURATION_UNIT } from "./DurationField";
@@ -0,0 +1,631 @@
$minHeight: 200px;
$fullscreenZIndex: 10;
.wrapper {
/* Fix blockquote styling of MDL: https://github.com/google/material-design-lite/issues/2037 */
blockquote {
> p:first-child {
padding-top: 16px;
}
> p:last-child {
margin-bottom: 0;
}
&::after {
margin-left: -0.5em;
}
}
}
.iconBold {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "format_bold";
}
}
.iconItalic {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "format_italic";
}
}
.iconTitle {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "title";
}
}
.iconQuote {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "format_quote";
}
}
.iconUnorderedList {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "format_list_bulleted";
}
}
.iconOrderedList {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "format_list_numbered";
}
}
.iconLink {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "link";
}
}
.iconImage {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "insert_photo";
}
}
.iconPreview {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "remove_red_eye";
}
}
.iconSideBySide {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "chrome_reader_mode";
}
}
.iconFullscreen {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "fullscreen";
}
}
.iconGuide {
composes: icon from "talk-ui/shared/icon.css";
&::before {
content: "help_outline";
}
}
/*
* These are modified styles taken from https://github.com/NextStepWebs/simplemde-markdown-editor and
* put through http://sebastianpontow.de/css2compass/.
*/
/*
* simplemde v1.11.2
* Copyright Next Step Webs, Inc.
* @link https://github.com/NextStepWebs/simplemde-markdown-editor
* @license MIT
*/
:global {
.CodeMirror {
font-family: monospace;
color: black;
position: relative;
overflow: hidden;
background: white;
height: auto;
min-height: $minHeight;
border: 1px solid #ddd;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
padding: 10px;
font: inherit;
z-index: 1;
pre {
padding: 0 4px;
border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
font-variant-ligatures: none;
}
span {
/*TODO: vertical-align: text-bottom;*/
}
.CodeMirror-code {
.cm-tag {
color: #63a35c;
}
.cm-attribute {
color: #795da3;
}
.cm-string {
color: #183691;
}
.cm-header-1 {
font-size: 200%;
line-height: 200%;
}
.cm-header-2 {
font-size: 160%;
line-height: 160%;
}
.cm-header-3 {
font-size: 125%;
line-height: 125%;
}
.cm-header-4 {
font-size: 110%;
line-height: 110%;
}
.cm-comment {
background: rgba(0, 0, 0, 0.05);
border-radius: 2px;
}
.cm-link {
color: #7f8c8d;
}
.cm-url {
color: #aab2b3;
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-tab {
display: inline-block;
text-decoration: inherit;
}
.CodeMirror-ruler {
border-left: 1px solid #ccc;
position: absolute;
}
.cm-header {
font-weight: bold;
}
.cm-strong {
font-weight: bold;
}
.cm-em {
font-style: italic;
}
.cm-link {
text-decoration: underline;
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-invalidchar {
color: #f00;
}
}
.CodeMirror-selected {
background: #d9d9d9;
}
.CodeMirror-placeholder {
opacity: 0.5;
}
div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) {
background: rgba(255, 0, 0, 0.15);
}
}
.CodeMirror-lines {
padding: 4px 0;
cursor: text;
min-height: 1px;
}
.CodeMirror-scrollbar-filler {
background-color: white;
position: absolute;
z-index: 6;
display: none;
right: 0;
bottom: 0;
}
.CodeMirror-gutter-filler {
background-color: white;
position: absolute;
z-index: 6;
display: none;
left: 0;
bottom: 0;
}
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: #f7f7f7;
white-space: nowrap;
position: absolute;
left: 0;
top: 0;
min-height: 100%;
z-index: 3;
box-sizing: content-box;
}
.CodeMirror-guttermarker {
color: black;
}
.CodeMirror-guttermarker-subtle {
color: #999;
}
.CodeMirror-cursors {
visibility: hidden;
position: relative;
z-index: 3;
}
.CodeMirror-cursor {
border-left: 1px solid black;
border-right: none;
width: 0;
position: absolute;
}
@keyframes blink {
0% {
}
50% {
background-color: transparent;
}
100% {
}
}
.CodeMirror-scroll {
overflow: scroll !important;
margin-bottom: -30px;
margin-right: -30px;
padding-bottom: 30px;
height: 100%;
outline: none;
position: relative;
min-height: $minHeight;
box-sizing: content-box;
}
.CodeMirror-sizer {
position: relative;
border-right: 30px solid transparent;
box-sizing: content-box;
}
.CodeMirror-vscrollbar {
position: absolute;
z-index: 6;
display: none;
right: 0;
top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
position: absolute;
z-index: 6;
display: none;
bottom: 0;
left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
display: inline-block;
vertical-align: top;
margin-bottom: -30px;
*zoom: 1;
*display: inline;
box-sizing: content-box;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
user-select: none;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0;
bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-code {
outline: none;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
pre {
position: static;
}
}
.CodeMirror-focused {
.CodeMirror-selected {
background: #d7d4f0;
}
div.CodeMirror-cursors {
visibility: visible;
}
}
.CodeMirror-selected {
background: #d9d9d9;
}
.CodeMirror-line::selection {
background: #d7d4f0;
}
.CodeMirror-line {
> span::selection {
background: #d7d4f0;
}
> span {
> span::selection {
background: #d7d4f0;
}
}
}
@media print {
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
.CodeMirror-fullscreen {
background: #fff;
position: fixed !important;
top: 50px;
left: 0;
right: 0;
bottom: 0;
height: auto;
z-index: $fullscreenZIndex;
}
.CodeMirror-sided {
width: 50% !important;
}
.editor-toolbar {
position: relative;
opacity: 0.6;
user-select: none;
padding: 0 10px;
border-top: 1px solid #bbb;
border-left: 1px solid #bbb;
border-right: 1px solid #bbb;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&:after {
display: block;
content: " ";
height: 1px;
margin-top: 8px;
}
&:before {
display: block;
content: " ";
height: 1px;
margin-bottom: 8px;
}
&:hover {
opacity: 0.8;
}
&.fullscreen {
width: 100%;
height: 50px;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
padding-top: 10px;
padding-bottom: 10px;
box-sizing: border-box;
background: #fff;
border: 0;
position: fixed;
top: 0;
left: 0;
opacity: 1;
z-index: $fullscreenZIndex;
}
&.fullscreen::before {
width: 20px;
height: 50px;
background: linear-gradient(
to right,
rgba(255, 255, 255, 1) 0,
rgba(255, 255, 255, 0) 100%
);
position: fixed;
top: 0;
left: 0;
margin: 0;
padding: 0;
}
&.fullscreen::after {
width: 20px;
height: 50px;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 1) 100%
);
position: fixed;
top: 0;
right: 0;
margin: 0;
padding: 0;
}
a {
display: inline-block;
text-align: center;
text-decoration: none !important;
color: #2c3e50 !important;
height: 30px;
margin: 0;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
outline: 0;
margin-right: 2px;
font-size: 1.5em;
width: 25px;
&.active {
background: #fcfcfc;
border-color: #95a5a6;
}
&:hover {
background: #fcfcfc;
border-color: #95a5a6;
}
&:active {
background: #eee;
}
&:before {
line-height: 30px;
}
}
i.separator {
display: inline-block;
width: 0;
border-left: 1px solid #d9d9d9;
border-right: 1px solid #fff;
color: transparent;
text-indent: -10px;
margin: 0 6px;
}
&.disabled-for-preview a:not(.no-disable) {
pointer-events: none;
background: #fff;
border-color: transparent;
text-shadow: inherit;
}
}
@media only screen and(max-width: 700px) {
.editor-toolbar a.no-mobile {
display: none;
}
}
.editor-statusbar {
padding: 8px 10px;
font-size: 12px;
color: #959694;
text-align: right;
span {
display: inline-block;
min-width: 4em;
margin-left: 1em;
}
.lines:before {
content: "lines: ";
}
.words:before {
content: "words: ";
}
.characters:before {
content: "characters: ";
}
}
.editor-preview {
padding: 10px;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: #fafafa;
z-index: 7;
overflow: auto;
display: none;
box-sizing: border-box;
> p {
margin-top: 0;
}
pre {
background: #eee;
margin-bottom: 10px;
}
table {
td {
border: 1px solid #ddd;
padding: 5px;
}
th {
border: 1px solid #ddd;
padding: 5px;
}
}
}
.editor-preview-side {
padding: 10px;
position: fixed;
bottom: 0;
width: 50%;
top: 50px;
right: 0;
background: #fafafa;
z-index: $fullscreenZIndex;
overflow: auto;
display: none;
box-sizing: border-box;
border: 1px solid #ddd;
> p {
margin-top: 0;
}
pre {
background: #eee;
margin-bottom: 10px;
}
table {
td {
border: 1px solid #ddd;
padding: 5px;
}
th {
border: 1px solid #ddd;
padding: 5px;
}
}
}
.editor-preview-active-side {
display: block;
}
.editor-preview-active {
display: block;
}
.CodeMirror-overwrite .CodeMirror-cursor {
}
.CodeMirror-wrap pre {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.cm-tab-wrap-hack:after {
content: "";
}
span.CodeMirror-selectedtext {
background: none;
}
.editor-wrapper input.title {
&:focus {
opacity: 0.8;
}
&:hover {
opacity: 0.8;
}
}
}
@@ -0,0 +1,215 @@
import cn from "classnames";
import React, { ChangeEvent, Component, Ref } from "react";
import SimpleMDE from "simplemde";
import { GetMessage, withGetMessage } from "talk-framework/lib/i18n";
import styles from "./MarkdownEditor.css";
interface Props {
id?: string;
name?: string;
getMessage: GetMessage;
onChange: (value: string) => void;
value: string;
}
class MarkdownEditor extends Component<Props> {
private config = {
status: false,
// Do not download fontAwesome icons as we replace them with
// material icons.
autoDownloadFontAwesome: false,
// Disable built-in spell checker as it is very rudimentary.
spellChecker: false,
toolbar: [
{
name: "bold",
action: SimpleMDE.toggleBold,
className: styles.iconBold,
title: this.props.getMessage("framework-markdownEditor-bold", "Bold"),
},
{
name: "italic",
action: SimpleMDE.toggleItalic,
className: styles.iconItalic,
title: this.props.getMessage(
"framework-markdownEditor-italic",
"Italic"
),
},
{
name: "title",
action: SimpleMDE.toggleHeadingSmaller,
className: styles.iconTitle,
title: this.props.getMessage(
"framework-markdownEditor-titleSubtitleHeading",
"Title, Subtitle, Heading"
),
},
"|",
{
name: "quote",
action: SimpleMDE.toggleBlockquote,
className: styles.iconQuote,
title: this.props.getMessage("framework-markdownEditor-quote", "Quote"),
},
{
name: "unordered-list",
action: SimpleMDE.toggleUnorderedList,
className: styles.iconUnorderedList,
title: this.props.getMessage(
"framework-markdownEditor-genericList",
"Generic List"
),
},
{
name: "ordered-list",
action: SimpleMDE.toggleOrderedList,
className: styles.iconOrderedList,
title: this.props.getMessage(
"framework-markdownEditor-numberedList",
"Numbered List"
),
},
"|",
{
name: "link",
action: SimpleMDE.drawLink,
className: styles.iconLink,
title: this.props.getMessage(
"framework-markdownEditor-createLink",
"Create Link"
),
},
{
name: "image",
action: SimpleMDE.drawImage,
className: styles.iconImage,
title: this.props.getMessage(
"framework-markdownEditor-insertImage",
"Insert Image"
),
},
"|",
{
name: "preview",
action: SimpleMDE.togglePreview,
className: cn(styles.iconPreview, "no-disable"),
title: this.props.getMessage(
"framework-markdownEditor-togglePreview",
"Toggle Preview"
),
},
{
name: "side-by-side",
action: SimpleMDE.toggleSideBySide,
className: cn(styles.iconSideBySide, "no-disable"),
title: this.props.getMessage(
"framework-markdownEditor-toggleSideBySide",
"Toggle Side by Side"
),
},
{
name: "fullscreen",
action: SimpleMDE.toggleFullScreen,
className: cn(styles.iconFullscreen, "no-disable"),
title: this.props.getMessage(
"framework-markdownEditor-toggleFullscreen",
"Toggle Fullscreen"
),
},
"|",
{
name: "guide",
action: "https://simplemde.com/markdown-guide",
className: styles.iconGuide,
title: this.props.getMessage(
"framework-markdownEditor-markdownGuide",
"Markdown Guide"
),
},
],
};
public textarea: HTMLTextAreaElement | null = null;
public editor: SimpleMDE | null = null;
public onRef: Ref<HTMLTextAreaElement> = ref => (this.textarea = ref);
public componentDidMount() {
this.editor = new SimpleMDE({
...this.config,
element: this.textarea!,
});
// Don't trap the key, to stay accessible.
this.editor.codemirror.options.extraKeys.Tab = false;
this.editor.codemirror.options.extraKeys["Shift-Tab"] = false;
this.editor.codemirror.on("change", this.onChange);
}
public componentWillReceiveProps(nextProps: Props) {
if (
this.props.value !== nextProps.value &&
nextProps.value !== this.editor!.value()
) {
this.editor!.value(nextProps.value);
}
}
public componentDidUpdate() {
// Workaround empty render issue.
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
this.editor!.codemirror.refresh();
}
public componentWillUnmount() {
this.editor!.toTextArea();
}
private onChange = () => {
if (this.props.onChange) {
this.props.onChange(this.editor!.value());
}
};
// This is for accessibility purposes.
private onTextAreaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
if (this.props.onChange) {
this.props.onChange(e.target.value);
}
};
public render() {
const { getMessage: _g, ...rest } = this.props;
return (
<div className={styles.wrapper}>
<textarea ref={this.onRef} {...rest} onChange={this.onTextAreaChange} />
</div>
);
}
}
let enhanced = withGetMessage(MarkdownEditor);
if (process.env.NODE_ENV === "test") {
// Replace with simple texteditor because it won't work in a jsdom environment.
enhanced = ({ onChange, ...rest }) => (
<div className={styles.wrapper}>
<textarea
{...rest}
onChange={(e: ChangeEvent<HTMLTextAreaElement> | string) => {
if (onChange) {
onChange(typeof e === "string" ? e : e.target.value);
}
}}
/>
</div>
);
}
export default enhanced;