Moving previews to client side rendering for consistency
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user