99% there

This commit is contained in:
Tom Moor
2017-11-08 00:08:35 -08:00
parent 51bc705488
commit 5d716d9c5f
10 changed files with 122 additions and 79 deletions

View File

@ -1,14 +1,13 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Portal from 'react-portal'; import Portal from 'react-portal';
import { findDOMNode, Node } from 'slate';
import { observable } from 'mobx'; import { observable } from 'mobx';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import styled from 'styled-components'; import styled from 'styled-components';
import { color } from 'shared/styles/constants'; import { color } from 'shared/styles/constants';
import PlusIcon from 'components/Icon/PlusIcon'; import PlusIcon from 'components/Icon/PlusIcon';
import type { State } from '../types'; import type { State } from '../types';
import { splitAndInsertBlock } from '../transforms';
type Props = { type Props = {
state: State, state: State,
@ -16,83 +15,95 @@ type Props = {
onInsertImage: File => Promise<*>, 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 @observer
export default class BlockInsert extends Component { export default class BlockInsert extends Component {
props: Props; props: Props;
mouseMoveTimeout: number; mouseMoveTimeout: number;
mouseMovementSinceClick: number = 0;
mouseClickX: number = 0;
mouseClickY: number = 0;
@observable closestRootNode: Node;
@observable active: boolean = false; @observable active: boolean = false;
@observable menuOpen: boolean = false;
@observable top: number; @observable top: number;
@observable left: number; @observable left: number;
@observable mouseX: number;
componentDidMount = () => { componentDidMount = () => {
this.update(); window.addEventListener('mousedown', this.handleWindowClick);
window.addEventListener('mousemove', this.handleMouseMove); window.addEventListener('mousemove', this.handleMouseMove);
}; };
componentWillUpdate = (nextProps: Props) => {
this.update(nextProps);
};
componentWillUnmount = () => { componentWillUnmount = () => {
window.removeEventListener('mousedown', this.handleWindowClick);
window.removeEventListener('mousemove', this.handleMouseMove); window.removeEventListener('mousemove', this.handleMouseMove);
}; };
setInactive = () => { setInactive = () => {
if (this.menuOpen) return;
this.active = false; this.active = false;
}; };
handleMouseMove = (ev: SyntheticMouseEvent) => { handleMouseMove = (ev: SyntheticMouseEvent) => {
const windowWidth = window.innerWidth / 3; const windowWidth = window.innerWidth / 2.5;
let active = ev.clientX < windowWidth; const result = findClosestRootNode(this.props.state, ev);
const movementToReset = 1000;
if (active !== this.active) { this.mouseMovementSinceClick += Math.abs(this.mouseClickX - ev.clientX);
this.active = active || this.menuOpen; this.active =
ev.clientX < windowWidth &&
this.mouseMovementSinceClick > movementToReset;
if (result) {
this.closestRootNode = result.node;
// do not show block menu on title heading or editor
const { type } = result.node;
if (type === 'heading1' || 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); clearTimeout(this.mouseMoveTimeout);
this.mouseMoveTimeout = setTimeout(this.setInactive, 2000); this.mouseMoveTimeout = setTimeout(this.setInactive, 2000);
} }
}; };
handleMenuOpen = () => { handleWindowClick = (ev: SyntheticMouseEvent) => {
this.menuOpen = true; this.mouseClickX = ev.clientX;
}; this.mouseClickY = ev.clientY;
this.mouseMovementSinceClick = 0;
handleMenuClose = () => { this.active = false;
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);
}; };
handleClick = () => { handleClick = () => {
const transform = splitAndInsertBlock(this.props.state, { const { state } = this.props;
type: { type: 'block-toolbar', isVoid: true }, const type = { type: 'block-toolbar', isVoid: true };
let transform = state.transform();
// 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);
}
}); });
const state = transform.apply();
this.props.onChange(state); transform.collapseToStartOf(this.closestRootNode).insertBlock(type);
this.active = false;
this.props.onChange(transform.apply());
}; };
render() { render() {
@ -101,7 +112,7 @@ export default class BlockInsert extends Component {
return ( return (
<Portal isOpened> <Portal isOpened>
<Trigger active={this.active} style={style}> <Trigger active={this.active} style={style}>
<PlusIcon onClick={this.handleClick} /> <PlusIcon onClick={this.handleClick} color={color.slate} />
</Trigger> </Trigger>
</Portal> </Portal>
); );
@ -113,13 +124,13 @@ const Trigger = styled.div`
z-index: 1; z-index: 1;
opacity: 0; opacity: 0;
background-color: ${color.white}; 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; line-height: 0;
margin-top: -3px;
margin-left: -10px; margin-left: -10px;
box-shadow: inset 0 0 0 2px ${color.slateDark}; box-shadow: inset 0 0 0 2px ${color.slate};
border-radius: 100%; border-radius: 100%;
transform: scale(.9); transform: scale(.9);
cursor: pointer;
&:hover { &:hover {
background-color: ${color.smokeDark}; background-color: ${color.smokeDark};

View File

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

View File

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

View File

@ -5,9 +5,9 @@ import type { Props } from '../types';
import { color } from 'shared/styles/constants'; import { color } from 'shared/styles/constants';
function HorizontalRule(props: Props) { function HorizontalRule(props: Props) {
const { state, node } = props; const { state, node, attributes } = props;
const active = state.isFocused && state.selection.hasEdgeIn(node); const active = state.isFocused && state.selection.hasEdgeIn(node);
return <StyledHr active={active} />; return <StyledHr active={active} {...attributes} />;
} }
const StyledHr = styled.hr` const StyledHr = styled.hr`

View File

@ -3,14 +3,15 @@ import React from 'react';
import type { Props } from '../types'; import type { Props } from '../types';
import TodoItem from './TodoItem'; 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'); const checked = node.data.get('checked');
if (checked !== undefined) { if (checked !== undefined) {
return ( return (
<TodoItem checked={checked} node={node} {...props}> <TodoItem checked={checked} node={node} {...attributes}>
{children} {children}
</TodoItem> </TodoItem>
); );
} }
return <li>{children}</li>; return <li {...attributes}>{children}</li>;
} }

View File

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

View File

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

View File

@ -1,5 +1,6 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import keydown from 'react-keydown';
import styled from 'styled-components'; import styled from 'styled-components';
import getDataTransferFiles from 'utils/getDataTransferFiles'; import getDataTransferFiles from 'utils/getDataTransferFiles';
import Heading1Icon from 'components/Icon/Heading1Icon'; import Heading1Icon from 'components/Icon/Heading1Icon';
@ -38,7 +39,6 @@ class BlockToolbar extends Component {
const becameInactive = !isActive && wasActive; const becameInactive = !isActive && wasActive;
if (becameInactive) { if (becameInactive) {
console.log('becameInactive');
const state = nextProps.state const state = nextProps.state
.transform() .transform()
.removeNodeByKey(nextProps.node.key) .removeNodeByKey(nextProps.node.key)
@ -47,10 +47,23 @@ class BlockToolbar extends Component {
} }
} }
insertBlock = (options: Options) => { @keydown('esc')
let transform = splitAndInsertBlock(this.props.state, options); removeSelf(ev: SyntheticEvent) {
ev.preventDefault();
ev.stopPropagation();
this.props.state.document.nodes.forEach(node => { 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') { if (node.type === 'block-toolbar') {
transform.removeNodeByKey(node.key); transform.removeNodeByKey(node.key);
} }
@ -61,7 +74,6 @@ class BlockToolbar extends Component {
handleClickBlock = (ev: SyntheticEvent, type: string) => { handleClickBlock = (ev: SyntheticEvent, type: string) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
switch (type) { switch (type) {
case 'heading1': case 'heading1':
@ -114,11 +126,11 @@ class BlockToolbar extends Component {
}; };
render() { render() {
const { state, node } = this.props; const { state, attributes, node } = this.props;
const active = state.isFocused && state.selection.hasEdgeIn(node); const active = state.isFocused && state.selection.hasEdgeIn(node);
return ( return (
<Bar active={active}> <Bar active={active} {...attributes}>
<HiddenInput <HiddenInput
type="file" type="file"
innerRef={ref => (this.file = ref)} innerRef={ref => (this.file = ref)}

View File

@ -45,16 +45,30 @@ const createSchema = ({ onInsertImage, onChange }: Options) => {
), ),
paragraph: (props: Props) => <Paragraph {...props} />, paragraph: (props: Props) => <Paragraph {...props} />,
'block-quote': (props: Props) => ( 'block-quote': (props: Props) => (
<blockquote>{props.children}</blockquote> <blockquote {...props.attributes}>{props.children}</blockquote>
), ),
'horizontal-rule': HorizontalRule, 'horizontal-rule': HorizontalRule,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>, 'bulleted-list': (props: Props) => (
'ordered-list': (props: Props) => <ol>{props.children}</ol>, <ul {...props.attributes}>{props.children}</ul>
'todo-list': (props: Props) => <TodoList>{props.children}</TodoList>, ),
table: (props: Props) => <table>{props.children}</table>, 'ordered-list': (props: Props) => (
'table-row': (props: Props) => <tr>{props.children}</tr>, <ol {...props.attributes}>{props.children}</ol>
'table-head': (props: Props) => <th>{props.children}</th>, ),
'table-cell': (props: Props) => <td>{props.children}</td>, '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, code: Code,
image: Image, image: Image,
link: Link, link: Link,

View File

@ -1,6 +1,6 @@
// @flow // @flow
import EditList from './plugins/EditList'; import EditList from './plugins/EditList';
import type { State } from './types'; import type { State, Transform } from './types';
const { transforms } = EditList; const { transforms } = EditList;
@ -10,9 +10,12 @@ type Options = {
append?: string | Object, append?: string | Object,
}; };
export function splitAndInsertBlock(state: State, options: Options) { export function splitAndInsertBlock(
transform: Transform,
state: State,
options: Options
) {
const { type, wrapper, append } = options; const { type, wrapper, append } = options;
let transform = state.transform();
const { document } = state; const { document } = state;
const parent = document.getParent(state.startBlock.key); const parent = document.getParent(state.startBlock.key);