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:
@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import React, { Component } from 'react';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
@ -10,14 +10,14 @@ import { color } from 'styles/constants';
|
|||||||
import { fadeAndScaleIn } from 'styles/animations';
|
import { fadeAndScaleIn } from 'styles/animations';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: React.Element<any>,
|
label: React.Element<*>,
|
||||||
onShow?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
children?: React.Element<any>,
|
children?: React.Element<*>,
|
||||||
style?: Object,
|
style?: Object,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer class DropdownMenu extends React.Component {
|
@observer class DropdownMenu extends Component {
|
||||||
props: Props;
|
props: Props;
|
||||||
actionRef: Object;
|
actionRef: Object;
|
||||||
@observable open: boolean = false;
|
@observable open: boolean = false;
|
||||||
@ -37,7 +37,7 @@ type Props = {
|
|||||||
this.open = true;
|
this.open = true;
|
||||||
this.top = targetRect.bottom - bodyRect.top;
|
this.top = targetRect.bottom - bodyRect.top;
|
||||||
this.right = bodyRect.width - targetRect.left - targetRect.width;
|
this.right = bodyRect.width - targetRect.left - targetRect.width;
|
||||||
if (this.props.onShow) this.props.onShow();
|
if (this.props.onOpen) this.props.onOpen();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ const DropdownMenuItem = ({
|
|||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void,
|
onClick?: SyntheticEvent => void,
|
||||||
children?: React.Element<any>,
|
children?: React.Element<any>,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@ -24,11 +24,15 @@ const MenuItem = styled.div`
|
|||||||
|
|
||||||
color: ${color.slateDark};
|
color: ${color.slateDark};
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: left;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -8,6 +8,7 @@ import getDataTransferFiles from 'utils/getDataTransferFiles';
|
|||||||
import Flex from 'components/Flex';
|
import Flex from 'components/Flex';
|
||||||
import ClickablePadding from './components/ClickablePadding';
|
import ClickablePadding from './components/ClickablePadding';
|
||||||
import Toolbar from './components/Toolbar';
|
import Toolbar from './components/Toolbar';
|
||||||
|
import BlockInsert from './components/BlockInsert';
|
||||||
import Placeholder from './components/Placeholder';
|
import Placeholder from './components/Placeholder';
|
||||||
import Markdown from './serializer';
|
import Markdown from './serializer';
|
||||||
import createSchema from './schema';
|
import createSchema from './schema';
|
||||||
@ -173,6 +174,8 @@ type KeyData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render = () => {
|
render = () => {
|
||||||
|
const { readOnly, emoji, onSave } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
onDrop={this.handleDrop}
|
onDrop={this.handleDrop}
|
||||||
@ -183,24 +186,31 @@ type KeyData = {
|
|||||||
auto
|
auto
|
||||||
>
|
>
|
||||||
<MaxWidth column auto>
|
<MaxWidth column auto>
|
||||||
<Header onClick={this.focusAtStart} readOnly={this.props.readOnly} />
|
<Header onClick={this.focusAtStart} readOnly={readOnly} />
|
||||||
<Toolbar state={this.state.state} onChange={this.onChange} />
|
{!readOnly &&
|
||||||
|
<Toolbar state={this.state.state} onChange={this.onChange} />}
|
||||||
|
{!readOnly &&
|
||||||
|
<BlockInsert
|
||||||
|
state={this.state.state}
|
||||||
|
onChange={this.onChange}
|
||||||
|
onInsertImage={this.insertImageFile}
|
||||||
|
/>}
|
||||||
<StyledEditor
|
<StyledEditor
|
||||||
innerRef={ref => (this.editor = ref)}
|
innerRef={ref => (this.editor = ref)}
|
||||||
placeholder="Start with a title…"
|
placeholder="Start with a title…"
|
||||||
bodyPlaceholder="…the rest is your canvas"
|
bodyPlaceholder="…the rest is your canvas"
|
||||||
schema={this.schema}
|
schema={this.schema}
|
||||||
plugins={this.plugins}
|
plugins={this.plugins}
|
||||||
emoji={this.props.emoji}
|
emoji={emoji}
|
||||||
state={this.state.state}
|
state={this.state.state}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onDocumentChange={this.onDocumentChange}
|
onDocumentChange={this.onDocumentChange}
|
||||||
onSave={this.props.onSave}
|
onSave={onSave}
|
||||||
readOnly={this.props.readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
<ClickablePadding
|
<ClickablePadding
|
||||||
onClick={!this.props.readOnly ? this.focusAtEnd : undefined}
|
onClick={!readOnly ? this.focusAtEnd : undefined}
|
||||||
grow
|
grow
|
||||||
/>
|
/>
|
||||||
</MaxWidth>
|
</MaxWidth>
|
||||||
|
186
frontend/components/Editor/components/BlockInsert.js
Normal file
186
frontend/components/Editor/components/BlockInsert.js
Normal 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;
|
||||||
|
`}
|
||||||
|
`;
|
@ -9,7 +9,6 @@ import Heading2Icon from 'components/Icon/Heading2Icon';
|
|||||||
import ItalicIcon from 'components/Icon/ItalicIcon';
|
import ItalicIcon from 'components/Icon/ItalicIcon';
|
||||||
import LinkIcon from 'components/Icon/LinkIcon';
|
import LinkIcon from 'components/Icon/LinkIcon';
|
||||||
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
|
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
|
||||||
import BulletedListIcon from 'components/Icon/BulletedListIcon';
|
|
||||||
|
|
||||||
export default class FormattingToolbar extends Component {
|
export default class FormattingToolbar extends Component {
|
||||||
props: {
|
props: {
|
||||||
@ -95,7 +94,6 @@ export default class FormattingToolbar extends Component {
|
|||||||
{this.renderMarkButton('deleted', StrikethroughIcon)}
|
{this.renderMarkButton('deleted', StrikethroughIcon)}
|
||||||
{this.renderBlockButton('heading1', Heading1Icon)}
|
{this.renderBlockButton('heading1', Heading1Icon)}
|
||||||
{this.renderBlockButton('heading2', Heading2Icon)}
|
{this.renderBlockButton('heading2', Heading2Icon)}
|
||||||
{this.renderBlockButton('bulleted-list', BulletedListIcon)}
|
|
||||||
{this.renderMarkButton('code', CodeIcon)}
|
{this.renderMarkButton('code', CodeIcon)}
|
||||||
<ToolbarButton onMouseDown={this.onCreateLink}>
|
<ToolbarButton onMouseDown={this.onCreateLink}>
|
||||||
<LinkIcon light />
|
<LinkIcon light />
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import DropOrPasteImages from '@tommoor/slate-drop-or-paste-images';
|
import DropOrPasteImages from '@tommoor/slate-drop-or-paste-images';
|
||||||
import PasteLinkify from 'slate-paste-linkify';
|
import PasteLinkify from 'slate-paste-linkify';
|
||||||
import EditList from 'slate-edit-list';
|
|
||||||
import CollapseOnEscape from 'slate-collapse-on-escape';
|
import CollapseOnEscape from 'slate-collapse-on-escape';
|
||||||
import TrailingBlock from 'slate-trailing-block';
|
import TrailingBlock from 'slate-trailing-block';
|
||||||
import EditCode from 'slate-edit-code';
|
import EditCode from 'slate-edit-code';
|
||||||
import Prism from 'slate-prism';
|
import Prism from 'slate-prism';
|
||||||
|
import EditList from './plugins/EditList';
|
||||||
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
|
import KeyboardShortcuts from './plugins/KeyboardShortcuts';
|
||||||
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
||||||
import insertImage from './insertImage';
|
import insertImage from './insertImage';
|
||||||
@ -35,10 +35,7 @@ const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
EditList({
|
EditList,
|
||||||
types: ['ordered-list', 'bulleted-list', 'todo-list'],
|
|
||||||
typeItem: 'list-item',
|
|
||||||
}),
|
|
||||||
EditCode({
|
EditCode({
|
||||||
onlyIn: onlyInCode,
|
onlyIn: onlyInCode,
|
||||||
containerType: 'code',
|
containerType: 'code',
|
||||||
|
7
frontend/components/Editor/plugins/EditList.js
Normal file
7
frontend/components/Editor/plugins/EditList.js
Normal 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',
|
||||||
|
});
|
@ -95,7 +95,7 @@ type Props = {
|
|||||||
<CollectionMenu
|
<CollectionMenu
|
||||||
history={history}
|
history={history}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
onShow={() => (this.menuOpen = true)}
|
onOpen={() => (this.menuOpen = true)}
|
||||||
onClose={() => (this.menuOpen = false)}
|
onClose={() => (this.menuOpen = false)}
|
||||||
onImport={this.handleImport}
|
onImport={this.handleImport}
|
||||||
open={this.menuOpen}
|
open={this.menuOpen}
|
||||||
|
49
frontend/menus/BlockMenu.js
Normal file
49
frontend/menus/BlockMenu.js
Normal 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;
|
@ -12,7 +12,7 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
|||||||
@observer class CollectionMenu extends Component {
|
@observer class CollectionMenu extends Component {
|
||||||
props: {
|
props: {
|
||||||
label?: React$Element<any>,
|
label?: React$Element<any>,
|
||||||
onShow?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
onImport?: () => void,
|
onImport?: () => void,
|
||||||
history: Object,
|
history: Object,
|
||||||
@ -36,13 +36,13 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { collection, label, onShow, onClose, onImport } = this.props;
|
const { collection, label, onOpen, onClose, onImport } = this.props;
|
||||||
const { allowDelete } = collection;
|
const { allowDelete } = collection;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
label={label || <MoreIcon type="MoreHorizontal" />}
|
label={label || <MoreIcon type="MoreHorizontal" />}
|
||||||
onShow={onShow}
|
onOpen={onOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{collection &&
|
{collection &&
|
||||||
|
Reference in New Issue
Block a user