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;
+};
+