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..38f430a
--- /dev/null
+++ b/src/components/editor/index.js
@@ -0,0 +1,261 @@
+import { PropTypes } from 'react';
+import reactStamp from 'react-stamp';
+import { fromJS, List, Repeat } from 'immutable';
+import Editor from 'draft-js-plugins-editor';
+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 = {
+ handlePastedText(text, html, { getEditorState, setEditorState }) {
+ if (html) {
+ const htmlFrag = convertFromHTML(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;
+ }
+ }
+ },
+};
+
+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();
+ });
+ },
+
+ 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/spec.js b/src/components/editor/spec.js
new file mode 100644
index 0000000..2a6ed03
--- /dev/null
+++ b/src/components/editor/spec.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import test from 'tape';
+import { shallow, mount } from 'enzyme';
+
+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');
+ t.equals(instance.find(UpstreamEditor).length, 1);
+
+ instance.simulate('click');
+
+ t.equals(getState(instance), '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');
+ t.equals(instance.find(UpstreamEditor).length, 1);
+
+ instance.simulate('click');
+
+ t.equals(getState(instance), 'Reading');
+});
+
+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);
+
+ instance.setProps({ content: genContent('New') });
+
+ currContent = instance.find(UpstreamEditor).props().editorState.getCurrentContent();
+ t.equals(is(currContent, expContent), true);
+});
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) }