From e608a89b680043135caf86d7047a24da869c6015 Mon Sep 17 00:00:00 2001 From: Gosha Arinich Date: Wed, 14 Sep 2016 13:02:43 +0300 Subject: [PATCH] Create an Editor component. Ref GH-57 --- package.json | 7 +- src/components/editor/index.js | 225 ++++++++++++++++++++ src/components/editor/paste-plugin/index.js | 55 +++++ src/components/editor/paste-plugin/spec.js | 72 +++++++ src/components/editor/spec.js | 108 ++++++++++ src/components/editor/story.js | 54 +++++ src/components/outlines/editor/index.js | 217 ++----------------- 7 files changed, 535 insertions(+), 203 deletions(-) create mode 100644 src/components/editor/index.js create mode 100644 src/components/editor/paste-plugin/index.js create mode 100644 src/components/editor/paste-plugin/spec.js create mode 100644 src/components/editor/spec.js create mode 100644 src/components/editor/story.js diff --git a/package.json b/package.json index c4c7114..c4f1900 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,10 @@ "babel-runtime": "^6.5.0", "css-loader": "^0.23.1", "debug": "^2.2.0", - "draft-js": "^0.5.0", - "draft-js-mention-plugin": "^1.1.0", - "draft-js-plugins-editor": "^1.0.1", + "draft-js": "0.9.0", + "draft-js-mention-plugin": "1.1.0", + "draft-js-plugins-editor": "1.1.0", + "enzyme": "^2.4.1", "express": "^4.13.4", "falcor": "^0.1.16", "falcor-http-datasource": "^0.1.3", diff --git a/src/components/editor/index.js b/src/components/editor/index.js new file mode 100644 index 0000000..e465bfc --- /dev/null +++ b/src/components/editor/index.js @@ -0,0 +1,225 @@ +import { PropTypes } from 'react'; +import reactStamp from 'react-stamp'; +import { fromJS, List, Repeat } from 'immutable'; +import Editor from 'draft-js-plugins-editor'; +import { + EditorState, + ContentBlock, + convertFromRaw, + RichUtils, + BlockMapBuilder, + genKey, + CharacterMetadata, +} from 'draft-js'; + +import stripPastePluginFactory from './paste-plugin'; + +const stripPastePlugin = stripPastePluginFactory(); + +const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ + propTypes: { + content: PropTypes.shape({ + entityMap: PropTypes.object.isRequired, + blocks: PropTypes.array.isRequired, + }).isRequired, + + onEditStart: PropTypes.func, + onEditEnd: PropTypes.func, + onChange: PropTypes.func, + + readOnly: PropTypes.bool, + plugins: PropTypes.array, + }, + + init() { + const content = convertFromRaw(this.props.content); + this.state = { + editor: EditorState.createWithContent(content), + readOnly: true, + }; + + this.onClick = this.onClick.bind(this); + this.onChange = this.onChange.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + + this.handleKeyCommand = this.handleKeyCommand.bind(this); + this.handleReturn = this.handleReturn.bind(this); + + this.save = this.save.bind(this); + }, + + componentWillUnmount() { + this.deleteTimeout(); + this.save(); + }, + + onClick() { + this.setState({ readOnly: false }, () => { + this.refs.upstream && this.refs.upstream.focus(); + }); + }, + + _focus() { + this.refs.upstream.focus(); + }, + + onFocus() { + if (this.props.onEditStart) { + this.props.onEditStart(); + } + }, + + onBlur() { + this.setState({ readOnly: true }); + if (this.props.onEditEnd) { + this.props.onEditEnd(); + } + }, + + onChange(editor) { + if (this.props.readOnly) { + return; + } + + this.setState({ editor }); + + if (this.props.onChange) { + this.deleteTimeout(); + this._timeout = setTimeout(this.save, 3000); + } + }, + + deleteTimeout() { + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + }, + + save() { + if (this.props.onChange) { + this.props.onChange(this.state.editor.getCurrentContent()); + } + }, + + handleKeyCommand(command) { + const { editor } = this.state; + const newState = RichUtils.handleKeyCommand(editor, command); + if (newState) { + this.onChange(newState); + return true; + } + return false; + }, + + handleReturn(e) { + const { editor } = this.state; + const selection = editor.getSelection(); + const content = editor.getCurrentContent(); + const block = content.getBlockForKey(selection.getStartKey()); + + // We only care if there is no current selection (e.g. selection is a caret). + if (!selection.isCollapsed()) { + console.log("not collapsed") + return; + } + + // We only care if we're at the end of the line. + // TODO: implement splitting current block at selection + if (block.getLength() !== selection.getStartOffset()) { + console.log("not at end of line") + return; + } + + const previousBlock = content.getBlockBefore(block.getKey()); + if (block.getText().length === 0) { + if (!previousBlock || previousBlock.getText().length === 0) { + // no empty lines between paragraphs + return true; + } else { + // insert header block + this._insertHeader(); + return true; + } + } else if (block.getType() === 'unstyled') { + // current line is non-empty and is unstyled already, so let the browser do its thing and + // insert another one. + return false; + } else { + // non-empty and not unstyled, so let's insert a new paragraph and move the cursor there. + this._insertParagraph(); + return true; + } + }, + + _insertParagraph () { + return this._insertBlock({ + key: genKey(), + type: 'unstyled', + text: '', + characterList: List() + }); + }, + + _insertBlock (blockData) { + const { editor } = this.state; + const selection = editor.getSelection(); + const content = editor.getCurrentContent(); + const block = content.getBlockForKey(selection.getStartKey()); + + // Insert new unstyled block + const newBlock = new ContentBlock(blockData); + + const blockArr = []; + content.getBlockMap().forEach((oldBlock, key) => { + blockArr.push(oldBlock); + + if (key === block.getKey()) { + blockArr.push(newBlock); + } + }); + + const newBlockMap = BlockMapBuilder.createFromArray(blockArr); + + const newContent = content.merge({ + blockMap: newBlockMap, + selectionBefore: selection, + selectionAfter: selection.merge({ + anchorKey: newBlock.getKey(), + anchorOffset: 0, + focusKey: newBlock.getKey(), + focusOffset: 0, + isBackward: false, + }), + }); + + const newState = EditorState.push(editor, newContent, 'insert-fragment'); + + this.setState({ editor: newState }); + }, + + render() { + const isReadOnly = this.props.readOnly ? true : this.state.readOnly; + const plugins = (this.props.plugins || []).concat([stripPastePlugin]); + return ( +
+ {isReadOnly ? 'Reading' : 'Writing'} + +
+ ); + }, +}); + +export default EditorComponent; diff --git a/src/components/editor/paste-plugin/index.js b/src/components/editor/paste-plugin/index.js new file mode 100644 index 0000000..82675ea --- /dev/null +++ b/src/components/editor/paste-plugin/index.js @@ -0,0 +1,55 @@ +import { + EditorState, + ContentState, + ContentBlock, + convertFromHTML, + convertToRaw, + convertFromRaw, + RichUtils, + Modifier, + BlockMapBuilder, + genKey, + CharacterMetadata, +} from 'draft-js'; + +function insertFragment(editorState, fragment, entityMap) { + var newContent = Modifier.replaceWithFragment( + editorState.getCurrentContent(), + editorState.getSelection(), + fragment + ); + + return EditorState.push( + editorState, + newContent, + 'insert-fragment' + ); +} + +const stripPastePlugin = (convert = convertFromHTML) => ({ + handlePastedText(text, html, { getEditorState, setEditorState }) { + if (html) { + const htmlFrag = convert(html); + if (htmlFrag) { + const ALLOWED_STYLE = ['ITALIC', 'BOLD']; + const contentBlocks = htmlFrag.map(block => { + const characterList = block.getCharacterList().map(list => { + const styles = list.getStyle().filter(s => { + return ALLOWED_STYLE.indexOf(s) !== -1; + }); + return list.set('style', styles); + }); + return block.set('type', 'unstyled').set('characterList', characterList); + }); + + const htmlMap = BlockMapBuilder.createFromArray(contentBlocks); + + const newState = insertFragment(getEditorState(), htmlMap); + setEditorState(newState); + return true; + } + } + }, +}); + +export default stripPastePlugin; diff --git a/src/components/editor/paste-plugin/spec.js b/src/components/editor/paste-plugin/spec.js new file mode 100644 index 0000000..a6443e2 --- /dev/null +++ b/src/components/editor/paste-plugin/spec.js @@ -0,0 +1,72 @@ +import test from 'tape'; +import spy from 'utils/spy'; + +import { + EditorState, + ContentState, + ContentBlock, + CharacterMetadata, + convertToRaw, + convertFromRaw, + genKey, +} from 'draft-js'; +import { is, fromJS, List, Repeat, Record } from 'immutable'; + +import stripPastePluginFactory from './'; + +const genBlock = (text, { key, style, type = 'unstyled' } = {}) => { + let newStyle = CharacterMetadata.EMPTY; + if (style) { + newStyle = CharacterMetadata.applyStyle(newStyle, style); + } + + return new ContentBlock({ + key, + type, + text, + characterList: List(Repeat(newStyle, text.length)) + }); +}; +const genContent = (text) => { + const contentState = ContentState.createFromBlockArray([ + genBlock(text, { key: 'abc' }), + ]); + return convertToRaw(contentState); +}; + +const stateFromRaw = (raw) => { + return EditorState.createWithContent(convertFromRaw(raw)); +} + +test('StripPastePlugin strips all markup but italics and bolds', t => { + t.plan(1); + + const convert = () => { + return ([ + genBlock('boldy', { style: 'BOLD', key: 'b' }), + genBlock('yo', { type: 'header-two', key: 'h' }), + ]); + }; + + const stripPastePlugin = stripPastePluginFactory(convert); + + const initialContent = stateFromRaw(genContent('Starter')); + const resultingContent = stateFromRaw(convertToRaw(ContentState.createFromBlockArray([ + genBlock('boldy', { style: 'BOLD', key: 'b' }), + genBlock('yoStarter', { key: 'h' }), + ]))); + + const mocks = { setState: () => {} }; + const setEditorState = spy(mocks, 'setState'); + const getEditorState = () => initialContent; + + stripPastePlugin.handlePastedText(null, 'test', { getEditorState, setEditorState: mocks.setState }); + + const blocksData = (state) => { + return JSON.stringify(convertToRaw(state).blocks.map(({ key, ...rest }) => rest)); + }; + + const finalContent = blocksData(setEditorState.calls[0].args[0].getCurrentContent()); + const result = blocksData(resultingContent.getCurrentContent()); + t.equals(finalContent, result, 'Sets processed state'); +}); diff --git a/src/components/editor/spec.js b/src/components/editor/spec.js new file mode 100644 index 0000000..b78b1ad --- /dev/null +++ b/src/components/editor/spec.js @@ -0,0 +1,108 @@ +import React from 'react'; +import test from 'tape'; +import { shallow, mount } from 'enzyme'; +import spy from 'utils/spy'; + +import { + EditorState, + ContentState, + ContentBlock, + CharacterMetadata, + convertToRaw, + convertFromRaw, + genKey, +} from 'draft-js'; +import { is, fromJS, List, Repeat } from 'immutable'; + +import EditorFactory from './'; +import UpstreamEditor from 'draft-js-plugins-editor'; + +const genContent = (text) => { + const contentState = ContentState.createFromBlockArray([ + new ContentBlock({ + key: 'abc', + type: 'unstyled', + text, + characterList: List(Repeat(CharacterMetadata.EMPTY, text.length)) + }), + ]); + return convertToRaw(contentState); +}; + +const stateFromRaw = (raw) => { + return EditorState.createWithContent(convertFromRaw(raw)); +} + +const getState = (el) => { + return el.find('span').text(); +}; + +test('Editor status', t => { + const Editor = EditorFactory(React); + t.plan(3); + + const content = genContent('Write here'); + const instance = shallow(); + + t.equals(getState(instance), 'Reading', 'Initial state is Reading'); + t.equals(instance.find(UpstreamEditor).length, 1, 'Draft.js Editor is render'); + + instance.simulate('click'); + + t.equals(getState(instance), 'Writing', 'Clicking changes it to Writing'); +}); + +test('Editor read-only status', t => { + const Editor = EditorFactory(React); + t.plan(3); + + const content = genContent('Write here'); + const instance = shallow(); + + t.equals(getState(instance), 'Reading', 'Starts out as Reading'); + t.equals(instance.find(UpstreamEditor).length, 1); + + instance.simulate('click'); + + t.equals(getState(instance), 'Reading', 'Stays Reading even after the click'); +}); + +test('Editor is not managed', t => { + const Editor = EditorFactory(React); + t.plan(2); + + const content = genContent('This'); + const instance = shallow(); + + const expContent = convertFromRaw(content); + let currContent = instance.find(UpstreamEditor).props().editorState.getCurrentContent(); + + t.equals(is(currContent, expContent), true, 'Initial content is taken from props'); + + instance.setProps({ content: genContent('New') }); + + currContent = instance.find(UpstreamEditor).props().editorState.getCurrentContent(); + t.equals(is(currContent, expContent), true, 'Updating content prop does not change current content'); +}); + +test('Editor lifecycle', t => { + const Editor = EditorFactory(React); + t.plan(4); + + const content = genContent('Write here'); + const mocks = { onStart: () => {}, onEnd: () => {} }; + const onEditStart = spy(mocks, 'onStart'); + const onEditEnd = spy(mocks, 'onEnd'); + const instance = shallow(); + + t.equals(onEditStart.calls.length, 0, 'onEditStart is not called initially'); + t.equals(onEditEnd.calls.length, 0, 'onEditEnd is not called initially'); + + instance.instance().onFocus(); + + t.equals(onEditStart.calls.length, 1, 'onEditStart is called after focusing in'); + + instance.instance().onBlur(); + + t.equals(onEditEnd.calls.length, 1, 'onEditEnd is called after blurring out'); +}); diff --git a/src/components/editor/story.js b/src/components/editor/story.js new file mode 100644 index 0000000..31bae58 --- /dev/null +++ b/src/components/editor/story.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { storiesOf, action } from '@kadira/storybook'; + +import EditorFactory from './'; + +const Editor = EditorFactory(React); + +import { + EditorState, + ContentState, + ContentBlock, + CharacterMetadata, + convertToRaw, + convertFromRaw, + genKey, +} from 'draft-js'; +import { is, fromJS, List, Repeat } from 'immutable'; + +const genContent = (text) => { + const contentState = ContentState.createFromBlockArray([ + new ContentBlock({ + key: 'abc', + type: 'unstyled', + text, + characterList: List(Repeat(CharacterMetadata.EMPTY, text.length)) + }), + ]); + return convertToRaw(contentState); +}; + +storiesOf('Editor', module) + .add('default', () => { + const initialContent = genContent('Here we go'); + return ( + + ); + }) + .add('read-only', () => { + const initialContent = genContent('Here we go'); + return ( + + ); + }); diff --git a/src/components/outlines/editor/index.js b/src/components/outlines/editor/index.js index 0e0243b..25ce764 100644 --- a/src/components/outlines/editor/index.js +++ b/src/components/outlines/editor/index.js @@ -1,27 +1,22 @@ +import React, { PropTypes } from 'react'; import reactStamp from 'react-stamp'; +import EditorFactory from 'components/editor'; import { fromJS, List, Repeat } from 'immutable'; -import Editor from 'draft-js-plugins-editor'; import 'draft-js-mention-plugin/lib/plugin.css'; import { - EditorState, ContentState, ContentBlock, convertToRaw, convertFromRaw, - getDefaultKeyBinding, - KeyBindingUtil, - RichUtils, - Modifier, - BlockMapBuilder, genKey, CharacterMetadata, } from 'draft-js'; import createMentionPlugin, { defaultSuggestionsFilter } from 'draft-js-mention-plugin'; -const { hasCommandModifier } = KeyBindingUtil; const mentionPlugin = createMentionPlugin(); const { MentionSuggestions } = mentionPlugin; +const Editor = EditorFactory(React); export default ( React, ...behaviours ) => reactStamp( React ).compose({ propTypes: { @@ -39,210 +34,42 @@ export default ( React, ...behaviours ) => reactStamp( React ).compose({ em[ k ].data.mention = fromJS( em[ k ].data.mention ); }); - content = ContentState.createFromBlockArray( convertFromRaw( this.props.value ) ); + content = this.props.value; } else { - const text = 'First Header'; - content = ContentState.createFromBlockArray([ + const text = 'Write here'; + const contentState = ContentState.createFromBlockArray([ new ContentBlock({ key: genKey(), - type: 'header-two', + type: 'unstyled', text, characterList: List( Repeat( CharacterMetadata.EMPTY, text.length ) ) }), ]); + content = convertToRaw(contentState); } this.state = { - editor: EditorState.createWithContent( content ), + content, suggestions: fromJS(this.props.suggestions || []), }; }, - // componentDidMount () { - // this.refs.editor.focus(); - // }, - /** * When the input value changes by the user, update the state of the controlled component and * begin a timeout to update the model. */ - _onChange ( editor ) { - if ( this.props.readOnly ) { - return; - } - - this.setState({ editor }); - - this._endTimeout(); - this._startTimeout( editor ); - }, - - focus () { - this.refs.editor.focus(); + _onChange ( content ) { + this.setState({ content }); + this._save(content); }, /** * Persist the changes through the callback. */ - _save ( editor ) { + _save ( content ) { if ( this.props.onChange ) { - this.props.onChange( convertToRaw( editor.getCurrentContent() ), editor ); - } - }, - - /** - * Create a new timeout using the delay from props, or 500ms if none is provided, after which we - * can trigger the callback provided through props. - */ - _startTimeout ( editor ) { - const delay = this.props.delay || 3000; - this._changeTimeout = setTimeout( () => this._save( editor ), delay ); - }, - - /** - * Cancel the timeout, if it exists. - */ - _endTimeout () { - if ( this._changeTimeout ) { - clearTimeout( this._changeTimeout ); - delete this._changeTimeout; - } - }, - - /** - * Ensure we clean up the timeout. - */ - componentWillUnmount () { - if ( this._changeTimeout ) { - this._endTimeout(); - this._save( this.state.editor ); - } - }, - - _keyBindings ( e ) { - if ( ( e.keyCode === 49 || e.keyCode === 97 ) && hasCommandModifier( e ) ) { - return 'header'; - } - - if ( ( e.keyCode === 48 || e.keyCode === 96 ) && hasCommandModifier( e ) ) { - return 'unstyled'; - } - - return getDefaultKeyBinding( e ); - }, - - _handleKeyCommand ( command ) { - const { editor } = this.state; - let newState; - - switch ( command ) { - case 'header': - newState = RichUtils.toggleBlockType( this.state.editor, 'header-two' ); - break; - case 'unstyled': - newState = RichUtils.toggleBlockType( this.state.editor, 'unstyled' ); - break; - default: - newState = RichUtils.handleKeyCommand( editor, command ); + this.props.onChange( convertToRaw( content ), content ); } - - if ( newState ) { - this._onChange( newState ); - return true; - } - - return false; - }, - - _handleReturn ( e ) { - const { editor } = this.state; - const selection = editor.getSelection(); - const content = editor.getCurrentContent(); - const block = content.getBlockForKey( selection.getStartKey() ); - - // We only care if there is no current selection (e.g. selection is a caret). - if ( ! selection.isCollapsed() ) { - console.log("not collapsed") - return; - } - - // We only care if we're at the end of the line. - // TODO: implement splitting current block at selection - if ( block.getLength() !== selection.getStartOffset() ) { - console.log("not at end of line") - return; - } - - const previousBlock = content.getBlockBefore( block.getKey() ); - if ( block.getText().length === 0 ) { - if ( ! previousBlock || previousBlock.getText().length === 0 ) { - // no empty lines between paragraphs - return true; - } else { - // insert header block - this._insertHeader(); - return true; - } - } else if ( block.getType() === 'unstyled' ) { - // current line is non-empty and is unstyled already, so let the browser do its thing and - // insert another one. - return false; - } else { - // non-empty and not unstyled, so let's insert a new paragraph and move the cursor there. - this._insertParagraph(); - return true; - } - }, - - _insertParagraph () { - return this._insertBlock({ - key: genKey(), - type: 'unstyled', - text: '', - characterList: List() - }); - }, - - _insertHeader () { - const newState = RichUtils.toggleBlockType( this.state.editor, 'header-two' ); - this.setState({ editor: newState }); - }, - - _insertBlock ( blockData ) { - const { editor } = this.state; - const selection = editor.getSelection(); - const content = editor.getCurrentContent(); - const block = content.getBlockForKey( selection.getStartKey() ); - - // Insert new unstyled block - const newBlock = new ContentBlock( blockData ); - - const blockArr = []; - content.getBlockMap().forEach( ( oldBlock, key ) => { - blockArr.push( oldBlock ); - - if ( key === block.getKey() ) { - blockArr.push( newBlock ); - } - }); - - const newBlockMap = BlockMapBuilder.createFromArray( blockArr ); - - const newContent = content.merge({ - blockMap: newBlockMap, - selectionBefore: selection, - selectionAfter: selection.merge({ - anchorKey: newBlock.getKey(), - anchorOffset: 0, - focusKey: newBlock.getKey(), - focusOffset: 0, - isBackward: false, - }), - }); - - const newState = EditorState.push( editor, newContent, 'insert-fragment' ); - - this.setState({ editor: newState }); }, onSearchChange ({ value }) { @@ -252,26 +79,16 @@ export default ( React, ...behaviours ) => reactStamp( React ).compose({ }, render () { - const { onChange, value, ...props } = this.props; - const { editor, suggestions } = this.state; + const { onChange } = this.props; + const { suggestions } = this.state; const plugins = suggestions.size ? [ mentionPlugin ] : []; - const styles = { - unstyled: { - marginBottom: 20, - }, - }; - return (
this._onChange( e )} - keyBindingFn={ e => this._keyBindings( e ) } - handleKeyCommand={c => this._handleKeyCommand( c )} - handleReturn={e => this._handleReturn( e )} /> this.onSearchChange(o) }