diff --git a/conf/storybook/.babelrc b/conf/storybook/.babelrc new file mode 100644 index 0000000..3c45d73 --- /dev/null +++ b/conf/storybook/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": [ + "es2015", + "react", + "stage-0" + ] +} diff --git a/package.json b/package.json index 304b838..eb950ae 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://github.com/StoryShop/app#readme", "devDependencies": { - "@kadira/storybook": "^2.14.0", + "@kadira/storybook": "^2.5.2", "autoprefixer": "^6.3.3", "babel-loader": "^6.2.2", "babel-plugin-module-resolver": "^2.2.0", @@ -60,9 +60,9 @@ "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", diff --git a/src/components/editor/index.js b/src/components/editor/index.js new file mode 100644 index 0000000..0d074cb --- /dev/null +++ b/src/components/editor/index.js @@ -0,0 +1,115 @@ +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() { + this.state = { + editor: EditorState.createWithContent( convertFromRaw( this.props.content ) ), + readOnly: true, + }; + }, + + 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()); + } + }, + + render() { + const { plugins = [] } = this.props; + const isReadOnly = this.props.readOnly ? true : this.state.readOnly; + + return ( +
+ +
+ ); + }, +}); + +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..357a471 --- /dev/null +++ b/src/components/editor/paste-plugin/index.js @@ -0,0 +1,45 @@ +import { + EditorState, + convertFromHTML, + Modifier, + BlockMapBuilder, +} from 'draft-js'; + +const stripPastePlugin = ( convert = convertFromHTML ) => ({ + handlePastedText(text, html, { getEditorState, setEditorState }) { + if ( ! html ) { + return; + } + + const htmlFrag = convert( html ); + if ( ! htmlFrag ) { + return; + } + + 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 editorState = getEditorState(); + const newContent = Modifier.replaceWithFragment( + editorState.getCurrentContent(), + editorState.getSelection(), + htmlMap + ); + + setEditorState( EditorState.push( editorState, newContent, 'insert-fragment' ) ); + + 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..06703cf --- /dev/null +++ b/src/components/editor/paste-plugin/spec.js @@ -0,0 +1,106 @@ +import test from 'tape'; +import spy, { createSpy } 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 './'; + +test( 'StripPastePlugin', t => { + let plugin, actual, expected, result, convertSpy; + + const html = '

does not matter

'; + + const initalContentState = ContentState.createFromBlockArray([ + new ContentBlock({ + key: 'initial', + type: 'unstyled', + text: 'Initial', + characterList: List( Repeat( CharacterMetadata.EMPTY, 7 ) ), + }), + ]); + const initialEditorState = EditorState.createWithContent( initalContentState ); + + const mocks = { + setEditorState: () => {}, + getEditorState: () => initialEditorState, + }; + const setEditorStateSpy = spy( mocks, 'setEditorState' ); + const getEditorStateSpy = spy( mocks, 'getEditorState' ); + + convertSpy = createSpy( () => [ + new ContentBlock({ + key: 'bold', + type: 'unstyled', + text: 'bold', + characterList: List( Repeat( CharacterMetadata.applyStyle( CharacterMetadata.EMPTY, 'BOLD' ), 4 ) ), + }), + new ContentBlock({ + key: 'header', + type: 'header-two', + text: 'header', + characterList: List( Repeat( CharacterMetadata.EMPTY, 6 ) ), + }), + ]); + + plugin = stripPastePluginFactory( convertSpy ); + + /** + * Ignore Pasted Text + */ + { + result = plugin.handlePastedText( null, null, mocks ); + t.notOk( result, 'should return false when no html is provided' ); + } + + /** + * Process Pasted HTML + */ + { + result = plugin.handlePastedText( null, html, mocks ); + t.ok( result, 'should return true' ); + t.equal( convertSpy.calls.length, 1, 'should call convert' ); + t.equal( convertSpy.calls[ 0 ].args[ 0 ], html, 'should call convert with the html' ); + t.equal( setEditorStateSpy.calls.length, 1, 'should call setEditorState' ); + } + + /** + * Strip Pasted HTML + */ + { + let blocks = setEditorStateSpy.calls[ 0 ].args[ 0 ].getCurrentContent().getBlockMap(); + + actual = blocks.count(); + expected = 2; + t.equal( actual, expected, 'should result in two blocks' ); + + let firstBlock = blocks.first(); + let secondBlock = blocks.last(); + + actual = firstBlock.get( 'text' ); + expected = 'bold'; + t.equals( actual, expected, 'first block should be first block of pasted text' ); + + actual = firstBlock.getInlineStyleAt( 0 ).toJS(); + expected = [ 'BOLD' ]; + t.deepEquals( actual, expected, 'pasted bold text should remain bold' ); + + actual = secondBlock.get( 'text' ); + expected = 'headerInitial'; + t.equals( actual, expected, 'second block should combine pasted with intial content' ); + + actual = secondBlock.get( 'type' ); + expected = 'unstyled'; + t.equals( actual, expected, 'second block should be unstyled' ); + } + + t.end(); +}); diff --git a/src/components/editor/spec.js b/src/components/editor/spec.js new file mode 100644 index 0000000..b913dd1 --- /dev/null +++ b/src/components/editor/spec.js @@ -0,0 +1,88 @@ +import React from 'react'; +import test from 'tape'; +import { shallow, mount } from 'enzyme'; +import spy, { createSpy } from '../../utils/spy'; + +import { + EditorState, + ContentState, + ContentBlock, + CharacterMetadata, + convertToRaw, + convertFromRaw, + genKey, +} from 'draft-js'; + +import EditorFactory from './'; +import UpstreamEditor from 'draft-js-plugins-editor'; + +const Editor = EditorFactory(React); + +test('Editor', t => { + let instance, actual, expected, editor; + + const content = { + entityMap: {}, + blocks: [{ text: '' }], + }; + + instance = shallow( ); + + /** + * Default state + */ + { + let editor = instance.find( UpstreamEditor ).at( 0 ); + t.ok( editor, 'should render a Draft Plugins Editor' ); + t.ok( editor.props().readOnly, 'should start in readOnly mode' ); + } + + /** + * Writable on click + */ + { + instance.simulate( 'click' ); + + let editor = instance.find( UpstreamEditor ).at( 0 ); + t.notOk( editor.props().readOnly, 'should move to writable mode on click' ); + } + + /** + * Read only + */ + { + instance = shallow( ); + instance.simulate( 'click' ); + + let editor = instance.find( UpstreamEditor ).at( 0 ); + t.ok( editor.props().readOnly, 'should not move to writeable on click if read only' ); + } + + /** + * Lifecycle methods + */ + { + let onEditStartSpy = createSpy(); + let onEditEndSpy = createSpy(); + + instance = shallow( + + ); + + t.equals( onEditStartSpy.calls.length, 0, 'onEditStart is not called initially'); + t.equals( onEditEndSpy.calls.length, 0, 'onEditEnd is not called initially'); + + instance.instance().onFocus(); + t.equals(onEditStartSpy.calls.length, 1, 'onEditStart is called after focusing in'); + + instance.instance().onBlur(); + t.equals(onEditEndSpy.calls.length, 1, 'onEditEnd is called after blurring out'); + } + + t.end(); +}); + 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) } diff --git a/src/utils/spy.js b/src/utils/spy.js index 6f7fc1a..1294e81 100644 --- a/src/utils/spy.js +++ b/src/utils/spy.js @@ -13,9 +13,28 @@ export default ( target, method ) => { args: args, }); - oldMethod.apply( target, args ); + return oldMethod.apply( target, args ); }; return spy; }; +export const createSpy = oldMethod => { + let spy; + + spy = function ( ...args ) { + spy.calls.push({ + args: args, + }); + + if ( oldMethod ) { + return oldMethod( ...args ); + } + }; + + spy.calls = []; + spy.reset = () => spy.calls = []; + + return spy; +}; +