Merge pull request #388 from jorilallo/block-insert

New block insert toolbar
This commit is contained in:
Jori Lallo
2017-11-08 23:50:20 -08:00
committed by GitHub
19 changed files with 381 additions and 200 deletions

View File

@@ -44,7 +44,10 @@ type KeyData = {
constructor(props: Props) {
super(props);
this.schema = createSchema();
this.schema = createSchema({
onInsertImage: this.insertImageFile,
onChange: this.onChange,
});
this.plugins = createPlugins({
onImageUploadStart: props.onImageUploadStart,
onImageUploadStop: props.onImageUploadStop,

View File

@@ -1,193 +1,142 @@
// @flow
import React, { Component } from 'react';
import EditList from '../plugins/EditList';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Portal from 'react-portal';
import { findDOMNode, Node } from 'slate';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { color } from 'shared/styles/constants';
import PlusIcon from 'components/Icon/PlusIcon';
import BlockMenu from 'menus/BlockMenu';
import type { State } from '../types';
const { transforms } = EditList;
type Props = {
state: State,
onChange: Function,
onInsertImage: File => Promise<*>,
};
function findClosestRootNode(state, ev) {
let previous;
for (const node of state.document.nodes) {
const element = findDOMNode(node);
const bounds = element.getBoundingClientRect();
if (bounds.top > ev.clientY) return previous;
previous = { node, element, bounds };
}
}
@observer
export default class BlockInsert extends Component {
props: Props;
mouseMoveTimeout: number;
file: HTMLInputElement;
mouseMovementSinceClick: number = 0;
lastClientX: number = 0;
lastClientY: number = 0;
@observable closestRootNode: Node;
@observable active: boolean = false;
@observable menuOpen: boolean = false;
@observable top: number;
@observable left: number;
@observable mouseX: number;
componentDidMount = () => {
this.update();
window.addEventListener('mousemove', this.handleMouseMove);
};
componentWillUpdate = (nextProps: Props) => {
this.update(nextProps);
};
componentWillUnmount = () => {
window.removeEventListener('mousemove', this.handleMouseMove);
};
setInactive = () => {
if (this.menuOpen) return;
this.active = false;
};
handleMouseMove = (ev: SyntheticMouseEvent) => {
const windowWidth = window.innerWidth / 3;
let active = ev.clientX < windowWidth;
const windowWidth = window.innerWidth / 2.5;
const result = findClosestRootNode(this.props.state, ev);
const movementThreshold = 200;
if (active !== this.active) {
this.active = active || this.menuOpen;
this.mouseMovementSinceClick +=
Math.abs(this.lastClientX - ev.clientX) +
Math.abs(this.lastClientY - ev.clientY);
this.lastClientX = ev.clientX;
this.lastClientY = ev.clientY;
this.active =
ev.clientX < windowWidth &&
this.mouseMovementSinceClick > movementThreshold;
if (result) {
this.closestRootNode = result.node;
// do not show block menu on title heading or editor
const firstNode = this.props.state.document.nodes.first();
if (result.node === firstNode || result.node.type === 'block-toolbar') {
this.left = -1000;
} else {
this.left = Math.round(result.bounds.left - 20);
this.top = Math.round(result.bounds.top + window.scrollY);
}
}
if (active) {
if (this.active) {
clearTimeout(this.mouseMoveTimeout);
this.mouseMoveTimeout = setTimeout(this.setInactive, 2000);
}
};
handleMenuOpen = () => {
this.menuOpen = true;
};
handleMenuClose = () => {
this.menuOpen = false;
};
update = (props?: Props) => {
if (!document.activeElement) return;
const { state } = props || this.props;
const boxRect = document.activeElement.getBoundingClientRect();
const selection = window.getSelection();
if (!selection.focusNode) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.top <= 0 || boxRect.left <= 0) return;
if (state.startBlock.type === 'heading1') {
this.active = false;
}
this.top = Math.round(rect.top + window.scrollY);
this.left = Math.round(boxRect.left + window.scrollX - 20);
};
insertBlock = (
ev: SyntheticEvent,
options: {
type: string | Object,
wrapper?: string | Object,
append?: string | Object,
}
) => {
ev.preventDefault();
const { type, wrapper, append } = options;
let { state } = this.props;
let transform = state.transform();
const { document } = state;
const parent = document.getParent(state.startBlock.key);
// lists get some special treatment
if (parent && parent.type === 'list-item') {
transform = transforms.unwrapList(
transforms
.splitListItem(transform.collapseToStart())
.collapseToEndOfPreviousBlock()
);
}
transform = transform.insertBlock(type);
if (wrapper) transform = transform.wrapBlock(wrapper);
if (append) transform = transform.insertBlock(append);
state = transform.focus().apply();
this.props.onChange(state);
handleClick = (ev: SyntheticMouseEvent) => {
this.mouseMovementSinceClick = 0;
this.active = false;
};
onPickImage = (ev: SyntheticEvent) => {
// simulate a click on the file upload input element
this.file.click();
};
const { state } = this.props;
const type = { type: 'block-toolbar', isVoid: true };
let transform = state.transform();
onChooseImage = async (ev: SyntheticEvent) => {
const files = getDataTransferFiles(ev);
for (const file of files) {
await this.props.onInsertImage(file);
}
// remove any existing toolbars in the document as a fail safe
state.document.nodes.forEach(node => {
if (node.type === 'block-toolbar') {
transform.removeNodeByKey(node.key);
}
});
transform
.collapseToStartOf(this.closestRootNode)
.collapseToEndOfPreviousBlock()
.insertBlock(type);
this.props.onChange(transform.apply());
};
render() {
const style = { top: `${this.top}px`, left: `${this.left}px` };
const todo = { type: 'list-item', data: { checked: false } };
const rule = { type: 'horizontal-rule', isVoid: true };
return (
<Portal isOpened>
<Trigger active={this.active} style={style}>
<HiddenInput
type="file"
innerRef={ref => (this.file = ref)}
onChange={this.onChooseImage}
accept="image/*"
/>
<BlockMenu
label={<PlusIcon />}
onPickImage={this.onPickImage}
onInsertList={ev =>
this.insertBlock(ev, {
type: 'list-item',
wrapper: 'bulleted-list',
})}
onInsertTodoList={ev =>
this.insertBlock(ev, { type: todo, wrapper: 'todo-list' })}
onInsertBreak={ev =>
this.insertBlock(ev, { type: rule, append: 'paragraph' })}
onOpen={this.handleMenuOpen}
onClose={this.handleMenuClose}
/>
<PlusIcon onClick={this.handleClick} color={color.slate} />
</Trigger>
</Portal>
);
}
}
const HiddenInput = styled.input`
position: absolute;
top: -100px;
left: -100px;
visibility: hidden;
`;
const Trigger = styled.div`
position: absolute;
z-index: 1;
opacity: 0;
background-color: ${color.white};
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
line-height: 0;
margin-top: -2px;
margin-left: -4px;
margin-left: -10px;
box-shadow: inset 0 0 0 2px ${color.slate};
border-radius: 100%;
transform: scale(.9);
cursor: pointer;
&:hover {
background-color: ${color.smokeDark};
}
${({ active }) => active && `
transform: scale(1);

View File

@@ -9,10 +9,10 @@ export default function Code({ children, node, readOnly, attributes }: Props) {
const language = node.data.get('language') || 'javascript';
return (
<Container>
<Container {...attributes}>
{readOnly && <CopyButton text={node.text} />}
<Pre className={`language-${language}`}>
<code {...attributes} className={`language-${language}`}>
<code className={`language-${language}`}>
{children}
</code>
</Pre>

View File

@@ -14,6 +14,7 @@ type Props = {
editor: Editor,
readOnly: boolean,
component?: string,
attributes: Object,
};
function Heading(props: Props) {
@@ -25,6 +26,7 @@ function Heading(props: Props) {
readOnly,
children,
component = 'h1',
attributes,
...rest
} = props;
const parentIsDocument = parent instanceof Document;
@@ -39,7 +41,7 @@ function Heading(props: Props) {
emoji && title.match(new RegExp(`^${emoji}\\s`));
return (
<Component {...rest} id={slugish}>
<Component {...attributes} {...rest} id={slugish}>
<Wrapper hasEmoji={startsWithEmojiAndSpace}>
{children}
</Wrapper>

View File

@@ -5,12 +5,15 @@ import type { Props } from '../types';
import { color } from 'shared/styles/constants';
function HorizontalRule(props: Props) {
const { state, node } = props;
const { state, node, attributes } = props;
const active = state.isFocused && state.selection.hasEdgeIn(node);
return <StyledHr active={active} />;
return <StyledHr active={active} {...attributes} />;
}
const StyledHr = styled.hr`
padding-top: .75em;
margin: 0;
border: 0;
border-bottom: 1px solid ${props => (props.active ? color.slate : color.slateLight)};
`;

View File

@@ -3,14 +3,15 @@ import React from 'react';
import type { Props } from '../types';
import TodoItem from './TodoItem';
export default function ListItem({ children, node, ...props }: Props) {
export default function ListItem({ children, node, attributes }: Props) {
const checked = node.data.get('checked');
if (checked !== undefined) {
return (
<TodoItem checked={checked} node={node} {...props}>
<TodoItem checked={checked} node={node} {...attributes}>
{children}
</TodoItem>
);
}
return <li>{children}</li>;
return <li {...attributes}>{children}</li>;
}

View File

@@ -23,7 +23,7 @@ export default function Link({
!node.text;
return (
<p>
<p {...attributes}>
{children}
{showPlaceholder &&
<Placeholder contentEditable={false}>

View File

@@ -20,10 +20,10 @@ export default class TodoItem extends Component {
};
render() {
const { children, checked, readOnly } = this.props;
const { children, checked, attributes, readOnly } = this.props;
return (
<ListItem checked={checked}>
<ListItem checked={checked} {...attributes}>
<Input
type="checkbox"
checked={checked}

View File

@@ -0,0 +1,194 @@
// @flow
import React, { Component } from 'react';
import keydown from 'react-keydown';
import styled from 'styled-components';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Heading1Icon from 'components/Icon/Heading1Icon';
import Heading2Icon from 'components/Icon/Heading2Icon';
import ImageIcon from 'components/Icon/ImageIcon';
import CodeIcon from 'components/Icon/CodeIcon';
import BulletedListIcon from 'components/Icon/BulletedListIcon';
import OrderedListIcon from 'components/Icon/OrderedListIcon';
import HorizontalRuleIcon from 'components/Icon/HorizontalRuleIcon';
import TodoListIcon from 'components/Icon/TodoListIcon';
import Flex from 'shared/components/Flex';
import ToolbarButton from './components/ToolbarButton';
import type { Props as BaseProps } from '../../types';
import { color } from 'shared/styles/constants';
import { fadeIn } from 'shared/styles/animations';
import { splitAndInsertBlock } from '../../transforms';
type Props = BaseProps & {
onInsertImage: Function,
onChange: Function,
};
type Options = {
type: string | Object,
wrapper?: string | Object,
append?: string | Object,
};
class BlockToolbar extends Component {
props: Props;
file: HTMLInputElement;
componentWillReceiveProps(nextProps: Props) {
const wasActive = this.props.state.selection.hasEdgeIn(this.props.node);
const isActive = nextProps.state.selection.hasEdgeIn(nextProps.node);
const becameInactive = !isActive && wasActive;
if (becameInactive) {
const state = nextProps.state
.transform()
.removeNodeByKey(nextProps.node.key)
.apply();
this.props.onChange(state);
}
}
@keydown('esc')
removeSelf(ev: SyntheticEvent) {
ev.preventDefault();
ev.stopPropagation();
const state = this.props.state
.transform()
.removeNodeByKey(this.props.node.key)
.apply();
this.props.onChange(state);
}
insertBlock = (options: Options) => {
const { state } = this.props;
let transform = splitAndInsertBlock(state.transform(), state, options);
state.document.nodes.forEach(node => {
if (node.type === 'block-toolbar') {
transform.removeNodeByKey(node.key);
}
});
this.props.onChange(transform.focus().apply());
};
handleClickBlock = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
switch (type) {
case 'heading1':
case 'heading2':
case 'code':
return this.insertBlock({ type });
case 'horizontal-rule':
return this.insertBlock({
type: { type: 'horizontal-rule', isVoid: true },
});
case 'bulleted-list':
return this.insertBlock({
type: 'list-item',
wrapper: 'bulleted-list',
});
case 'ordered-list':
return this.insertBlock({
type: 'list-item',
wrapper: 'ordered-list',
});
case 'todo-list':
return this.insertBlock({
type: { type: 'list-item', data: { checked: false } },
wrapper: 'todo-list',
});
case 'image':
return this.onPickImage();
default:
}
};
onPickImage = () => {
// simulate a click on the file upload input element
this.file.click();
};
onImagePicked = async (ev: SyntheticEvent) => {
const files = getDataTransferFiles(ev);
for (const file of files) {
await this.props.onInsertImage(file);
}
};
renderBlockButton = (type: string, IconClass: Function) => {
return (
<ToolbarButton onMouseDown={ev => this.handleClickBlock(ev, type)}>
<IconClass color={color.text} />
</ToolbarButton>
);
};
render() {
const { state, attributes, node } = this.props;
const active = state.isFocused && state.selection.hasEdgeIn(node);
return (
<Bar active={active} {...attributes}>
<HiddenInput
type="file"
innerRef={ref => (this.file = ref)}
onChange={this.onImagePicked}
accept="image/*"
/>
{this.renderBlockButton('heading1', Heading1Icon)}
{this.renderBlockButton('heading2', Heading2Icon)}
<Separator />
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
{this.renderBlockButton('ordered-list', OrderedListIcon)}
{this.renderBlockButton('todo-list', TodoListIcon)}
<Separator />
{this.renderBlockButton('code', CodeIcon)}
{this.renderBlockButton('horizontal-rule', HorizontalRuleIcon)}
{this.renderBlockButton('image', ImageIcon)}
</Bar>
);
}
}
const Separator = styled.div`
height: 100%;
width: 1px;
background: ${color.smokeDark};
display: inline-block;
margin-left: 10px;
`;
const Bar = styled(Flex)`
z-index: 100;
animation: ${fadeIn} 150ms ease-in-out;
position: relative;
align-items: center;
background: ${color.smoke};
height: 44px;
&:before,
&:after {
content: "";
position: absolute;
left: -100%;
width: 100%;
height: 44px;
background: ${color.smoke};
}
&:after {
left: auto;
right: -100%;
}
`;
const HiddenInput = styled.input`
position: absolute;
top: -100px;
left: -100px;
visibility: hidden;
`;
export default BlockToolbar;

View File

@@ -140,7 +140,7 @@ export default class Toolbar extends Component {
const Menu = styled.div`
padding: 8px 16px;
position: absolute;
z-index: 1;
z-index: 2;
top: -10000px;
left: -10000px;
opacity: 0;

View File

@@ -16,9 +16,15 @@ import {
Heading6,
} from './components/Heading';
import Paragraph from './components/Paragraph';
import BlockToolbar from './components/Toolbar/BlockToolbar';
import type { Props, Node, Transform } from './types';
const createSchema = () => {
type Options = {
onInsertImage: Function,
onChange: Function,
};
const createSchema = ({ onInsertImage, onChange }: Options) => {
return {
marks: {
bold: (props: Props) => <strong>{props.children}</strong>,
@@ -30,18 +36,39 @@ const createSchema = () => {
},
nodes: {
'block-toolbar': (props: Props) => (
<BlockToolbar
onChange={onChange}
onInsertImage={onInsertImage}
{...props}
/>
),
paragraph: (props: Props) => <Paragraph {...props} />,
'block-quote': (props: Props) => (
<blockquote>{props.children}</blockquote>
<blockquote {...props.attributes}>{props.children}</blockquote>
),
'horizontal-rule': HorizontalRule,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
'todo-list': (props: Props) => <TodoList>{props.children}</TodoList>,
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>,
'bulleted-list': (props: Props) => (
<ul {...props.attributes}>{props.children}</ul>
),
'ordered-list': (props: Props) => (
<ol {...props.attributes}>{props.children}</ol>
),
'todo-list': (props: Props) => (
<TodoList {...props.attributes}>{props.children}</TodoList>
),
table: (props: Props) => (
<table {...props.attributes}>{props.children}</table>
),
'table-row': (props: Props) => (
<tr {...props.attributes}>{props.children}</tr>
),
'table-head': (props: Props) => (
<th {...props.attributes}>{props.children}</th>
),
'table-cell': (props: Props) => (
<td {...props.attributes}>{props.children}</td>
),
code: Code,
image: Image,
link: Link,

View File

@@ -0,0 +1,37 @@
// @flow
import EditList from './plugins/EditList';
import type { State, Transform } from './types';
const { transforms } = EditList;
type Options = {
type: string | Object,
wrapper?: string | Object,
append?: string | Object,
};
export function splitAndInsertBlock(
transform: Transform,
state: State,
options: Options
) {
const { type, wrapper, append } = options;
const { document } = state;
const parent = document.getParent(state.startBlock.key);
// lists get some special treatment
if (parent && parent.type === 'list-item') {
transform = transforms.unwrapList(
transforms
.splitListItem(transform.collapseToStart())
.collapseToEndOfPreviousBlock()
);
}
transform = transform.insertBlock(type);
if (wrapper) transform = transform.wrapBlock(wrapper);
if (append) transform = transform.insertBlock(append);
return transform;
}

View File

@@ -42,7 +42,12 @@ export type StateTransform = {
wrapText: Function,
};
export type Transform = NodeTransform & StateTransform;
export type SelectionTransform = {
collapseToStart: Function,
collapseToEnd: Function,
};
export type Transform = NodeTransform & StateTransform & SelectionTransform;
export type Editor = {
props: Object,