From c6542143e979950fc360d6678976dea991446daf Mon Sep 17 00:00:00 2001 From: Gosha Arinich Date: Wed, 14 Sep 2016 13:02:43 +0300 Subject: [PATCH 1/2] Create an Editor component. Ref GH-57 --- package.json | 6 +- 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, 534 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 304b838..10abbd7 100644 --- a/package.json +++ b/package.json @@ -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..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) } From 45bf05f4652c532538e37a2ea2d29b30f9eaff5c Mon Sep 17 00:00:00 2001 From: Josh David Miller Date: Thu, 22 Sep 2016 00:08:18 -0700 Subject: [PATCH 2/2] refactor: revised editor unit tests, implementation --- conf/storybook/.babelrc | 7 + package.json | 2 +- src/components/editor/index.js | 128 ++-------------- src/components/editor/paste-plugin/index.js | 70 ++++----- src/components/editor/paste-plugin/spec.js | 124 +++++++++------ src/components/editor/spec.js | 158 +++++++++----------- src/utils/spy.js | 21 ++- 7 files changed, 215 insertions(+), 295 deletions(-) create mode 100644 conf/storybook/.babelrc 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 10abbd7..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", diff --git a/src/components/editor/index.js b/src/components/editor/index.js index e465bfc..0d074cb 100644 --- a/src/components/editor/index.js +++ b/src/components/editor/index.js @@ -32,21 +32,10 @@ const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ }, init() { - const content = convertFromRaw(this.props.content); this.state = { - editor: EditorState.createWithContent(content), + editor: EditorState.createWithContent( convertFromRaw( this.props.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() { @@ -86,7 +75,7 @@ const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ if (this.props.onChange) { this.deleteTimeout(); - this._timeout = setTimeout(this.save, 3000); + this._timeout = setTimeout(::this.save, 3000); } }, @@ -103,119 +92,20 @@ const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ } }, - 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 { plugins = [] } = this.props; const isReadOnly = this.props.readOnly ? true : this.state.readOnly; - const plugins = (this.props.plugins || []).concat([stripPastePlugin]); + return ( -
- {isReadOnly ? 'Reading' : 'Writing'} +
); diff --git a/src/components/editor/paste-plugin/index.js b/src/components/editor/paste-plugin/index.js index 82675ea..357a471 100644 --- a/src/components/editor/paste-plugin/index.js +++ b/src/components/editor/paste-plugin/index.js @@ -1,54 +1,44 @@ 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) => ({ +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); + 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 htmlMap = BlockMapBuilder.createFromArray(contentBlocks); - const newState = insertFragment(getEditorState(), htmlMap); - setEditorState(newState); - return true; - } - } + const editorState = getEditorState(); + const newContent = Modifier.replaceWithFragment( + editorState.getCurrentContent(), + editorState.getSelection(), + htmlMap + ); + + setEditorState( EditorState.push( editorState, newContent, 'insert-fragment' ) ); + + return true; }, }); diff --git a/src/components/editor/paste-plugin/spec.js b/src/components/editor/paste-plugin/spec.js index a6443e2..06703cf 100644 --- a/src/components/editor/paste-plugin/spec.js +++ b/src/components/editor/paste-plugin/spec.js @@ -1,5 +1,5 @@ import test from 'tape'; -import spy from 'utils/spy'; +import spy, { createSpy } from '../../../utils/spy'; import { EditorState, @@ -14,59 +14,93 @@ 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); - } +test( 'StripPastePlugin', t => { + let plugin, actual, expected, result, convertSpy; + + const html = '

does not matter

'; - return new ContentBlock({ - key, - type, - text, - characterList: List(Repeat(newStyle, text.length)) - }); -}; -const genContent = (text) => { - const contentState = ContentState.createFromBlockArray([ - genBlock(text, { key: 'abc' }), + const initalContentState = ContentState.createFromBlockArray([ + new ContentBlock({ + key: 'initial', + type: 'unstyled', + text: 'Initial', + characterList: List( Repeat( CharacterMetadata.EMPTY, 7 ) ), + }), ]); - return convertToRaw(contentState); -}; + const initialEditorState = EditorState.createWithContent( initalContentState ); -const stateFromRaw = (raw) => { - return EditorState.createWithContent(convertFromRaw(raw)); -} + const mocks = { + setEditorState: () => {}, + getEditorState: () => initialEditorState, + }; + const setEditorStateSpy = spy( mocks, 'setEditorState' ); + const getEditorStateSpy = spy( mocks, 'getEditorState' ); -test('StripPastePlugin strips all markup but italics and bolds', t => { - t.plan(1); + 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 ) ), + }), + ]); - const convert = () => { - return ([ - genBlock('boldy', { style: 'BOLD', key: 'b' }), - genBlock('yo', { type: 'header-two', key: 'h' }), - ]); - }; + plugin = stripPastePluginFactory( convertSpy ); - const stripPastePlugin = stripPastePluginFactory(convert); + /** + * Ignore Pasted Text + */ + { + result = plugin.handlePastedText( null, null, mocks ); + t.notOk( result, 'should return false when no html is provided' ); + } - const initialContent = stateFromRaw(genContent('Starter')); - const resultingContent = stateFromRaw(convertToRaw(ContentState.createFromBlockArray([ - genBlock('boldy', { style: 'BOLD', key: 'b' }), - genBlock('yoStarter', { key: 'h' }), - ]))); + /** + * 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' ); + } - const mocks = { setState: () => {} }; - const setEditorState = spy(mocks, 'setState'); - const getEditorState = () => initialContent; + /** + * Strip Pasted HTML + */ + { + let blocks = setEditorStateSpy.calls[ 0 ].args[ 0 ].getCurrentContent().getBlockMap(); - stripPastePlugin.handlePastedText(null, 'test', { getEditorState, setEditorState: mocks.setState }); + actual = blocks.count(); + expected = 2; + t.equal( actual, expected, 'should result in two blocks' ); - const blocksData = (state) => { - return JSON.stringify(convertToRaw(state).blocks.map(({ key, ...rest }) => rest)); - }; + 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' ); + } - const finalContent = blocksData(setEditorState.calls[0].args[0].getCurrentContent()); - const result = blocksData(resultingContent.getCurrentContent()); - t.equals(finalContent, result, 'Sets processed state'); + t.end(); }); diff --git a/src/components/editor/spec.js b/src/components/editor/spec.js index b78b1ad..b913dd1 100644 --- a/src/components/editor/spec.js +++ b/src/components/editor/spec.js @@ -1,7 +1,7 @@ import React from 'react'; import test from 'tape'; import { shallow, mount } from 'enzyme'; -import spy from 'utils/spy'; +import spy, { createSpy } from '../../utils/spy'; import { EditorState, @@ -12,97 +12,77 @@ import { 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'); +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(); }); -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/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; +}; +