Skip to content

Commit

Permalink
Create an Editor component. Ref GH-57
Browse files Browse the repository at this point in the history
  • Loading branch information
goshacmd committed Sep 16, 2016
1 parent e02402b commit 209bf09
Show file tree
Hide file tree
Showing 4 changed files with 367 additions and 203 deletions.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
261 changes: 261 additions & 0 deletions src/components/editor/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<div onClick={this.onClick}>
<span style={{ fontSize: 12, color: '#555' }}>{isReadOnly ? 'Reading' : 'Writing'}</span>
<Editor
ref="upstream"
editorState={this.state.editor}
plugins={plugins}
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
readOnly={isReadOnly}

handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
/>
</div>
);
},
});

export default EditorComponent;
85 changes: 85 additions & 0 deletions src/components/editor/spec.js
Original file line number Diff line number Diff line change
@@ -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(<Editor content={content} />);

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(<Editor content={content} readOnly />);

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(<Editor content={content} />);

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);
});
Loading

0 comments on commit 209bf09

Please sign in to comment.