diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index e556841d7..b6f5403e2 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -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, diff --git a/app/components/Editor/components/BlockInsert.js b/app/components/Editor/components/BlockInsert.js index 8988881a8..475b97577 100644 --- a/app/components/Editor/components/BlockInsert.js +++ b/app/components/Editor/components/BlockInsert.js @@ -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 ( - (this.file = ref)} - onChange={this.onChooseImage} - accept="image/*" - /> - } - 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} - /> + ); } } -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); diff --git a/app/components/Editor/components/Code.js b/app/components/Editor/components/Code.js index 883d57535..e37455be2 100644 --- a/app/components/Editor/components/Code.js +++ b/app/components/Editor/components/Code.js @@ -9,10 +9,10 @@ export default function Code({ children, node, readOnly, attributes }: Props) { const language = node.data.get('language') || 'javascript'; return ( - + {readOnly && }
-        
+        
           {children}
         
       
diff --git a/app/components/Editor/components/Heading.js b/app/components/Editor/components/Heading.js index 29a7ccc37..72e97e0ac 100644 --- a/app/components/Editor/components/Heading.js +++ b/app/components/Editor/components/Heading.js @@ -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 ( - + {children} diff --git a/app/components/Editor/components/HorizontalRule.js b/app/components/Editor/components/HorizontalRule.js index 49cf4ca0f..79cd52c39 100644 --- a/app/components/Editor/components/HorizontalRule.js +++ b/app/components/Editor/components/HorizontalRule.js @@ -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 ; + return ; } const StyledHr = styled.hr` + padding-top: .75em; + margin: 0; + border: 0; border-bottom: 1px solid ${props => (props.active ? color.slate : color.slateLight)}; `; diff --git a/app/components/Editor/components/ListItem.js b/app/components/Editor/components/ListItem.js index 11227c2d4..defea91d3 100644 --- a/app/components/Editor/components/ListItem.js +++ b/app/components/Editor/components/ListItem.js @@ -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 ( - + {children} ); } - return
  • {children}
  • ; + return
  • {children}
  • ; } diff --git a/app/components/Editor/components/Paragraph.js b/app/components/Editor/components/Paragraph.js index 1716373b1..101227609 100644 --- a/app/components/Editor/components/Paragraph.js +++ b/app/components/Editor/components/Paragraph.js @@ -23,7 +23,7 @@ export default function Link({ !node.text; return ( -

    +

    {children} {showPlaceholder && diff --git a/app/components/Editor/components/TodoItem.js b/app/components/Editor/components/TodoItem.js index 7b18c7b88..a26182ce7 100644 --- a/app/components/Editor/components/TodoItem.js +++ b/app/components/Editor/components/TodoItem.js @@ -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 ( - + { + 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 ( + this.handleClickBlock(ev, type)}> + + + ); + }; + + render() { + const { state, attributes, node } = this.props; + const active = state.isFocused && state.selection.hasEdgeIn(node); + + return ( + + (this.file = ref)} + onChange={this.onImagePicked} + accept="image/*" + /> + {this.renderBlockButton('heading1', Heading1Icon)} + {this.renderBlockButton('heading2', Heading2Icon)} + + {this.renderBlockButton('bulleted-list', BulletedListIcon)} + {this.renderBlockButton('ordered-list', OrderedListIcon)} + {this.renderBlockButton('todo-list', TodoListIcon)} + + {this.renderBlockButton('code', CodeIcon)} + {this.renderBlockButton('horizontal-rule', HorizontalRuleIcon)} + {this.renderBlockButton('image', ImageIcon)} + + ); + } +} + +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; diff --git a/app/components/Editor/components/Toolbar/Toolbar.js b/app/components/Editor/components/Toolbar/Toolbar.js index 281d40cbc..d34966d90 100644 --- a/app/components/Editor/components/Toolbar/Toolbar.js +++ b/app/components/Editor/components/Toolbar/Toolbar.js @@ -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; diff --git a/app/components/Editor/schema.js b/app/components/Editor/schema.js index 2010e4c22..d4ed674b6 100644 --- a/app/components/Editor/schema.js +++ b/app/components/Editor/schema.js @@ -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) => {props.children}, @@ -30,18 +36,39 @@ const createSchema = () => { }, nodes: { + 'block-toolbar': (props: Props) => ( + + ), paragraph: (props: Props) => , 'block-quote': (props: Props) => ( -

    {props.children}
    +
    {props.children}
    ), 'horizontal-rule': HorizontalRule, - 'bulleted-list': (props: Props) =>
      {props.children}
    , - 'ordered-list': (props: Props) =>
      {props.children}
    , - 'todo-list': (props: Props) => {props.children}, - table: (props: Props) => {props.children}
    , - 'table-row': (props: Props) => {props.children}, - 'table-head': (props: Props) => {props.children}, - 'table-cell': (props: Props) => {props.children}, + 'bulleted-list': (props: Props) => ( +
      {props.children}
    + ), + 'ordered-list': (props: Props) => ( +
      {props.children}
    + ), + 'todo-list': (props: Props) => ( + {props.children} + ), + table: (props: Props) => ( + {props.children}
    + ), + 'table-row': (props: Props) => ( + {props.children} + ), + 'table-head': (props: Props) => ( + {props.children} + ), + 'table-cell': (props: Props) => ( + {props.children} + ), code: Code, image: Image, link: Link, diff --git a/app/components/Editor/transforms.js b/app/components/Editor/transforms.js new file mode 100644 index 000000000..d404c570f --- /dev/null +++ b/app/components/Editor/transforms.js @@ -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; +} diff --git a/app/components/Editor/types.js b/app/components/Editor/types.js index 4406f20f9..51562825a 100644 --- a/app/components/Editor/types.js +++ b/app/components/Editor/types.js @@ -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, diff --git a/app/components/Icon/CollectionIcon.js b/app/components/Icon/CollectionIcon.js index 411d0920f..948ccec65 100644 --- a/app/components/Icon/CollectionIcon.js +++ b/app/components/Icon/CollectionIcon.js @@ -10,7 +10,7 @@ export default function CollectionIcon({ return ( {expanded - ? + ? : } ); diff --git a/app/components/Icon/Heading1Icon.js b/app/components/Icon/Heading1Icon.js index 41fd8552f..1fddb1075 100644 --- a/app/components/Icon/Heading1Icon.js +++ b/app/components/Icon/Heading1Icon.js @@ -6,7 +6,7 @@ import type { Props } from './Icon'; export default function Heading1Icon(props: Props) { return ( - + ); } diff --git a/app/components/Icon/Heading2Icon.js b/app/components/Icon/Heading2Icon.js index 567fa4181..37a269aa8 100644 --- a/app/components/Icon/Heading2Icon.js +++ b/app/components/Icon/Heading2Icon.js @@ -6,7 +6,7 @@ import type { Props } from './Icon'; export default function Heading2Icon(props: Props) { return ( - + ); } diff --git a/app/components/Icon/KeyboardIcon.js b/app/components/Icon/KeyboardIcon.js new file mode 100644 index 000000000..d11264b05 --- /dev/null +++ b/app/components/Icon/KeyboardIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function KeyboardIcon(props: Props) { + return ( + + + + ); +} diff --git a/app/components/Icon/OrderedListIcon.js b/app/components/Icon/OrderedListIcon.js index 855794bea..488520e52 100644 --- a/app/components/Icon/OrderedListIcon.js +++ b/app/components/Icon/OrderedListIcon.js @@ -6,7 +6,7 @@ import type { Props } from './Icon'; export default function OrderedListIcon(props: Props) { return ( - + ); } diff --git a/app/menus/BlockMenu.js b/app/menus/BlockMenu.js deleted file mode 100644 index 08192ce35..000000000 --- a/app/menus/BlockMenu.js +++ /dev/null @@ -1,52 +0,0 @@ -// @flow -import React, { Component } from 'react'; -import ImageIcon from 'components/Icon/ImageIcon'; -import BulletedListIcon from 'components/Icon/BulletedListIcon'; -import HorizontalRuleIcon from 'components/Icon/HorizontalRuleIcon'; -import TodoListIcon from 'components/Icon/TodoListIcon'; -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 ( - - - Add images - - - Start list - - - Start checklist - - - Add break - - - ); - } -} - -export default BlockMenu;