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
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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%;
|
||||
|
@ -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>
|
||||
|
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 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 />
|
||||
|
@ -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',
|
||||
|
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
|
||||
history={history}
|
||||
collection={collection}
|
||||
onShow={() => (this.menuOpen = true)}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
onImport={this.handleImport}
|
||||
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 {
|
||||
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 &&
|
||||
|
Reference in New Issue
Block a user