Moving previews to client side rendering for consistency

This commit is contained in:
Tom Moor
2017-05-25 00:07:41 -07:00
parent 3da1c06620
commit e791509dac
28 changed files with 74 additions and 55 deletions

View File

@@ -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;

View File

@@ -0,0 +1,14 @@
.container {
padding-top: 50px;
cursor: text;
}
.grow {
flex-grow: 1;
}
@media all and (max-width: 960px) {
.container {
padding-top: 50px;
}
}

View File

@@ -0,0 +1,3 @@
// @flow
import ClickablePadding from './ClickablePadding';
export default ClickablePadding;

View 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>
);
}

View 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>
);
}

View 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')}
/>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}
}

View 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>
);
}
}

View 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;
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -0,0 +1,3 @@
// @flow
import Toolbar from './Toolbar';
export default Toolbar;