diff --git a/.flowconfig b/.flowconfig index 7c57deb48..64330e697 100644 --- a/.flowconfig +++ b/.flowconfig @@ -8,6 +8,9 @@ .*/node_modules/polished/.* .*/node_modules/react-side-effect/.* .*/node_modules/fbjs/.* +.*/node_modules/slate-edit-code/.* +.*/node_modules/slate-edit-list/.* +.*/node_modules/slate-prism/.* .*/node_modules/config-chain/.* *.test.js diff --git a/Makefile b/Makefile index 6a8d4b860..89f8f1ccd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ up: docker-compose up -d redis postgres s3 - docker-compose run --rm outline yarn sequelize db:migrate + docker-compose run --rm outline bash -c "yarn && yarn sequelize db:migrate" docker-compose up outline build: diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index 92b5e6ef5..5f6de6d92 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -2,9 +2,10 @@ import React, { Component } from 'react'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; -import { Editor, Plain } from 'slate'; +import { Value, Change } from 'slate'; +import { Editor } from 'slate-react'; +import type { SlateNodeProps, Plugin } from './types'; import keydown from 'react-keydown'; -import type { State, Editor as EditorType } from './types'; import getDataTransferFiles from 'utils/getDataTransferFiles'; import Flex from 'shared/components/Flex'; import ClickablePadding from './components/ClickablePadding'; @@ -13,61 +14,52 @@ import BlockInsert from './components/BlockInsert'; import Placeholder from './components/Placeholder'; import Contents from './components/Contents'; import Markdown from './serializer'; -import createSchema from './schema'; import createPlugins from './plugins'; -import insertImage from './insertImage'; +import { insertImageFile } from './changes'; +import renderMark from './marks'; +import createRenderNode from './nodes'; +import schema from './schema'; import styled from 'styled-components'; type Props = { text: string, - onChange: Function, - onSave: Function, - onCancel: Function, - onImageUploadStart: Function, - onImageUploadStop: Function, + onChange: Change => *, + onSave: (redirect?: boolean) => *, + onCancel: () => void, + onImageUploadStart: () => void, + onImageUploadStop: () => void, emoji?: string, readOnly: boolean, }; -type KeyData = { - isMeta: boolean, - key: string, -}; - @observer class MarkdownEditor extends Component { props: Props; - editor: EditorType; - schema: Object; - plugins: Array; - @observable editorState: State; + editor: Editor; + renderNode: SlateNodeProps => *; + plugins: Plugin[]; + @observable editorValue: Value; constructor(props: Props) { super(props); - this.schema = createSchema({ + this.renderNode = createRenderNode({ onInsertImage: this.insertImageFile, - onChange: this.onChange, }); this.plugins = createPlugins({ onImageUploadStart: props.onImageUploadStart, onImageUploadStop: props.onImageUploadStop, }); - if (props.text.trim().length) { - this.editorState = Markdown.deserialize(props.text); - } else { - this.editorState = Plain.deserialize(''); - } + this.editorValue = Markdown.deserialize(props.text); } componentDidMount() { - if (!this.props.readOnly) { - if (this.props.text) { - this.focusAtEnd(); - } else { - this.focusAtStart(); - } + if (this.props.readOnly) return; + if (this.props.text) { + this.focusAtEnd(); + } else { + this.focusAtStart(); } } @@ -77,12 +69,11 @@ class MarkdownEditor extends Component { } } - onChange = (editorState: State) => { - if (this.editorState !== editorState) { - this.props.onChange(Markdown.serialize(editorState)); + onChange = (change: Change) => { + if (this.editorValue !== change.value) { + this.props.onChange(Markdown.serialize(change.value)); + this.editorValue = change.value; } - - this.editorState = editorState; }; handleDrop = async (ev: SyntheticEvent) => { @@ -102,18 +93,16 @@ class MarkdownEditor extends Component { } }; - insertImageFile = async (file: window.File) => { - const state = this.editor.getState(); - let transform = state.transform(); - - transform = await insertImage( - transform, - file, - this.editor, - this.props.onImageUploadStart, - this.props.onImageUploadStop + insertImageFile = (file: window.File) => { + this.editor.change(change => + change.call( + insertImageFile, + file, + this.editor, + this.props.onImageUploadStart, + this.props.onImageUploadStop + ) ); - this.editor.onChange(transform.apply()); }; cancelEvent = (ev: SyntheticEvent) => { @@ -136,7 +125,7 @@ class MarkdownEditor extends Component { ev.preventDefault(); ev.stopPropagation(); - this.props.onSave({ redirect: false }); + this.props.onSave(true); } @keydown('esc') @@ -146,37 +135,33 @@ class MarkdownEditor extends Component { } // Handling of keyboard shortcuts within editor focus - onKeyDown = (ev: SyntheticKeyboardEvent, data: KeyData, state: State) => { - if (!data.isMeta) return; + onKeyDown = (ev: SyntheticKeyboardEvent, change: Change) => { + if (!ev.metaKey) return; - switch (data.key) { + switch (ev.key) { case 's': this.onSave(ev); - return state; - case 'enter': + return change; + case 'Enter': this.onSaveAndExit(ev); - return state; - case 'escape': + return change; + case 'Escape': this.onCancel(); - return state; + return change; default: } }; focusAtStart = () => { - const state = this.editor.getState(); - const transform = state.transform(); - transform.collapseToStartOf(state.document); - transform.focus(); - this.editorState = transform.apply(); + this.editor.change(change => + change.collapseToStartOf(change.value.document).focus() + ); }; focusAtEnd = () => { - const state = this.editor.getState(); - const transform = state.transform(); - transform.collapseToEndOf(state.document); - transform.focus(); - this.editorState = transform.apply(); + this.editor.change(change => + change.collapseToEndOf(change.value.document).focus() + ); }; render = () => { @@ -193,29 +178,33 @@ class MarkdownEditor extends Component { >
- {readOnly && } - {!readOnly && ( - - )} - {!readOnly && ( - - )} + {readOnly && this.editor && } + {!readOnly && + this.editor && ( + + )} + {!readOnly && + this.editor && ( + + )} (this.editor = ref)} placeholder="Start with a title…" bodyPlaceholder="…the rest is your canvas" - schema={this.schema} plugins={this.plugins} emoji={emoji} - state={this.editorState} + value={this.editorValue} + renderNode={this.renderNode} + renderMark={renderMark} + schema={schema} onKeyDown={this.onKeyDown} onChange={this.onChange} onSave={onSave} readOnly={readOnly} + spellCheck={!readOnly} /> void, + onImageUploadStop: () => void +) { + onImageUploadStart(); + try { + // load the file as a data URL + const id = uuid.v4(); + const alt = ''; + const reader = new FileReader(); + reader.addEventListener('load', () => { + const src = reader.result; + const node = { + type: 'image', + isVoid: true, + data: { src, id, alt, loading: true }, + }; + + // insert / replace into document as uploading placeholder replacing + // empty paragraphs if available. + if ( + !change.value.startBlock.text && + change.value.startBlock.type === 'paragraph' + ) { + change.setBlock(node); + } else { + change.insertBlock(node); + } + + editor.onChange(change); + }); + reader.readAsDataURL(file); + + // now we have a placeholder, start the upload + const asset = await uploadFile(file); + const src = asset.url; + + // we dont use the original change provided to the callback here + // as the state may have changed significantly in the time it took to + // upload the file. + const placeholder = editor.value.document.findDescendant( + node => node.data && node.data.get('id') === id + ); + + change.setNodeByKey(placeholder.key, { + data: { src, alt, loading: false }, + }); + editor.onChange(change); + } catch (err) { + throw err; + } finally { + onImageUploadStop(); + } +} diff --git a/app/components/Editor/components/BlockInsert.js b/app/components/Editor/components/BlockInsert.js index fe7ac38e8..0f6615bf0 100644 --- a/app/components/Editor/components/BlockInsert.js +++ b/app/components/Editor/components/BlockInsert.js @@ -1,29 +1,29 @@ // @flow import React, { Component } from 'react'; import { Portal } from 'react-portal'; -import { findDOMNode, Node } from 'slate'; +import { Node } from 'slate'; +import { Editor, findDOMNode } from 'slate-react'; 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 type { State } from '../types'; type Props = { - state: State, - onChange: Function, - onInsertImage: File => Promise<*>, + editor: Editor, }; -function findClosestRootNode(state, ev) { +function findClosestRootNode(value, ev) { let previous; - for (const node of state.document.nodes) { + for (const node of value.document.nodes) { const element = findDOMNode(node); const bounds = element.getBoundingClientRect(); if (bounds.top > ev.clientY) return previous; previous = { node, element, bounds }; } + + return previous; } @observer @@ -53,7 +53,7 @@ export default class BlockInsert extends Component { handleMouseMove = (ev: SyntheticMouseEvent) => { const windowWidth = window.innerWidth / 2.5; - const result = findClosestRootNode(this.props.state, ev); + const result = findClosestRootNode(this.props.editor.value, ev); const movementThreshold = 200; this.mouseMovementSinceClick += @@ -70,7 +70,7 @@ export default class BlockInsert extends Component { this.closestRootNode = result.node; // do not show block menu on title heading or editor - const firstNode = this.props.state.document.nodes.first(); + const firstNode = this.props.editor.value.document.nodes.first(); if (result.node === firstNode || result.node.type === 'block-toolbar') { this.left = -1000; } else { @@ -86,26 +86,38 @@ export default class BlockInsert extends Component { }; handleClick = (ev: SyntheticMouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + this.mouseMovementSinceClick = 0; this.active = false; - const { state } = this.props; - const type = { type: 'block-toolbar', isVoid: true }; - let transform = state.transform(); + const { editor } = this.props; - // 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); + editor.change(change => { + // remove any existing toolbars in the document as a fail safe + editor.value.document.nodes.forEach(node => { + if (node.type === 'block-toolbar') { + change.removeNodeByKey(node.key); + } + }); + + change.collapseToStartOf(this.closestRootNode); + + // if we're on an empty paragraph then just replace it with the block + // toolbar. Otherwise insert the toolbar as an extra Node. + if ( + !this.closestRootNode.text && + this.closestRootNode.type === 'paragraph' + ) { + change.setNodeByKey(this.closestRootNode.key, { + type: 'block-toolbar', + isVoid: true, + }); + } else { + change.insertBlock({ type: 'block-toolbar', isVoid: true }); } }); - - transform - .collapseToStartOf(this.closestRootNode) - .collapseToEndOfPreviousBlock() - .insertBlock(type); - - this.props.onChange(transform.apply()); }; render() { @@ -136,7 +148,11 @@ const Trigger = styled.div` cursor: pointer; &:hover { - background-color: ${color.smokeDark}; + background-color: ${color.slate}; + + svg { + fill: ${color.white}; + } } ${({ active }) => diff --git a/app/components/Editor/components/ClickablePadding/ClickablePadding.js b/app/components/Editor/components/ClickablePadding.js similarity index 100% rename from app/components/Editor/components/ClickablePadding/ClickablePadding.js rename to app/components/Editor/components/ClickablePadding.js diff --git a/app/components/Editor/components/ClickablePadding/index.js b/app/components/Editor/components/ClickablePadding/index.js deleted file mode 100644 index 76c2ddada..000000000 --- a/app/components/Editor/components/ClickablePadding/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import ClickablePadding from './ClickablePadding'; -export default ClickablePadding; diff --git a/app/components/Editor/components/Code.js b/app/components/Editor/components/Code.js index 99cef6ba6..f2396b545 100644 --- a/app/components/Editor/components/Code.js +++ b/app/components/Editor/components/Code.js @@ -1,11 +1,16 @@ // @flow import React from 'react'; import styled from 'styled-components'; +import type { SlateNodeProps } from '../types'; import CopyButton from './CopyButton'; import { color } from 'shared/styles/constants'; -import type { Props } from '../types'; -export default function Code({ children, node, readOnly, attributes }: Props) { +export default function Code({ + children, + node, + readOnly, + attributes, +}: SlateNodeProps) { const language = node.data.get('language') || 'javascript'; return ( diff --git a/app/components/Editor/components/Contents.js b/app/components/Editor/components/Contents.js index d89facb08..b7bcfb9cd 100644 --- a/app/components/Editor/components/Contents.js +++ b/app/components/Editor/components/Contents.js @@ -2,14 +2,15 @@ import React, { Component } from 'react'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; +import { Editor } from 'slate-react'; +import { Block } from 'slate'; import { List } from 'immutable'; import { color } from 'shared/styles/constants'; import headingToSlug from '../headingToSlug'; -import type { State, Block } from '../types'; import styled from 'styled-components'; type Props = { - state: State, + editor: Editor, }; @observer @@ -54,9 +55,9 @@ class Contents extends Component { } get headings(): List { - const { state } = this.props; + const { editor } = this.props; - return state.document.nodes.filter((node: Block) => { + return editor.value.document.nodes.filter((node: Block) => { if (!node.text) return false; return node.type.match(/^heading/); }); @@ -74,7 +75,7 @@ class Contents extends Component { const active = this.activeHeading === slug; return ( - + {heading.text} diff --git a/app/components/Editor/components/Heading.js b/app/components/Editor/components/Heading.js index 9c29c6dbd..03b03d168 100644 --- a/app/components/Editor/components/Heading.js +++ b/app/components/Editor/components/Heading.js @@ -1,21 +1,15 @@ // @flow import React from 'react'; import { Document } from 'slate'; +import type { SlateNodeProps } from '../types'; import styled from 'styled-components'; import headingToSlug from '../headingToSlug'; -import type { Node, Editor } from '../types'; import Placeholder from './Placeholder'; -type Props = { - children: React$Element<*>, - placeholder?: boolean, - parent: Node, - node: Node, - editor: Editor, - readOnly: boolean, - component?: string, - attributes: Object, - className?: string, +type Props = SlateNodeProps & { + component: string, + className: string, + placeholder: string, }; function Heading(props: Props) { @@ -60,7 +54,7 @@ function Heading(props: Props) { const Wrapper = styled.div` display: inline; - margin-left: ${(props: Props) => (props.hasEmoji ? '-1.2em' : 0)}; + margin-left: ${(props: SlateNodeProps) => (props.hasEmoji ? '-1.2em' : 0)}; `; const Anchor = styled.a` @@ -83,21 +77,21 @@ export const StyledHeading = styled(Heading)` } } `; -export const Heading1 = (props: Props) => ( +export const Heading1 = (props: SlateNodeProps) => ( ); -export const Heading2 = (props: Props) => ( +export const Heading2 = (props: SlateNodeProps) => ( ); -export const Heading3 = (props: Props) => ( +export const Heading3 = (props: SlateNodeProps) => ( ); -export const Heading4 = (props: Props) => ( +export const Heading4 = (props: SlateNodeProps) => ( ); -export const Heading5 = (props: Props) => ( +export const Heading5 = (props: SlateNodeProps) => ( ); -export const Heading6 = (props: Props) => ( +export const Heading6 = (props: SlateNodeProps) => ( ); diff --git a/app/components/Editor/components/HorizontalRule.js b/app/components/Editor/components/HorizontalRule.js index 9eed79e06..4afd19448 100644 --- a/app/components/Editor/components/HorizontalRule.js +++ b/app/components/Editor/components/HorizontalRule.js @@ -1,12 +1,13 @@ // @flow import React from 'react'; import styled from 'styled-components'; -import type { Props } from '../types'; +import type { SlateNodeProps } from '../types'; import { color } from 'shared/styles/constants'; -function HorizontalRule(props: Props) { - const { state, node, attributes } = props; - const active = state.isFocused && state.selection.hasEdgeIn(node); +function HorizontalRule(props: SlateNodeProps) { + const { editor, node, attributes } = props; + const active = + editor.value.isFocused && editor.value.selection.hasEdgeIn(node); return ; } diff --git a/app/components/Editor/components/Image.js b/app/components/Editor/components/Image.js index 3200a104b..50784ab4f 100644 --- a/app/components/Editor/components/Image.js +++ b/app/components/Editor/components/Image.js @@ -2,23 +2,20 @@ import React, { Component } from 'react'; import ImageZoom from 'react-medium-image-zoom'; import styled from 'styled-components'; -import type { Props } from '../types'; +import type { SlateNodeProps } from '../types'; import { color } from 'shared/styles/constants'; class Image extends Component { - props: Props; + props: SlateNodeProps; handleChange = (ev: SyntheticInputEvent) => { const alt = ev.target.value; const { editor, node } = this.props; const data = node.data.toObject(); - const state = editor - .getState() - .transform() - .setNodeByKey(node.key, { data: { ...data, alt } }) - .apply(); - editor.onChange(state); + editor.change(change => + change.setNodeByKey(node.key, { data: { ...data, alt } }) + ); }; handleClick = (ev: SyntheticInputEvent) => { @@ -26,11 +23,12 @@ class Image extends Component { }; render() { - const { attributes, state, node, readOnly } = this.props; + const { attributes, editor, node, readOnly } = this.props; const loading = node.data.get('loading'); const caption = node.data.get('alt'); const src = node.data.get('src'); - const active = state.isFocused && state.selection.hasEdgeIn(node); + const active = + editor.value.isFocused && editor.value.selection.hasEdgeIn(node); const showCaption = !readOnly || caption; return ( diff --git a/app/components/Editor/components/Link.js b/app/components/Editor/components/Link.js index 0642415e6..57a99feb6 100644 --- a/app/components/Editor/components/Link.js +++ b/app/components/Editor/components/Link.js @@ -1,7 +1,7 @@ // @flow import React from 'react'; import { Link as InternalLink } from 'react-router-dom'; -import type { Props } from '../types'; +import type { SlateNodeProps } from '../types'; function getPathFromUrl(href: string) { if (href[0] === '/') return href; @@ -14,7 +14,7 @@ function getPathFromUrl(href: string) { } } -function isOutlineUrl(href: string) { +function isInternalUrl(href: string) { if (href[0] === '/') return true; try { @@ -26,11 +26,16 @@ function isOutlineUrl(href: string) { } } -export default function Link({ attributes, node, children, readOnly }: Props) { +export default function Link({ + attributes, + node, + children, + readOnly, +}: SlateNodeProps) { const href = node.data.get('href'); const path = getPathFromUrl(href); - if (isOutlineUrl(href) && readOnly) { + if (isInternalUrl(href) && readOnly) { return ( {children} @@ -38,7 +43,7 @@ export default function Link({ attributes, node, children, readOnly }: Props) { ); } else { return ( - + {children} ); diff --git a/app/components/Editor/components/ListItem.js b/app/components/Editor/components/ListItem.js index 0c151765d..227a3ad12 100644 --- a/app/components/Editor/components/ListItem.js +++ b/app/components/Editor/components/ListItem.js @@ -1,6 +1,6 @@ // @flow import React from 'react'; -import type { Props } from '../types'; +import type { SlateNodeProps } from '../types'; import TodoItem from './TodoItem'; export default function ListItem({ @@ -8,17 +8,12 @@ export default function ListItem({ node, attributes, ...props -}: Props) { +}: SlateNodeProps) { const checked = node.data.get('checked'); if (checked !== undefined) { return ( - + {children} ); diff --git a/app/components/Editor/components/Paragraph.js b/app/components/Editor/components/Paragraph.js index 5b4b9a484..952ae174e 100644 --- a/app/components/Editor/components/Paragraph.js +++ b/app/components/Editor/components/Paragraph.js @@ -1,7 +1,7 @@ // @flow import React from 'react'; import { Document } from 'slate'; -import type { Props } from '../types'; +import type { SlateNodeProps } from '../types'; import Placeholder from './Placeholder'; export default function Link({ @@ -11,7 +11,7 @@ export default function Link({ parent, children, readOnly, -}: Props) { +}: SlateNodeProps) { const parentIsDocument = parent instanceof Document; const firstParagraph = parent && parent.nodes.get(1) === node; const lastParagraph = parent && parent.nodes.last() === node; diff --git a/app/components/Editor/components/TodoItem.js b/app/components/Editor/components/TodoItem.js index 91e62cb31..f2e5b77ef 100644 --- a/app/components/Editor/components/TodoItem.js +++ b/app/components/Editor/components/TodoItem.js @@ -2,25 +2,22 @@ import React, { Component } from 'react'; import styled from 'styled-components'; import { color } from 'shared/styles/constants'; -import type { Props } from '../types'; +import type { SlateNodeProps } from '../types'; export default class TodoItem extends Component { - props: Props & { checked: boolean }; + props: SlateNodeProps; handleChange = (ev: SyntheticInputEvent) => { const checked = ev.target.checked; const { editor, node } = this.props; - const state = editor - .getState() - .transform() - .setNodeByKey(node.key, { data: { checked } }) - .apply(); - - editor.onChange(state); + editor.change(change => + change.setNodeByKey(node.key, { data: { checked } }) + ); }; render() { - const { children, checked, attributes, readOnly } = this.props; + const { children, node, attributes, readOnly } = this.props; + const checked = node.data.get('checked'); return ( diff --git a/app/components/Editor/components/Toolbar/BlockToolbar.js b/app/components/Editor/components/Toolbar/BlockToolbar.js index bdeb70285..8e5bd48d9 100644 --- a/app/components/Editor/components/Toolbar/BlockToolbar.js +++ b/app/components/Editor/components/Toolbar/BlockToolbar.js @@ -1,5 +1,6 @@ // @flow import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; import keydown from 'react-keydown'; import styled from 'styled-components'; import getDataTransferFiles from 'utils/getDataTransferFiles'; @@ -13,63 +14,73 @@ import HorizontalRuleIcon from 'components/Icon/HorizontalRuleIcon'; import TodoListIcon from 'components/Icon/TodoListIcon'; import Flex from 'shared/components/Flex'; import ToolbarButton from './components/ToolbarButton'; -import type { Props as BaseProps } from '../../types'; +import type { SlateNodeProps } from '../../types'; import { color } from 'shared/styles/constants'; import { fadeIn } from 'shared/styles/animations'; -import { splitAndInsertBlock } from '../../transforms'; +import { splitAndInsertBlock } from '../../changes'; -type Props = BaseProps & { - onInsertImage: Function, - onChange: Function, +type Props = SlateNodeProps & { + onInsertImage: *, }; type Options = { type: string | Object, wrapper?: string | Object, - append?: string | Object, }; class BlockToolbar extends Component { props: Props; + bar: HTMLDivElement; file: HTMLInputElement; - componentWillReceiveProps(nextProps: Props) { - const wasActive = this.props.state.selection.hasEdgeIn(this.props.node); - const isActive = nextProps.state.selection.hasEdgeIn(nextProps.node); - const becameInactive = !isActive && wasActive; - - if (becameInactive) { - const state = nextProps.state - .transform() - .removeNodeByKey(nextProps.node.key) - .apply(); - this.props.onChange(state); - } + componentDidMount() { + window.addEventListener('click', this.handleOutsideMouseClick); } + componentWillUnmount() { + window.removeEventListener('click', this.handleOutsideMouseClick); + } + + handleOutsideMouseClick = (ev: SyntheticMouseEvent) => { + const element = findDOMNode(this.bar); + + if ( + !element || + (ev.target instanceof Node && element.contains(ev.target)) || + (ev.button && ev.button !== 0) + ) { + return; + } + this.removeSelf(ev); + }; + @keydown('esc') removeSelf(ev: SyntheticEvent) { ev.preventDefault(); ev.stopPropagation(); - const state = this.props.state - .transform() - .removeNodeByKey(this.props.node.key) - .apply(); - this.props.onChange(state); + this.props.editor.change(change => + change.removeNodeByKey(this.props.node.key) + ); } - insertBlock = (options: Options) => { - const { state } = this.props; - let transform = splitAndInsertBlock(state.transform(), state, options); + insertBlock = ( + options: Options, + cursorPosition: 'before' | 'on' | 'after' = 'on' + ) => { + const { editor } = this.props; - state.document.nodes.forEach(node => { - if (node.type === 'block-toolbar') { - transform.removeNodeByKey(node.key); - } + editor.change(change => { + change + .collapseToEndOf(this.props.node) + .call(splitAndInsertBlock, options) + .removeNodeByKey(this.props.node.key) + .collapseToEnd(); + + if (cursorPosition === 'before') change.collapseToStartOfPreviousBlock(); + if (cursorPosition === 'after') change.collapseToStartOfNextBlock(); + return change.focus(); }); - - this.props.onChange(transform.focus().apply()); }; handleClickBlock = (ev: SyntheticEvent, type: string) => { @@ -81,9 +92,12 @@ class BlockToolbar extends Component { case 'code': return this.insertBlock({ type }); case 'horizontal-rule': - return this.insertBlock({ - type: { type: 'horizontal-rule', isVoid: true }, - }); + return this.insertBlock( + { + type: { type: 'horizontal-rule', isVoid: true }, + }, + 'after' + ); case 'bulleted-list': return this.insertBlock({ type: 'list-item', @@ -126,11 +140,12 @@ class BlockToolbar extends Component { }; render() { - const { state, attributes, node } = this.props; - const active = state.isFocused && state.selection.hasEdgeIn(node); + const { editor, attributes, node } = this.props; + const active = + editor.value.isFocused && editor.value.selection.hasEdgeIn(node); return ( - + (this.bar = ref)}> (this.file = ref)} diff --git a/app/components/Editor/components/Toolbar/Toolbar.js b/app/components/Editor/components/Toolbar/Toolbar.js index 929d60e9f..5721ed8f9 100644 --- a/app/components/Editor/components/Toolbar/Toolbar.js +++ b/app/components/Editor/components/Toolbar/Toolbar.js @@ -3,23 +3,36 @@ import React, { Component } from 'react'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; import { Portal } from 'react-portal'; +import { Editor, findDOMNode } from 'slate-react'; +import { Node, Value } from 'slate'; import styled from 'styled-components'; import _ from 'lodash'; -import type { State } from '../../types'; import FormattingToolbar from './components/FormattingToolbar'; import LinkToolbar from './components/LinkToolbar'; +function getLinkInSelection(value): any { + try { + const selectedLinks = value.document + .getInlinesAtRange(value.selection) + .filter(node => node.type === 'link'); + if (selectedLinks.size) { + return selectedLinks.first(); + } + } catch (err) { + // It's okay. + } +} + @observer export default class Toolbar extends Component { @observable active: boolean = false; - @observable focused: boolean = false; - @observable link: ?React$Element; + @observable link: ?Node; @observable top: string = ''; @observable left: string = ''; props: { - state: State, - onChange: (state: State) => void, + editor: Editor, + value: Value, }; menu: HTMLElement; @@ -32,35 +45,24 @@ export default class Toolbar extends Component { this.update(); }; - handleFocus = () => { - this.focused = true; + hideLinkToolbar = () => { + this.link = undefined; }; - handleBlur = () => { - this.focused = false; + showLinkToolbar = (ev: SyntheticEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + const link = getLinkInSelection(this.props.value); + this.link = link; }; - get linkInSelection(): any { - const { state } = this.props; - - try { - const selectedLinks = state.startBlock - .getInlinesAtRange(state.selection) - .filter(node => node.type === 'link'); - if (selectedLinks.size) { - return selectedLinks.first(); - } - } catch (err) { - // - } - } - update = () => { - const { state } = this.props; - const link = this.linkInSelection; + const { value } = this.props; + const link = getLinkInSelection(value); - if (state.isBlurred || (state.isCollapsed && !link)) { - if (this.active && !this.focused) { + if (value.isBlurred || (value.isCollapsed && !link)) { + if (this.active && !this.link) { this.active = false; this.link = undefined; this.top = ''; @@ -70,22 +72,27 @@ export default class Toolbar extends Component { } // don't display toolbar for document title - const firstNode = state.document.nodes.first(); - if (firstNode === state.startBlock) return; + const firstNode = value.document.nodes.first(); + if (firstNode === value.startBlock) return; - // don't display toolbar for code blocks - if (state.startBlock.type === 'code') return; + // don't display toolbar for code blocks, code-lines inline code. + if (value.startBlock.type.match(/code/)) return; this.active = true; - this.focused = !!link; - this.link = link; + this.link = this.link || link; const padding = 16; const selection = window.getSelection(); - const range = selection.getRangeAt(0); - const rect = range.getBoundingClientRect(); + let rect; - if (rect.top === 0 && rect.left === 0) { + if (link) { + rect = findDOMNode(link).getBoundingClientRect(); + } else if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + rect = range.getBoundingClientRect(); + } + + if (!rect || (rect.top === 0 && rect.left === 0)) { return; } @@ -114,11 +121,11 @@ export default class Toolbar extends Component { ) : ( )} diff --git a/app/components/Editor/components/Toolbar/components/DocumentResult.js b/app/components/Editor/components/Toolbar/components/DocumentResult.js index 68aa6f9af..6f0c83e0e 100644 --- a/app/components/Editor/components/Toolbar/components/DocumentResult.js +++ b/app/components/Editor/components/Toolbar/components/DocumentResult.js @@ -25,10 +25,12 @@ function DocumentResult({ document, ...rest }: Props) { const ListItem = styled.a` display: flex; align-items: center; - height: 24px; - padding: 4px 8px 4px 0; + height: 28px; + padding: 6px 8px 6px 0; color: ${color.white}; font-size: 15px; + overflow: hidden; + white-space: nowrap; i { visibility: hidden; diff --git a/app/components/Editor/components/Toolbar/components/FormattingToolbar.js b/app/components/Editor/components/Toolbar/components/FormattingToolbar.js index df56d7037..dcf21e074 100644 --- a/app/components/Editor/components/Toolbar/components/FormattingToolbar.js +++ b/app/components/Editor/components/Toolbar/components/FormattingToolbar.js @@ -1,7 +1,7 @@ // @flow import React, { Component } from 'react'; import styled from 'styled-components'; -import type { State } from '../../../types'; +import { Editor } from 'slate-react'; import ToolbarButton from './ToolbarButton'; import BoldIcon from 'components/Icon/BoldIcon'; import CodeIcon from 'components/Icon/CodeIcon'; @@ -13,9 +13,8 @@ import StrikethroughIcon from 'components/Icon/StrikethroughIcon'; class FormattingToolbar extends Component { props: { - state: State, - onChange: Function, - onCreateLink: Function, + editor: Editor, + onCreateLink: SyntheticEvent => void, }; /** @@ -25,11 +24,12 @@ class FormattingToolbar extends Component { * @return {Boolean} */ hasMark = (type: string) => { - return this.props.state.marks.some(mark => mark.type === type); + return this.props.editor.value.marks.some(mark => mark.type === type); }; isBlock = (type: string) => { - return this.props.state.startBlock.type === type; + const startBlock = this.props.editor.value.startBlock; + return startBlock && startBlock.type === type; }; /** @@ -40,37 +40,23 @@ class FormattingToolbar extends Component { */ onClickMark = (ev: SyntheticEvent, type: string) => { ev.preventDefault(); - let { state } = this.props; - - state = state - .transform() - .toggleMark(type) - .apply(); - this.props.onChange(state); + this.props.editor.change(change => change.toggleMark(type)); }; onClickBlock = (ev: SyntheticEvent, type: string) => { ev.preventDefault(); - let { state } = this.props; - - state = state - .transform() - .setBlock(type) - .apply(); - this.props.onChange(state); + this.props.editor.change(change => change.setBlock(type)); }; - onCreateLink = (ev: SyntheticEvent) => { + handleCreateLink = (ev: SyntheticEvent) => { ev.preventDefault(); ev.stopPropagation(); - let { state } = this.props; + const data = { href: '' }; - state = state - .transform() - .wrapInline({ type: 'link', data }) - .apply(); - this.props.onChange(state); - this.props.onCreateLink(); + this.props.editor.change(change => { + change.wrapInline({ type: 'link', data }); + this.props.onCreateLink(ev); + }); }; renderMarkButton = (type: string, IconClass: Function) => { @@ -107,7 +93,7 @@ class FormattingToolbar extends Component { {this.renderBlockButton('heading1', Heading1Icon)} {this.renderBlockButton('heading2', Heading2Icon)} - + diff --git a/app/components/Editor/components/Toolbar/components/LinkToolbar.js b/app/components/Editor/components/Toolbar/components/LinkToolbar.js index 4bdd21c7b..9a3966688 100644 --- a/app/components/Editor/components/Toolbar/components/LinkToolbar.js +++ b/app/components/Editor/components/Toolbar/components/LinkToolbar.js @@ -1,14 +1,15 @@ // @flow import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; +import { findDOMNode } from 'react-dom'; import { observable, action } from 'mobx'; import { observer, inject } from 'mobx-react'; import { withRouter } from 'react-router-dom'; +import { Node } from 'slate'; +import { Editor } from 'slate-react'; import styled from 'styled-components'; import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; import ToolbarButton from './ToolbarButton'; import DocumentResult from './DocumentResult'; -import type { State } from '../../../types'; import DocumentsStore from 'stores/DocumentsStore'; import keydown from 'react-keydown'; import CloseIcon from 'components/Icon/CloseIcon'; @@ -19,15 +20,15 @@ import Flex from 'shared/components/Flex'; @keydown @observer class LinkToolbar extends Component { + wrapper: HTMLSpanElement; input: HTMLElement; firstDocument: HTMLElement; props: { - state: State, - link: Object, + editor: Editor, + link: Node, documents: DocumentsStore, onBlur: () => void, - onChange: State => void, }; @observable isEditing: boolean = false; @@ -35,10 +36,39 @@ class LinkToolbar extends Component { @observable resultIds: string[] = []; @observable searchTerm: ?string = null; - componentWillMount() { + componentDidMount() { this.isEditing = !!this.props.link.data.get('href'); + setImmediate(() => + window.addEventListener('click', this.handleOutsideMouseClick) + ); } + componentWillUnmount() { + window.removeEventListener('click', this.handleOutsideMouseClick); + } + + handleOutsideMouseClick = (ev: SyntheticMouseEvent) => { + const element = findDOMNode(this.wrapper); + + if ( + !element || + (ev.target instanceof HTMLElement && element.contains(ev.target)) || + (ev.button && ev.button !== 0) + ) { + return; + } + + this.close(); + }; + + close = () => { + if (this.input.value) { + this.props.onBlur(); + } else { + this.removeLink(); + } + }; + @action search = async () => { this.isFetching = true; @@ -67,11 +97,11 @@ class LinkToolbar extends Component { ev.preventDefault(); return this.save(ev.target.value); case 27: // escape - return this.input.blur(); + return this.close(); case 40: // down ev.preventDefault(); if (this.firstDocument) { - const element = ReactDOM.findDOMNode(this.firstDocument); + const element = findDOMNode(this.firstDocument); if (element instanceof HTMLElement) element.focus(); } break; @@ -91,16 +121,6 @@ class LinkToolbar extends Component { this.resultIds = []; }; - onBlur = () => { - if (!this.resultIds.length) { - if (this.input.value) { - this.props.onBlur(); - } else { - this.removeLink(); - } - } - }; - removeLink = () => { this.save(''); }; @@ -111,18 +131,19 @@ class LinkToolbar extends Component { }; save = (href: string) => { + const { editor, link } = this.props; href = href.trim(); - const { state } = this.props; - const transform = state.transform(); - if (href) { - transform.setInline({ type: 'link', data: { href } }); - } else { - transform.unwrapInline('link'); - } - - this.props.onChange(transform.apply()); - this.props.onBlur(); + editor.change(change => { + if (href) { + change.setInline({ type: 'link', data: { href } }); + } else if (link) { + const selContainsLink = !!change.value.startBlock.getChild(link.key); + if (selContainsLink) change.unwrapInlineByKey(link.key); + } + change.deselect(); + this.props.onBlur(); + }); }; setFirstDocumentRef = ref => { @@ -134,13 +155,12 @@ class LinkToolbar extends Component { const hasResults = this.resultIds.length > 0; return ( - + (this.wrapper = ref)}> (this.input = ref)} defaultValue={href} placeholder="Search or paste a link…" - onBlur={this.onBlur} onKeyDown={this.onKeyDown} onChange={this.onChange} autoFocus diff --git a/app/components/Editor/headingToSlug.js b/app/components/Editor/headingToSlug.js index 04b9d6273..eb06dfcde 100644 --- a/app/components/Editor/headingToSlug.js +++ b/app/components/Editor/headingToSlug.js @@ -1,6 +1,6 @@ // @flow import { escape } from 'lodash'; -import type { Node } from './types'; +import { Node } from 'slate'; import slug from 'slug'; export default function headingToSlug(node: Node) { diff --git a/app/components/Editor/insertImage.js b/app/components/Editor/insertImage.js deleted file mode 100644 index d3e4c6bd7..000000000 --- a/app/components/Editor/insertImage.js +++ /dev/null @@ -1,56 +0,0 @@ -// @flow -import uuid from 'uuid'; -import uploadFile from 'utils/uploadFile'; -import type { Editor, Transform } from './types'; - -export default async function insertImageFile( - transform: Transform, - file: window.File, - editor: Editor, - onImageUploadStart: () => void, - onImageUploadStop: () => void -) { - onImageUploadStart(); - - try { - // load the file as a data URL - const id = uuid.v4(); - const alt = ''; - const reader = new FileReader(); - reader.addEventListener('load', () => { - const src = reader.result; - - // insert into document as uploading placeholder - const state = transform - .insertBlock({ - type: 'image', - isVoid: true, - data: { src, id, alt, loading: true }, - }) - .apply(); - editor.onChange(state); - }); - reader.readAsDataURL(file); - - // now we have a placeholder, start the upload - const asset = await uploadFile(file); - const src = asset.url; - - // we dont use the original transform provided to the callback here - // as the state may have changed significantly in the time it took to - // upload the file. - const state = editor.getState(); - const finalTransform = state.transform(); - const placeholder = state.document.findDescendant( - node => node.data && node.data.get('id') === id - ); - - return finalTransform.setNodeByKey(placeholder.key, { - data: { src, alt, loading: false }, - }); - } catch (err) { - throw err; - } finally { - onImageUploadStop(); - } -} diff --git a/app/components/Editor/marks.js b/app/components/Editor/marks.js new file mode 100644 index 000000000..eaa4c3349 --- /dev/null +++ b/app/components/Editor/marks.js @@ -0,0 +1,27 @@ +// @flow +import React from 'react'; +import Code from './components/InlineCode'; +import { Mark } from 'slate'; + +type Props = { + children: React$Element<*>, + mark: Mark, +}; + +export default function renderMark(props: Props) { + switch (props.mark.type) { + case 'bold': + return {props.children}; + case 'code': + return {props.children}; + case 'italic': + return {props.children}; + case 'underlined': + return {props.children}; + case 'deleted': + return {props.children}; + case 'added': + return {props.children}; + default: + } +} diff --git a/app/components/Editor/nodes.js b/app/components/Editor/nodes.js new file mode 100644 index 000000000..68ab6e2da --- /dev/null +++ b/app/components/Editor/nodes.js @@ -0,0 +1,75 @@ +// @flow +import React from 'react'; +import Code from './components/Code'; +import BlockToolbar from './components/Toolbar/BlockToolbar'; +import HorizontalRule from './components/HorizontalRule'; +import Image from './components/Image'; +import Link from './components/Link'; +import ListItem from './components/ListItem'; +import TodoList from './components/TodoList'; +import { + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, +} from './components/Heading'; +import Paragraph from './components/Paragraph'; +import type { SlateNodeProps } from './types'; + +type Options = { + onInsertImage: *, +}; + +export default function createRenderNode({ onInsertImage }: Options) { + return function renderNode(props: SlateNodeProps) { + const { attributes } = props; + + switch (props.node.type) { + case 'paragraph': + return ; + case 'block-toolbar': + return ; + case 'block-quote': + return
{props.children}
; + case 'bulleted-list': + return
    {props.children}
; + case 'ordered-list': + return
    {props.children}
; + case 'todo-list': + return {props.children}; + case 'table': + return {props.children}
; + case 'table-row': + return {props.children}; + case 'table-head': + return {props.children}; + case 'table-cell': + return {props.children}; + case 'list-item': + return ; + case 'horizontal-rule': + return ; + case 'code': + return ; + case 'image': + return ; + case 'link': + return ; + case 'heading1': + return ; + case 'heading2': + return ; + case 'heading3': + return ; + case 'heading4': + return ; + case 'heading5': + return ; + case 'heading6': + return ; + default: + } + }; +} diff --git a/app/components/Editor/plugins.js b/app/components/Editor/plugins.js index 30688ea11..2362d07cb 100644 --- a/app/components/Editor/plugins.js +++ b/app/components/Editor/plugins.js @@ -1,5 +1,5 @@ // @flow -import DropOrPasteImages from '@tommoor/slate-drop-or-paste-images'; +import InsertImages from '@tommoor/slate-drop-or-paste-images'; import PasteLinkify from 'slate-paste-linkify'; import CollapseOnEscape from 'slate-collapse-on-escape'; import TrailingBlock from 'slate-trailing-block'; @@ -8,26 +8,26 @@ import Prism from 'slate-prism'; import EditList from './plugins/EditList'; import KeyboardShortcuts from './plugins/KeyboardShortcuts'; import MarkdownShortcuts from './plugins/MarkdownShortcuts'; -import insertImage from './insertImage'; - -const onlyInCode = node => node.type === 'code'; +import { insertImageFile } from './changes'; type Options = { - onImageUploadStart: Function, - onImageUploadStop: Function, + onImageUploadStart: () => void, + onImageUploadStop: () => void, }; +const onlyInCode = node => node.type === 'code'; + const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => { return [ PasteLinkify({ type: 'link', collapseTo: 'end', }), - DropOrPasteImages({ - extensions: ['png', 'jpg', 'gif'], - applyTransform: (transform, file, editor) => { - return insertImage( - transform, + InsertImages({ + extensions: ['png', 'jpg', 'gif', 'webp'], + insertImage: async (change, file, editor) => { + return change.call( + insertImageFile, file, editor, onImageUploadStart, diff --git a/app/components/Editor/plugins/EditList.js b/app/components/Editor/plugins/EditList.js index 7bef46f48..9d3dea9a7 100644 --- a/app/components/Editor/plugins/EditList.js +++ b/app/components/Editor/plugins/EditList.js @@ -4,4 +4,5 @@ import EditList from 'slate-edit-list'; export default EditList({ types: ['ordered-list', 'bulleted-list', 'todo-list'], typeItem: 'list-item', + typeDefault: 'paragraph', }); diff --git a/app/components/Editor/plugins/KeyboardShortcuts.js b/app/components/Editor/plugins/KeyboardShortcuts.js index bf1a2fb02..7ec48efca 100644 --- a/app/components/Editor/plugins/KeyboardShortcuts.js +++ b/app/components/Editor/plugins/KeyboardShortcuts.js @@ -1,47 +1,34 @@ // @flow +import { Change } from 'slate'; export default function KeyboardShortcuts() { return { - /** - * On key down, check for our specific key shortcuts. - * - * @param {Event} e - * @param {Data} data - * @param {State} state - * @return {State or Null} state - */ - onKeyDown(ev: SyntheticEvent, data: Object, state: Object) { - if (!data.isMeta) return null; + onKeyDown(ev: SyntheticKeyboardEvent, change: Change) { + if (!ev.metaKey) return null; - switch (data.key) { + switch (ev.key) { case 'b': - return this.toggleMark(state, 'bold'); + return this.toggleMark(change, 'bold'); case 'i': - return this.toggleMark(state, 'italic'); + return this.toggleMark(change, 'italic'); case 'u': - return this.toggleMark(state, 'underlined'); + return this.toggleMark(change, 'underlined'); case 'd': - return this.toggleMark(state, 'deleted'); + return this.toggleMark(change, 'deleted'); case 'k': - return state - .transform() - .wrapInline({ type: 'link', data: { href: '' } }) - .apply(); + return change.wrapInline({ type: 'link', data: { href: '' } }); default: return null; } }, - toggleMark(state: Object, type: string) { + toggleMark(change: Change, type: string) { + const { value } = change; // don't allow formatting of document title - const firstNode = state.document.nodes.first(); - if (firstNode === state.startBlock) return; + const firstNode = value.document.nodes.first(); + if (firstNode === value.startBlock) return; - state = state - .transform() - .toggleMark(type) - .apply(); - return state; + change.toggleMark(type); }, }; } diff --git a/app/components/Editor/plugins/MarkdownShortcuts.js b/app/components/Editor/plugins/MarkdownShortcuts.js index 6132a8c61..0cd0a5b72 100644 --- a/app/components/Editor/plugins/MarkdownShortcuts.js +++ b/app/components/Editor/plugins/MarkdownShortcuts.js @@ -1,4 +1,6 @@ // @flow +import { Change } from 'slate'; + const inlineShortcuts = [ { mark: 'bold', shortcut: '**' }, { mark: 'bold', shortcut: '__' }, @@ -11,23 +13,20 @@ const inlineShortcuts = [ export default function MarkdownShortcuts() { return { - /** - * On key down, check for our specific key shortcuts. - */ - onKeyDown(ev: SyntheticEvent, data: Object, state: Object) { - switch (data.key) { + onKeyDown(ev: SyntheticKeyboardEvent, change: Change) { + switch (ev.key) { case '-': - return this.onDash(ev, state); + return this.onDash(ev, change); case '`': - return this.onBacktick(ev, state); - case 'tab': - return this.onTab(ev, state); - case 'space': - return this.onSpace(ev, state); - case 'backspace': - return this.onBackspace(ev, state); - case 'enter': - return this.onEnter(ev, state); + return this.onBacktick(ev, change); + case 'Tab': + return this.onTab(ev, change); + case ' ': + return this.onSpace(ev, change); + case 'Backspace': + return this.onBackspace(ev, change); + case 'Enter': + return this.onEnter(ev, change); default: return null; } @@ -37,9 +36,10 @@ export default function MarkdownShortcuts() { * On space, if it was after an auto-markdown shortcut, convert the current * node into the shortcut's corresponding type. */ - onSpace(ev: SyntheticEvent, state: Object) { - if (state.isExpanded) return; - const { startBlock, startOffset } = state; + onSpace(ev: SyntheticKeyboardEvent, change: Change) { + const { value } = change; + if (value.isExpanded) return; + const { startBlock, startOffset } = value; const chars = startBlock.text.slice(0, startOffset).trim(); const type = this.getType(chars); @@ -50,25 +50,29 @@ export default function MarkdownShortcuts() { let checked; if (chars === '[x]') checked = true; if (chars === '[ ]') checked = false; - const transform = state - .transform() - .setBlock({ type, data: { checked } }); + + change + .extendToStartOf(startBlock) + .delete() + .setBlock( + { + type, + data: { checked }, + }, + { normalize: false } + ); if (type === 'list-item') { if (checked !== undefined) { - transform.wrapBlock('todo-list'); + change.wrapBlock('todo-list'); } else if (chars === '1.') { - transform.wrapBlock('ordered-list'); + change.wrapBlock('ordered-list'); } else { - transform.wrapBlock('bulleted-list'); + change.wrapBlock('bulleted-list'); } } - state = transform - .extendToStartOf(startBlock) - .delete() - .apply(); - return state; + return true; } for (const key of inlineShortcuts) { @@ -76,7 +80,8 @@ export default function MarkdownShortcuts() { let { mark, shortcut } = key; let inlineTags = []; - // only add tags if they have spaces around them or the tag is beginning or the end of the block + // only add tags if they have spaces around them or the tag is beginning + // or the end of the block for (let i = 0; i < startBlock.text.length; i++) { const { text } = startBlock; const start = i; @@ -91,95 +96,85 @@ export default function MarkdownShortcuts() { if ( text.slice(start, end) === shortcut && (beginningOfBlock || endOfBlock || surroundedByWhitespaces) - ) + ) { inlineTags.push(i); + } } // if we have multiple tags then mark the text between as inline code if (inlineTags.length > 1) { - const transform = state.transform(); const firstText = startBlock.getFirstText(); const firstCodeTagIndex = inlineTags[0]; const lastCodeTagIndex = inlineTags[inlineTags.length - 1]; - transform.removeTextByKey( - firstText.key, - lastCodeTagIndex, - shortcut.length - ); - transform.removeTextByKey( - firstText.key, - firstCodeTagIndex, - shortcut.length - ); - transform.moveOffsetsTo( - firstCodeTagIndex, - lastCodeTagIndex - shortcut.length - ); - transform.addMark(mark); - state = transform + return change + .removeTextByKey(firstText.key, lastCodeTagIndex, shortcut.length) + .removeTextByKey(firstText.key, firstCodeTagIndex, shortcut.length) + .moveOffsetsTo( + firstCodeTagIndex, + lastCodeTagIndex - shortcut.length + ) + .addMark(mark) .collapseToEnd() - .removeMark(mark) - .apply(); - return state; + .removeMark(mark); } } }, - onDash(ev: SyntheticEvent, state: Object) { - if (state.isExpanded) return; - const { startBlock, startOffset } = state; + onDash(ev: SyntheticKeyboardEvent, change: Change) { + const { value } = change; + if (value.isExpanded) return; + const { startBlock, startOffset } = value; const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, ''); if (chars === '--') { ev.preventDefault(); - return state - .transform() + return change .extendToStartOf(startBlock) .delete() - .setBlock({ - type: 'horizontal-rule', - isVoid: true, - }) - .collapseToStartOfNextBlock() + .setBlock( + { + type: 'horizontal-rule', + isVoid: true, + }, + { normalize: false } + ) .insertBlock('paragraph') - .apply(); + .collapseToStart(); } }, - onBacktick(ev: SyntheticEvent, state: Object) { - if (state.isExpanded) return; - const { startBlock, startOffset } = state; + onBacktick(ev: SyntheticKeyboardEvent, change: Change) { + const { value } = change; + if (value.isExpanded) return; + const { startBlock, startOffset } = value; const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, ''); if (chars === '``') { ev.preventDefault(); - return state - .transform() + return change .extendToStartOf(startBlock) .delete() - .setBlock({ - type: 'code', - }) - .apply(); + .setBlock({ type: 'code' }); } }, - onBackspace(ev: SyntheticEvent, state: Object) { - if (state.isExpanded) return; - const { startBlock, selection, startOffset } = state; + onBackspace(ev: SyntheticKeyboardEvent, change: Change) { + const { value } = change; + if (value.isExpanded) return; + const { startBlock, selection, startOffset } = value; // If at the start of a non-paragraph, convert it back into a paragraph if (startOffset === 0) { if (startBlock.type === 'paragraph') return; ev.preventDefault(); - const transform = state.transform().setBlock('paragraph'); + change.setBlock('paragraph'); - if (startBlock.type === 'list-item') - transform.unwrapBlock('bulleted-list'); + if (startBlock.type === 'list-item') { + change.unwrapBlock('bulleted-list'); + } - state = transform.apply(); - return state; + return change; } // If at the end of a code mark hitting backspace should remove the mark @@ -198,15 +193,12 @@ export default function MarkdownShortcuts() { .reverse() .takeUntil((v, k) => !v.marks.some(mark => mark.type === 'code')); - const transform = state.transform(); - transform.removeMarkByKey( + change.removeMarkByKey( textNode.key, - state.startOffset - charsInCodeBlock.size, - state.startOffset, + startOffset - charsInCodeBlock.size, + startOffset, 'code' ); - state = transform.apply(); - return state; } } }, @@ -215,14 +207,12 @@ export default function MarkdownShortcuts() { * On tab, if at the end of the heading jump to the main body content * as if it is another input field (act the same as enter). */ - onTab(ev: SyntheticEvent, state: Object) { - if (state.startBlock.type === 'heading1') { + onTab(ev: SyntheticKeyboardEvent, change: Change) { + const { value } = change; + + if (value.startBlock.type === 'heading1') { ev.preventDefault(); - return state - .transform() - .splitBlock() - .setBlock('paragraph') - .apply(); + change.splitBlock().setBlock('paragraph'); } }, @@ -230,44 +220,33 @@ export default function MarkdownShortcuts() { * On return, if at the end of a node type that should not be extended, * create a new paragraph below it. */ - onEnter(ev: SyntheticEvent, state: Object) { - if (state.isExpanded) return; - const { startBlock, startOffset, endOffset } = state; + onEnter(ev: SyntheticKeyboardEvent, change: Change) { + const { value } = change; + if (value.isExpanded) return; + + const { startBlock, startOffset, endOffset } = value; if (startOffset === 0 && startBlock.length === 0) - return this.onBackspace(ev, state); - if (endOffset !== startBlock.length) return; + return this.onBackspace(ev, change); + + // Hitting enter at the end of the line reverts to standard behavior + if (endOffset === startBlock.length) return; // Hitting enter while an image is selected should jump caret below and // insert a new paragraph if (startBlock.type === 'image') { ev.preventDefault(); - return state - .transform() - .collapseToEnd() - .insertBlock('paragraph') - .apply(); + return change.collapseToEnd().insertBlock('paragraph'); } // Hitting enter in a heading or blockquote will split the node at that // point and make the new node a paragraph if ( - startBlock.type !== 'heading1' && - startBlock.type !== 'heading2' && - startBlock.type !== 'heading3' && - startBlock.type !== 'heading4' && - startBlock.type !== 'heading5' && - startBlock.type !== 'heading6' && - startBlock.type !== 'block-quote' + startBlock.type.startsWith('heading') || + startBlock.type === 'block-quote' ) { - return; + ev.preventDefault(); + return change.splitBlock().setBlock('paragraph'); } - - ev.preventDefault(); - return state - .transform() - .splitBlock() - .setBlock('paragraph') - .apply(); }, /** diff --git a/app/components/Editor/schema.js b/app/components/Editor/schema.js index d4ed674b6..8e2abcc2d 100644 --- a/app/components/Editor/schema.js +++ b/app/components/Editor/schema.js @@ -1,133 +1,73 @@ // @flow -import React from 'react'; -import Code from './components/Code'; -import HorizontalRule from './components/HorizontalRule'; -import InlineCode from './components/InlineCode'; -import Image from './components/Image'; -import Link from './components/Link'; -import ListItem from './components/ListItem'; -import TodoList from './components/TodoList'; -import { - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, -} from './components/Heading'; -import Paragraph from './components/Paragraph'; -import BlockToolbar from './components/Toolbar/BlockToolbar'; -import type { Props, Node, Transform } from './types'; +import { Block, Change, Node, Mark } from 'slate'; -type Options = { - onInsertImage: Function, - onChange: Function, -}; - -const createSchema = ({ onInsertImage, onChange }: Options) => { - return { - marks: { - bold: (props: Props) => {props.children}, - code: (props: Props) => {props.children}, - italic: (props: Props) => {props.children}, - underlined: (props: Props) => {props.children}, - deleted: (props: Props) => {props.children}, - added: (props: Props) => {props.children}, +const schema = { + blocks: { + heading1: { marks: [''] }, + heading2: { marks: [''] }, + heading3: { marks: [''] }, + heading4: { marks: [''] }, + heading5: { marks: [''] }, + heading6: { marks: [''] }, + table: { + nodes: [{ types: ['table-row', 'table-head', 'table-cell'] }], }, - - nodes: { - 'block-toolbar': (props: Props) => ( - - ), - paragraph: (props: Props) => , - 'block-quote': (props: Props) => ( -
{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} - ), - code: Code, - image: Image, - link: Link, - 'list-item': ListItem, - heading1: (props: Props) => , - heading2: (props: Props) => , - heading3: (props: Props) => , - heading4: (props: Props) => , - heading5: (props: Props) => , - heading6: (props: Props) => , + 'horizontal-rule': { + isVoid: true, }, - - rules: [ - // ensure first node is always a heading + 'block-toolbar': { + isVoid: true, + }, + }, + document: { + nodes: [ + { types: ['heading1'], min: 1, max: 1 }, { - match: (node: Node) => { - return node.kind === 'document'; - }, - validate: (document: Node) => { - const firstNode = document.nodes.first(); - return firstNode && firstNode.type === 'heading1' ? null : firstNode; - }, - normalize: (transform: Transform, document: Node, firstNode: Node) => { - transform.setBlock({ type: 'heading1' }); - }, - }, - - // automatically removes any marks in first heading - { - match: (node: Node) => { - return node.kind === 'heading1'; - }, - validate: (heading: Node) => { - const hasMarks = heading.getMarks().isEmpty(); - const hasInlines = heading.getInlines().isEmpty(); - - return !(hasMarks && hasInlines); - }, - normalize: (transform: Transform, heading: Node) => { - transform.unwrapInlineByKey(heading.key); - - heading.getMarks().forEach(mark => { - heading.nodes.forEach(textNode => { - if (textNode.kind === 'text') { - transform.removeMarkByKey( - textNode.key, - 0, - textNode.text.length, - mark - ); - } - }); - }); - - return transform; - }, + types: [ + 'paragraph', + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + 'code', + 'horizontal-rule', + 'image', + 'bulleted-list', + 'ordered-list', + 'todo-list', + 'block-toolbar', + 'table', + ], + min: 1, }, ], - }; + normalize: ( + change: Change, + reason: string, + { + node, + child, + mark, + index, + }: { node: Node, mark?: Mark, child: Node, index: number } + ) => { + switch (reason) { + case 'child_type_invalid': { + return change.setNodeByKey( + child.key, + index === 0 ? 'heading1' : 'paragraph' + ); + } + case 'child_required': { + const block = Block.create(index === 0 ? 'heading1' : 'paragraph'); + return change.insertNodeByKey(node.key, index, block); + } + default: + } + }, + }, }; -export default createSchema; +export default schema; diff --git a/app/components/Editor/transforms.js b/app/components/Editor/transforms.js deleted file mode 100644 index d404c570f..000000000 --- a/app/components/Editor/transforms.js +++ /dev/null @@ -1,37 +0,0 @@ -// @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 51562825a..d39ed74e2 100644 --- a/app/components/Editor/types.js +++ b/app/components/Editor/types.js @@ -1,118 +1,19 @@ // @flow -import { List, Set, Map } from 'immutable'; -import { Selection } from 'slate'; +import { Value, Change, Node } from 'slate'; +import { Editor } from 'slate-react'; -export type NodeTransform = { - addMarkByKey: Function, - insertNodeByKey: Function, - insertTextByKey: Function, - moveNodeByKey: Function, - removeMarkByKey: Function, - removeNodeByKey: Function, - removeTextByKey: Function, - setMarkByKey: Function, - setNodeByKey: Function, - splitNodeByKey: Function, - unwrapInlineByKey: Function, - unwrapBlockByKey: Function, - unwrapNodeByKey: Function, - wrapBlockByKey: Function, - wrapInlineByKey: Function, -}; - -export type StateTransform = { - deleteBackward: Function, - deleteForward: Function, - delete: Function, - insertBlock: Function, - insertFragment: Function, - insertInline: Function, - insertText: Function, - addMark: Function, - setBlock: Function, - setInline: Function, - splitBlock: Function, - splitInline: Function, - removeMark: Function, - toggleMark: Function, - unwrapBlock: Function, - unwrapInline: Function, - wrapBlock: Function, - wrapInline: Function, - wrapText: Function, -}; - -export type SelectionTransform = { - collapseToStart: Function, - collapseToEnd: Function, -}; - -export type Transform = NodeTransform & StateTransform & SelectionTransform; - -export type Editor = { - props: Object, - className: string, - onChange: Function, - onDocumentChange: Function, - onSelectionChange: Function, - plugins: Array, +export type SlateNodeProps = { + children: React$Element<*>, readOnly: boolean, - state: Object, - style: Object, - placeholder?: string, - placeholderClassName?: string, - placeholderStyle?: string, - blur: Function, - focus: Function, - getSchema: Function, - getState: Function, -}; - -export type Node = { - key: string, - kind: string, - type: string, - length: number, - text: string, - data: Map, - nodes: List, - getMarks: Function, - getBlocks: Function, - getParent: Function, - getInlines: Function, - getInlinesAtRange: Function, - setBlock: Function, -}; - -export type Block = Node & { - type: string, -}; - -export type Document = Node; - -export type State = { - document: Document, - selection: Selection, - startBlock: Block, - endBlock: Block, - startText: Node, - endText: Node, - marks: Set<*>, - blocks: List, - fragment: Document, - lines: List, - tests: List, - startBlock: Block, - transform: Function, - isBlurred: Function, -}; - -export type Props = { - node: Node, - parent?: Node, - attributes?: Object, - state: State, + attributes: Object, + value: Value, editor: Editor, - readOnly?: boolean, - children?: React$Element, + node: Node, + parent: Node, +}; + +export type Plugin = { + validateNode?: Node => *, + onClick?: SyntheticEvent => *, + onKeyDown?: (SyntheticKeyboardEvent, Change) => *, }; diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index da430b8ef..dc6efe6db 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -183,8 +183,10 @@ class DocumentScene extends Component { }; onChange = text => { - if (!this.document) return; - this.document.updateData({ text }, true); + let document = this.document; + if (!document) return; + if (document.text.trim() === text.trim()) return; + document.updateData({ text }, true); }; onDiscard = () => { @@ -229,10 +231,12 @@ class DocumentScene extends Component { {!isFetching && document && ( - + {this.isEditing && ( + + )}