Moving previews to client side rendering for consistency
This commit is contained in:
121
frontend/components/Editor/Editor.js
Normal file
121
frontend/components/Editor/Editor.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Editor, Plain } from 'slate';
|
||||
import classnames from 'classnames/bind';
|
||||
import type { Document, State, Editor as EditorType } from './types';
|
||||
import ClickablePadding from './components/ClickablePadding';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import schema from './schema';
|
||||
import Markdown from './serializer';
|
||||
import createPlugins from './plugins';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
const cx = classnames.bind(styles);
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
onChange: Function,
|
||||
onSave: Function,
|
||||
onCancel: Function,
|
||||
onImageUploadStart: Function,
|
||||
onImageUploadStop: Function,
|
||||
readOnly: boolean,
|
||||
};
|
||||
|
||||
type KeyData = {
|
||||
isMeta: boolean,
|
||||
key: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
export default class MarkdownEditor extends Component {
|
||||
props: Props;
|
||||
editor: EditorType;
|
||||
plugins: Array<Object>;
|
||||
|
||||
state: {
|
||||
state: State,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.plugins = createPlugins({
|
||||
onImageUploadStart: props.onImageUploadStart,
|
||||
onImageUploadStop: props.onImageUploadStop,
|
||||
});
|
||||
|
||||
if (props.text) {
|
||||
this.state = { state: Markdown.deserialize(props.text) };
|
||||
} else {
|
||||
this.state = { state: Plain.deserialize('') };
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (state: State) => {
|
||||
this.setState({ state });
|
||||
};
|
||||
|
||||
onDocumentChange = (document: Document, state: State) => {
|
||||
this.props.onChange(Markdown.serialize(state));
|
||||
};
|
||||
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent, data: KeyData, state: State) => {
|
||||
if (!data.isMeta) return;
|
||||
|
||||
switch (data.key) {
|
||||
case 's':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return this.props.onSave({ redirect: false });
|
||||
case 'enter':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onSave();
|
||||
return state;
|
||||
case 'escape':
|
||||
return this.props.onCancel();
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
focusAtStart = () => {
|
||||
const state = this.editor.getState();
|
||||
const transform = state.transform();
|
||||
transform.collapseToStartOf(state.document);
|
||||
transform.focus();
|
||||
this.setState({ state: transform.apply() });
|
||||
};
|
||||
|
||||
focusAtEnd = () => {
|
||||
const state = this.editor.getState();
|
||||
const transform = state.transform();
|
||||
transform.collapseToEndOf(state.document);
|
||||
transform.focus();
|
||||
this.setState({ state: transform.apply() });
|
||||
};
|
||||
|
||||
render = () => {
|
||||
return (
|
||||
<span className={styles.container}>
|
||||
<ClickablePadding onClick={this.focusAtStart} />
|
||||
<Toolbar state={this.state.state} onChange={this.onChange} />
|
||||
<Editor
|
||||
ref={ref => (this.editor = ref)}
|
||||
placeholder="Start with a title…"
|
||||
className={cx(styles.editor, { readOnly: this.props.readOnly })}
|
||||
schema={schema}
|
||||
plugins={this.plugins}
|
||||
state={this.state.state}
|
||||
onChange={this.onChange}
|
||||
onDocumentChange={this.onDocumentChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onSave={this.props.onSave}
|
||||
readOnly={this.props.readOnly}
|
||||
/>
|
||||
<ClickablePadding onClick={this.focusAtEnd} grow />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}
|
||||
143
frontend/components/Editor/Editor.scss
Normal file
143
frontend/components/Editor/Editor.scss
Normal file
@@ -0,0 +1,143 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
font-weight: 400;
|
||||
font-size: 1em;
|
||||
line-height: 1.5em;
|
||||
|
||||
padding: 0 3em;
|
||||
max-width: 50em;
|
||||
}
|
||||
|
||||
.editor {
|
||||
background: #fff;
|
||||
color: #1b2631;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 500;
|
||||
|
||||
.anchor {
|
||||
visibility: hidden;
|
||||
color: #dedede;
|
||||
padding-left: .25em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.anchor {
|
||||
visibility: visible;
|
||||
|
||||
&:hover {
|
||||
color: #cdcdcd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 1em .1em;
|
||||
padding-left: 1em;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: .1em;
|
||||
}
|
||||
}
|
||||
|
||||
li p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.todoList {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
|
||||
.todoList {
|
||||
padding-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.todo {
|
||||
span:last-child:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
background: #efefef;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #dedede;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0 .5em;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid #efefef;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 5px 20px 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
@media all and (max-width: 2000px) and (min-width: 960px) {
|
||||
.container {
|
||||
// margin-top: 48px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 960px) {
|
||||
.container {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import styles from './ClickablePadding.scss';
|
||||
|
||||
type Props = {
|
||||
onClick: Function,
|
||||
grow?: boolean,
|
||||
};
|
||||
|
||||
const ClickablePadding = (props: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={classnames(styles.container, { [styles.grow]: props.grow })}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClickablePadding;
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
padding-top: 50px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media all and (max-width: 960px) {
|
||||
.container {
|
||||
padding-top: 50px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import ClickablePadding from './ClickablePadding';
|
||||
export default ClickablePadding;
|
||||
13
frontend/components/Editor/components/Code.js
Normal file
13
frontend/components/Editor/components/Code.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
|
||||
export default function Code({ children, attributes }: Props) {
|
||||
return (
|
||||
<pre>
|
||||
<code {...attributes}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
43
frontend/components/Editor/components/Heading.js
Normal file
43
frontend/components/Editor/components/Heading.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import slug from 'slug';
|
||||
import type { Node, Editor } from '../types';
|
||||
import styles from '../Editor.scss';
|
||||
|
||||
type Props = {
|
||||
children: React$Element<any>,
|
||||
placeholder?: boolean,
|
||||
parent: Node,
|
||||
node: Node,
|
||||
editor: Editor,
|
||||
readOnly: boolean,
|
||||
component?: string,
|
||||
};
|
||||
|
||||
export default function Heading({
|
||||
parent,
|
||||
placeholder,
|
||||
node,
|
||||
editor,
|
||||
readOnly,
|
||||
children,
|
||||
component = 'h1',
|
||||
}: Props) {
|
||||
const firstHeading = parent.nodes.first() === node;
|
||||
const showPlaceholder = placeholder && firstHeading && !node.text;
|
||||
const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`);
|
||||
const Component = component;
|
||||
|
||||
return (
|
||||
<Component className={styles.title}>
|
||||
{children}
|
||||
{showPlaceholder &&
|
||||
<span className={styles.placeholder}>
|
||||
{editor.props.placeholder}
|
||||
</span>}
|
||||
{slugish &&
|
||||
<a name={slugish} className={styles.anchor} href={`#${slugish}`}>#</a>}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
13
frontend/components/Editor/components/Image.js
Normal file
13
frontend/components/Editor/components/Image.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
|
||||
export default function Image({ attributes, node }: Props) {
|
||||
return (
|
||||
<img
|
||||
{...attributes}
|
||||
src={node.data.get('src')}
|
||||
alt={node.data.get('alt')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
frontend/components/Editor/components/Link.js
Normal file
11
frontend/components/Editor/components/Link.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
|
||||
export default function Link({ attributes, node, children }: Props) {
|
||||
return (
|
||||
<a {...attributes} href={node.data.get('href')}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
16
frontend/components/Editor/components/ListItem.js
Normal file
16
frontend/components/Editor/components/ListItem.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import type { Props } from '../types';
|
||||
import TodoItem from './TodoItem';
|
||||
|
||||
export default function ListItem({ children, node, ...props }: Props) {
|
||||
const checked = node.data.get('checked');
|
||||
if (checked !== undefined) {
|
||||
return (
|
||||
<TodoItem checked={checked} node={node} {...props}>
|
||||
{children}
|
||||
</TodoItem>
|
||||
);
|
||||
}
|
||||
return <li>{children}</li>;
|
||||
}
|
||||
39
frontend/components/Editor/components/TodoItem.js
Normal file
39
frontend/components/Editor/components/TodoItem.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import type { Props } from '../types';
|
||||
import styles from '../Editor.scss';
|
||||
|
||||
export default class TodoItem extends Component {
|
||||
props: Props & { checked: boolean };
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent) => {
|
||||
const checked = ev.target.checked;
|
||||
const { editor, node } = this.props;
|
||||
const state = editor
|
||||
.getState()
|
||||
.transform()
|
||||
.setNodeByKey(node.key, { data: { checked } })
|
||||
.apply();
|
||||
|
||||
editor.onChange(state);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, checked, readOnly } = this.props;
|
||||
|
||||
return (
|
||||
<li contentEditable={false} className={styles.todo}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={this.handleChange}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{' '}
|
||||
<span contentEditable={!readOnly} suppressContentEditableWarning>
|
||||
{children}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
142
frontend/components/Editor/components/Toolbar/Toolbar.js
Normal file
142
frontend/components/Editor/components/Toolbar/Toolbar.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import Portal from 'react-portal';
|
||||
import classnames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import type { State } from '../../types';
|
||||
import FormattingToolbar from './components/FormattingToolbar';
|
||||
import LinkToolbar from './components/LinkToolbar';
|
||||
import styles from './Toolbar.scss';
|
||||
|
||||
export default class Toolbar extends Component {
|
||||
props: {
|
||||
state: State,
|
||||
onChange: Function,
|
||||
};
|
||||
|
||||
menu: HTMLElement;
|
||||
state: {
|
||||
active: boolean,
|
||||
focused: boolean,
|
||||
link: React$Element<any>,
|
||||
top: string,
|
||||
left: string,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
focused: false,
|
||||
link: null,
|
||||
top: '',
|
||||
left: '',
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
this.update();
|
||||
};
|
||||
|
||||
componentDidUpdate = () => {
|
||||
this.update();
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ focused: false });
|
||||
};
|
||||
|
||||
get linkInSelection(): any {
|
||||
const { state } = this.props;
|
||||
|
||||
try {
|
||||
const selectedLinks = state.startBlock
|
||||
.getInlinesAtRange(state.selection)
|
||||
.filter(node => node.type === 'link');
|
||||
if (selectedLinks.size) {
|
||||
return selectedLinks.first();
|
||||
}
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
update = () => {
|
||||
const { state } = this.props;
|
||||
const link = this.linkInSelection;
|
||||
|
||||
if (state.isBlurred || (state.isCollapsed && !link)) {
|
||||
if (this.state.active && !this.state.focused) {
|
||||
this.setState({ active: false, link: null, top: '', left: '' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// don't display toolbar for document title
|
||||
const firstNode = state.document.nodes.first();
|
||||
if (firstNode === state.startBlock) return;
|
||||
|
||||
// don't display toolbar for code blocks
|
||||
if (state.startBlock.type === 'code') return;
|
||||
|
||||
const data = {
|
||||
...this.state,
|
||||
active: true,
|
||||
link,
|
||||
focused: !!link,
|
||||
};
|
||||
|
||||
if (!_.isEqual(data, this.state)) {
|
||||
const padding = 16;
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
if (rect.top === 0 && rect.left === 0) {
|
||||
this.setState(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const left =
|
||||
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
|
||||
data.top = `${Math.round(rect.top + window.scrollY - this.menu.offsetHeight)}px`;
|
||||
data.left = `${Math.round(Math.max(padding, left))}px`;
|
||||
this.setState(data);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.menu = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const link = this.state.link;
|
||||
const classes = classnames(styles.menu, {
|
||||
[styles.active]: this.state.active,
|
||||
});
|
||||
|
||||
const style = {
|
||||
top: this.state.top,
|
||||
left: this.state.left,
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal isOpened>
|
||||
<div className={classes} style={style} ref={this.setRef}>
|
||||
{link &&
|
||||
<LinkToolbar
|
||||
{...this.props}
|
||||
link={link}
|
||||
onBlur={this.handleBlur}
|
||||
/>}
|
||||
{!link &&
|
||||
<FormattingToolbar
|
||||
onCreateLink={this.handleFocus}
|
||||
{...this.props}
|
||||
/>}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
||||
62
frontend/components/Editor/components/Toolbar/Toolbar.scss
Normal file
62
frontend/components/Editor/components/Toolbar/Toolbar.scss
Normal file
@@ -0,0 +1,62 @@
|
||||
.menu {
|
||||
padding: 8px 16px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
background-color: #222;
|
||||
border-radius: 4px;
|
||||
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
|
||||
line-height: 0;
|
||||
height: 40px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.active {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.linkEditor {
|
||||
display: flex;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
|
||||
input {
|
||||
background: rgba(255,255,255,.1);
|
||||
border-radius: 2px;
|
||||
padding: 5px 8px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
flex: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
padding: 0;
|
||||
opacity: .7;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import styles from '../Toolbar.scss';
|
||||
import type { State } from '../../../types';
|
||||
import BoldIcon from 'components/Icon/BoldIcon';
|
||||
import CodeIcon from 'components/Icon/CodeIcon';
|
||||
import Heading1Icon from 'components/Icon/Heading1Icon';
|
||||
import Heading2Icon from 'components/Icon/Heading2Icon';
|
||||
import LinkIcon from 'components/Icon/LinkIcon';
|
||||
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
|
||||
import BulletedListIcon from 'components/Icon/BulletedListIcon';
|
||||
|
||||
export default class FormattingToolbar extends Component {
|
||||
props: {
|
||||
state: State,
|
||||
onChange: Function,
|
||||
onCreateLink: Function,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current selection has a mark with `type` in it.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {Boolean}
|
||||
*/
|
||||
hasMark = (type: string) => {
|
||||
return this.props.state.marks.some(mark => mark.type === type);
|
||||
};
|
||||
|
||||
isBlock = (type: string) => {
|
||||
return this.props.state.startBlock.type === type;
|
||||
};
|
||||
|
||||
/**
|
||||
* When a mark button is clicked, toggle the current mark.
|
||||
*
|
||||
* @param {Event} ev
|
||||
* @param {String} type
|
||||
*/
|
||||
onClickMark = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
let { state } = this.props;
|
||||
|
||||
state = state.transform().toggleMark(type).apply();
|
||||
this.props.onChange(state);
|
||||
};
|
||||
|
||||
onClickBlock = (ev: SyntheticEvent, type: string) => {
|
||||
ev.preventDefault();
|
||||
let { state } = this.props;
|
||||
|
||||
state = state.transform().setBlock(type).apply();
|
||||
this.props.onChange(state);
|
||||
};
|
||||
|
||||
onCreateLink = (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
let { state } = this.props;
|
||||
const data = { href: '' };
|
||||
state = state.transform().wrapInline({ type: 'link', data }).apply();
|
||||
this.props.onChange(state);
|
||||
this.props.onCreateLink();
|
||||
};
|
||||
|
||||
renderMarkButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.hasMark(type);
|
||||
const onMouseDown = ev => this.onClickMark(ev, type);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={styles.button}
|
||||
onMouseDown={onMouseDown}
|
||||
data-active={isActive}
|
||||
>
|
||||
<IconClass light />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderBlockButton = (type: string, IconClass: Function) => {
|
||||
const isActive = this.isBlock(type);
|
||||
const onMouseDown = ev =>
|
||||
this.onClickBlock(ev, isActive ? 'paragraph' : type);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={styles.button}
|
||||
onMouseDown={onMouseDown}
|
||||
data-active={isActive}
|
||||
>
|
||||
<IconClass light />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
{this.renderMarkButton('bold', BoldIcon)}
|
||||
{this.renderMarkButton('deleted', StrikethroughIcon)}
|
||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
|
||||
{this.renderMarkButton('code', CodeIcon)}
|
||||
<button className={styles.button} onMouseDown={this.onCreateLink}>
|
||||
<LinkIcon light />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import type { State } from '../../../types';
|
||||
import keydown from 'react-keydown';
|
||||
import styles from '../Toolbar.scss';
|
||||
import CloseIcon from 'components/Icon/CloseIcon';
|
||||
|
||||
@keydown
|
||||
export default class LinkToolbar extends Component {
|
||||
input: HTMLElement;
|
||||
props: {
|
||||
state: State,
|
||||
link: Object,
|
||||
onBlur: Function,
|
||||
onChange: Function,
|
||||
};
|
||||
|
||||
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
|
||||
switch (ev.keyCode) {
|
||||
case 13: // enter
|
||||
ev.preventDefault();
|
||||
return this.save(ev.target.value);
|
||||
case 26: // escape
|
||||
return this.input.blur();
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
removeLink = () => {
|
||||
this.save('');
|
||||
};
|
||||
|
||||
save = (href: string) => {
|
||||
href = href.trim();
|
||||
const transform = this.props.state.transform();
|
||||
transform.unwrapInline('link');
|
||||
|
||||
if (href) {
|
||||
const data = { href };
|
||||
transform.wrapInline({ type: 'link', data });
|
||||
}
|
||||
|
||||
const state = transform.apply();
|
||||
this.props.onChange(state);
|
||||
this.input.blur();
|
||||
};
|
||||
|
||||
render() {
|
||||
const href = this.props.link.data.get('href');
|
||||
return (
|
||||
<span className={styles.linkEditor}>
|
||||
<input
|
||||
ref={ref => (this.input = ref)}
|
||||
defaultValue={href}
|
||||
placeholder="http://"
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button className={styles.button} onMouseDown={this.removeLink}>
|
||||
<CloseIcon light />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
3
frontend/components/Editor/components/Toolbar/index.js
Normal file
3
frontend/components/Editor/components/Toolbar/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Toolbar from './Toolbar';
|
||||
export default Toolbar;
|
||||
3
frontend/components/Editor/index.js
Normal file
3
frontend/components/Editor/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Editor from './Editor';
|
||||
export default Editor;
|
||||
70
frontend/components/Editor/plugins.js
Normal file
70
frontend/components/Editor/plugins.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import DropOrPasteImages from 'slate-drop-or-paste-images';
|
||||
import PasteLinkify from 'slate-paste-linkify';
|
||||
import EditList from 'slate-edit-list';
|
||||
import CollapseOnEscape from 'slate-collapse-on-escape';
|
||||
import TrailingBlock from 'slate-trailing-block';
|
||||
import EditCode from 'slate-edit-code';
|
||||
import Prism from 'slate-prism';
|
||||
import uploadFile from 'utils/uploadFile';
|
||||
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
|
||||
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
||||
|
||||
const onlyInCode = node => node.type === 'code';
|
||||
|
||||
const createPlugins = ({
|
||||
onImageUploadStart,
|
||||
onImageUploadStop,
|
||||
}: {
|
||||
onImageUploadStart: Function,
|
||||
onImageUploadStop: Function,
|
||||
}) => {
|
||||
return [
|
||||
PasteLinkify({
|
||||
type: 'link',
|
||||
collapseTo: 'end',
|
||||
}),
|
||||
DropOrPasteImages({
|
||||
extensions: ['png', 'jpg', 'gif'],
|
||||
applyTransform: async (transform, file) => {
|
||||
try {
|
||||
onImageUploadStart();
|
||||
const asset = await uploadFile(file);
|
||||
const alt = file.name;
|
||||
const src = asset.url;
|
||||
|
||||
return transform.insertBlock({
|
||||
type: 'image',
|
||||
isVoid: true,
|
||||
data: { src, alt },
|
||||
});
|
||||
} catch (err) {
|
||||
// TODO: Show a failure alert
|
||||
} finally {
|
||||
onImageUploadStop();
|
||||
}
|
||||
},
|
||||
}),
|
||||
EditList({
|
||||
types: ['ordered-list', 'bulleted-list', 'todo-list'],
|
||||
typeItem: 'list-item',
|
||||
}),
|
||||
EditCode({
|
||||
onlyIn: onlyInCode,
|
||||
containerType: 'code',
|
||||
lineType: 'code-line',
|
||||
exitBlocktype: 'paragraph',
|
||||
selectAll: true,
|
||||
}),
|
||||
Prism({
|
||||
onlyIn: onlyInCode,
|
||||
getSyntax: node => 'javascript',
|
||||
}),
|
||||
CollapseOnEscape({ toEdge: 'end' }),
|
||||
TrailingBlock({ type: 'paragraph' }),
|
||||
KeyboardShortcuts(),
|
||||
MarkdownShortcuts(),
|
||||
];
|
||||
};
|
||||
|
||||
export default createPlugins;
|
||||
39
frontend/components/Editor/plugins/KeyboardShortcuts.js
Normal file
39
frontend/components/Editor/plugins/KeyboardShortcuts.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
|
||||
export default function KeyboardShortcuts() {
|
||||
return {
|
||||
/**
|
||||
* On key down, check for our specific key shortcuts.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {Data} data
|
||||
* @param {State} state
|
||||
* @return {State or Null} state
|
||||
*/
|
||||
onKeyDown(ev: SyntheticEvent, data: Object, state: Object) {
|
||||
if (!data.isMeta) return null;
|
||||
|
||||
switch (data.key) {
|
||||
case 'b':
|
||||
return this.toggleMark(state, 'bold');
|
||||
case 'i':
|
||||
return this.toggleMark(state, 'italic');
|
||||
case 'u':
|
||||
return this.toggleMark(state, 'underlined');
|
||||
case 'd':
|
||||
return this.toggleMark(state, 'deleted');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
toggleMark(state: Object, type: string) {
|
||||
// don't allow formatting of document title
|
||||
const firstNode = state.document.nodes.first();
|
||||
if (firstNode === state.startBlock) return;
|
||||
|
||||
state = state.transform().toggleMark(type).apply();
|
||||
return state;
|
||||
},
|
||||
};
|
||||
}
|
||||
244
frontend/components/Editor/plugins/MarkdownShortcuts.js
Normal file
244
frontend/components/Editor/plugins/MarkdownShortcuts.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// @flow
|
||||
const inlineShortcuts = [
|
||||
{ mark: 'bold', shortcut: '**' },
|
||||
{ mark: 'bold', shortcut: '__' },
|
||||
{ mark: 'italic', shortcut: '*' },
|
||||
{ mark: 'italic', shortcut: '_' },
|
||||
{ mark: 'code', shortcut: '`' },
|
||||
{ mark: 'added', shortcut: '++' },
|
||||
{ mark: 'deleted', shortcut: '~~' },
|
||||
];
|
||||
|
||||
export default function MarkdownShortcuts() {
|
||||
return {
|
||||
/**
|
||||
* On key down, check for our specific key shortcuts.
|
||||
*/
|
||||
onKeyDown(ev: SyntheticEvent, data: Object, state: Object) {
|
||||
switch (data.key) {
|
||||
case '-':
|
||||
return this.onDash(ev, state);
|
||||
case '`':
|
||||
return this.onBacktick(ev, state);
|
||||
case 'space':
|
||||
return this.onSpace(ev, state);
|
||||
case 'backspace':
|
||||
return this.onBackspace(ev, state);
|
||||
case 'enter':
|
||||
return this.onEnter(ev, state);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On space, if it was after an auto-markdown shortcut, convert the current
|
||||
* node into the shortcut's corresponding type.
|
||||
*/
|
||||
onSpace(ev: SyntheticEvent, state: Object) {
|
||||
if (state.isExpanded) return;
|
||||
const { startBlock, startOffset } = state;
|
||||
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
|
||||
const type = this.getType(chars);
|
||||
|
||||
if (type) {
|
||||
if (type === 'list-item' && startBlock.type === 'list-item') return;
|
||||
ev.preventDefault();
|
||||
|
||||
const transform = state.transform().setBlock(type);
|
||||
|
||||
if (type === 'list-item') {
|
||||
if (chars === '1.') {
|
||||
transform.wrapBlock('ordered-list');
|
||||
} else {
|
||||
transform.wrapBlock('bulleted-list');
|
||||
}
|
||||
}
|
||||
|
||||
state = transform.extendToStartOf(startBlock).delete().apply();
|
||||
return state;
|
||||
}
|
||||
|
||||
for (const key of inlineShortcuts) {
|
||||
// find all inline characters
|
||||
let { mark, shortcut } = key;
|
||||
let inlineTags = [];
|
||||
|
||||
for (let i = 0; i < startBlock.text.length; i++) {
|
||||
if (startBlock.text.slice(i, i + shortcut.length) === shortcut)
|
||||
inlineTags.push(i);
|
||||
}
|
||||
|
||||
// if we have multiple tags then mark the text between as inline code
|
||||
if (inlineTags.length > 1) {
|
||||
const transform = state.transform();
|
||||
const firstText = startBlock.getFirstText();
|
||||
const firstCodeTagIndex = inlineTags[0];
|
||||
const lastCodeTagIndex = inlineTags[inlineTags.length - 1];
|
||||
transform.removeTextByKey(
|
||||
firstText.key,
|
||||
lastCodeTagIndex,
|
||||
shortcut.length
|
||||
);
|
||||
transform.removeTextByKey(
|
||||
firstText.key,
|
||||
firstCodeTagIndex,
|
||||
shortcut.length
|
||||
);
|
||||
transform.moveOffsetsTo(
|
||||
firstCodeTagIndex,
|
||||
lastCodeTagIndex - shortcut.length
|
||||
);
|
||||
transform.addMark(mark);
|
||||
state = transform.collapseToEnd().removeMark(mark).apply();
|
||||
return state;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDash(ev: SyntheticEvent, state: Object) {
|
||||
if (state.isExpanded) return;
|
||||
const { startBlock, startOffset } = state;
|
||||
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
|
||||
|
||||
if (chars === '--') {
|
||||
ev.preventDefault();
|
||||
const transform = state
|
||||
.transform()
|
||||
.extendToStartOf(startBlock)
|
||||
.delete()
|
||||
.setBlock({
|
||||
type: 'horizontal-rule',
|
||||
isVoid: true,
|
||||
});
|
||||
state = transform
|
||||
.collapseToStartOfNextBlock()
|
||||
.insertBlock('paragraph')
|
||||
.apply();
|
||||
return state;
|
||||
}
|
||||
},
|
||||
|
||||
onBacktick(ev: SyntheticEvent, state: Object) {
|
||||
if (state.isExpanded) return;
|
||||
const { startBlock, startOffset } = state;
|
||||
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '');
|
||||
|
||||
if (chars === '``') {
|
||||
ev.preventDefault();
|
||||
return state
|
||||
.transform()
|
||||
.extendToStartOf(startBlock)
|
||||
.delete()
|
||||
.setBlock({
|
||||
type: 'code',
|
||||
})
|
||||
.apply();
|
||||
}
|
||||
},
|
||||
|
||||
onBackspace(ev: SyntheticEvent, state: Object) {
|
||||
if (state.isExpanded) return;
|
||||
const { startBlock, selection, startOffset } = state;
|
||||
|
||||
// If at the start of a non-paragraph, convert it back into a paragraph
|
||||
if (startOffset === 0) {
|
||||
if (startBlock.type === 'paragraph') return;
|
||||
ev.preventDefault();
|
||||
|
||||
const transform = state.transform().setBlock('paragraph');
|
||||
|
||||
if (startBlock.type === 'list-item')
|
||||
transform.unwrapBlock('bulleted-list');
|
||||
|
||||
state = transform.apply();
|
||||
return state;
|
||||
}
|
||||
|
||||
// If at the end of a code mark hitting backspace should remove the mark
|
||||
if (selection.isCollapsed) {
|
||||
const marksAtCursor = startBlock.getMarksAtRange(selection);
|
||||
const codeMarksAtCursor = marksAtCursor.filter(
|
||||
mark => mark.type === 'code'
|
||||
);
|
||||
|
||||
if (codeMarksAtCursor.size > 0) {
|
||||
ev.preventDefault();
|
||||
|
||||
const textNode = startBlock.getTextAtOffset(startOffset);
|
||||
const charsInCodeBlock = textNode.characters
|
||||
.takeUntil((v, k) => k === startOffset)
|
||||
.reverse()
|
||||
.takeUntil((v, k) => !v.marks.some(mark => mark.type === 'code'));
|
||||
|
||||
const transform = state.transform();
|
||||
transform.removeMarkByKey(
|
||||
textNode.key,
|
||||
state.startOffset - charsInCodeBlock.size,
|
||||
state.startOffset,
|
||||
'code'
|
||||
);
|
||||
state = transform.apply();
|
||||
return state;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On return, if at the end of a node type that should not be extended,
|
||||
* create a new paragraph below it.
|
||||
*/
|
||||
onEnter(ev: SyntheticEvent, state: Object) {
|
||||
if (state.isExpanded) return;
|
||||
const { startBlock, startOffset, endOffset } = state;
|
||||
if (startOffset === 0 && startBlock.length === 0)
|
||||
return this.onBackspace(ev, state);
|
||||
if (endOffset !== startBlock.length) return;
|
||||
|
||||
if (
|
||||
startBlock.type !== 'heading1' &&
|
||||
startBlock.type !== 'heading2' &&
|
||||
startBlock.type !== 'heading3' &&
|
||||
startBlock.type !== 'heading4' &&
|
||||
startBlock.type !== 'heading5' &&
|
||||
startBlock.type !== 'heading6' &&
|
||||
startBlock.type !== 'block-quote'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
return state.transform().splitBlock().setBlock('paragraph').apply();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the block type for a series of auto-markdown shortcut `chars`.
|
||||
*/
|
||||
getType(chars: string) {
|
||||
switch (chars) {
|
||||
case '*':
|
||||
case '-':
|
||||
case '+':
|
||||
case '1.':
|
||||
return 'list-item';
|
||||
case '>':
|
||||
return 'block-quote';
|
||||
case '#':
|
||||
return 'heading1';
|
||||
case '##':
|
||||
return 'heading2';
|
||||
case '###':
|
||||
return 'heading3';
|
||||
case '####':
|
||||
return 'heading4';
|
||||
case '#####':
|
||||
return 'heading5';
|
||||
case '######':
|
||||
return 'heading6';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
94
frontend/components/Editor/schema.js
Normal file
94
frontend/components/Editor/schema.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import Code from './components/Code';
|
||||
import Image from './components/Image';
|
||||
import Link from './components/Link';
|
||||
import ListItem from './components/ListItem';
|
||||
import Heading from './components/Heading';
|
||||
import type { Props, Node, Transform } from './types';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
const schema = {
|
||||
marks: {
|
||||
bold: (props: Props) => <strong>{props.children}</strong>,
|
||||
code: (props: Props) => <code>{props.children}</code>,
|
||||
italic: (props: Props) => <em>{props.children}</em>,
|
||||
underlined: (props: Props) => <u>{props.children}</u>,
|
||||
deleted: (props: Props) => <del>{props.children}</del>,
|
||||
added: (props: Props) => <mark>{props.children}</mark>,
|
||||
},
|
||||
|
||||
nodes: {
|
||||
paragraph: (props: Props) => <p>{props.children}</p>,
|
||||
'block-quote': (props: Props) => <blockquote>{props.children}</blockquote>,
|
||||
'horizontal-rule': (props: Props) => <hr />,
|
||||
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
|
||||
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
|
||||
'todo-list': (props: Props) => (
|
||||
<ul className={styles.todoList}>{props.children}</ul>
|
||||
),
|
||||
table: (props: Props) => <table>{props.children}</table>,
|
||||
'table-row': (props: Props) => <tr>{props.children}</tr>,
|
||||
'table-head': (props: Props) => <th>{props.children}</th>,
|
||||
'table-cell': (props: Props) => <td>{props.children}</td>,
|
||||
code: Code,
|
||||
image: Image,
|
||||
link: Link,
|
||||
'list-item': ListItem,
|
||||
heading1: (props: Props) => <Heading placeholder {...props} />,
|
||||
heading2: (props: Props) => <Heading component="h2" {...props} />,
|
||||
heading3: (props: Props) => <Heading component="h3" {...props} />,
|
||||
heading4: (props: Props) => <Heading component="h4" {...props} />,
|
||||
heading5: (props: Props) => <Heading component="h5" {...props} />,
|
||||
heading6: (props: Props) => <Heading component="h6" {...props} />,
|
||||
},
|
||||
|
||||
rules: [
|
||||
// ensure first node is a heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'document';
|
||||
},
|
||||
validate: (document: Node) => {
|
||||
const firstNode = document.nodes.first();
|
||||
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
|
||||
},
|
||||
normalize: (transform: Transform, document: Node, firstNode: Node) => {
|
||||
transform.setBlock({ type: 'heading1' });
|
||||
},
|
||||
},
|
||||
|
||||
// remove any marks in first heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'heading1';
|
||||
},
|
||||
validate: (heading: Node) => {
|
||||
const hasMarks = heading.getMarks().isEmpty();
|
||||
const hasInlines = heading.getInlines().isEmpty();
|
||||
|
||||
return !(hasMarks && hasInlines);
|
||||
},
|
||||
normalize: (transform: Transform, heading: Node) => {
|
||||
transform.unwrapInlineByKey(heading.key);
|
||||
|
||||
heading.getMarks().forEach(mark => {
|
||||
heading.nodes.forEach(textNode => {
|
||||
if (textNode.kind === 'text') {
|
||||
transform.removeMarkByKey(
|
||||
textNode.key,
|
||||
0,
|
||||
textNode.text.length,
|
||||
mark
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return transform;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default schema;
|
||||
3
frontend/components/Editor/serializer.js
Normal file
3
frontend/components/Editor/serializer.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import MarkdownSerializer from 'slate-markdown-serializer';
|
||||
export default new MarkdownSerializer();
|
||||
110
frontend/components/Editor/types.js
Normal file
110
frontend/components/Editor/types.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// @flow
|
||||
import { List, Set, Map } from 'immutable';
|
||||
|
||||
export type NodeTransform = {
|
||||
addMarkByKey: Function,
|
||||
insertNodeByKey: Function,
|
||||
insertTextByKey: Function,
|
||||
moveNodeByKey: Function,
|
||||
removeMarkByKey: Function,
|
||||
removeNodeByKey: Function,
|
||||
removeTextByKey: Function,
|
||||
setMarkByKey: Function,
|
||||
setNodeByKey: Function,
|
||||
splitNodeByKey: Function,
|
||||
unwrapInlineByKey: Function,
|
||||
unwrapBlockByKey: Function,
|
||||
unwrapNodeByKey: Function,
|
||||
wrapBlockByKey: Function,
|
||||
wrapInlineByKey: Function,
|
||||
};
|
||||
|
||||
export type StateTransform = {
|
||||
deleteBackward: Function,
|
||||
deleteForward: Function,
|
||||
delete: Function,
|
||||
insertBlock: Function,
|
||||
insertFragment: Function,
|
||||
insertInline: Function,
|
||||
insertText: Function,
|
||||
addMark: Function,
|
||||
setBlock: Function,
|
||||
setInline: Function,
|
||||
splitBlock: Function,
|
||||
splitInline: Function,
|
||||
removeMark: Function,
|
||||
toggleMark: Function,
|
||||
unwrapBlock: Function,
|
||||
unwrapInline: Function,
|
||||
wrapBlock: Function,
|
||||
wrapInline: Function,
|
||||
wrapText: Function,
|
||||
};
|
||||
|
||||
export type Transform = NodeTransform & StateTransform;
|
||||
|
||||
export type Editor = {
|
||||
props: Object,
|
||||
className: string,
|
||||
onChange: Function,
|
||||
onDocumentChange: Function,
|
||||
onSelectionChange: Function,
|
||||
plugins: Array<Object>,
|
||||
readOnly: boolean,
|
||||
state: Object,
|
||||
style: Object,
|
||||
placeholder?: string,
|
||||
placeholderClassName?: string,
|
||||
placeholderStyle?: string,
|
||||
blur: Function,
|
||||
focus: Function,
|
||||
getSchema: Function,
|
||||
getState: Function,
|
||||
};
|
||||
|
||||
export type Node = {
|
||||
key: string,
|
||||
kind: string,
|
||||
length: number,
|
||||
text: string,
|
||||
data: Map<string, any>,
|
||||
nodes: List<Node>,
|
||||
getMarks: Function,
|
||||
getBlocks: Function,
|
||||
getParent: Function,
|
||||
getInlines: Function,
|
||||
getInlinesAtRange: Function,
|
||||
setBlock: Function,
|
||||
};
|
||||
|
||||
export type Block = Node & {
|
||||
type: string,
|
||||
};
|
||||
|
||||
export type Document = Node;
|
||||
|
||||
export type Props = {
|
||||
node: Node,
|
||||
parent?: Node,
|
||||
attributes?: Object,
|
||||
editor: Editor,
|
||||
readOnly?: boolean,
|
||||
children?: React$Element<any>,
|
||||
};
|
||||
|
||||
export type State = {
|
||||
document: Document,
|
||||
selection: Selection,
|
||||
startBlock: Block,
|
||||
endBlock: Block,
|
||||
startText: Node,
|
||||
endText: Node,
|
||||
marks: Set<*>,
|
||||
blocks: List<Block>,
|
||||
fragment: Document,
|
||||
lines: List<Node>,
|
||||
tests: List<Node>,
|
||||
startBlock: Block,
|
||||
transform: Function,
|
||||
isBlurred: Function,
|
||||
};
|
||||
Reference in New Issue
Block a user