Insert block menu (#297)

* Insert block behavior

* Functional with horizontal rule

* Add list and image upload working

* Cleanup typing

* Closest to correct behavior so far

* Improve block insert on list

* Hide (+) after clicking menu item

* Bad merge
This commit is contained in:
Tom Moor
2017-10-17 20:22:20 -07:00
committed by GitHub
parent af031d33ab
commit 3613e01094
10 changed files with 276 additions and 25 deletions

View File

@ -1,5 +1,5 @@
// @flow
import React from 'react';
import React, { Component } from 'react';
import invariant from 'invariant';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
@ -10,14 +10,14 @@ import { color } from 'styles/constants';
import { fadeAndScaleIn } from 'styles/animations';
type Props = {
label: React.Element<any>,
onShow?: () => void,
label: React.Element<*>,
onOpen?: () => void,
onClose?: () => void,
children?: React.Element<any>,
children?: React.Element<*>,
style?: Object,
};
@observer class DropdownMenu extends React.Component {
@observer class DropdownMenu extends Component {
props: Props;
actionRef: Object;
@observable open: boolean = false;
@ -37,7 +37,7 @@ type Props = {
this.open = true;
this.top = targetRect.bottom - bodyRect.top;
this.right = bodyRect.width - targetRect.left - targetRect.width;
if (this.props.onShow) this.props.onShow();
if (this.props.onOpen) this.props.onOpen();
}
};

View File

@ -7,7 +7,7 @@ const DropdownMenuItem = ({
onClick,
children,
}: {
onClick?: () => void,
onClick?: SyntheticEvent => void,
children?: React.Element<any>,
}) => {
return (
@ -24,11 +24,15 @@ const MenuItem = styled.div`
color: ${color.slateDark};
display: flex;
justify-content: space-between;
justify-content: left;
align-items: center;
cursor: pointer;
font-size: 15px;
svg {
margin-right: 8px;
}
a {
text-decoration: none;
width: 100%;

View File

@ -8,6 +8,7 @@ import getDataTransferFiles from 'utils/getDataTransferFiles';
import Flex from 'components/Flex';
import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar';
import BlockInsert from './components/BlockInsert';
import Placeholder from './components/Placeholder';
import Markdown from './serializer';
import createSchema from './schema';
@ -173,6 +174,8 @@ type KeyData = {
};
render = () => {
const { readOnly, emoji, onSave } = this.props;
return (
<Flex
onDrop={this.handleDrop}
@ -183,24 +186,31 @@ type KeyData = {
auto
>
<MaxWidth column auto>
<Header onClick={this.focusAtStart} readOnly={this.props.readOnly} />
<Toolbar state={this.state.state} onChange={this.onChange} />
<Header onClick={this.focusAtStart} readOnly={readOnly} />
{!readOnly &&
<Toolbar state={this.state.state} onChange={this.onChange} />}
{!readOnly &&
<BlockInsert
state={this.state.state}
onChange={this.onChange}
onInsertImage={this.insertImageFile}
/>}
<StyledEditor
innerRef={ref => (this.editor = ref)}
placeholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
schema={this.schema}
plugins={this.plugins}
emoji={this.props.emoji}
emoji={emoji}
state={this.state.state}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onDocumentChange={this.onDocumentChange}
onSave={this.props.onSave}
readOnly={this.props.readOnly}
onSave={onSave}
readOnly={readOnly}
/>
<ClickablePadding
onClick={!this.props.readOnly ? this.focusAtEnd : undefined}
onClick={!readOnly ? this.focusAtEnd : undefined}
grow
/>
</MaxWidth>

View File

@ -0,0 +1,186 @@
// @flow
import React, { Component } from 'react';
import EditList from '../plugins/EditList';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Portal from 'react-portal';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { color } from 'styles/constants';
import Icon from 'components/Icon';
import BlockMenu from 'menus/BlockMenu';
import type { State } from '../types';
const { transforms } = EditList;
type Props = {
state: State,
onChange: Function,
onInsertImage: File => Promise<*>,
};
@observer
export default class BlockInsert extends Component {
props: Props;
mouseMoveTimeout: number;
file: HTMLInputElement;
@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;
if (active !== this.active) {
this.active = active || this.menuOpen;
}
if (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);
};
onClickBlock = (
ev: SyntheticEvent,
type: string | Object,
wrapBlock?: string
) => {
ev.preventDefault();
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 (wrapBlock) transform = transform.wrapBlock(wrapBlock);
state = transform.focus().apply();
this.props.onChange(state);
this.active = false;
};
onPickImage = (ev: SyntheticEvent) => {
// simulate a click on the file upload input element
this.file.click();
};
onChooseImage = async (ev: SyntheticEvent) => {
const files = getDataTransferFiles(ev);
for (const file of files) {
await this.props.onInsertImage(file);
}
};
render() {
const style = { top: `${this.top}px`, left: `${this.left}px` };
const todo = { type: 'list-item', data: { checked: false } };
return (
<Portal isOpened>
<Trigger active={this.active} style={style}>
<HiddenInput
type="file"
innerRef={ref => (this.file = ref)}
onChange={this.onChooseImage}
accept="image/*"
/>
<BlockMenu
label={<Icon type="PlusCircle" />}
onPickImage={this.onPickImage}
onInsertList={ev =>
this.onClickBlock(ev, 'list-item', 'bulleted-list')}
onInsertTodoList={ev => this.onClickBlock(ev, todo, 'todo-list')}
onInsertBreak={ev => this.onClickBlock(ev, 'horizontal-rule')}
onOpen={this.handleMenuOpen}
onClose={this.handleMenuClose}
/>
</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};
border-radius: 4px;
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
line-height: 0;
height: 16px;
width: 16px;
transform: scale(.9);
${({ active }) => active && `
transform: scale(1);
opacity: .9;
`}
`;

View File

@ -9,7 +9,6 @@ import Heading2Icon from 'components/Icon/Heading2Icon';
import ItalicIcon from 'components/Icon/ItalicIcon';
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: {
@ -95,7 +94,6 @@ export default class FormattingToolbar extends Component {
{this.renderMarkButton('deleted', StrikethroughIcon)}
{this.renderBlockButton('heading1', Heading1Icon)}
{this.renderBlockButton('heading2', Heading2Icon)}
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
{this.renderMarkButton('code', CodeIcon)}
<ToolbarButton onMouseDown={this.onCreateLink}>
<LinkIcon light />

View File

@ -1,11 +1,11 @@
// @flow
import DropOrPasteImages from '@tommoor/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 EditList from './plugins/EditList';
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
import insertImage from './insertImage';
@ -35,10 +35,7 @@ const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => {
);
},
}),
EditList({
types: ['ordered-list', 'bulleted-list', 'todo-list'],
typeItem: 'list-item',
}),
EditList,
EditCode({
onlyIn: onlyInCode,
containerType: 'code',

View File

@ -0,0 +1,7 @@
// @flow
import EditList from 'slate-edit-list';
export default EditList({
types: ['ordered-list', 'bulleted-list', 'todo-list'],
typeItem: 'list-item',
});

View File

@ -95,7 +95,7 @@ type Props = {
<CollectionMenu
history={history}
collection={collection}
onShow={() => (this.menuOpen = true)}
onOpen={() => (this.menuOpen = true)}
onClose={() => (this.menuOpen = false)}
onImport={this.handleImport}
open={this.menuOpen}

View File

@ -0,0 +1,49 @@
// @flow
import React, { Component } from 'react';
import Icon from 'components/Icon';
import { observer } from 'mobx-react';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
@observer class BlockMenu extends Component {
props: {
label?: React$Element<*>,
onPickImage: SyntheticEvent => void,
onInsertList: SyntheticEvent => void,
onInsertTodoList: SyntheticEvent => void,
onInsertBreak: SyntheticEvent => void,
};
render() {
const {
label,
onPickImage,
onInsertList,
onInsertTodoList,
onInsertBreak,
...rest
} = this.props;
return (
<DropdownMenu
style={{ marginRight: -70, marginTop: 5 }}
label={label}
{...rest}
>
<DropdownMenuItem onClick={onPickImage}>
<Icon type="Image" /> Add images
</DropdownMenuItem>
<DropdownMenuItem onClick={onInsertList}>
<Icon type="List" /> Start list
</DropdownMenuItem>
<DropdownMenuItem onClick={onInsertTodoList}>
<Icon type="CheckSquare" /> Start checklist
</DropdownMenuItem>
<DropdownMenuItem onClick={onInsertBreak}>
<Icon type="Minus" /> Add break
</DropdownMenuItem>
</DropdownMenu>
);
}
}
export default BlockMenu;

View File

@ -12,7 +12,7 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
@observer class CollectionMenu extends Component {
props: {
label?: React$Element<any>,
onShow?: () => void,
onOpen?: () => void,
onClose?: () => void,
onImport?: () => void,
history: Object,
@ -36,13 +36,13 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
};
render() {
const { collection, label, onShow, onClose, onImport } = this.props;
const { collection, label, onOpen, onClose, onImport } = this.props;
const { allowDelete } = collection;
return (
<DropdownMenu
label={label || <MoreIcon type="MoreHorizontal" />}
onShow={onShow}
onOpen={onOpen}
onClose={onClose}
>
{collection &&