diff --git a/docs/APIReference-Modifier.md b/docs/APIReference-Modifier.md index d6c56eba30..42ab2314c9 100644 --- a/docs/APIReference-Modifier.md +++ b/docs/APIReference-Modifier.md @@ -4,6 +4,7 @@ title: Modifier layout: docs category: API Reference permalink: docs/api-reference-modifier.html +next: experimental-nesting --- The `Modifier` module is a static set of utility functions that encapsulate common diff --git a/docs/Experimental-Nesting.md b/docs/Experimental-Nesting.md new file mode 100644 index 0000000000..392fd81a49 --- /dev/null +++ b/docs/Experimental-Nesting.md @@ -0,0 +1,81 @@ +--- +id: experimental-nesting +title: Nesting +layout: docs +category: Experimental +next: api-reference-data-conversion +permalink: docs/experimental-nesting.html +--- + +## Overview + +By default Draft.js doesn't support nested blocks, but it can be enabled using some component props. + +### Usage + +```js +import {Editor, EditorState, NestedUtils} from 'draft-js'; + +class MyEditor extends React.Component { + constructor(props) { + super(props); + this.state = {editorState: EditorState.createEmpty()}; + this.onChange = (editorState) => this.setState({editorState}); + this.handleKeyCommand = this.handleKeyCommand.bind(this); + } + handleKeyCommand(command) { + const newState = NestedUtils.handleKeyCommand(this.state.editorState, command); + if (newState) { + this.onChange(newState); + return true; + } + return false; + } + render() { + return ( + + ); + } +} +``` + +### Commands + +`NestedUtils` provides two methods: `NestedUtils.keyBinding` and `NestedUtils.handleKeyCommand` to respond to user interactions with the right behavior for nested content. + +### Data Conversion + +`convertFromRaw` and `convertToRaw` supports nested blocks: + +```js +import {convertFromRaw} from 'draft-js'; + +var contentState = convertFromRaw({ + blocks: [ + { + type: 'heading-one', + text: 'My Awesome Article' + }, + { + type: 'blockquote', + blocks: [ + { + type: 'heading-two', + text: 'Another heading in a blockquote' + }, + { + type: 'unstyled', + text: 'Followed by a paragraph.' + } + ] + } + ], + entityMap: {} +}); +``` diff --git a/examples/tree/tree.css b/examples/tree/tree.css new file mode 100644 index 0000000000..7458e5118f --- /dev/null +++ b/examples/tree/tree.css @@ -0,0 +1,85 @@ +.RichEditor-root { + background: #fff; + border: 1px solid #ddd; + font-family: 'Georgia', serif; + font-size: 14px; + padding: 15px; +} + +.RichEditor-editor { + border-top: 1px solid #ddd; + cursor: text; + font-size: 16px; + margin-top: 10px; +} + +.RichEditor-editor .public-DraftEditorPlaceholder-root, +.RichEditor-editor .public-DraftEditor-content { + margin: 0 -15px -15px; + padding: 15px; +} + +.RichEditor-editor .public-DraftEditor-content { + min-height: 100px; +} + +.RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root { + display: none; +} + +.RichEditor-editor .RichEditor-blockquote { + border-left: 5px solid #eee; + color: #666; + font-family: 'Hoefler Text', 'Georgia', serif; + font-style: italic; + margin: 16px 0; + padding: 10px 20px; +} + +.RichEditor-editor .public-DraftStyleDefault-pre { + background-color: rgba(0, 0, 0, 0.05); + font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; + font-size: 16px; + padding: 20px; +} + +.RichEditor-controls { + font-family: 'Helvetica', sans-serif; + font-size: 14px; + margin-bottom: 5px; + user-select: none; +} + +.RichEditor-styleButton { + color: #999; + cursor: pointer; + margin-right: 16px; + padding: 2px 0; + display: inline-block; +} + +.RichEditor-activeButton { + color: #5890ff; +} + +.RichEditor-editor table { + border: 1px solid #ccc; + display: block; +} + +.RichEditor-editor tr { + padding: 0; + margin: 0; +} + +.RichEditor-editor td { + border: 1px solid black; +} + +#target { + width: 600px; +} + +.public-DraftStyleDefault-block { + padding-left: 20px; +} diff --git a/examples/tree/tree.html b/examples/tree/tree.html new file mode 100644 index 0000000000..d5aa9bb0da --- /dev/null +++ b/examples/tree/tree.html @@ -0,0 +1,436 @@ + + + + + + Draft • Tree Editor + + + + +
+ + + + + + + + + diff --git a/src/Draft.js b/src/Draft.js index dd370384b3..e34d1ece7b 100644 --- a/src/Draft.js +++ b/src/Draft.js @@ -27,6 +27,7 @@ const DraftEntityInstance = require('DraftEntityInstance'); const EditorState = require('EditorState'); const KeyBindingUtil = require('KeyBindingUtil'); const RichTextEditorUtil = require('RichTextEditorUtil'); +const NestedTextEditorUtil = require('NestedTextEditorUtil'); const SelectionState = require('SelectionState'); const convertFromDraftStateToRaw = require('convertFromDraftStateToRaw'); @@ -34,6 +35,7 @@ const convertFromHTMLToContentBlocks = require('convertFromHTMLToContentBlocks'); const convertFromRawToDraftState = require('convertFromRawToDraftState'); const generateRandomKey = require('generateRandomKey'); +const generateNestedKey = require('generateNestedKey'); const getDefaultKeyBinding = require('getDefaultKeyBinding'); const getVisibleSelectionRect = require('getVisibleSelectionRect'); @@ -56,6 +58,7 @@ const DraftPublic = { KeyBindingUtil, Modifier: DraftModifier, RichUtils: RichTextEditorUtil, + NestedUtils: NestedTextEditorUtil, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, @@ -64,6 +67,7 @@ const DraftPublic = { convertFromRaw: convertFromRawToDraftState, convertToRaw: convertFromDraftStateToRaw, genKey: generateRandomKey, + genNestedKey: generateNestedKey, getDefaultKeyBinding, getVisibleSelectionRect, }; diff --git a/src/component/base/DraftEditor.react.js b/src/component/base/DraftEditor.react.js index 41d726ce49..f049045eca 100644 --- a/src/component/base/DraftEditor.react.js +++ b/src/component/base/DraftEditor.react.js @@ -219,7 +219,6 @@ class DraftEditor extends React.Component { 'DraftEditor/alignRight': textAlignment === 'right', 'DraftEditor/alignCenter': textAlignment === 'center', }); - const hasContent = this.props.editorState.getCurrentContent().hasText(); const contentStyle = { outline: 'none', diff --git a/src/component/contents/DraftEditorBlock.react.js b/src/component/contents/DraftEditorBlock.react.js index 81d5a5c583..3f8a78b66e 100644 --- a/src/component/contents/DraftEditorBlock.react.js +++ b/src/component/contents/DraftEditorBlock.react.js @@ -32,12 +32,14 @@ const nullthrows = require('nullthrows'); import type {BidiDirection} from 'UnicodeBidiDirection'; import type {DraftDecoratorType} from 'DraftDecoratorType'; +import type {BlockMap} from 'BlockMap'; import type {List} from 'immutable'; const SCROLL_BUFFER = 10; type Props = { block: ContentBlock, + blockMapTree: Object, customStyleMap: Object, tree: List, selection: SelectionState, @@ -57,10 +59,20 @@ type Props = { */ class DraftEditorBlock extends React.Component { shouldComponentUpdate(nextProps: Props): boolean { + const { + block, + direction, + blockMapTree, + tree, + } = this.props; + + const key = block.getKey(); + return ( - this.props.block !== nextProps.block || - this.props.tree !== nextProps.tree || - this.props.direction !== nextProps.direction || + blockMapTree.getIn([key, 'childrenBlocks']) !== nextProps.blockMapTree.getIn([key, 'childrenBlocks']) || + block !== nextProps.block || + tree !== nextProps.tree || + direction !== nextProps.direction || ( isBlockOnSelectionEdge( nextProps.selection, @@ -193,17 +205,28 @@ class DraftEditorBlock extends React.Component { }).toArray(); } + _renderBlockMap( + blocks: BlockMap + ): React.Element { + var DraftEditorBlocks = this.props.DraftEditorBlocks; + return ; + } + render(): React.Element { - const {direction, offsetKey} = this.props; + const {direction, offsetKey, blockMap} = this.props; const className = cx({ 'public/DraftStyleDefault/block': true, 'public/DraftStyleDefault/ltr': direction === 'LTR', 'public/DraftStyleDefault/rtl': direction === 'RTL', }); + // Render nested blocks or text but never both at the same time. return (
- {this._renderChildren()} + {blockMap && blockMap.size && blockMap.size > 0 ? + this._renderBlockMap(blockMap) : + this._renderChildren() + }
); } diff --git a/src/component/contents/DraftEditorBlocks.react.js b/src/component/contents/DraftEditorBlocks.react.js new file mode 100644 index 0000000000..0cd7dd61db --- /dev/null +++ b/src/component/contents/DraftEditorBlocks.react.js @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule DraftEditorBlocks.react + * @typechecks + * @flow + */ + +'use strict'; + +const DraftEditorBlock = require('DraftEditorBlock.react'); +const DraftOffsetKey = require('DraftOffsetKey'); +const React = require('React'); + +const cx = require('cx'); +const joinClasses = require('joinClasses'); +const nullthrows = require('nullthrows'); + +import type {BidiDirection} from 'UnicodeBidiDirection'; + +/** + * `DraftEditorBlocks` is the container component for all block components + * rendered for a `DraftEditor`. It is optimized to aggressively avoid + * re-rendering blocks whenever possible. + * + * This component is separate from `DraftEditor` because certain props + * (for instance, ARIA props) must be allowed to update without affecting + * the contents of the editor. + */ +class DraftEditorBlocks extends React.Component { + render(): React.Element { + const { + type, + blockRenderMap, + blockRendererFn, + blockStyleFn, + customStyleMap, + blockMap, + blockMapTree, + selection, + forceSelection, + decorator, + directionMap, + getBlockTree, + getBlockChildren, + getBlockDescendants + } = this.props; + + const blocks = []; + let currentWrapperElement = null; + let currentWrapperTemplate = null; + let currentDepth = null; + let currentWrappedBlocks; + let key, blockType, child, childProps, wrapperTemplate; + + blockMap.forEach((block) => { + key = block.getKey(); + blockType = block.getType(); + + const customRenderer = blockRendererFn(block); + let CustomComponent, customProps, customEditable; + if (customRenderer) { + CustomComponent = customRenderer.component; + customProps = customRenderer.props; + customEditable = customRenderer.editable; + } + + const direction = directionMap.get(key); + const offsetKey = DraftOffsetKey.encode(key, 0, 0); + const blockChildren = blockMapTree.getIn([key, 'firstLevelBlocks']); + + const componentProps = { + block, + blockProps: customProps, + customStyleMap, + decorator, + direction, + directionMap, + forceSelection, + key, + offsetKey, + selection, + blockRenderMap, + blockRendererFn, + blockStyleFn, + blockMapTree, + blockMap: blockChildren, + getBlockTree, + getBlockChildren, + getBlockDescendants, + DraftEditorBlocks: DraftEditorBlocks, + tree: getBlockTree(key) + }; + + // Block render map must have a configuration specified for this + // block type. + const configForType = nullthrows(blockRenderMap.get(blockType)); + + wrapperTemplate = configForType.wrapper; + + const useNewWrapper = wrapperTemplate !== currentWrapperTemplate; + + const Element = ( + blockRenderMap.get(blockType).element || + blockRenderMap.get('unstyled').element + ); + + const depth = block.getDepth(); + let className = blockStyleFn(block); + + // List items are special snowflakes, since we handle nesting and + // counters manually. + if (Element === 'li') { + const shouldResetCount = ( + useNewWrapper || + currentDepth === null || + depth > currentDepth + ); + className = joinClasses( + className, + getListItemClasses(blockType, depth, shouldResetCount, direction) + ); + } + + const Component = CustomComponent || DraftEditorBlock; + childProps = { + className, + 'data-block': true, + 'data-editor': this.props.editorKey, + 'data-offset-key': offsetKey, + key, + }; + if (customEditable !== undefined) { + childProps = { + ...childProps, + contentEditable: customEditable, + suppressContentEditableWarning: true, + }; + } + + child = React.createElement( + Element, + childProps, + , + ); + + if (wrapperTemplate) { + if (useNewWrapper) { + currentWrappedBlocks = []; + currentWrapperElement = React.cloneElement( + wrapperTemplate, + { + key: key + '-wrap', + 'data-offset-key': offsetKey, + }, + currentWrappedBlocks + ); + currentWrapperTemplate = wrapperTemplate; + blocks.push(currentWrapperElement); + } + currentDepth = block.getDepth(); + nullthrows(currentWrappedBlocks).push(child); + } else { + currentWrappedBlocks = null; + currentWrapperElement = null; + currentWrapperTemplate = null; + currentDepth = null; + blocks.push(child); + } + }); + + const dataContents = type === 'contents' ? true : null; + const dataBlocks = dataContents ? null : true; + + return ( + // data-contents will be true for the root level block otherwise + // it will just be a block container +
{blocks}
+ ); + } +} + +/** + * Provide default styling for list items. This way, lists will be styled with + * proper counters and indentation even if the caller does not specify + * their own styling at all. If more than five levels of nesting are needed, + * the necessary CSS classes can be provided via `blockStyleFn` configuration. + */ +function getListItemClasses( + type: string, + depth: number, + shouldResetCount: boolean, + direction: BidiDirection +): string { + return cx({ + 'public/DraftStyleDefault/unorderedListItem': + type === 'unordered-list-item', + 'public/DraftStyleDefault/orderedListItem': + type === 'ordered-list-item', + 'public/DraftStyleDefault/reset': shouldResetCount, + 'public/DraftStyleDefault/depth0': depth === 0, + 'public/DraftStyleDefault/depth1': depth === 1, + 'public/DraftStyleDefault/depth2': depth === 2, + 'public/DraftStyleDefault/depth3': depth === 3, + 'public/DraftStyleDefault/depth4': depth === 4, + 'public/DraftStyleDefault/listLTR': direction === 'LTR', + 'public/DraftStyleDefault/listRTL': direction === 'RTL', + }); +} + +module.exports = DraftEditorBlocks; diff --git a/src/component/contents/DraftEditorContents.react.js b/src/component/contents/DraftEditorContents.react.js index a630dae779..fff8de8a11 100644 --- a/src/component/contents/DraftEditorContents.react.js +++ b/src/component/contents/DraftEditorContents.react.js @@ -13,16 +13,11 @@ 'use strict'; -const DraftEditorBlock = require('DraftEditorBlock.react'); -const DraftOffsetKey = require('DraftOffsetKey'); +const DraftEditorBlocks = require('DraftEditorBlocks.react'); const EditorState = require('EditorState'); const React = require('React'); - -const cx = require('cx'); -const joinClasses = require('joinClasses'); const nullthrows = require('nullthrows'); -import type {BidiDirection} from 'UnicodeBidiDirection'; import type ContentBlock from 'ContentBlock'; type Props = { @@ -94,6 +89,7 @@ class DraftEditorContents extends React.Component { const { blockRenderMap, blockRendererFn, + blockStyleFn, customStyleMap, editorState, } = this.props; @@ -103,150 +99,26 @@ class DraftEditorContents extends React.Component { const forceSelection = editorState.mustForceSelection(); const decorator = editorState.getDecorator(); const directionMap = nullthrows(editorState.getDirectionMap()); - - const blocksAsArray = content.getBlocksAsArray(); - const blocks = []; - let currentWrapperElement = null; - let currentWrapperTemplate = null; - let currentDepth = null; - let currentWrappedBlocks; - let block, key, blockType, child, childProps, wrapperTemplate; - - for (let ii = 0; ii < blocksAsArray.length; ii++) { - block = blocksAsArray[ii]; - key = block.getKey(); - blockType = block.getType(); - - const customRenderer = blockRendererFn(block); - let CustomComponent, customProps, customEditable; - if (customRenderer) { - CustomComponent = customRenderer.component; - customProps = customRenderer.props; - customEditable = customRenderer.editable; - } - - const direction = directionMap.get(key); - const offsetKey = DraftOffsetKey.encode(key, 0, 0); - const componentProps = { - block, - blockProps: customProps, - customStyleMap, - decorator, - direction, - forceSelection, - key, - offsetKey, - selection, - tree: editorState.getBlockTree(key), - }; - - // Block render map must have a configuration specified for this - // block type. - const configForType = nullthrows(blockRenderMap.get(blockType)); - - wrapperTemplate = configForType.wrapper; - - const useNewWrapper = wrapperTemplate !== currentWrapperTemplate; - - const Element = ( - blockRenderMap.get(blockType).element || - blockRenderMap.get('unstyled').element - ); - - const depth = block.getDepth(); - let className = this.props.blockStyleFn(block); - - // List items are special snowflakes, since we handle nesting and - // counters manually. - if (Element === 'li') { - const shouldResetCount = ( - useNewWrapper || - currentDepth === null || - depth > currentDepth - ); - className = joinClasses( - className, - getListItemClasses(blockType, depth, shouldResetCount, direction) - ); - } - - const Component = CustomComponent || DraftEditorBlock; - childProps = { - className, - 'data-block': true, - 'data-editor': this.props.editorKey, - 'data-offset-key': offsetKey, - key, - }; - if (customEditable !== undefined) { - childProps = { - ...childProps, - contentEditable: customEditable, - suppressContentEditableWarning: true, - }; - } - - child = React.createElement( - Element, - childProps, - , - ); - - if (wrapperTemplate) { - if (useNewWrapper) { - currentWrappedBlocks = []; - currentWrapperElement = React.cloneElement( - wrapperTemplate, - { - key: key + '-wrap', - 'data-offset-key': offsetKey, - }, - currentWrappedBlocks - ); - currentWrapperTemplate = wrapperTemplate; - blocks.push(currentWrapperElement); - } - currentDepth = block.getDepth(); - nullthrows(currentWrappedBlocks).push(child); - } else { - currentWrappedBlocks = null; - currentWrapperElement = null; - currentWrapperTemplate = null; - currentDepth = null; - blocks.push(child); - } - } - - return
{blocks}
; + const blockMapTree = content.getBlockDescendants(); + const blockMap = blockMapTree.getIn(['__ROOT__', 'firstLevelBlocks']); + + return ; } } -/** - * Provide default styling for list items. This way, lists will be styled with - * proper counters and indentation even if the caller does not specify - * their own styling at all. If more than five levels of nesting are needed, - * the necessary CSS classes can be provided via `blockStyleFn` configuration. - */ -function getListItemClasses( - type: string, - depth: number, - shouldResetCount: boolean, - direction: BidiDirection -): string { - return cx({ - 'public/DraftStyleDefault/unorderedListItem': - type === 'unordered-list-item', - 'public/DraftStyleDefault/orderedListItem': - type === 'ordered-list-item', - 'public/DraftStyleDefault/reset': shouldResetCount, - 'public/DraftStyleDefault/depth0': depth === 0, - 'public/DraftStyleDefault/depth1': depth === 1, - 'public/DraftStyleDefault/depth2': depth === 2, - 'public/DraftStyleDefault/depth3': depth === 3, - 'public/DraftStyleDefault/depth4': depth === 4, - 'public/DraftStyleDefault/listLTR': direction === 'LTR', - 'public/DraftStyleDefault/listRTL': direction === 'RTL', - }); -} - module.exports = DraftEditorContents; diff --git a/src/component/contents/__tests__/DraftEditorBlock.react-test.js b/src/component/contents/__tests__/DraftEditorBlock.react-test.js index 367688cef6..fe04e47212 100644 --- a/src/component/contents/__tests__/DraftEditorBlock.react-test.js +++ b/src/component/contents/__tests__/DraftEditorBlock.react-test.js @@ -99,6 +99,7 @@ function getSelection() { function getProps(block, decorator) { return { + blockMapTree: new Immutable.Map(), block, tree: BlockTree.generate(block, decorator), selection: getSelection(), diff --git a/src/component/selection/DraftOffsetKey.js b/src/component/selection/DraftOffsetKey.js index 626c683c02..c5a14dfde6 100644 --- a/src/component/selection/DraftOffsetKey.js +++ b/src/component/selection/DraftOffsetKey.js @@ -27,10 +27,11 @@ var DraftOffsetKey = { decode: function(offsetKey: string): DraftOffsetKeyPath { var [blockKey, decoratorKey, leafKey] = offsetKey.split(KEY_DELIMITER); + return { blockKey, decoratorKey: parseInt(decoratorKey, 10), - leafKey: parseInt(leafKey, 10), + leafKey: parseInt(leafKey, 10) }; }, }; diff --git a/src/component/selection/__tests__/getDraftEditorSelection-test.js b/src/component/selection/__tests__/getDraftEditorSelection-test.js index d2d3cd7505..d3bdbc17df 100644 --- a/src/component/selection/__tests__/getDraftEditorSelection-test.js +++ b/src/component/selection/__tests__/getDraftEditorSelection-test.js @@ -12,16 +12,9 @@ 'use strict'; jest.disableAutomock(); - -var CharacterMetadata = require('CharacterMetadata'); -var ContentBlock = require('ContentBlock'); -var ContentState = require('ContentState'); -var EditorState = require('EditorState'); -var Immutable = require('immutable'); var SelectionState = require('SelectionState'); - -var {BOLD} = require('SampleDraftInlineStyle'); -var {EMPTY} = CharacterMetadata; +var getSampleSelectionMocksForTesting = require('getSampleSelectionMocksForTesting'); +var getSampleSelectionMocksForTestingNestedBlocks = require('getSampleSelectionMocksForTestingNestedBlocks'); var getDraftEditorSelection = require('getDraftEditorSelection'); @@ -43,116 +36,6 @@ describe('getDraftEditorSelection', function() { var leafChildren; var textNodes; - beforeEach(function() { - window.getSelection = jest.fn(); - root = document.createElement('div'); - contents = document.createElement('div'); - contents.setAttribute('data-contents', 'true'); - root.appendChild(contents); - - var text = [ - 'Washington', - 'Jefferson', - 'Lincoln', - 'Roosevelt', - 'Kennedy', - 'Obama', - ]; - - var textA = text[0] + text[1]; - var textB = text[2] + text[3]; - var textC = text[4] + text[5]; - - var boldChar = CharacterMetadata.create({style: BOLD}); - var aChars = Immutable.List( - Immutable.Repeat(EMPTY, text[0].length).concat( - Immutable.Repeat(boldChar, text[1].length) - ) - ); - var bChars = Immutable.List( - Immutable.Repeat(EMPTY, text[2].length).concat( - Immutable.Repeat(boldChar, text[3].length) - ) - ); - var cChars = Immutable.List( - Immutable.Repeat(EMPTY, text[4].length).concat( - Immutable.Repeat(boldChar, text[5].length) - ) - ); - - var contentBlocks = [ - new ContentBlock({ - key: 'a', - type: 'unstyled', - text: textA, - characterList: aChars, - }), - new ContentBlock({ - key: 'b', - type: 'unstyled', - text: textB, - characterList: bChars, - }), - new ContentBlock({ - key: 'c', - type: 'unstyled', - text: textC, - characterList: cChars, - }), - ]; - - var contentState = ContentState.createFromBlockArray(contentBlocks); - editorState = EditorState.createWithContent(contentState); - - textNodes = text - .map( - function(text) { - return document.createTextNode(text); - } - ); - leafChildren = textNodes - .map( - function(textNode) { - var span = document.createElement('span'); - span.appendChild(textNode); - return span; - } - ); - leafs = ['a-0-0', 'a-0-1', 'b-0-0', 'b-0-1', 'c-0-0', 'c-0-1'] - .map( - function(blockKey, ii) { - var span = document.createElement('span'); - span.setAttribute('data-offset-key', '' + blockKey); - span.appendChild(leafChildren[ii]); - return span; - } - ); - decorators = ['a-0-0', 'b-0-0', 'c-0-0'] - .map( - function(decoratorKey, ii) { - var span = document.createElement('span'); - span.setAttribute('data-offset-key', '' + decoratorKey); - span.appendChild(leafs[(ii * 2)]); - span.appendChild(leafs[(ii * 2) + 1]); - return span; - } - ); - blocks = ['a-0-0', 'b-0-0', 'c-0-0'] - .map( - function(blockKey, ii) { - var blockElement = document.createElement('div'); - blockElement.setAttribute('data-offset-key', '' + blockKey); - blockElement.appendChild(decorators[ii]); - return blockElement; - } - ); - blocks.forEach( - function(blockElem) { - contents.appendChild(blockElem); - } - ); - }); - function assertEquals(result, assert) { var resultSelection = result.selectionState; var resultRecovery = result.needsRecovery; @@ -171,6 +54,20 @@ describe('getDraftEditorSelection', function() { expect(resultRecovery).toBe(assertRecovery); } + function resetMocks(sampleMock) { + window.getSelection = jest.fn(); + editorState = sampleMock.editorState; + root = sampleMock.root; + contents = sampleMock.contents; + blocks = sampleMock.blocks; + decorators = sampleMock.decorators; + leafs = sampleMock.leafs; + leafChildren = sampleMock.leafChildren; + textNodes = sampleMock.textNodes; + } + + beforeEach(() => resetMocks(getSampleSelectionMocksForTesting())); + describe('Modern Selection', function() { beforeEach(function() { document.selection = null; @@ -409,59 +306,62 @@ describe('getDraftEditorSelection', function() { }); }); - it('starts at head of text node, ends at end of leaf child', function() { - var leaf = leafChildren[4]; + it('starts at head of text node, ends at head of leaf child with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], anchorOffset: 0, - focusNode: leaf, - focusOffset: leaf.childNodes.length, + focusNode: leafChildren[2], + focusOffset: 0, }); var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', anchorOffset: 0, - focusKey: 'c', - focusOffset: leaf.textContent.length, + focusKey: 'b/c', + focusOffset: 0, isBackward: false, }), needsRecovery: true, }); }); - - it('starts within text node, ends at start of leaf child', function() { + it('starts at head of text node, ends at end of leaf child', function() { var leaf = leafChildren[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], - anchorOffset: 4, + anchorOffset: 0, focusNode: leaf, - focusOffset: 0, + focusOffset: leaf.childNodes.length, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 4, + anchorOffset: 0, focusKey: 'c', - focusOffset: 0, + focusOffset: leaf.textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('starts within text node, ends at end of leaf child', function() { - var leaf = leafChildren[4]; + it('starts at head of text node, ends at end of leaf child with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafChildren[2]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], - anchorOffset: 4, + anchorOffset: 0, focusNode: leaf, focusOffset: leaf.childNodes.length, }); @@ -470,8 +370,8 @@ describe('getDraftEditorSelection', function() { assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 4, - focusKey: 'c', + anchorOffset: 0, + focusKey: 'b/c', focusOffset: leaf.textContent.length, isBackward: false, }), @@ -479,37 +379,39 @@ describe('getDraftEditorSelection', function() { }); }); - it('is a reversed text-to-leaf-child selection', function() { + + it('starts within text node, ends at start of leaf child', function() { var leaf = leafChildren[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leaf, - anchorOffset: 0, - focusNode: textNodes[0], - focusOffset: 4, + anchorNode: textNodes[0], + anchorOffset: 4, + focusNode: leaf, + focusOffset: 0, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ - anchorKey: 'c', - anchorOffset: 0, - focusKey: 'a', - focusOffset: 4, - isBackward: true, + anchorKey: 'a', + anchorOffset: 4, + focusKey: 'c', + focusOffset: 0, + isBackward: false, }), needsRecovery: true, }); }); - }); - describe('One end is a text node, the other is a leaf span', function() { - it('starts at head of text node, ends at head of leaf span', function() { + it('starts within text node, ends at start of leaf child with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafChildren[2]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], - anchorOffset: 0, - focusNode: leafs[4], + anchorOffset: 4, + focusNode: leaf, focusOffset: 0, }); @@ -517,8 +419,8 @@ describe('getDraftEditorSelection', function() { assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 0, - focusKey: 'c', + anchorOffset: 4, + focusKey: 'b/c', focusOffset: 0, isBackward: false, }), @@ -526,12 +428,12 @@ describe('getDraftEditorSelection', function() { }); }); - it('starts at head of text node, ends at end of leaf span', function() { - var leaf = leafs[4]; + it('starts within text node, ends at end of leaf child', function() { + var leaf = leafChildren[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], - anchorOffset: 0, + anchorOffset: 4, focusNode: leaf, focusOffset: leaf.childNodes.length, }); @@ -540,7 +442,7 @@ describe('getDraftEditorSelection', function() { assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 0, + anchorOffset: 4, focusKey: 'c', focusOffset: leaf.textContent.length, isBackward: false, @@ -549,15 +451,16 @@ describe('getDraftEditorSelection', function() { }); }); + it('starts within text node, ends at end of leaf child with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); - it('starts within text node, ends at start of leaf span', function() { - var leaf = leafs[4]; + var leaf = leafChildren[2]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], anchorOffset: 4, focusNode: leaf, - focusOffset: 0, + focusOffset: leaf.childNodes.length, }); var selection = getDraftEditorSelection(editorState, root); @@ -565,16 +468,18 @@ describe('getDraftEditorSelection', function() { selectionState: new SelectionState({ anchorKey: 'a', anchorOffset: 4, - focusKey: 'c', - focusOffset: 0, + focusKey: 'b/c', + focusOffset: leaf.textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('starts within text node, ends at end of leaf span', function() { - var leaf = leafs[4]; + it('starts within text node, ends at end of leaf child with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafChildren[2]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: textNodes[0], @@ -588,7 +493,7 @@ describe('getDraftEditorSelection', function() { selectionState: new SelectionState({ anchorKey: 'a', anchorOffset: 4, - focusKey: 'c', + focusKey: 'b/c', focusOffset: leaf.textContent.length, isBackward: false, }), @@ -596,8 +501,8 @@ describe('getDraftEditorSelection', function() { }); }); - it('is a reversed text-to-leaf selection', function() { - var leaf = leafs[4]; + it('is a reversed text-to-leaf-child selection', function() { + var leaf = leafChildren[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: leaf, @@ -618,111 +523,113 @@ describe('getDraftEditorSelection', function() { needsRecovery: true, }); }); - }); - describe('A single leaf span is selected', function() { - it('is collapsed at start', function() { - var leaf = leafs[0]; + it('is a reversed text-to-leaf-child selection with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafChildren[2]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: leaf, anchorOffset: 0, - focusNode: leaf, - focusOffset: 0, + focusNode: textNodes[0], + focusOffset: 4, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ - anchorKey: 'a', + anchorKey: 'b/c', anchorOffset: 0, focusKey: 'a', - focusOffset: 0, - isBackward: false, + focusOffset: 4, + isBackward: true, }), needsRecovery: true, }); }); + }); - it('is collapsed at end', function() { - var leaf = leafs[0]; + describe('One end is a text node, the other is a leaf span', function() { + it('starts at head of text node, ends at head of leaf span', function() { window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leaf, - anchorOffset: leaf.childNodes.length, - focusNode: leaf, - focusOffset: leaf.childNodes.length, + anchorNode: textNodes[0], + anchorOffset: 0, + focusNode: leafs[4], + focusOffset: 0, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: leaf.textContent.length, - focusKey: 'a', - focusOffset: leaf.textContent.length, + anchorOffset: 0, + focusKey: 'c', + focusOffset: 0, isBackward: false, }), needsRecovery: true, }); }); + it('starts at head of text node, ends at head of leaf span with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); - it('contains an entire leaf', function() { - var leaf = leafs[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leaf, + anchorNode: textNodes[0], anchorOffset: 0, - focusNode: leaf, - focusOffset: leaf.childNodes.length, + focusNode: leafs[3], + focusOffset: 0, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ - anchorKey: 'c', + anchorKey: 'a', anchorOffset: 0, - focusKey: 'c', - focusOffset: leaf.textContent.length, + focusKey: 'b/d', + focusOffset: 0, isBackward: false, }), needsRecovery: true, }); }); - it('is reversed on entire leaf', function() { + it('starts at head of text node, ends at end of leaf span', function() { var leaf = leafs[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leaf, - anchorOffset: leaf.childNodes.length, + anchorNode: textNodes[0], + anchorOffset: 0, focusNode: leaf, - focusOffset: 0, + focusOffset: leaf.childNodes.length, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ - anchorKey: 'c', - anchorOffset: leaf.textContent.length, + anchorKey: 'a', + anchorOffset: 0, focusKey: 'c', - focusOffset: 0, - isBackward: true, + focusOffset: leaf.textContent.length, + isBackward: false, }), needsRecovery: true, }); }); - }); - describe('Multiple leaf spans are selected', function() { - it('from start of one to start of another', function() { + it('starts at head of text node, ends at end of leaf span with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[3]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leafs[0], + anchorNode: textNodes[0], anchorOffset: 0, - focusNode: leafs[4], - focusOffset: 0, + focusNode: leaf, + focusOffset: leaf.childNodes.length, }); var selection = getDraftEditorSelection(editorState, root); @@ -730,128 +637,658 @@ describe('getDraftEditorSelection', function() { selectionState: new SelectionState({ anchorKey: 'a', anchorOffset: 0, - focusKey: 'c', - focusOffset: 0, + focusKey: 'b/d', + focusOffset: leaf.textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('from start of one to end of other', function() { + it('starts within text node, ends at start of leaf span', function() { + var leaf = leafs[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leafs[0], - anchorOffset: 0, - focusNode: leafs[4], - focusOffset: leafs[4].childNodes.length, + anchorNode: textNodes[0], + anchorOffset: 4, + focusNode: leaf, + focusOffset: 0, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 0, + anchorOffset: 4, focusKey: 'c', - focusOffset: leafs[4].textContent.length, + focusOffset: 0, isBackward: false, }), needsRecovery: true, }); }); + it('starts within text node, ends at start of leaf span with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); - it('reversed leaf to leaf', function() { + var leaf = leafs[3]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: leafs[4], - anchorOffset: leafs[4].childNodes.length, - focusNode: leafs[0], + anchorNode: textNodes[0], + anchorOffset: 4, + focusNode: leaf, focusOffset: 0, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ - anchorKey: 'c', - anchorOffset: leafs[4].textContent.length, - focusKey: 'a', + anchorKey: 'a', + anchorOffset: 4, + focusKey: 'b/d', focusOffset: 0, - isBackward: true, + isBackward: false, }), needsRecovery: true, }); }); - }); - describe('A single block is selected', function() { - it('is collapsed at start', function() { - var block = blocks[0]; + it('starts within text node, ends at end of leaf span', function() { + var leaf = leafs[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: block, - anchorOffset: 0, - focusNode: block, - focusOffset: 0, + anchorNode: textNodes[0], + anchorOffset: 4, + focusNode: leaf, + focusOffset: leaf.childNodes.length, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 0, - focusKey: 'a', - focusOffset: 0, + anchorOffset: 4, + focusKey: 'c', + focusOffset: leaf.textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('is collapsed at end', function() { - var block = blocks[0]; - var decorators = block.childNodes; - var leafs = decorators[0].childNodes; + it('starts within text node, ends at end of leaf span with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[2]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: block, - anchorOffset: decorators.length, - focusNode: block, - focusOffset: decorators.length, + anchorNode: textNodes[0], + anchorOffset: 4, + focusNode: leaf, + focusOffset: leaf.childNodes.length, }); - var textLength = 0; - for (var ii = 0; ii < leafs.length; ii++) { - textLength += leafs[ii].textContent.length; - } - var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: textLength, - focusKey: 'a', - focusOffset: textLength, + anchorOffset: 4, + focusKey: 'b/c', + focusOffset: leaf.textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('is entirely selected', function() { - var block = blocks[0]; - var decorators = block.childNodes; - var leafs = decorators[0].childNodes; + it('is a reversed text-to-leaf selection', function() { + var leaf = leafs[4]; window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: block, + anchorNode: leaf, anchorOffset: 0, - focusNode: block, - focusOffset: decorators.length, + focusNode: textNodes[0], + focusOffset: 4, }); - var textLength = 0; - for (var ii = 0; ii < leafs.length; ii++) { + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'c', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 4, + isBackward: true, + }), + needsRecovery: true, + }); + }); + + it('is a reversed text-to-leaf selection with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[2]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: 0, + focusNode: textNodes[0], + focusOffset: 4, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/c', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 4, + isBackward: true, + }), + needsRecovery: true, + }); + }); + }); + + describe('A single leaf span is selected', function() { + it('is collapsed at start', function() { + var leaf = leafs[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: 0, + focusNode: leaf, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is collapsed at start with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: 0, + focusNode: leaf, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is collapsed at end', function() { + var leaf = leafs[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: leaf.childNodes.length, + focusNode: leaf, + focusOffset: leaf.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: leaf.textContent.length, + focusKey: 'a', + focusOffset: leaf.textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is collapsed at end with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: leaf.childNodes.length, + focusNode: leaf, + focusOffset: leaf.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: leaf.textContent.length, + focusKey: 'a', + focusOffset: leaf.textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('contains an entire leaf', function() { + var leaf = leafs[4]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: 0, + focusNode: leaf, + focusOffset: leaf.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'c', + anchorOffset: 0, + focusKey: 'c', + focusOffset: leaf.textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('contains an entire leaf with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[2]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: 0, + focusNode: leaf, + focusOffset: leaf.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/c', + anchorOffset: 0, + focusKey: 'b/c', + focusOffset: leaf.textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is reversed on entire leaf', function() { + var leaf = leafs[4]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: leaf.childNodes.length, + focusNode: leaf, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'c', + anchorOffset: leaf.textContent.length, + focusKey: 'c', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); + + it('is reversed on entire leaf with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var leaf = leafs[2]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leaf, + anchorOffset: leaf.childNodes.length, + focusNode: leaf, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/c', + anchorOffset: leaf.textContent.length, + focusKey: 'b/c', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); + }); + + describe('Multiple leaf spans are selected', function() { + it('from start of one to start of another', function() { + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leafs[0], + anchorOffset: 0, + focusNode: leafs[4], + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'c', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('from start of one to start of another with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leafs[0], + anchorOffset: 0, + focusNode: leafs[2], + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'b/c', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('from start of one to end of other', function() { + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leafs[0], + anchorOffset: 0, + focusNode: leafs[4], + focusOffset: leafs[4].childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'c', + focusOffset: leafs[4].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('from start of one to end of other with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leafs[0], + anchorOffset: 0, + focusNode: leafs[2], + focusOffset: leafs[2].childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'b/c', + focusOffset: leafs[2].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('reversed leaf to leaf', function() { + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leafs[4], + anchorOffset: leafs[4].childNodes.length, + focusNode: leafs[0], + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'c', + anchorOffset: leafs[4].textContent.length, + focusKey: 'a', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); + + it('reversed leaf to leaf with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: leafs[2], + anchorOffset: leafs[2].childNodes.length, + focusNode: leafs[0], + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/c', + anchorOffset: leafs[2].textContent.length, + focusKey: 'a', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); + }); + + describe('A single block is selected', function() { + it('is collapsed at start', function() { + var block = blocks[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: 0, + focusNode: block, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is collapsed at start with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var block = blocks[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: 0, + focusNode: block, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is collapsed at end', function() { + var block = blocks[0]; + var leafs = decorators[0].childNodes; + + decorators = block.childNodes; + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: decorators.length, + focusNode: block, + focusOffset: decorators.length, + }); + + var textLength = 0; + for (var ii = 0; ii < leafs.length; ii++) { + textLength += leafs[ii].textContent.length; + } + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: textLength, + focusKey: 'a', + focusOffset: textLength, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is collapsed at end with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var block = blocks[0]; + var leafs = decorators[0].childNodes; + + decorators = block.childNodes; + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: decorators.length, + focusNode: block, + focusOffset: decorators.length, + }); + + var textLength = 0; + for (var ii = 0; ii < leafs.length; ii++) { + textLength += leafs[ii].textContent.length; + } + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: textLength, + focusKey: 'a', + focusOffset: textLength, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is entirely selected', function() { + var block = blocks[0]; + var leafs = decorators[0].childNodes; + + decorators = block.childNodes; + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: 0, + focusNode: block, + focusOffset: decorators.length, + }); + + var textLength = 0; + for (var ii = 0; ii < leafs.length; ii++) { + textLength += leafs[ii].textContent.length; + } + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: textLength, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('is entirely selected with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var block = blocks[0]; + var leafs = decorators[0].childNodes; + + decorators = block.childNodes; + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: 0, + focusNode: block, + focusOffset: decorators.length, + }); + + var textLength = 0; + for (var ii = 0; ii < leafs.length; ii++) { textLength += leafs[ii].textContent.length; } @@ -899,6 +1336,32 @@ describe('getDraftEditorSelection', function() { }); }); + it('begins at text node zero, ends at end of block with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var textNode = textNodes[0]; + var block = blocks[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: textNode, + anchorOffset: 0, + focusNode: block, + focusOffset: block.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: block.textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + // No idea if this is possible. it('begins within text node, ends at end of block', function() { var textNode = textNodes[0]; @@ -924,15 +1387,161 @@ describe('getDraftEditorSelection', function() { }); }); - // No idea if this is possible. - it('is reversed from the first case', function() { - var textNode = textNodes[0]; - var block = blocks[0]; + it('begins within text node, ends at end of block with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var textNode = textNodes[0]; + var block = blocks[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: textNode, + anchorOffset: 5, + focusNode: block, + focusOffset: block.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 5, + focusKey: 'a', + focusOffset: block.textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + // No idea if this is possible. + it('is reversed from the first case', function() { + var textNode = textNodes[0]; + var block = blocks[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: block.childNodes.length, + focusNode: textNode, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: block.textContent.length, + focusKey: 'a', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); + + it('is reversed from the first case with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + var textNode = textNodes[0]; + var block = blocks[0]; + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: block, + anchorOffset: block.childNodes.length, + focusNode: textNode, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: block.textContent.length, + focusKey: 'a', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); + }); + + describe('Multiple blocks are selected', function() { + it('goes from start of one to end of other', function() { + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: blocks[0], + anchorOffset: 0, + focusNode: blocks[2], + focusOffset: blocks[2].childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'c', + focusOffset: blocks[2].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('goes from start of one to end of other with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: blocks[0], + anchorOffset: 0, + focusNode: blocks[2], + focusOffset: blocks[2].childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'b/c', + focusOffset: blocks[2].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('goes from start of one to start of other', function() { + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: blocks[0], + anchorOffset: 0, + focusNode: blocks[2], + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'c', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + + it('goes from start of one to start of other with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + window.getSelection.mockReturnValueOnce({ rangeCount: 1, - anchorNode: block, - anchorOffset: block.childNodes.length, - focusNode: textNode, + anchorNode: blocks[0], + anchorOffset: 0, + focusNode: blocks[2], focusOffset: 0, }); @@ -940,22 +1549,20 @@ describe('getDraftEditorSelection', function() { assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: block.textContent.length, - focusKey: 'a', + anchorOffset: 0, + focusKey: 'b/c', focusOffset: 0, - isBackward: true, + isBackward: false, }), needsRecovery: true, }); }); - }); - describe('Multiple blocks are selected', function() { - it('goes from start of one to end of other', function() { + it('goes from end of one to end of other', function() { window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: blocks[0], - anchorOffset: 0, + anchorOffset: blocks[0].childNodes.length, focusNode: blocks[2], focusOffset: blocks[2].childNodes.length, }); @@ -964,7 +1571,7 @@ describe('getDraftEditorSelection', function() { assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 0, + anchorOffset: blocks[0].textContent.length, focusKey: 'c', focusOffset: blocks[2].textContent.length, isBackward: false, @@ -973,35 +1580,37 @@ describe('getDraftEditorSelection', function() { }); }); - it('goes from start of one to start of other', function() { + it('goes from end of one to end of other with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: blocks[0], - anchorOffset: 0, + anchorOffset: blocks[0].childNodes.length, focusNode: blocks[2], - focusOffset: 0, + focusOffset: blocks[2].childNodes.length, }); var selection = getDraftEditorSelection(editorState, root); assertEquals(selection, { selectionState: new SelectionState({ anchorKey: 'a', - anchorOffset: 0, - focusKey: 'c', - focusOffset: 0, + anchorOffset: blocks[0].textContent.length, + focusKey: 'b/c', + focusOffset: blocks[2].textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('goes from end of one to end of other', function() { + it('goes from within one to within another', function() { window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: blocks[0], - anchorOffset: blocks[0].childNodes.length, - focusNode: blocks[2], - focusOffset: blocks[2].childNodes.length, + anchorOffset: 1, + focusNode: blocks[2].firstChild, + focusOffset: 1, }); var selection = getDraftEditorSelection(editorState, root); @@ -1010,14 +1619,16 @@ describe('getDraftEditorSelection', function() { anchorKey: 'a', anchorOffset: blocks[0].textContent.length, focusKey: 'c', - focusOffset: blocks[2].textContent.length, + focusOffset: textNodes[4].textContent.length, isBackward: false, }), needsRecovery: true, }); }); - it('goes from within one to within another', function() { + it('goes from within one to within another with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + window.getSelection.mockReturnValueOnce({ rangeCount: 1, anchorNode: blocks[0], @@ -1031,8 +1642,8 @@ describe('getDraftEditorSelection', function() { selectionState: new SelectionState({ anchorKey: 'a', anchorOffset: blocks[0].textContent.length, - focusKey: 'c', - focusOffset: textNodes[4].textContent.length, + focusKey: 'b/c', + focusOffset: textNodes[2].textContent.length, isBackward: false, }), needsRecovery: true, @@ -1060,6 +1671,30 @@ describe('getDraftEditorSelection', function() { needsRecovery: true, }); }); + + it('is the same as above but reversed with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: blocks[2].firstChild, + anchorOffset: 1, + focusNode: blocks[0], + focusOffset: 1, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/c', + anchorOffset: textNodes[2].textContent.length, + focusKey: 'a', + focusOffset: blocks[0].textContent.length, + isBackward: true, + }), + needsRecovery: true, + }); + }); }); describe('The content wrapper is selected', () => { @@ -1085,6 +1720,30 @@ describe('getDraftEditorSelection', function() { }); }); + it('is collapsed at the start of the contents with nesting enabled', () => { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: contents, + anchorOffset: 0, + focusNode: contents, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + it('occupies a single child of the contents', () => { window.getSelection.mockReturnValueOnce({ rangeCount: 1, @@ -1107,6 +1766,30 @@ describe('getDraftEditorSelection', function() { }); }); + it('occupies a single child of the contents with nesting enabled', () => { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: contents, + anchorOffset: 0, + focusNode: contents, + focusOffset: 1, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: blocks[0].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + it('is collapsed at the end of a child', () => { window.getSelection.mockReturnValueOnce({ rangeCount: 1, @@ -1129,6 +1812,30 @@ describe('getDraftEditorSelection', function() { }); }); + it('is collapsed at the end of a child with nesting enabled', () => { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: contents, + anchorOffset: 1, + focusNode: contents, + focusOffset: 1, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: blocks[0].textContent.length, + focusKey: 'a', + focusOffset: blocks[0].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + it('is contains multiple children', () => { window.getSelection.mockReturnValueOnce({ rangeCount: 1, @@ -1150,6 +1857,30 @@ describe('getDraftEditorSelection', function() { needsRecovery: true, }); }); + + it('is contains multiple children with nesting enabled', () => { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: contents, + anchorOffset: 0, + focusNode: contents, + focusOffset: 2, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'b/d', + focusOffset: blocks[3].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); }); /** @@ -1178,6 +1909,30 @@ describe('getDraftEditorSelection', function() { }); }); + it('is collapsed at start with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: root, + anchorOffset: 0, + focusNode: root, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'a', + focusOffset: 0, + isBackward: false, + }), + needsRecovery: true, + }); + }); + it('is collapsed at end', function() { window.getSelection.mockReturnValueOnce({ rangeCount: 1, @@ -1200,6 +1955,30 @@ describe('getDraftEditorSelection', function() { }); }); + it('is collapsed at end with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: root, + anchorOffset: root.childNodes.length, + focusNode: root, + focusOffset: root.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/d', + anchorOffset: blocks[3].textContent.length, + focusKey: 'b/d', + focusOffset: blocks[3].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + it('is completely selected', function() { window.getSelection.mockReturnValueOnce({ rangeCount: 1, @@ -1222,6 +2001,30 @@ describe('getDraftEditorSelection', function() { }); }); + it('is completely selected with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: root, + anchorOffset: 0, + focusNode: root, + focusOffset: root.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'b/d', + focusOffset: blocks[3].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); + it('is reversed from above', function() { window.getSelection.mockReturnValueOnce({ rangeCount: 1, @@ -1243,6 +2046,30 @@ describe('getDraftEditorSelection', function() { needsRecovery: true, }); }); + + it('is reversed from above with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: root, + anchorOffset: root.childNodes.length, + focusNode: root, + focusOffset: 0, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'b/d', + anchorOffset: blocks[3].textContent.length, + focusKey: 'a', + focusOffset: 0, + isBackward: true, + }), + needsRecovery: true, + }); + }); }); /** @@ -1272,6 +2099,30 @@ describe('getDraftEditorSelection', function() { needsRecovery: true, }); }); + + it('does the crazy stuff described above with nesting enabled', function() { + resetMocks(getSampleSelectionMocksForTestingNestedBlocks()); + + window.getSelection.mockReturnValueOnce({ + rangeCount: 1, + anchorNode: textNodes[0], + anchorOffset: 0, + focusNode: root, + focusOffset: root.childNodes.length, + }); + + var selection = getDraftEditorSelection(editorState, root); + assertEquals(selection, { + selectionState: new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'b/d', + focusOffset: blocks[3].textContent.length, + isBackward: false, + }), + needsRecovery: true, + }); + }); }); }); }); diff --git a/src/component/selection/getDraftEditorSelectionWithNodes.js b/src/component/selection/getDraftEditorSelectionWithNodes.js index 14f54f03d5..6e7c8d93ef 100644 --- a/src/component/selection/getDraftEditorSelectionWithNodes.js +++ b/src/component/selection/getDraftEditorSelectionWithNodes.js @@ -121,7 +121,17 @@ function getDraftEditorSelectionWithNodes( * Identify the first leaf descendant for the given node. */ function getFirstLeaf(node: Node): Node { - while (node.firstChild && getSelectionOffsetKeyForNode(node.firstChild)) { + while ( + node.firstChild && + ( + // data-blocks has no offset + ( + node.firstChild instanceof Element && + node.firstChild.getAttribute('data-blocks') === 'true' + ) || + getSelectionOffsetKeyForNode(node.firstChild) + ) + ) { node = node.firstChild; } return node; @@ -131,7 +141,17 @@ function getFirstLeaf(node: Node): Node { * Identify the last leaf descendant for the given node. */ function getLastLeaf(node: Node): Node { - while (node.lastChild && getSelectionOffsetKeyForNode(node.lastChild)) { + while ( + node.lastChild && + ( + // data-blocks has no offset + ( + node.lastChild instanceof Element && + node.lastChild.getAttribute('data-blocks') === 'true' + ) || + getSelectionOffsetKeyForNode(node.lastChild) + ) + ) { node = node.lastChild; } return node; diff --git a/src/component/selection/getSampleSelectionMocksForTesting.js b/src/component/selection/getSampleSelectionMocksForTesting.js new file mode 100644 index 0000000000..6169bd4f30 --- /dev/null +++ b/src/component/selection/getSampleSelectionMocksForTesting.js @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule getSampleSelectionMocksForTesting + * @typechecks + * @flow + */ + + +'use strict'; + +var CharacterMetadata = require('CharacterMetadata'); +var ContentBlock = require('ContentBlock'); +var ContentState = require('ContentState'); +var EditorState = require('EditorState'); +var Immutable = require('immutable'); + +var {BOLD} = require('SampleDraftInlineStyle'); +var {EMPTY} = CharacterMetadata; + +function getSampleSelectionMocksForTesting() { + var editorState; + var root; + var contents; + var blocks; + var decorators; + var leafs; + var leafChildren; + var textNodes; + + root = document.createElement('div'); + contents = document.createElement('div'); + contents.setAttribute('data-contents', 'true'); + root.appendChild(contents); + + var text = [ + 'Washington', + 'Jefferson', + 'Lincoln', + 'Roosevelt', + 'Kennedy', + 'Obama', + ]; + + var textA = text[0] + text[1]; + var textB = text[2] + text[3]; + var textC = text[4] + text[5]; + + var boldChar = CharacterMetadata.create({ + style: BOLD + }); + var aChars = Immutable.List( + Immutable.Repeat(EMPTY, text[0].length).concat( + Immutable.Repeat(boldChar, text[1].length) + ) + ); + var bChars = Immutable.List( + Immutable.Repeat(EMPTY, text[2].length).concat( + Immutable.Repeat(boldChar, text[3].length) + ) + ); + var cChars = Immutable.List( + Immutable.Repeat(EMPTY, text[4].length).concat( + Immutable.Repeat(boldChar, text[5].length) + ) + ); + + var contentBlocks = [ + new ContentBlock({ + key: 'a', + type: 'unstyled', + text: textA, + characterList: aChars, + }), + new ContentBlock({ + key: 'b', + type: 'unstyled', + text: textB, + characterList: bChars, + }), + new ContentBlock({ + key: 'c', + type: 'unstyled', + text: textC, + characterList: cChars, + }), + ]; + + var contentState = ContentState.createFromBlockArray(contentBlocks); + editorState = EditorState.createWithContent(contentState); + + textNodes = text + .map( + function(text) { + return document.createTextNode(text); + } + ); + leafChildren = textNodes + .map( + function(textNode) { + var span = document.createElement('span'); + span.appendChild(textNode); + return span; + } + ); + leafs = ['a-0-0', 'a-0-1', 'b-0-0', 'b-0-1', 'c-0-0', 'c-0-1'] + .map( + function(blockKey, ii) { + var span = document.createElement('span'); + span.setAttribute('data-offset-key', '' + blockKey); + span.appendChild(leafChildren[ii]); + return span; + } + ); + decorators = ['a-0-0', 'b-0-0', 'c-0-0'] + .map( + function(decoratorKey, ii) { + var span = document.createElement('span'); + span.setAttribute('data-offset-key', '' + decoratorKey); + span.appendChild(leafs[(ii * 2)]); + span.appendChild(leafs[(ii * 2) + 1]); + return span; + } + ); + blocks = ['a-0-0', 'b-0-0', 'c-0-0'] + .map( + function(blockKey, ii) { + var blockElement = document.createElement('div'); + blockElement.setAttribute('data-offset-key', '' + blockKey); + blockElement.appendChild(decorators[ii]); + return blockElement; + } + ); + blocks.forEach( + function(blockElem) { + contents.appendChild(blockElem); + } + ); + + return { + editorState, + root, + contents, + blocks, + decorators, + leafs, + leafChildren, + textNodes + }; +} + +module.exports = getSampleSelectionMocksForTesting; diff --git a/src/component/selection/getSampleSelectionMocksForTestingNestedBlocks.js b/src/component/selection/getSampleSelectionMocksForTestingNestedBlocks.js new file mode 100644 index 0000000000..385e32ab0c --- /dev/null +++ b/src/component/selection/getSampleSelectionMocksForTestingNestedBlocks.js @@ -0,0 +1,186 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule getSampleSelectionMocksForTestingNestedBlocks + * @typechecks + * @flow + */ + +'use strict'; + +var CharacterMetadata = require('CharacterMetadata'); +var ContentBlock = require('ContentBlock'); +var ContentState = require('ContentState'); +var EditorState = require('EditorState'); +var Immutable = require('immutable'); + +var {BOLD} = require('SampleDraftInlineStyle'); +var {EMPTY} = CharacterMetadata; + +function getSampleSelectionMocksForTestingNestedBlocks() { + var editorState; + var root; + var contents; + var blocks; + var decorators; + var leafs; + var leafChildren; + var textNodes; + + root = document.createElement('div'); + contents = document.createElement('div'); + contents.setAttribute('data-contents', 'true'); + root.appendChild(contents); + + var text = [ + 'Washington', + '', + 'Lincoln', + 'Kennedy' + ]; + + var textA = text[0]; + var textB = text[1]; + var textC = text[2]; + var textD = text[3]; + + var boldChar = CharacterMetadata.create({ + style: BOLD + }); + var aChars = Immutable.List( + Immutable.Repeat(boldChar, text[0].length) + ); + var bChars = Immutable.List( + Immutable.Repeat(EMPTY, text[1].length) + ); + var cChars = Immutable.List( + Immutable.Repeat(boldChar, text[2].length) + ); + var dChars = Immutable.List( + Immutable.Repeat(EMPTY, text[3].length) + ); + + var contentBlocks = [ + new ContentBlock({ + key: 'a', + type: 'unstyled', + text: textA, + characterList: aChars, + }), + new ContentBlock({ + key: 'b', + type: 'unstyled', + text: textB, + characterList: bChars, + }), + new ContentBlock({ + key: 'b/c', + type: 'unstyled', + text: textC, + characterList: cChars, + }), + new ContentBlock({ + key: 'b/d', + type: 'unstyled', + text: textD, + characterList: dChars, + }), + ]; + + var contentState = ContentState.createFromBlockArray(contentBlocks); + var blockKeys = contentState.getBlockMap().keySeq(); + + editorState = EditorState.createWithContent(contentState); + + textNodes = text + .map( + function(text) { + return document.createTextNode(text); + } + ); + leafChildren = textNodes + .map( + function(textNode) { + var span = document.createElement('span'); + span.appendChild(textNode); + return span; + } + ); + leafs = ['a-0-0', 'b-0-0', 'b/c-0-0', 'b/d-0-0'] + .map( + function(blockKey, ii) { + var span = document.createElement('span'); + span.setAttribute('data-offset-key', '' + blockKey); + span.appendChild(leafChildren[ii]); + return span; + } + ); + decorators = ['a-0-0', 'b-0-0', 'b/c-0-0', 'b/d-0-0'] + .map( + function(decoratorKey, ii) { + var span = document.createElement('span'); + span.setAttribute('data-offset-key', '' + decoratorKey); + span.appendChild(leafs[ii]); + return span; + } + ); + blocks = ['a-0-0', 'b-0-0', 'b/c-0-0', 'b/d-0-0'] + .map( + function(blockKey, ii) { + var blockElement = document.createElement('div'); + var dataBlock = document.createElement('div'); + blockElement.setAttribute('data-offset-key', '' + blockKey); + blockElement.appendChild(decorators[ii]); + + dataBlock.setAttribute('data-offset-key', '' + blockKey); + dataBlock.setAttribute('data-block', 'true'); + dataBlock.appendChild(blockElement); + + return dataBlock; + } + ); + + blocks.forEach( + function(blockElem, index, arr) { + const currentBlock = contentBlocks[index]; + const parentKey = currentBlock.getParentKey(); + const hasChildren = contentState.getBlockChildren(currentBlock.getKey()).size > 0; + + if (hasChildren) { // if a block has children it should not have leafs just a data-blocks container + const dataBlocks = document.createElement('div'); + dataBlocks.setAttribute('data-blocks', 'true'); + blockElem.firstChild.replaceChild(dataBlocks, blockElem.querySelector('span')); + } + + if (parentKey) { + const parentIndex = blockKeys.indexOf(parentKey); + const parentBlockElement = arr[parentIndex]; + const blockContainer = parentBlockElement.querySelector('div[data-blocks]'); + + if (blockContainer) { + blockContainer.appendChild(blockElem); + } + } else { + contents.appendChild(blockElem); + } + } + ); + + return { + editorState, + root, + contents, + blocks, + decorators, + leafs, + leafChildren, + textNodes + }; +} + +module.exports = getSampleSelectionMocksForTestingNestedBlocks; diff --git a/src/model/constants/DraftEditorCommand.js b/src/model/constants/DraftEditorCommand.js index 828e17d0b9..bfe1418aa7 100644 --- a/src/model/constants/DraftEditorCommand.js +++ b/src/model/constants/DraftEditorCommand.js @@ -66,6 +66,16 @@ export type DraftEditorCommand = ( */ 'split-block' | + /** + * Split a block in two by creating two nested blocks + */ + 'split-nested-block' | + + /** + * Split the parent block in two + */ + 'split-parent-block' | + /** * Self-explanatory. */ diff --git a/src/model/encoding/RawDraftContentBlock.js b/src/model/encoding/RawDraftContentBlock.js index acc721a2e8..71497e1ad7 100644 --- a/src/model/encoding/RawDraftContentBlock.js +++ b/src/model/encoding/RawDraftContentBlock.js @@ -27,4 +27,5 @@ export type RawDraftContentBlock = { depth: ?number; inlineStyleRanges: ?Array; entityRanges: ?Array; + blocks: ?Array; }; diff --git a/src/model/encoding/__tests__/convertFromRawToDraftState.js b/src/model/encoding/__tests__/convertFromRawToDraftState.js new file mode 100644 index 0000000000..e207bbd1b9 --- /dev/null +++ b/src/model/encoding/__tests__/convertFromRawToDraftState.js @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+ui_infra + */ + +jest.disableAutomock(); + +const ContentState = require('ContentState'); +const NestedTextEditorUtil = require('NestedTextEditorUtil'); + +describe('convertFromRawToDraftState', () => { + const convertFromRawToDraftState = require('convertFromRawToDraftState'); + + it('should generate a ContentState', () => { + const result = convertFromRawToDraftState({ + blocks: [ + { + type: 'unstyled', + text: 'Hello World' + } + ], + entityMap: {} + }); + + expect(result).toEqual(jasmine.any(ContentState)); + expect(result.getBlockMap().size).toEqual(1); + }); + + it('should generate a nested ContentState', () => { + const contentState = convertFromRawToDraftState({ + blocks: [ + { + type: 'blockquote', + text: '', + blocks: [ + { + type: 'unstyled', + text: 'Hello' + }, + { + type: 'unstyled', + text: 'World' + } + ] + } + ], + entityMap: {} + }, NestedTextEditorUtil.DefaultBlockRenderMap); + + expect(contentState.getBlockMap().size).toEqual(3); + + const mainBlocks = contentState.getFirstLevelBlocks(); + const mainBlock = mainBlocks.first(); + + expect(mainBlocks.size).toBe(1); + expect(mainBlock.getType()).toBe('blockquote'); + + const mainKey = mainBlock.getKey(); + + // Verify nesting + const children = contentState.getBlockChildren(mainKey); + expect(children.size).toBe(2); + + // Check order in blockMap + expect(contentState.getKeyBefore(mainKey)).toBeFalsy(); + expect(contentState.getKeyAfter(mainKey)).toBe(children.first().getKey()); + }); + +}); diff --git a/src/model/encoding/convertFromDraftStateToRaw.js b/src/model/encoding/convertFromDraftStateToRaw.js index 441bbe646e..3186d1e301 100644 --- a/src/model/encoding/convertFromDraftStateToRaw.js +++ b/src/model/encoding/convertFromDraftStateToRaw.js @@ -21,6 +21,7 @@ var encodeInlineStyleRanges = require('encodeInlineStyleRanges'); import type ContentBlock from 'ContentBlock'; import type ContentState from 'ContentState'; import type {RawDraftContentState} from 'RawDraftContentState'; +import type {RawDraftContentBlock} from 'RawDraftContentBlock'; function convertFromDraftStateToRaw( contentState: ContentState @@ -29,7 +30,11 @@ function convertFromDraftStateToRaw( var entityStorageMap = {}; var rawBlocks = []; - contentState.getBlockMap().forEach((block, blockKey) => { + function convertBlockFromDraftToRaw( + block: ContentBlock + ) : RawDraftContentBlock { + var innerRawBlocks = []; + block.findEntityRanges( character => character.getEntity() !== null, start => { @@ -43,14 +48,23 @@ function convertFromDraftStateToRaw( } ); - rawBlocks.push({ - key: blockKey, + contentState.getBlockChildren(block.getKey()).forEach((innerBlock) => { + innerRawBlocks.push(convertBlockFromDraftToRaw(innerBlock)); + }); + + return { + key: block.getInnerKey(), text: block.getText(), type: block.getType(), depth: canHaveDepth(block) ? block.getDepth() : 0, inlineStyleRanges: encodeInlineStyleRanges(block), entityRanges: encodeEntityRanges(block, entityStorageMap), - }); + blocks: innerRawBlocks + }; + } + + contentState.getFirstLevelBlocks().forEach((block, blockKey) => { + rawBlocks.push(convertBlockFromDraftToRaw(block)); }); // Flip storage map so that our storage keys map to global @@ -72,6 +86,7 @@ function convertFromDraftStateToRaw( }; } + function canHaveDepth(block: ContentBlock): boolean { var type = block.getType(); return type === 'ordered-list-item' || type === 'unordered-list-item'; diff --git a/src/model/encoding/convertFromHTMLToContentBlocks.js b/src/model/encoding/convertFromHTMLToContentBlocks.js index 46aeeca2ed..8ba7b29c63 100644 --- a/src/model/encoding/convertFromHTMLToContentBlocks.js +++ b/src/model/encoding/convertFromHTMLToContentBlocks.js @@ -20,6 +20,7 @@ const DraftEntity = require('DraftEntity'); const Immutable = require('immutable'); const URI = require('URI'); +const generateNestedKey = require('generateNestedKey'); const generateRandomKey = require('generateRandomKey'); const getSafeBodyFromHTML = require('getSafeBodyFromHTML'); const invariant = require('invariant'); @@ -33,6 +34,7 @@ import type {DraftInlineStyle} from 'DraftInlineStyle'; var { List, OrderedSet, + Repeat, } = Immutable; var NBSP = ' '; @@ -72,6 +74,7 @@ type Chunk = { inlines: Array; entities: Array; blocks: Array; + keys: Array; }; function getEmptyChunk(): Chunk { @@ -80,6 +83,7 @@ function getEmptyChunk(): Chunk { inlines: [], entities: [], blocks: [], + keys: [] }; } @@ -93,6 +97,7 @@ function getWhitespaceChunk(inEntity: ?string): Chunk { inlines: [OrderedSet()], entities, blocks: [], + keys: [] }; } @@ -102,10 +107,11 @@ function getSoftNewlineChunk(): Chunk { inlines: [OrderedSet()], entities: new Array(1), blocks: [], + keys: [] }; } -function getBlockDividerChunk(block: DraftBlockType, depth: number): Chunk { +function getBlockDividerChunk(block: DraftBlockType, depth: number, key: string = generateRandomKey()): Chunk { return { text: '\r', inlines: [OrderedSet()], @@ -114,6 +120,7 @@ function getBlockDividerChunk(block: DraftBlockType, depth: number): Chunk { type: block, depth: Math.max(0, Math.min(MAX_DEPTH, depth)), }], + keys: key ? [key] : [], }; } @@ -130,11 +137,17 @@ function getListBlockType( function getBlockMapSupportedTags( blockRenderMap: DraftBlockRenderMap ): Array { + // Some blocks must be treated as unstyled when not present on the blockRenderMap const unstyledElement = blockRenderMap.get('unstyled').element; - return blockRenderMap + const defaultUnstyledSet = new Immutable.Set(['p']); + const userDefinedSupportedBlockSet = ( + blockRenderMap .map((config) => config.element) .valueSeq() .toSet() + ); + + return defaultUnstyledSet.merge(userDefinedSupportedBlockSet) .filter((tag) => tag !== unstyledElement) .toArray() .sort(); @@ -142,21 +155,23 @@ function getBlockMapSupportedTags( // custom element conversions function getMultiMatchedType( - tag: string, + tag: ?string, lastList: ?string, multiMatchExtractor: Array ): ?DraftBlockType { - for (let ii = 0; ii < multiMatchExtractor.length; ii++) { - const matchType = multiMatchExtractor[ii](tag, lastList); - if (matchType) { - return matchType; + if (tag) { + for (let ii = 0; ii < multiMatchExtractor.length; ii++) { + const matchType = multiMatchExtractor[ii](tag, lastList); + if (matchType) { + return matchType; + } } } return null; } function getBlockTypeForTag( - tag: string, + tag: ?string, lastList: ?string, blockRenderMap: DraftBlockRenderMap ): DraftBlockType { @@ -214,7 +229,7 @@ function processInlineTag( return currentStyle; } -function joinChunks(A: Chunk, B: Chunk): Chunk { +function joinChunks(A: Chunk, B: Chunk, hasNestedBlock:boolean=false): Chunk { // Sometimes two blocks will touch in the DOM and we need to strip the // extra delimiter to preserve niceness. var lastInA = A.text.slice(-1); @@ -222,12 +237,14 @@ function joinChunks(A: Chunk, B: Chunk): Chunk { if ( lastInA === '\r' && - firstInB === '\r' + firstInB === '\r' && + !hasNestedBlock ) { A.text = A.text.slice(0, -1); A.inlines.pop(); A.entities.pop(); A.blocks.pop(); + A.keys.pop(); } // Kill whitespace after blocks @@ -248,6 +265,7 @@ function joinChunks(A: Chunk, B: Chunk): Chunk { inlines: A.inlines.concat(B.inlines), entities: A.entities.concat(B.entities), blocks: A.blocks.concat(B.blocks), + keys: A.keys.concat(B.keys) }; } @@ -280,12 +298,16 @@ function genFragment( blockTags: Array, depth: number, blockRenderMap: DraftBlockRenderMap, - inEntity?: string + inEntity?: string, + blockKey?: string ): Chunk { var nodeName = node.nodeName.toLowerCase(); var newBlock = false; var nextBlockType = 'unstyled'; var lastLastBlock = lastBlock; + var isValidBlock = blockTags.indexOf(nodeName) !== -1; + var isListContainer = nodeName === 'ul' || nodeName === 'ol'; + var inBlockType = getBlockTypeForTag(inBlock, lastList, blockRenderMap); // Base Case if (nodeName === '#text') { @@ -306,6 +328,7 @@ function genFragment( inlines: Array(text.length).fill(inlineStyle), entities: Array(text.length).fill(inEntity), blocks: [], + keys: [] }; } @@ -318,10 +341,10 @@ function genFragment( lastLastBlock === 'br' && ( !inBlock || - getBlockTypeForTag(inBlock, lastList, blockRenderMap) === 'unstyled' + inBlockType === 'unstyled' ) ) { - return getBlockDividerChunk('unstyled', depth); + return getBlockDividerChunk('unstyled', depth, blockKey); } return getSoftNewlineChunk(); } @@ -333,31 +356,35 @@ function genFragment( inlineStyle = processInlineTag(nodeName, node, inlineStyle); // Handle lists - if (nodeName === 'ul' || nodeName === 'ol') { + if (isListContainer) { if (lastList) { depth += 1; } lastList = nodeName; } - // Block Tags - if (!inBlock && blockTags.indexOf(nodeName) !== -1) { - chunk = getBlockDividerChunk( - getBlockTypeForTag(nodeName, lastList, blockRenderMap), - depth - ); - inBlock = nodeName; - newBlock = true; - } else if (lastList && inBlock === 'li' && nodeName === 'li') { + var blockType = getBlockTypeForTag(nodeName, lastList, blockRenderMap); + var inBlockConfig = blockRenderMap.get(inBlockType); + + if (lastList && inBlock === 'li' && nodeName === 'li') { chunk = getBlockDividerChunk( - getBlockTypeForTag(nodeName, lastList, blockRenderMap), - depth + blockType, + depth, + blockKey ); + newBlock = !inBlockConfig.nestingEnabled; inBlock = nodeName; - newBlock = true; nextBlockType = lastList === 'ul' ? 'unordered-list-item' : 'ordered-list-item'; + } else if ((!inBlock || inBlockConfig.nestingEnabled) && blockTags.indexOf(nodeName) !== -1) { + chunk = getBlockDividerChunk( + blockType, + depth, + blockKey + ); + newBlock = !inBlockConfig.nestingEnabled; + inBlock = nodeName; } // Recurse through children @@ -368,6 +395,7 @@ function genFragment( var entityId: ?string = null; var href: ?string = null; + var hasNestingEnabled: boolean = inBlockConfig && inBlockConfig.nestingEnabled; while (child) { if (nodeName === 'a' && child.href && hasValidLinkText(child)) { @@ -377,6 +405,26 @@ function genFragment( entityId = undefined; } + // if we are on an invalid block we can re-use the key since it wont generate a block + isValidBlock = blockTags.indexOf(nodeName) !== -1; + + var insideANestableBlock = ( + blockKey && + chunk.keys.indexOf(blockKey) !== -1 && + lastBlock && blockRenderMap.get(lastBlock) && + blockRenderMap.get(lastBlock).nestingEnabled + ); + + var chunkKey = ( + blockKey && (hasNestingEnabled || insideANestableBlock) ? + ( + isValidBlock ? + generateNestedKey(blockKey) : + blockKey + ) : + isValidBlock ? generateRandomKey() : '' + ); + newChunk = genFragment( child, inlineStyle, @@ -385,17 +433,36 @@ function genFragment( blockTags, depth, blockRenderMap, - entityId || inEntity + entityId || inEntity, + chunkKey ); - chunk = joinChunks(chunk, newChunk); + if (isValidBlock && !hasNestingEnabled) { + // check to see if we have a valid parent that could adopt this child + var directParent: ?Node= child.parentNode; + + while (!hasNestingEnabled && directParent) { + if (directParent) { + blockType = getBlockTypeForTag(nodeName, lastList, blockRenderMap); + var parentBlockType = getBlockTypeForTag(directParent.nodeName.toLowerCase(), lastList, blockRenderMap); + var parentBlockConfig = blockRenderMap.get(parentBlockType); + + hasNestingEnabled = parentBlockConfig && parentBlockConfig.nestingEnabled; + } + + directParent = directParent && directParent.parentNode ? directParent.parentNode : null; + } + } + + chunk = joinChunks(chunk, newChunk, hasNestingEnabled); var sibling: ?Node = child.nextSibling; // Put in a newline to break up blocks inside blocks if ( sibling && - blockTags.indexOf(nodeName) >= 0 && - inBlock + inBlock && + isValidBlock && + chunkKey.split('/').length === 1 // not nested element or invalid ) { chunk = joinChunks(chunk, getSoftNewlineChunk()); } @@ -406,9 +473,14 @@ function genFragment( } if (newBlock) { + chunkKey = blockKey && hasNestingEnabled ? generateNestedKey(blockKey) : generateRandomKey(); chunk = joinChunks( chunk, - getBlockDividerChunk(nextBlockType, depth) + getBlockDividerChunk( + nextBlockType, + depth, + chunkKey + ) ); } @@ -452,7 +524,6 @@ function getChunkForHTML( blockRenderMap ); - // join with previous block to prevent weirdness on paste if (chunk.text.indexOf('\r') === 0) { chunk = { @@ -460,6 +531,7 @@ function getChunkForHTML( inlines: chunk.inlines.slice(1), entities: chunk.entities.slice(1), blocks: chunk.blocks, + keys: chunk.keys }; } @@ -494,14 +566,14 @@ function convertFromHTMLtoContentBlocks( // Be ABSOLUTELY SURE that the dom builder you pass here won't execute // arbitrary code in whatever environment you're running this in. For an // example of how we try to do this in-browser, see getSafeBodyFromHTML. - var chunk = getChunkForHTML(html, DOMBuilder, blockRenderMap); if (chunk == null) { return null; } var start = 0; - return chunk.text.split('\r').map( + + var contentBlocks = chunk.text.split('\r').map( (textBlock, ii) => { // Make absolutely certain that our text is acceptable. textBlock = sanitizeDraftText(textBlock); @@ -517,17 +589,52 @@ function convertFromHTMLtoContentBlocks( return CharacterMetadata.create(data); }) ); + var key = nullthrows(chunk).keys[ii]; start = end + 1; + var blockType = nullthrows(chunk).blocks[ii].type; + var blockConfig = blockRenderMap.get(blockType); + var nextChunkKey = nullthrows(chunk).keys[ii+1]; + var hasChildren = key && nextChunkKey && nextChunkKey.indexOf(key + '/') !== -1; + + if (blockConfig && blockConfig.nestingEnabled && hasChildren) { + var character = ''; + var blockKey = key || generateRandomKey(); + + // if we have a valid block that support nesting, that also has children + // we should make sure that it's text is converted to an unstyled element + // since blocks can only either have text or children an never both + if ((hasChildren && textBlock)) { + return [ + new ContentBlock({ + key: blockKey, + type: blockType, + depth: nullthrows(chunk).blocks[ii].depth, + text: character, + characterList: List(Repeat(CharacterMetadata.create(), character.length)) + }), + new ContentBlock({ + key: generateNestedKey(blockKey), + type: 'unstyled', + text: textBlock, + characterList, + }) + ]; + } + } + return new ContentBlock({ - key: generateRandomKey(), - type: nullthrows(chunk).blocks[ii].type, + key: key, + type: blockType, depth: nullthrows(chunk).blocks[ii].depth, text: textBlock, characterList, }); } ); + + // we need to flatten the array + return contentBlocks.reduce((a, b) => a.concat(b), []); } module.exports = convertFromHTMLtoContentBlocks; diff --git a/src/model/encoding/convertFromRawToDraftState.js b/src/model/encoding/convertFromRawToDraftState.js index a48141929c..5d2f64d6c9 100644 --- a/src/model/encoding/convertFromRawToDraftState.js +++ b/src/model/encoding/convertFromRawToDraftState.js @@ -16,35 +16,50 @@ var ContentBlock = require('ContentBlock'); var ContentState = require('ContentState'); var DraftEntity = require('DraftEntity'); +var DefaultDraftBlockRenderMap = require('DefaultDraftBlockRenderMap'); var createCharacterList = require('createCharacterList'); var decodeEntityRanges = require('decodeEntityRanges'); var decodeInlineStyleRanges = require('decodeInlineStyleRanges'); var generateRandomKey = require('generateRandomKey'); +const generateNestedKey = require('generateNestedKey'); import type {RawDraftContentState} from 'RawDraftContentState'; +import type {DraftBlockRenderMap} from 'DraftBlockRenderMap'; +import type {RawDraftContentBlock} from 'RawDraftContentBlock'; -function convertFromRawToDraftState( - rawState: RawDraftContentState -): ContentState { - var {blocks, entityMap} = rawState; +function convertBlocksFromRaw( + inputBlocks: Array, + fromStorageToLocal: Object, + blockRenderMap: DraftBlockRenderMap, + parentKey: ?string, + parentBlock: ?Object, +) : Array { + return inputBlocks.reduce( + (result, block) => { + var { + key, + type, + text, + depth, + inlineStyleRanges, + entityRanges, + blocks + } = block; - var fromStorageToLocal = {}; - Object.keys(entityMap).forEach( - storageKey => { - var encodedEntity = entityMap[storageKey]; - var {type, mutability, data} = encodedEntity; - var newKey = DraftEntity.create(type, mutability, data || {}); - fromStorageToLocal[storageKey] = newKey; - } - ); + var parentBlockRenderingConfig = parentBlock ? + blockRenderMap.get(parentBlock.type) : + null; - var contentBlocks = blocks.map( - block => { - var {key, type, text, depth, inlineStyleRanges, entityRanges} = block; key = key || generateRandomKey(); depth = depth || 0; inlineStyleRanges = inlineStyleRanges || []; entityRanges = entityRanges || []; + blocks = blocks || []; + + key = parentKey && parentBlockRenderingConfig && + parentBlockRenderingConfig.nestingEnabled ? + generateNestedKey(parentKey, key) : + key; var inlineStyles = decodeInlineStyleRanges(text, inlineStyleRanges); @@ -58,10 +73,42 @@ function convertFromRawToDraftState( var entities = decodeEntityRanges(text, filteredEntityRanges); var characterList = createCharacterList(inlineStyles, entities); - return new ContentBlock({key, type, text, depth, characterList}); + // Push parent block first + result.push(new ContentBlock({key, type, text, depth, characterList})); + + // Then push child blocks + result = result.concat( + convertBlocksFromRaw( + blocks, + fromStorageToLocal, + blockRenderMap, + key, + block + ) + ); + + return result; + }, [] + ); +} + +function convertFromRawToDraftState( + rawState: RawDraftContentState, + blockRenderMap:DraftBlockRenderMap=DefaultDraftBlockRenderMap +): ContentState { + var {blocks, entityMap} = rawState; + + var fromStorageToLocal = {}; + Object.keys(entityMap).forEach( + storageKey => { + var encodedEntity = entityMap[storageKey]; + var {type, mutability, data} = encodedEntity; + var newKey = DraftEntity.create(type, mutability, data || {}); + fromStorageToLocal[storageKey] = newKey; } ); + var contentBlocks = convertBlocksFromRaw(blocks, fromStorageToLocal, blockRenderMap); return ContentState.createFromBlockArray(contentBlocks); } diff --git a/src/model/immutable/ContentBlock.js b/src/model/immutable/ContentBlock.js index 1303597c54..4d50bf3657 100644 --- a/src/model/immutable/ContentBlock.js +++ b/src/model/immutable/ContentBlock.js @@ -70,6 +70,24 @@ class ContentBlock extends ContentBlockRecord { return this.get('depth'); } + getParentKey(): string { + var key = this.getKey(); + var parts = key.split('/'); + + return parts.slice(0, -1).join('/'); + } + + hasParent(): boolean { + return (this.getParentKey() !== ''); + } + + getInnerKey(): string { + var key = this.getKey(); + var parts = key.split('/'); + + return parts[parts.length - 1]; + } + getInlineStyleAt(offset: number): DraftInlineStyle { var character = this.getCharacterList().get(offset); return character ? character.getStyle() : EMPTY_SET; diff --git a/src/model/immutable/ContentState.js b/src/model/immutable/ContentState.js index eb8faf897d..fb97547afb 100644 --- a/src/model/immutable/ContentState.js +++ b/src/model/immutable/ContentState.js @@ -10,7 +10,6 @@ * @typechecks * @flow */ - 'use strict'; const BlockMapBuilder = require('BlockMapBuilder'); @@ -56,6 +55,92 @@ class ContentState extends ContentStateRecord { return block; } + getFirstLevelBlocks(): BlockMap { + return this.getBlockChildren(''); + } + + /* + * This algorithm is used to create the blockMap nesting as well as to + * enhance performance checks for nested blocks allowing each block to + * know when any of it's children has changed. + */ + getBlockDescendants() { + return this.getBlockMap() + .reverse() + .reduce((treeMap, block) => { + const key = block.getKey(); + const parentKey = block.getParentKey(); + const rootKey = '__ROOT__'; + + // create one if does not exist + const blockList = ( + treeMap.get(key) ? + treeMap : + treeMap.set(key, new Immutable.Map({ + firstLevelBlocks: new Immutable.OrderedMap(), + childrenBlocks: new Immutable.Set() + })) + ); + + if (parentKey) { + // create one if does not exist + const parentList = ( + blockList.get(parentKey) ? + blockList : + blockList.set(parentKey, new Immutable.Map({ + firstLevelBlocks: new Immutable.OrderedMap(), + childrenBlocks: new Immutable.Set() + })) + ); + + // add current block to parent children list + const addBlockToParentList = parentList.setIn([parentKey, 'firstLevelBlocks', key], block); + const addGrandChildren = addBlockToParentList.setIn( + [parentKey, 'childrenBlocks'], + addBlockToParentList.getIn([parentKey, 'childrenBlocks']) + .add( + // we include all the current block children and itself + addBlockToParentList.getIn([key, 'childrenBlocks']).add(block) + ) + ); + + return addGrandChildren; + } else { + // we are root level block + // lets create a new key called firstLevelBlocks + const rootLevelBlocks = ( + blockList.get(rootKey) ? + blockList : + blockList.set(rootKey, new Immutable.Map({ + firstLevelBlocks: new Immutable.OrderedMap(), + childrenBlocks: new Immutable.Set() + })) + ); + + const rootFirstLevelBlocks = rootLevelBlocks.setIn([rootKey, 'firstLevelBlocks', key], block); + + const addToRootChildren = rootFirstLevelBlocks.setIn( + [rootKey, 'childrenBlocks'], + rootFirstLevelBlocks.getIn([rootKey, 'childrenBlocks']) + .add( + // we include all the current block children and itself + rootFirstLevelBlocks.getIn([key, 'childrenBlocks']).add(block) + ) + ); + + return addToRootChildren; + } + }, new Immutable.Map()) + .map((block) => block.set('firstLevelBlocks', block.get('firstLevelBlocks').reverse())); + } + + getBlockChildren(key: string): BlockMap { + return this.getBlockMap() + .filter(function(block) { + return block.getParentKey() === key; + }); + } + getKeyBefore(key: string): ?string { return this.getBlockMap() .reverse() diff --git a/src/model/immutable/DefaultDraftBlockRenderMap.js b/src/model/immutable/DefaultDraftBlockRenderMap.js index 25de00eaac..9ac6a66511 100644 --- a/src/model/immutable/DefaultDraftBlockRenderMap.js +++ b/src/model/immutable/DefaultDraftBlockRenderMap.js @@ -24,41 +24,53 @@ const PRE_WRAP =
;
 module.exports = Map({
   'header-one': {
     element: 'h1',
+    nestingEnabled: false
   },
   'header-two': {
     element: 'h2',
+    nestingEnabled: false
   },
   'header-three': {
     element: 'h3',
+    nestingEnabled: false
   },
   'header-four': {
     element: 'h4',
+    nestingEnabled: false
   },
   'header-five': {
     element: 'h5',
+    nestingEnabled: false
   },
   'header-six': {
     element: 'h6',
+    nestingEnabled: false
   },
   'unordered-list-item': {
     element: 'li',
     wrapper: UL_WRAP,
+    nestingEnabled: false
   },
   'ordered-list-item': {
     element: 'li',
     wrapper: OL_WRAP,
+    nestingEnabled: false
   },
   'blockquote': {
     element: 'blockquote',
+    nestingEnabled: false
   },
   'atomic': {
     element: 'figure',
+    nestingEnabled: false
   },
   'code-block': {
     element: 'pre',
     wrapper: PRE_WRAP,
+    nestingEnabled: false
   },
   'unstyled': {
     element: 'div',
+    nestingEnabled: false
   },
 });
diff --git a/src/model/immutable/EditorChangeType.js b/src/model/immutable/EditorChangeType.js
index 3863708f1f..588478fb42 100644
--- a/src/model/immutable/EditorChangeType.js
+++ b/src/model/immutable/EditorChangeType.js
@@ -27,5 +27,6 @@ export type EditorChangeType = (
   'remove-range' |
   'spellcheck-change' |
   'split-block' |
+  'split-nested-block' |
   'undo'
 );
diff --git a/src/model/immutable/EditorState.js b/src/model/immutable/EditorState.js
index 3934b5af50..3483dc2389 100644
--- a/src/model/immutable/EditorState.js
+++ b/src/model/immutable/EditorState.js
@@ -21,11 +21,12 @@ var SelectionState = require('SelectionState');
 import type {BlockMap} from 'BlockMap';
 import type {DraftDecoratorType} from 'DraftDecoratorType';
 import type {DraftInlineStyle} from 'DraftInlineStyle';
-import type {List, OrderedMap} from 'immutable';
+import type {List} from 'immutable';
 import type {EditorChangeType} from 'EditorChangeType';
 
 var {
   OrderedSet,
+  OrderedMap,
   Record,
   Stack,
 } = Immutable;
diff --git a/src/model/immutable/__tests__/ContentBlock-test.js b/src/model/immutable/__tests__/ContentBlock-test.js
index 8583599cf9..da72400deb 100644
--- a/src/model/immutable/__tests__/ContentBlock-test.js
+++ b/src/model/immutable/__tests__/ContentBlock-test.js
@@ -106,4 +106,29 @@ describe('ContentBlock', () => {
       expect(calls[2]).toEqual([4, 5]);
     });
   });
+
+  describe('parent key retrieval', () => {
+    it('must properly retrieve key of parent if first level', () => {
+      var block = getSampleBlock();
+      expect(block.getParentKey()).toBe('');
+    });
+
+    it('must properly retrieve key of parent if nested', () => {
+      var block = new ContentBlock({
+        key: 'a/b',
+        type: 'unstyled',
+        text: ''
+      });
+      expect(block.getParentKey()).toBe('a');
+    });
+
+    it('must properly retrieve key of parent if deep nested', () => {
+      var block = new ContentBlock({
+        key: 'a/b/b',
+        type: 'unstyled',
+        text: ''
+      });
+      expect(block.getParentKey()).toBe('a/b');
+    });
+  });
 });
diff --git a/src/model/immutable/__tests__/ContentState-test.js b/src/model/immutable/__tests__/ContentState-test.js
index 978449fc3e..fc6dec1672 100644
--- a/src/model/immutable/__tests__/ContentState-test.js
+++ b/src/model/immutable/__tests__/ContentState-test.js
@@ -26,6 +26,13 @@ var MULTI_BLOCK = [
   {text: 'Four score', key: 'b'},
   {text: 'and seven', key: 'c'},
 ];
+var NESTED_BLOCK = [
+  {text: 'Four score', key: 'd'},
+  {text: 'and seven', key: 'e'},
+  {text: 'Nested in e', key: 'e/f'},
+  {text: 'Nested in e', key: 'e/g'},
+  {text: 'Nested in e/g', key: 'e/g/h'},
+];
 
 var SelectionState = require('SelectionState');
 
@@ -88,4 +95,41 @@ describe('ContentState', () => {
       expect(state.getBlockForKey('x')).toBe(undefined);
     });
   });
+
+  describe('nested block fetching', () => {
+    it('must retrieve nested block for key', () => {
+      var state = getSample(NESTED_BLOCK);
+      var blocks = state.getBlockChildren('e');
+
+      expect(blocks.size).toBe(2);
+      expect(blocks.has('e/f')).toBe(true);
+      expect(blocks.has('e/g')).toBe(true);
+    });
+
+    it('must retrieve nested block for a deeper key', () => {
+      var state = getSample(NESTED_BLOCK);
+      var blocks = state.getBlockChildren('e/g');
+
+      expect(blocks.size).toBe(1);
+      expect(blocks.has('e/g/h')).toBe(true);
+    });
+
+    it('must return an empty map if none', () => {
+      var state = getSample(NESTED_BLOCK);
+      var blocks = state.getBlockChildren('d');
+
+      expect(blocks.size).toBe(0);
+    });
+  });
+
+  describe('first level block fetching', () => {
+    it('must retrieve first level block', () => {
+      var state = getSample(NESTED_BLOCK);
+      var blocks = state.getFirstLevelBlocks();
+
+      expect(blocks.size).toBe(2);
+      expect(blocks.has('d')).toBe(true);
+      expect(blocks.has('e')).toBe(true);
+    });
+  });
 });
diff --git a/src/model/keys/__tests__/generateNestedKey-test.js b/src/model/keys/__tests__/generateNestedKey-test.js
new file mode 100644
index 0000000000..8839b491d3
--- /dev/null
+++ b/src/model/keys/__tests__/generateNestedKey-test.js
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @emails oncall+ui_infra
+ */
+
+'use strict';
+
+jest.disableAutomock();
+
+const generateNestedKey = require('generateNestedKey');
+
+describe('generateNestedKey', () => {
+  const parentKey = 'foo';
+
+  it('must generate a new nested key for a parentKey', () => {
+    const newNestedKey = generateNestedKey(parentKey);
+    const newNestedKeyArr = newNestedKey.split('/');
+
+    expect(newNestedKey).not.toBe(parentKey);
+    expect(newNestedKeyArr.length).toBe(2);
+    expect(newNestedKeyArr[0]).toBe(parentKey);
+  });
+
+  it('must allow child key to be used to generate a a new nested key for a parentKey', () => {
+    const childKey = 'bar';
+    const newNestedKey = generateNestedKey(parentKey, childKey);
+    const newNestedKeyArr = newNestedKey.split('/');
+
+    expect(newNestedKey).not.toBe(parentKey);
+    expect(newNestedKeyArr.length).toBe(2);
+    expect(newNestedKeyArr[1]).toBe(childKey);
+  });
+});
diff --git a/src/model/keys/__tests__/randomizeBlockMapKeys-test.js b/src/model/keys/__tests__/randomizeBlockMapKeys-test.js
new file mode 100644
index 0000000000..4d89e7cc14
--- /dev/null
+++ b/src/model/keys/__tests__/randomizeBlockMapKeys-test.js
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @emails oncall+ui_infra
+ */
+
+'use strict';
+
+jest.disableAutomock();
+
+const getSampleStateForTesting = require('getSampleStateForTesting');
+const getSampleStateForTestingNestedBlocks = require('getSampleStateForTestingNestedBlocks');
+
+const randomizeBlockMapKeys = require('randomizeBlockMapKeys');
+
+describe('randomizeBlockMapKeys', () => {
+  it('must randomize blockMap keys', () => {
+    const {
+      contentState
+    } = getSampleStateForTesting();
+
+    const blockMap = contentState.getBlockMap();
+    const blockKeys = blockMap.keySeq().toArray();
+
+    const newBlockMap = randomizeBlockMapKeys(blockMap);
+    const newKeys = newBlockMap.keySeq().toArray();
+
+    expect(blockKeys).not.toBe(newKeys);
+    expect(blockMap.first().getText()).toBe(newBlockMap.first().getText());
+    expect(blockMap.first().getKey()).not.toBe(newBlockMap.first().getKey());
+    expect(blockKeys.length).toBe(newKeys.length);
+  });
+
+  it('must randomize blockMap keys with nesting enabled', () => {
+    const {
+      contentState
+    } = getSampleStateForTestingNestedBlocks();
+
+    const blockMap = contentState.getBlockMap();
+    const blockKeys = blockMap.keySeq().toArray();
+
+    const blockWithParent = blockMap.skip(2).first();
+    const blockWithParentKeyArr = blockWithParent.getKey().split('/');
+
+    const newBlockMap = randomizeBlockMapKeys(blockMap);
+    const newKeys = newBlockMap.keySeq().toArray();
+
+    const newBlockWithParent = newBlockMap.skip(2).first();
+    const newBlockWithParentKeyArr = newBlockWithParent.getKey().split('/');
+
+    expect(blockKeys).not.toBe(newKeys);
+    expect(blockMap.first().getText()).toBe(newBlockMap.first().getText());
+    expect(blockMap.first().getKey()).not.toBe(newBlockMap.first().getKey());
+    expect(blockWithParent.getKey()).not.toBe(newBlockWithParent.getKey());
+    expect(blockWithParentKeyArr.length).toBe(newBlockWithParentKeyArr.length);
+    expect(blockKeys.length).toBe(newKeys.length);
+  });
+
+  it('must retain parent key from fragment that has not supplied a parent block', () => {
+    const {
+      contentState
+    } = getSampleStateForTestingNestedBlocks();
+
+    const blockMap = contentState.getBlockMap().skip(2); // not including 'a' and root block 'b'
+    const blockKeys = blockMap.keySeq().toArray();
+
+    const blockWithParent = blockMap.first();
+    const blockWithParentKeyArr = blockWithParent.getKey().split('/');
+
+    const newBlockMap = randomizeBlockMapKeys(blockMap);
+    const newKeys = newBlockMap.keySeq().toArray();
+
+    const newBlockWithParent = newBlockMap.first();
+    const newBlockWithParentKeyArr = newBlockWithParent.getKey().split('/');
+
+    expect(blockKeys).not.toBe(newKeys);
+    expect(blockMap.first().getText()).toBe(newBlockMap.first().getText());
+    expect(blockMap.first().getKey()).not.toBe(newBlockMap.first().getKey());
+    expect(blockWithParent.getKey()).not.toBe(newBlockWithParent.getKey());
+    expect(blockWithParentKeyArr.length).toBe(newBlockWithParentKeyArr.length);
+    expect(blockKeys.length).toBe(newKeys.length);
+  });
+});
diff --git a/src/model/keys/generateNestedKey.js b/src/model/keys/generateNestedKey.js
new file mode 100644
index 0000000000..5e2fcf85b4
--- /dev/null
+++ b/src/model/keys/generateNestedKey.js
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @providesModule generateNestedKey
+ * @typechecks
+ * @flow
+ */
+
+'use strict';
+
+const generateRandomKey = require('generateRandomKey');
+
+/*
+ * Returns a nested key based on a parent key. If a child key is
+ * supplied it will be used, otherwise a new random key will be
+ * created.
+ */
+function generateNestedKey(
+  parentKey: string,
+  childKey: ?string
+): string {
+  const key = childKey || generateRandomKey();
+  return parentKey + '/' + key;
+}
+
+module.exports = generateNestedKey;
diff --git a/src/model/keys/randomizeBlockMapKeys.js b/src/model/keys/randomizeBlockMapKeys.js
new file mode 100644
index 0000000000..ee73c40844
--- /dev/null
+++ b/src/model/keys/randomizeBlockMapKeys.js
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @providesModule randomizeBlockMapKeys
+ * @typechecks
+ * @flow
+ */
+
+'use strict';
+
+const BlockMapBuilder = require('BlockMapBuilder');
+const ContentBlock = require('ContentBlock');
+const generateNestedKey = require('generateNestedKey');
+const generateRandomKey = require('generateRandomKey');
+
+import type {BlockMap} from 'BlockMap';
+
+/*
+ * Returns a new randomized keys blockmap that will
+ * also respect nesting keys rules
+ */
+function randomizeBlockMapKeys(
+  blockMap: BlockMap
+): BlockMap {
+  let newKeyHashMap = {};
+  const contentBlocks = (
+    blockMap
+    .map((block, blockKey) => {
+      const parentKey = block.getParentKey();
+
+      const newKey = newKeyHashMap[blockKey] = (
+        parentKey ?
+          newKeyHashMap[parentKey] ? // we could be inserting just a fragment
+            generateNestedKey(newKeyHashMap[parentKey]) :
+            generateNestedKey(parentKey) :
+          generateRandomKey()
+      );
+
+      return new ContentBlock({
+        key: newKey,
+        type: block.getType(),
+        depth: block.getDepth(),
+        text: block.getText(),
+        characterList: block.getCharacterList()
+      });
+    })
+    .toArray()
+  );
+
+  return BlockMapBuilder.createFromArray(contentBlocks);
+}
+
+module.exports = randomizeBlockMapKeys;
diff --git a/src/model/modifier/AtomicBlockUtils.js b/src/model/modifier/AtomicBlockUtils.js
index b13c3e0d24..a4e9914d29 100644
--- a/src/model/modifier/AtomicBlockUtils.js
+++ b/src/model/modifier/AtomicBlockUtils.js
@@ -21,6 +21,7 @@ const EditorState = require('EditorState');
 const Immutable = require('immutable');
 
 const generateRandomKey = require('generateRandomKey');
+const generateNestedKey = require('generateNestedKey');
 
 const {
   List,
@@ -35,6 +36,9 @@ const AtomicBlockUtils = {
   ): EditorState {
     const contentState = editorState.getCurrentContent();
     const selectionState = editorState.getSelection();
+    const targetKey = selectionState.getStartKey();
+    const targetBlock = contentState.getBlockForKey(targetKey);
+    const targetBlockParentKey = targetBlock.getParentKey();
 
     const afterRemoval = DraftModifier.removeRange(
       contentState,
@@ -56,13 +60,13 @@ const AtomicBlockUtils = {
 
     const fragmentArray = [
       new ContentBlock({
-        key: generateRandomKey(),
+        key: targetBlockParentKey ? generateNestedKey(targetBlockParentKey) : generateRandomKey(),
         type: 'atomic',
         text: character,
         characterList: List(Repeat(charData, character.length)),
       }),
       new ContentBlock({
-        key: generateRandomKey(),
+        key: targetBlockParentKey ? generateNestedKey(targetBlockParentKey) : generateRandomKey(),
         type: 'unstyled',
         text: '',
         characterList: List(),
diff --git a/src/model/modifier/NestedTextEditorUtil.js b/src/model/modifier/NestedTextEditorUtil.js
new file mode 100644
index 0000000000..a0109d6d33
--- /dev/null
+++ b/src/model/modifier/NestedTextEditorUtil.js
@@ -0,0 +1,588 @@
+/*
+ * Copyright (c) 2013-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @providesModule NestedTextEditorUtil
+ * @typechecks
+ * @flow
+ */
+const CharacterMetadata = require('CharacterMetadata');
+const ContentBlock = require('ContentBlock');
+const ContentState = require('ContentState');
+const DefaultDraftBlockRenderMap = require('DefaultDraftBlockRenderMap');
+const EditorState = require('EditorState');
+const Immutable = require('immutable');
+const generateNestedKey = require('generateNestedKey');
+const generateRandomKey = require('generateRandomKey');
+const splitBlockWithNestingInContentState = require('splitBlockWithNestingInContentState');
+
+import type {
+  DraftBlockType
+} from 'DraftBlockType';
+import type {
+  DraftEditorCommand
+} from 'DraftEditorCommand';
+import type {
+  DraftBlockRenderMap
+} from 'DraftBlockRenderMap';
+
+const {
+  List,
+  Repeat,
+} = Immutable;
+
+const EMPTY_CHAR = '';
+const EMPTY_CHAR_LIST = List(Repeat(CharacterMetadata.create(), EMPTY_CHAR.length));
+
+const DefaultBlockRenderMap = new Immutable.Map(
+  new Immutable.fromJS(
+    DefaultDraftBlockRenderMap.toJS()
+  ).mergeDeep(
+    new Immutable.fromJS({
+      'blockquote': {
+        nestingEnabled: true
+      },
+      'unordered-list-item': {
+        nestingEnabled: true
+      },
+      'ordered-list-item': {
+        nestingEnabled: true
+      }
+    })
+  ).toJS()
+);
+
+const NestedTextEditorUtil = {
+  DefaultBlockRenderMap: DefaultBlockRenderMap,
+
+  toggleBlockType: function(
+    editorState: EditorState,
+    blockType: DraftBlockType,
+    blockRenderMap: DefaultDraftBlockRenderMap = NestedTextEditorUtil.DefaultBlockRenderMap
+  ): Object {
+    const contentState = editorState.getCurrentContent();
+    const selectionState = editorState.getSelection();
+    const currentBlock = contentState.getBlockForKey(selectionState.getStartKey());
+    const key = currentBlock.getKey();
+    const renderOpt = blockRenderMap.get(currentBlock.getType());
+    const hasNestingEnabled = renderOpt && renderOpt.nestingEnabled;
+    const targetTypeRenderOpt = blockRenderMap.get(blockType);
+    const parentKey = currentBlock.getParentKey();
+    const parentBlock = contentState.getBlockForKey(parentKey);
+    const parentRenderOpt = parentBlock && blockRenderMap.get(parentBlock.getType());
+    const isCousinType = (
+      renderOpt &&
+      targetTypeRenderOpt &&
+      renderOpt.element === targetTypeRenderOpt.element
+    );
+    const isParentCousinType = (
+      parentRenderOpt &&
+      targetTypeRenderOpt &&
+      parentRenderOpt.element === targetTypeRenderOpt.element
+    );
+
+    const canHandleCommand = (
+      (
+        hasNestingEnabled ||
+        targetTypeRenderOpt.nestingEnabled
+      ) &&
+      blockType !== currentBlock.getType()
+    );
+
+    if (!canHandleCommand) {
+      return {
+        editorState,
+        blockType
+      };
+    }
+
+    const blockMap = contentState.getBlockMap();
+
+    if (isParentCousinType) {
+      const toggleCousinBlockContentState = ContentState.createFromBlockArray(
+        blockMap
+        .map((block, index) => {
+          if (block === parentBlock) {
+            return new ContentBlock({
+              key: block.getKey(),
+              type: blockType,
+              depth: block.getDepth(),
+              text: block.getText(),
+              characterList: block.getCharacterList()
+            });
+          }
+          if (block === currentBlock) {
+            return new ContentBlock({
+              key: block.getKey(),
+              // since we use the toggleUtils together with RichUtils we
+              // need to update this type to something else so that it does not get
+              // toggled and instead just get restored
+              // this is a temporary hack while nesting tree is not a first customer
+              type: 'unstyled',
+              depth: block.getDepth(),
+              text: block.getText(),
+              characterList: block.getCharacterList()
+            });
+          }
+          return block;
+        })
+        .toArray()
+      );
+
+      return {
+        editorState: EditorState.push(
+          editorState,
+          toggleCousinBlockContentState.merge({
+            selectionBefore: selectionState,
+            selectionAfter: selectionState.merge({
+              anchorKey: key,
+              anchorOffset: selectionState.getAnchorOffset(),
+              focusKey: key,
+              focusOffset: selectionState.getFocusOffset(),
+              isBackward: false,
+            })
+          }),
+          'change-block-type'
+        ),
+        blockType: currentBlock.getType() // we then send the original type to be restored
+      };
+    }
+
+    // we want to move the current text to inside this block
+    const targetKey = generateNestedKey(key);
+
+    const newContentState = ContentState.createFromBlockArray(
+      blockMap
+      .map((block, index) => {
+        if (block === currentBlock) {
+          if (isCousinType) {
+            return new ContentBlock({
+              key: key,
+              type: 'unstyled',
+              depth: currentBlock.getDepth(),
+              text: currentBlock.getText(),
+              characterList: currentBlock.getCharacterList()
+            });
+          } else {
+            return [
+              new ContentBlock({
+                key: key,
+                type: currentBlock.getType(),
+                depth: currentBlock.getDepth(),
+                text: EMPTY_CHAR,
+                characterList: EMPTY_CHAR_LIST
+              }),
+              new ContentBlock({
+                key: targetKey,
+                type: 'unstyled',
+                depth: 0,
+                text: currentBlock.getText(),
+                characterList: currentBlock.getCharacterList()
+              })
+            ];
+          }
+        }
+        return block;
+      })
+      .reduce((a, b) => a.concat(b), [])
+    );
+
+    return {
+      editorState: EditorState.push(
+        editorState,
+        newContentState.merge({
+          selectionBefore: selectionState,
+          selectionAfter: selectionState.merge({
+            anchorKey: isCousinType ? key : targetKey,
+            anchorOffset: selectionState.getAnchorOffset(),
+            focusKey: isCousinType ? key : targetKey,
+            focusOffset: selectionState.getFocusOffset(),
+            isBackward: false,
+          })
+        }),
+        'change-block-type'
+      ),
+      blockType
+    };
+  },
+
+  handleKeyCommand: function(
+    editorState: EditorState,
+    command: DraftEditorCommand,
+    blockRenderMap: DraftBlockRenderMap = DefaultBlockRenderMap
+  ): ? EditorState {
+    const selectionState = editorState.getSelection();
+    const contentState = editorState.getCurrentContent();
+    const key = selectionState.getAnchorKey();
+
+    const currentBlock = contentState.getBlockForKey(key);
+    const nestedBlocks = contentState.getBlockChildren(key);
+
+    const parentKey = currentBlock.getParentKey();
+    const parentBlock = contentState.getBlockForKey(parentKey);
+    const nextBlock = contentState.getBlockAfter(key);
+
+    // Option of rendering for the current block
+    const renderOpt = blockRenderMap.get(currentBlock.getType());
+    const parentRenderOpt = parentBlock && blockRenderMap.get(parentBlock.getType());
+
+    const hasNestingEnabled = renderOpt && renderOpt.nestingEnabled;
+    const hasWrapper = renderOpt && renderOpt.wrapper;
+
+    const parentHasWrapper = parentRenderOpt && parentRenderOpt.wrapper;
+
+    // Press enter
+    if (command === 'split-block') {
+      if (
+        currentBlock.hasParent() &&
+        (!hasNestingEnabled ||
+          currentBlock.getLength() === 0
+        ) &&
+        (!nextBlock ||
+          (
+            hasWrapper &&
+            nextBlock.getType() !== currentBlock.getType()
+          ) ||
+          (
+            nextBlock.getParentKey() !== currentBlock.getParentKey() &&
+            (currentBlock.getLength() === 0 || parentHasWrapper)
+          )
+        )
+      ) {
+        command = 'split-parent-block';
+      }
+
+      // In a block that already have some nested blocks
+      if (command === 'split-block' && nestedBlocks.size > 0) {
+        command = 'split-nested-block';
+      }
+    }
+
+    // Prevent creation of nested blocks
+    if (!hasNestingEnabled && command === 'split-nested-block') {
+      command = 'split-block';
+    }
+
+    switch (command) {
+      case 'backspace':
+        return NestedTextEditorUtil.onBackspace(editorState, blockRenderMap);
+      case 'delete':
+        return NestedTextEditorUtil.onDelete(editorState, blockRenderMap);
+      case 'split-nested-block':
+        return NestedTextEditorUtil.onSplitNestedBlock(editorState, blockRenderMap);
+      case 'split-parent-block':
+        return NestedTextEditorUtil.onSplitParent(editorState, blockRenderMap);
+      default:
+        return null;
+    }
+  },
+
+  keyBinding: function(e: SyntheticKeyboardEvent) {
+    if (e.keyCode === 13 /* `Enter` key */ && e.shiftKey) {
+      return 'split-nested-block';
+    }
+  },
+
+  onBackspace: function(
+    editorState: EditorState,
+    blockRenderMap: DraftBlockRenderMap = DefaultBlockRenderMap
+  ): ? EditorState {
+    const selectionState = editorState.getSelection();
+    const isCollapsed = selectionState.isCollapsed();
+    const contentState = editorState.getCurrentContent();
+    const key = selectionState.getAnchorKey();
+
+    const currentBlock = contentState.getBlockForKey(key);
+    const previousBlock = contentState.getBlockBefore(key);
+
+    const canHandleCommand = (
+      isCollapsed &&
+      selectionState.getEndOffset() === 0 &&
+      previousBlock &&
+      previousBlock.getKey() === currentBlock.getParentKey()
+    );
+
+    if (!canHandleCommand) {
+      return null;
+    }
+
+    const targetBlock = getFirstAvailableLeafBeforeBlock(
+      currentBlock,
+      contentState
+    );
+
+    if (targetBlock === currentBlock) {
+      return null;
+    }
+
+    const blockMap = contentState.getBlockMap();
+
+    const targetKey = targetBlock.getKey();
+
+    const newContentState = ContentState.createFromBlockArray(
+      blockMap
+      .filter(block => block !== null)
+      .map((block, index) => {
+        if (!targetBlock && previousBlock === block) {
+          return [
+            new ContentBlock({
+              key: targetKey,
+              type: currentBlock.getType(),
+              depth: currentBlock.getDepth(),
+              text: currentBlock.getText(),
+              characterList: currentBlock.getCharacterList()
+            }),
+            block
+          ];
+        } else if (targetBlock && block === targetBlock) {
+          return new ContentBlock({
+            key: targetKey,
+            type: targetBlock.getType(),
+            depth: targetBlock.getDepth(),
+            text: targetBlock.getText() + currentBlock.getText(),
+            characterList: targetBlock.getCharacterList().concat(currentBlock.getCharacterList())
+          });
+        }
+        return block;
+      })
+      .filter(block => block !== currentBlock)
+      .reduce((a, b) => a.concat(b), [])
+    );
+
+    const selectionOffset = newContentState.getBlockForKey(targetKey).getLength();
+
+    return EditorState.push(
+      editorState,
+      newContentState.merge({
+        selectionBefore: selectionState,
+        selectionAfter: selectionState.merge({
+          anchorKey: targetKey,
+          anchorOffset: selectionOffset,
+          focusKey: targetKey,
+          focusOffset: selectionOffset,
+          isBackward: false,
+        })
+      }),
+      'backspace-character'
+    );
+  },
+
+  onDelete: function(
+    editorState: EditorState,
+    blockRenderMap: DraftBlockRenderMap = DefaultBlockRenderMap
+  ): ? EditorState {
+    const selectionState = editorState.getSelection();
+    const contentState = editorState.getCurrentContent();
+    const key = selectionState.getAnchorKey();
+
+    const currentBlock = contentState.getBlockForKey(key);
+
+    const nextBlock = contentState.getBlockAfter(key);
+    const isCollapsed = selectionState.isCollapsed();
+
+    const canHandleCommand = (
+      nextBlock &&
+      isCollapsed &&
+      selectionState.getEndOffset() === currentBlock.getLength() &&
+      contentState.getBlockChildren(
+        nextBlock.getKey()
+      ).size
+    );
+
+    if (!canHandleCommand) {
+      return null;
+    }
+
+    // are pressing delete while being just befefore a block that has children
+    // we want instead to move the block and all its children up to this block if it supports nesting
+    // otherwise split the children right after in case it doesnt
+    // find the first descendand from the nextElement
+    const blockMap = contentState.getBlockMap();
+
+    // the previous block is invalid so we need a new target
+    const targetBlock = getFirstAvailableLeafAfterBlock(currentBlock, contentState);
+
+    const newContentState = ContentState.createFromBlockArray(
+      blockMap
+      .filter(block => block !== null)
+      .map((block, index) => {
+        if (block === currentBlock) {
+          return new ContentBlock({
+            key: key,
+            type: currentBlock.getType(),
+            depth: currentBlock.getDepth(),
+            text: currentBlock.getText() + targetBlock.getText(),
+            characterList: currentBlock.getCharacterList().concat(targetBlock.getCharacterList())
+          });
+        }
+        return block;
+      })
+      .filter(block => block !== targetBlock)
+      .reduce((a, b) => a.concat(b), [])
+    );
+
+    const selectionOffset = currentBlock.getLength();
+
+    return EditorState.push(
+      editorState,
+      newContentState.merge({
+        selectionBefore: selectionState,
+        selectionAfter: selectionState.merge({
+          anchorKey: key,
+          anchorOffset: selectionOffset,
+          focusKey: key,
+          focusOffset: selectionOffset,
+          isBackward: false,
+        })
+      }),
+      'delete-character'
+    );
+
+  },
+
+  onSplitNestedBlock: function(
+    editorState: EditorState,
+    blockRenderMap: DraftBlockRenderMap = DefaultBlockRenderMap
+  ): ? EditorState {
+    const selectionState = editorState.getSelection();
+    const contentState = editorState.getCurrentContent();
+
+    return EditorState.push(
+      editorState,
+      splitBlockWithNestingInContentState(contentState, selectionState),
+      'split-block'
+    );
+  },
+
+  onSplitParent: function(
+    editorState: EditorState,
+    blockRenderMap: DraftBlockRenderMap = DefaultBlockRenderMap
+  ): ? EditorState {
+    const selectionState = editorState.getSelection();
+    const contentState = editorState.getCurrentContent();
+    const key = selectionState.getAnchorKey();
+
+    const currentBlock = contentState.getBlockForKey(key);
+
+    const parentKey = currentBlock.getParentKey();
+    const parentBlock = contentState.getBlockForKey(parentKey);
+
+    // Option of rendering for the current block
+    const renderOpt = blockRenderMap.get(currentBlock.getType());
+    const parentRenderOpt = parentBlock && blockRenderMap.get(parentBlock.getType());
+
+    const hasWrapper = renderOpt && renderOpt.wrapper;
+
+    const parentHasWrapper = parentRenderOpt && parentRenderOpt.wrapper;
+
+    const blockMap = contentState.getBlockMap();
+
+    const targetKey = (
+      hasWrapper ?
+      generateNestedKey(parentKey) :
+      parentBlock && parentBlock.getParentKey() ?
+      generateNestedKey(parentBlock.getParentKey()) :
+      generateRandomKey()
+    );
+
+    const newContentState = ContentState.createFromBlockArray(
+      blockMap
+      .filter(block => block !== null)
+      .map((block, index) => {
+        if (block === currentBlock) {
+          const splittedBlockType = (!parentHasWrapper && (hasWrapper || !parentBlock.getParentKey()) ?
+            'unstyled' :
+            parentBlock.getType()
+          );
+          const splittedBlock = new ContentBlock({
+            key: targetKey,
+            type: splittedBlockType,
+            depth: parentBlock ? parentBlock.getDepth() : 0,
+            text: currentBlock.getText().slice(selectionState.getEndOffset()),
+            characterList: currentBlock.getCharacterList().slice(selectionState.getEndOffset())
+          });
+
+          // if we are on an empty block when we split we should remove it
+          // therefore we only return the splitted block
+          if (
+            currentBlock.getLength() === 0 &&
+            contentState.getBlockChildren(key).size === 0
+          ) {
+            return splittedBlock;
+          }
+
+          return [
+            new ContentBlock({
+              key: block.getKey(),
+              type: block.getType(),
+              depth: block.getDepth(),
+              text: currentBlock.getText().slice(0, selectionState.getStartOffset()),
+              characterList: currentBlock.getCharacterList().slice(0, selectionState.getStartOffset())
+            }),
+            splittedBlock
+          ];
+        }
+        return block;
+      })
+      .filter(block => block !== null)
+      .reduce((a, b) => a.concat(b), [])
+    );
+
+    return EditorState.push(
+      editorState,
+      newContentState.merge({
+        selectionBefore: selectionState,
+        selectionAfter: selectionState.merge({
+          anchorKey: targetKey,
+          anchorOffset: 0,
+          focusKey: targetKey,
+          focusOffset: 0,
+          isBackward: false,
+        })
+      }),
+      'split-block'
+    );
+
+  }
+};
+
+function getFirstAvailableLeafBeforeBlock(
+  block: ContentBlock,
+  contentState: ContentState,
+  condition: Function = function() {}
+): ContentBlock {
+  let previousLeafBlock = contentState.getBlockBefore(block.getKey());
+
+  while (!!previousLeafBlock &&
+    contentState.getBlockChildren(previousLeafBlock.getKey()).size !== 0 &&
+    !condition(previousLeafBlock)
+  ) {
+    previousLeafBlock = contentState.getBlockBefore(previousLeafBlock.getKey());
+  }
+
+  return previousLeafBlock || block;
+}
+
+function getFirstAvailableLeafAfterBlock(
+  block: ContentBlock,
+  contentState: ContentState,
+  condition: Function = function() {}
+): ContentBlock {
+  let nextLeafBlock = contentState.getBlockAfter(block.getKey());
+
+  while (!!nextLeafBlock &&
+    contentState.getBlockChildren(nextLeafBlock.getKey()).size !== 0 &&
+    contentState.getBlockAfter(nextLeafBlock.getKey()) &&
+    !condition(nextLeafBlock)
+  ) {
+    nextLeafBlock = contentState.getBlockAfter(nextLeafBlock.getKey());
+  }
+
+  return nextLeafBlock || block;
+}
+
+module.exports = NestedTextEditorUtil;
diff --git a/src/model/modifier/__tests__/NestedTextEditorUtil-test.js b/src/model/modifier/__tests__/NestedTextEditorUtil-test.js
new file mode 100644
index 0000000000..8e736da705
--- /dev/null
+++ b/src/model/modifier/__tests__/NestedTextEditorUtil-test.js
@@ -0,0 +1,346 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @emails isaac, oncall+ui_infra
+ */
+
+jest.disableAutomock();
+
+const EditorState = require('EditorState');
+
+const NestedTextEditorUtil = require('NestedTextEditorUtil');
+const getSampleStateForTestingNestedBlocks = require('getSampleStateForTestingNestedBlocks');
+
+describe('NestedTextEditorUtil', () => {
+  const {
+    editorState,
+    selectionState
+  } = getSampleStateForTestingNestedBlocks();
+
+  describe('onBackspace', () => {
+    const {
+      onBackspace
+    } = NestedTextEditorUtil;
+
+    it('does not handle non-zero-offset or non-collapsed selections', () => {
+      const nonZero = selectionState.merge({
+        anchorKey: 'b/c',
+        focusKey: 'b/c',
+        anchorOffset: 7,
+        focusOffset: 7
+      });
+      expect(
+        onBackspace(EditorState.forceSelection(editorState, nonZero))
+      ).toBe(
+        null
+      );
+
+      const nonCollapsed = nonZero.merge({
+        anchorOffset: 0
+      });
+      expect(
+        onBackspace(EditorState.forceSelection(editorState, nonCollapsed))
+      ).toBe(
+        null
+      );
+    });
+
+    it('does not handle if the previous block is not its parent', () => {
+      const nonFirstChild = selectionState.merge({
+        anchorKey: 'b/d',
+        focusKey: 'b/d',
+        anchorOffset: 0,
+        focusOffset: 0
+      });
+
+      expect(
+        onBackspace(EditorState.forceSelection(editorState, nonFirstChild))
+      ).toBe(
+        null
+      );
+    });
+
+    it('backspace on the start of a leaf block should remove block and merge text to previous leaf', () => {
+      const contentState = editorState.getCurrentContent();
+      const targetBlock = contentState.getBlockForKey('a');
+      const oldBlock = contentState.getBlockForKey('b/c');
+
+      const firstChildLeaf = selectionState.merge({
+        anchorKey: 'b/c',
+        focusKey: 'b/c',
+        anchorOffset: 0,
+        focusOffset: 0
+      });
+
+      const deletedState = onBackspace(
+        EditorState.forceSelection(editorState, firstChildLeaf)
+      );
+      const newContentState = deletedState.getCurrentContent();
+      const transformedTargetBlock = newContentState.getBlockForKey('a');
+
+      const expectedText = targetBlock.getText() + oldBlock.getText();
+
+      expect(
+        transformedTargetBlock.getText()
+      ).toBe(
+        expectedText
+      );
+
+      expect(
+        newContentState.getBlockForKey('b/c')
+      ).toBe(
+        undefined
+      );
+    });
+  });
+
+  describe('onDelete', () => {
+    const {
+      onDelete
+    } = NestedTextEditorUtil;
+
+    it('does not handle non-block-end or non-collapsed selections', () => {
+      const nonBlockEnd = selectionState.merge({
+        anchorKey: 'a',
+        focusKey: 'a',
+        anchorOffset: 0,
+        focusOffset: 0
+      });
+      expect(
+        onDelete(EditorState.forceSelection(editorState, nonBlockEnd))
+      ).toBe(
+        null
+      );
+
+      const nonCollapsed = nonBlockEnd.merge({
+        anchorOffset: 5,
+      });
+      expect(
+        onDelete(EditorState.forceSelection(editorState, nonCollapsed))
+      ).toBe(
+        null
+      );
+    });
+
+    it('does not handle if it is the last block on the blockMap', () => {
+      const lastBlock = selectionState.merge({
+        anchorKey: 'f',
+        focusKey: 'f',
+        anchorOffset: 7,
+        focusOffset: 7
+      });
+      expect(
+        onDelete(EditorState.forceSelection(editorState, lastBlock))
+      ).toBe(
+        null
+      );
+    });
+
+    it('does not handle if the next block has no children', () => {
+      const noChildrenSelection = selectionState.merge({
+        anchorKey: 'b/d/e',
+        focusKey: 'b/d/e',
+        anchorOffset: 4,
+        focusOffset: 4
+      });
+      expect(
+        onDelete(EditorState.forceSelection(editorState, noChildrenSelection))
+      ).toBe(
+        null
+      );
+    });
+
+    it('delete on the end of a leaf block should remove block and merge text to previous leaf', () => {
+      const contentState = editorState.getCurrentContent();
+      const targetBlock = contentState.getBlockForKey('a');
+      const oldBlock = contentState.getBlockForKey('b/c');
+
+      const firstChildLeaf = selectionState.merge({
+        anchorKey: 'a',
+        focusKey: 'a',
+        anchorOffset: targetBlock.getLength(),
+        focusOffset: targetBlock.getLength()
+      });
+
+      const deletedState = onDelete(
+        EditorState.forceSelection(editorState, firstChildLeaf)
+      );
+      const newContentState = deletedState.getCurrentContent();
+      const transformedTargetBlock = newContentState.getBlockForKey('a');
+
+      const expectedText = targetBlock.getText() + oldBlock.getText();
+
+      expect(
+        transformedTargetBlock.getText()
+      ).toBe(
+        expectedText
+      );
+
+      expect(
+        newContentState.getBlockForKey('b/c')
+      ).toBe(
+        undefined
+      );
+    });
+  });
+
+  describe('toggleBlockType', () => {
+    const {
+      toggleBlockType,
+      DefaultBlockRenderMap
+    } = NestedTextEditorUtil;
+
+    it('does not handle non nesting enabled blocks', () => {
+      const nestingDisabledBlock = selectionState.merge({
+        anchorKey: 'a',
+        focusKey: 'a'
+      });
+      const selectedBlockState = EditorState.forceSelection(
+        editorState,
+        nestingDisabledBlock
+      );
+      expect(
+        toggleBlockType(
+          selectedBlockState,
+          'header-two',
+          DefaultBlockRenderMap
+        ).editorState
+      ).toBe(
+        selectedBlockState
+      );
+    });
+
+    it('does not handle nesting enabled blocks with same blockType', () => {
+      const nestingDisabledBlock = selectionState.merge({
+        anchorKey: 'b',
+        focusKey: 'b'
+      });
+      const selectedBlockState = EditorState.forceSelection(
+        editorState,
+        nestingDisabledBlock
+      );
+      expect(
+        toggleBlockType(
+          selectedBlockState,
+          'blockquote',
+          DefaultBlockRenderMap
+        ).editorState
+      ).toBe(
+        selectedBlockState
+      );
+    });
+
+    // Example:
+    //
+    // Having the cursor on the H1 and trying to change blocktype to unordered-list
+    // it should not update h1 instead it should udate its parent block type
+    //
+    // ordered-list > h1
+    // should become
+    // unordered-list > h1
+    it('should change parent block type when changing type for same tag element', () => {
+      const selectedBlock = selectionState.merge({
+        anchorKey: 'b/d/e',
+        focusKey: 'b/d/e'
+      });
+      const selectedBlockState = EditorState.forceSelection(
+        editorState,
+        selectedBlock
+      );
+      const toggledState = toggleBlockType(
+          selectedBlockState,
+          'ordered-list-item',
+          DefaultBlockRenderMap
+      ).editorState;
+
+      const parentBlockType = editorState.getCurrentContent().getBlockForKey('b/d');
+      const updatedParentBlockType = toggledState.getCurrentContent().getBlockForKey('b/d');
+
+      expect(parentBlockType.getType()).toBe('unordered-list-item');
+      expect(updatedParentBlockType.getType()).toBe('ordered-list-item');
+    });
+
+    // Example:
+    //
+    // Changing the block type inside a nested enable block that has text should
+    // transfer it's text to a nested unstyled block example
+    //
+    // blockquote > ordered-list-item
+    // should become
+    // blockquote > ordered-list-item > unstyled
+    //
+    it('should retain parent type and create a new nested block with text from parent', () => {
+      const targetBlockKey = 'b/d';
+      const selectedBlock = selectionState.merge({
+        anchorKey: targetBlockKey,
+        focusKey: targetBlockKey,
+        focusOffset: 0,
+        anchorOffset: 0
+      });
+      const selectedBlockState = EditorState.forceSelection(
+        editorState,
+        selectedBlock
+      );
+      const toggledState = toggleBlockType(
+          selectedBlockState,
+          'unstyled',
+          DefaultBlockRenderMap
+      ).editorState;
+
+      const oldContentState = editorState.getCurrentContent();
+      const newContentState = toggledState.getCurrentContent();
+
+      const initialBlock = oldContentState.getBlockForKey(targetBlockKey);
+      const updatedBlock = newContentState.getBlockForKey(targetBlockKey);
+      const newBlock = newContentState.getBlockAfter(targetBlockKey);
+
+      expect(oldContentState.getBlockChildren(targetBlockKey).size).toBe(1);
+      expect(newContentState.getBlockChildren(targetBlockKey).size).toBe(2);
+      expect(initialBlock.getType()).toBe(updatedBlock.getType());
+      expect(updatedBlock.getText()).toBe('');
+      expect(newBlock.getText()).toBe(initialBlock.getText());
+      expect(newBlock.getType()).toBe('unstyled');
+    });
+  });
+
+  describe('onSplitParent', () => {
+    const {
+      onSplitParent,
+      DefaultBlockRenderMap
+    } = NestedTextEditorUtil;
+
+    const contentState = editorState.getCurrentContent();
+
+    it('must split a nested block retaining parent', () => {
+      const selectedBlock = selectionState.merge({
+        anchorKey: 'b/d',
+        focusKey: 'b/d',
+        focusOffset: 0,
+        anchorOffset: 0
+      });
+      const selectedBlockState = EditorState.forceSelection(
+        editorState,
+        selectedBlock
+      );
+      const afterSplit = onSplitParent(selectedBlockState, DefaultBlockRenderMap).getCurrentContent();
+      const afterBlockMap = afterSplit.getBlockMap();
+      const initialBlock = contentState.getBlockForKey('b/d');
+      const splittedBlock = afterSplit.getBlockForKey('b/d');
+      const newBlock = afterSplit.getBlockAfter('b/d');
+
+      expect(editorState.getCurrentContent().getBlockMap().size).toBe(6);
+      expect(afterBlockMap.size).toBe(7);
+
+      expect(splittedBlock.getText()).toBe('');
+      expect(splittedBlock.getType()).toBe(initialBlock.getType());
+      expect(newBlock.getText()).toBe(initialBlock.getText());
+      expect(newBlock.getType()).toBe('unstyled');
+      expect(newBlock.getParentKey()).toBe(initialBlock.getParentKey());
+    });
+  });
+});
diff --git a/src/model/paste/__tests__/DraftPasteProcessor-test.js b/src/model/paste/__tests__/DraftPasteProcessor-test.js
index cf577835de..4e54e5fd98 100644
--- a/src/model/paste/__tests__/DraftPasteProcessor-test.js
+++ b/src/model/paste/__tests__/DraftPasteProcessor-test.js
@@ -39,44 +39,60 @@ var CUSTOM_BLOCK_MAP = Immutable.Map({
   'code-block': {
     element: 'pre',
   },
-  'paragraph': {
-    element: 'p',
-  },
   'unstyled': {
     element: 'div',
   },
 });
+function assertInlineStyles(block, comparison) {
+  var styles = block.getCharacterList().map(c => c.getStyle());
+  expect(styles.toJS()).toEqual(comparison);
+}
+
+// Don't want to couple this to a specific way of generating entity IDs so
+// just checking their existance
+function assertEntities(block, comparison) {
+  var entities = block.getCharacterList().map(c => c.getEntity());
+  entities.toJS().forEach((entity, ii) => {
+    expect(comparison[ii]).toBe(!!entity);
+  });
+}
+
+function assertDepths(blocks, comparison) {
+  expect(
+    blocks.map(b => b.getDepth())
+  ).toEqual(
+    comparison
+  );
+}
+
+function assertBlockTypes(blocks, comparison) {
+  expect(
+    blocks.map(b => b.getType())
+  ).toEqual(
+    comparison
+  );
+}
+
+function assertBlockTexts(blocks, comparison) {
+  expect(
+    blocks.map(b => b.getText().trim())
+  ).toEqual(
+    comparison
+  );
+}
+
+function assertBlockIsChildrenOf(block, comparison) {
+  var blockKey = block.getKey();
+  var comparisonKey = comparison.getKey();
+
+  expect(
+    blockKey.indexOf(comparisonKey) !== -1
+  ).toBe(
+    true
+  );
+}
 
 describe('DraftPasteProcessor', function() {
-  function assertInlineStyles(block, comparison) {
-    var styles = block.getCharacterList().map(c => c.getStyle());
-    expect(styles.toJS()).toEqual(comparison);
-  }
-
-  // Don't want to couple this to a specific way of generating entity IDs so
-  // just checking their existance
-  function assertEntities(block, comparison) {
-    var entities = block.getCharacterList().map(c => c.getEntity());
-    entities.toJS().forEach((entity, ii) => {
-      expect(comparison[ii]).toBe(!!entity);
-    });
-  }
-
-  function assertDepths(blocks, comparison) {
-    expect(
-      blocks.map(b => b.getDepth())
-    ).toEqual(
-      comparison
-    );
-  }
-
-  function assertBlockTypes(blocks, comparison) {
-    expect(
-      blocks.map(b => b.getType())
-    ).toEqual(
-      comparison
-    );
-  }
 
   it('must identify italics text', function() {
     var html = 'hello hi';
@@ -163,7 +179,7 @@ describe('DraftPasteProcessor', function() {
     var html = '

Word' + ',

'; var output = DraftPasteProcessor.processHTML(html, CUSTOM_BLOCK_MAP); - assertBlockTypes(output, ['paragraph']); + assertBlockTypes(output, ['unstyled']); }); it('must preserve spaces', function() { @@ -198,8 +214,8 @@ describe('DraftPasteProcessor', function() { var html = '

hi

hello

'; var output = DraftPasteProcessor.processHTML(html, CUSTOM_BLOCK_MAP); assertBlockTypes(output, [ - 'paragraph', - 'paragraph', + 'unstyled', + 'unstyled', ]); }); @@ -373,3 +389,141 @@ describe('DraftPasteProcessor', function() { assertDepths(output, [0, 0, 0, 1, 1, 0]); }); }); + +describe('DraftPasteProcessor when nesting support is enabled', function() { + const nestingEnabledBlockRenderMap = CUSTOM_BLOCK_MAP.merge( + Immutable.Map({ + 'blockquote': { + element: 'blockquote', + nestingEnabled: true + }, + 'unstyled': { + element: 'div', + nestingEnabled: true + }, + 'unordered-list-item': { + element: 'li', + nestingEnabled: true + }, + 'ordered-list-item' : { + element: 'li', + nestingEnabled: true + }, + 'header-one': { + element: 'h1', + nestingEnabled: true + } + }) + ); + + it('must generate blocks with nested keys', function() { + var html = ` +
+

Nested block

+
+ `; + + var output = DraftPasteProcessor.processHTML( + html, + nestingEnabledBlockRenderMap + ); + + assertBlockIsChildrenOf(output[1], output[0]); + + assertBlockTypes(output, [ + 'blockquote', + 'header-one' + ]); + }); + + it('leaft with text and blocks should wrap text on unstyled', function() { + var nestingText = 'nesting enabled block'; + var html = ` +
  • ${nestingText}

    foo

  • + `; + + var output = DraftPasteProcessor.processHTML( + html, + nestingEnabledBlockRenderMap + ); + + var listBlock = output[0]; + var unstyledBlock = output[1]; + + assertBlockTypes(output, [ + 'unordered-list-item', + 'unstyled', + 'header-one' + ]); + + assertBlockIsChildrenOf(unstyledBlock, listBlock); + + expect(unstyledBlock.getText()).toBe(nestingText); + }); + + it('should create multiple nesting sibblings', function() { + var html = ` +

    Draft.js tree PR demo

    +

    This website is a very simple demo of Draft.js working with a Tree data structure.

    +
      +
    • +

      Why a Tree data structure

      +

      It allows the use of nested blocks inside lists / blockquotes / etc

      +
    • +
    • +

      How to test it?

      +

      You can change the input HTML in the textarea below.

      +
    • +
    + `; + + var output = DraftPasteProcessor.processHTML( + html, + nestingEnabledBlockRenderMap + ); + + assertBlockTypes(output, [ + 'header-one', + 'unstyled', + 'unordered-list-item', + 'header-three', + 'unstyled', + 'unordered-list-item', + 'header-three', + 'unstyled' + ]); + }); + + it('should create multiple nesting sibblings and retain their text', function() { + var html = ` +

    Draft.js tree PR demo

    +

    This website is a very simple demo of Draft.js working with a Tree data structure.

    +
      +
    • +

      Why a Tree data structure

      +

      It allows the use of nested blocks inside lists / blockquotes / etc

      +
    • +
    • +

      How to test it?

      +

      You can change the input HTML in the textarea below.

      +
    • +
    + `; + + var output = DraftPasteProcessor.processHTML( + html, + nestingEnabledBlockRenderMap + ); + + assertBlockTexts(output, [ + 'Draft.js tree PR demo', + 'This website is a very simple demo of Draft.js working with a Tree data structure.', + '', + 'Why a Tree data structure', + 'It allows the use of nested blocks inside lists / blockquotes / etc', + '', + 'How to test it?', + 'You can change the input HTML in the textarea below.' + ]); + }); +}); diff --git a/src/model/transaction/__tests__/getContentStateFragment-test.js b/src/model/transaction/__tests__/getContentStateFragment-test.js new file mode 100644 index 0000000000..fc90b2dd49 --- /dev/null +++ b/src/model/transaction/__tests__/getContentStateFragment-test.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+ui_infra + */ + +'use strict'; + +jest.disableAutomock(); + +const getContentStateFragment = require('getContentStateFragment'); +const getSampleStateForTesting = require('getSampleStateForTesting'); +const getSampleStateForTestingNestedBlocks = require('getSampleStateForTestingNestedBlocks'); + +describe('getContentStateFragment', () => { + it('must return a new blockMap with randomized block keys', () => { + const { + contentState, + selectionState, + } = getSampleStateForTesting(); + + const selection = selectionState.merge({ + focusKey: 'c' + }); + + const blockMap = contentState.getBlockMap(); + const blockKeys = blockMap.keySeq().toArray(); + + const newBlockMap = getContentStateFragment(contentState, selection); + const newKeys = newBlockMap.keySeq().toArray(); + + expect(blockKeys).not.toBe(newKeys); + expect(blockMap.first().getText()).toBe(newBlockMap.first().getText()); + expect(blockMap.skip(1).first().getText()).toBe(newBlockMap.skip(1).first().getText()); + expect(blockMap.last().getText()).toBe(newBlockMap.last().getText()); + expect(blockKeys.length).toBe(newKeys.length); + }); + + it('must return a new blockMap with randomized block keys with nesting enabled', () => { + const { + contentState, + selectionState, + } = getSampleStateForTestingNestedBlocks(); + + const selection = selectionState.merge({ + focusKey: 'f' + }); + + const blockMap = contentState.getBlockMap(); + const blockKeys = blockMap.keySeq().toArray(); + + const newBlockMap = getContentStateFragment(contentState, selection); + const newKeys = newBlockMap.keySeq().toArray(); + + expect(blockKeys).not.toBe(newKeys); + expect(blockMap.first().getText()).toBe(newBlockMap.first().getText()); + expect(blockMap.skip(1).first().getText()).toBe(newBlockMap.skip(1).first().getText()); + expect(blockMap.last().getText()).toBe(newBlockMap.last().getText()); + expect(blockKeys.length).toBe(newKeys.length); + }); +}); diff --git a/src/model/transaction/__tests__/insertFragmentIntoContentState-test.js b/src/model/transaction/__tests__/insertFragmentIntoContentState-test.js new file mode 100644 index 0000000000..624ee086ac --- /dev/null +++ b/src/model/transaction/__tests__/insertFragmentIntoContentState-test.js @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+ui_infra + */ + +'use strict'; + +jest.disableAutomock(); + +const insertFragmentIntoContentState = require('insertFragmentIntoContentState'); + +const getSampleStateForTesting = require('getSampleStateForTesting'); +const getSampleStateForTestingNestedBlocks = require('getSampleStateForTestingNestedBlocks'); + +describe('insertFragmentIntoContentState+', () => { + it('must randomize fragment keys and insert blocks at the end of last block text', () => { + const { + contentState, + selectionState, + } = getSampleStateForTesting(); + + const blockMap = contentState.getBlockMap(); + const blockKeys = blockMap.keySeq().toArray(); + + const fisrtBlock = blockMap.first(); + const lastBlock = blockMap.last(); + + const selection = selectionState.merge({ + focusKey: lastBlock.getKey(), + anchorKey: lastBlock.getKey(), + focusOffset: lastBlock.getLength(), + anchorOffset: lastBlock.getLength() + }); + + // we are trying to use the current blockMap to insert and replace the existing one + const fragmentBlockMap = contentState.getBlockMap(); + const newKeys = fragmentBlockMap.keySeq().toArray(); + + const newContentState = insertFragmentIntoContentState( + contentState, + selection, + fragmentBlockMap + ); + + const newBlockMap = newContentState.getBlockMap(); + + expect(blockKeys).not.toBe(newKeys); + expect(lastBlock.getText() + fisrtBlock.getText()).toBe(newBlockMap.skip(2).first().getText()); + expect(blockMap.last().getText()).toBe(newBlockMap.last().getText()); + }); + + it('must randomize fragment keys and insert blocks at the end of last block text with nesting enabled', () => { + const { + contentState, + selectionState, + } = getSampleStateForTestingNestedBlocks(); + + const blockMap = contentState.getBlockMap(); + const blockKeys = blockMap.keySeq().toArray(); + + const fisrtBlock = blockMap.first(); + const lastBlock = blockMap.last(); + + const selection = selectionState.merge({ + focusKey: lastBlock.getKey(), + anchorKey: lastBlock.getKey(), + focusOffset: lastBlock.getLength(), + anchorOffset: lastBlock.getLength() + }); + + // we are trying to use the current blockMap to insert and replace the existing one + const fragmentBlockMap = contentState.getBlockMap(); + const newKeys = fragmentBlockMap.keySeq().toArray(); + + const newContentState = insertFragmentIntoContentState( + contentState, + selection, + fragmentBlockMap + ); + + const newBlockMap = newContentState.getBlockMap(); + + expect(blockKeys).not.toBe(newKeys); + expect(lastBlock.getText() + fisrtBlock.getText()).toBe(newBlockMap.skip(5).first().getText()); + expect(blockMap.last().getText()).toBe(newBlockMap.last().getText()); + }); +}); diff --git a/src/model/transaction/__tests__/removeRangeFromContentState-test.js b/src/model/transaction/__tests__/removeRangeFromContentState-test.js index 8942b03c5e..d6bb3b3d09 100644 --- a/src/model/transaction/__tests__/removeRangeFromContentState-test.js +++ b/src/model/transaction/__tests__/removeRangeFromContentState-test.js @@ -15,6 +15,7 @@ jest.disableAutomock(); var Immutable = require('immutable'); var getSampleStateForTesting = require('getSampleStateForTesting'); +var getSampleStateForTestingNestedBlocks = require('getSampleStateForTestingNestedBlocks'); var removeRangeFromContentState = require('removeRangeFromContentState'); var ContentBlock = require('ContentBlock'); @@ -425,4 +426,101 @@ describe('removeRangeFromContentState', () => { checkForCharacterList(alteredBlock); }); }); + + describe('Removal across nested blocks', () => { + var { + contentState, + selectionState, + } = getSampleStateForTestingNestedBlocks(); + + it('must preserve parent blocks that do not have all children selected', () => { + var selection = selectionState.merge({ + anchorOffset: 3, + focusKey: 'b/c', + focusOffset: 3 + }); + + var originalBlockA = contentState.getBlockMap().first(); + var originalBlockB = contentState.getBlockMap().skip(1).first(); + var originalBlockBC = contentState.getBlockMap().skip(2).first(); + + var afterRemoval = removeRangeFromContentState(contentState, selection); + var afterBlockMap = afterRemoval.getBlockMap(); + + // we retain the 'b' parent since it has other children 'b/d' and 'b/d/e' + expect(afterBlockMap.size).toBe(5); + + var alteredBlock = afterBlockMap.first(); + + expect(alteredBlock).not.toBe(originalBlockA); + expect(alteredBlock).not.toBe(originalBlockB); + expect(alteredBlock).not.toBe(originalBlockBC); + expect(alteredBlock.getType()).toBe(originalBlockA.getType()); + expect(alteredBlock.getText()).toBe( + originalBlockA.getText().slice(0, 3) + + originalBlockBC.getText().slice(3) + ); + + var stylesToJS = getInlineStyles(originalBlockA); + expect(getInlineStyles(alteredBlock)).toEqual( + stylesToJS.slice(0, 3).concat( + getInlineStyles(originalBlockBC).slice(3) + ) + ); + + var entitiesToJS = getEntities(originalBlockA); + expect(getEntities(alteredBlock)).toEqual( + entitiesToJS.slice(0, 3).concat( + getEntities(originalBlockBC).slice(3) + ) + ); + + checkForCharacterList(alteredBlock); + }); + + it('must remove blocks within the selection while preserving parent of non selected sibblings', () => { + var selection = selectionState.merge({ + anchorOffset: 0, + anchorKey: 'b', + focusKey: 'b/c', + focusOffset: 0 + }); + + var originalBlockB = contentState.getBlockMap().skip(1).first(); + var originalBlockBC = contentState.getBlockMap().skip(2).first(); + + var afterRemoval = removeRangeFromContentState(contentState, selection); + var afterBlockMap = afterRemoval.getBlockMap(); + + expect(afterBlockMap.size).toBe(5); + + // the block 'b' should still been preserved and remain the second element on the list + var retainedBlock = afterBlockMap.skip(1).first(); + var alteredBlock = afterBlockMap.skip(2).first(); + + expect(retainedBlock).toBe(originalBlockB); + expect(alteredBlock).not.toBe(originalBlockBC); + }); + + it('must remove blocks within the selection and their top most common parent', () => { + var selection = selectionState.merge({ + anchorOffset: 4, + anchorKey: 'b/c', + focusKey: 'b/d/e', + focusOffset: 0 + }); + + var originalBlockB = contentState.getBlockMap().skip(1).first(); + + var afterRemoval = removeRangeFromContentState(contentState, selection); + var afterBlockMap = afterRemoval.getBlockMap(); + + var blockKeys = afterBlockMap.keySeq().toArray(); + var retainedBlock = afterBlockMap.skip(1).first(); + + expect(retainedBlock).toBe(originalBlockB); + expect(blockKeys.indexOf('b/d')).toBe(-1); + expect(blockKeys.indexOf('b/d/e')).toBe(-1); + }); + }); }); diff --git a/src/model/transaction/__tests__/splitBlockWithNestingInContentState-test.js b/src/model/transaction/__tests__/splitBlockWithNestingInContentState-test.js new file mode 100644 index 0000000000..64e61b214f --- /dev/null +++ b/src/model/transaction/__tests__/splitBlockWithNestingInContentState-test.js @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+ui_infra + */ + +'use strict'; + +jest.disableAutomock(); + +var Immutable = require('immutable'); +var getSampleStateForTesting = require('getSampleStateForTesting'); +var splitBlockWithNestingInContentState = require('splitBlockWithNestingInContentState'); + +describe('splitBlockWithNestingInContentState', () => { + const { + contentState, + selectionState, + } = getSampleStateForTesting(); + + function checkForCharacterList(block) { + expect(Immutable.List.isList(block.getCharacterList())).toBe(true); + } + + function getInlineStyles(block) { + return block.getCharacterList().map(c => c.getStyle()).toJS(); + } + + function getEntities(block) { + return block.getCharacterList().map(c => c.getEntity()).toJS(); + } + + it('must be restricted to collapsed selections', () => { + expect(() => { + const nonCollapsed = selectionState.set('focusOffset', 1); + return splitBlockWithNestingInContentState(contentState, nonCollapsed); + }).toThrow(); + + expect(() => { + return splitBlockWithNestingInContentState(contentState, selectionState); + }).not.toThrow(); + }); + + it('must split at the beginning of a block', () => { + const initialBlock = contentState.getBlockMap().first(); + const afterSplit = splitBlockWithNestingInContentState(contentState, selectionState); + const afterBlockMap = afterSplit.getBlockMap(); + expect(afterBlockMap.size).toBe(5); + + const preSplitBlock = afterBlockMap.first(); + expect(preSplitBlock.getKey()).toBe(initialBlock.getKey()); + expect(preSplitBlock.getText()).toBe(''); + expect(getInlineStyles(preSplitBlock)).toEqual([]); + expect(getEntities(preSplitBlock)).toEqual([]); + + const nestedBlocks = afterSplit.getBlockChildren(initialBlock.getKey()); + expect(nestedBlocks.size).toBe(2); + + const firstNestedBlock = nestedBlocks.first(); + const lastNestedBlock = nestedBlocks.last(); + + // First block should contain nothing + expect(firstNestedBlock.getKey()).not.toBe(lastNestedBlock.getKey()); + expect(firstNestedBlock.getType()).toBe(lastNestedBlock.getType()); + expect(firstNestedBlock.getType()).toBe('unstyled'); + expect(firstNestedBlock.getText()).toBe(''); + expect(getInlineStyles(firstNestedBlock)).toEqual([]); + expect(getEntities(firstNestedBlock)).toEqual([]); + + // Last block should contain everything + expect(lastNestedBlock.getKey()).not.toBe(firstNestedBlock.getKey()); + expect(lastNestedBlock.getText()).toBe(initialBlock.getText()); + + expect( + getInlineStyles(initialBlock) + ).toEqual( + getInlineStyles(lastNestedBlock) + ); + expect( + getEntities(lastNestedBlock) + ).toEqual( + getEntities(initialBlock) + ); + + checkForCharacterList(firstNestedBlock); + checkForCharacterList(lastNestedBlock); + }); + + it('must split within a block', () => { + const initialBlock = contentState.getBlockMap().first(); + const SPLIT_OFFSET = 3; + const selection = selectionState.merge({ + anchorOffset: SPLIT_OFFSET, + focusOffset: SPLIT_OFFSET, + }); + + const afterSplit = splitBlockWithNestingInContentState(contentState, selection); + const afterBlockMap = afterSplit.getBlockMap(); + expect(afterBlockMap.size).toBe(5); + + const preSplitBlock = afterBlockMap.first(); + expect(preSplitBlock.getKey()).toBe(initialBlock.getKey()); + expect(preSplitBlock.getText()).toBe(''); + expect(getInlineStyles(preSplitBlock)).toEqual([]); + expect(getEntities(preSplitBlock)).toEqual([]); + + const nestedBlocks = afterSplit.getBlockChildren(initialBlock.getKey()); + expect(nestedBlocks.size).toBe(2); + + const firstNestedBlock = nestedBlocks.first(); + const lastNestedBlock = nestedBlocks.last(); + + // First block should contain everything until offset + expect(firstNestedBlock.getText()).toBe(initialBlock.getText().slice(0, SPLIT_OFFSET)); + expect( + getInlineStyles(firstNestedBlock) + ).toEqual( + getInlineStyles(initialBlock).slice(0, SPLIT_OFFSET) + ); + expect( + getEntities(firstNestedBlock) + ).toEqual( + getEntities(initialBlock).slice(0, SPLIT_OFFSET) + ); + + // First block should contain everything after offset + expect(lastNestedBlock.getText()).toBe(initialBlock.getText().slice(SPLIT_OFFSET)); + expect( + getInlineStyles(lastNestedBlock) + ).toEqual( + getInlineStyles(initialBlock).slice(SPLIT_OFFSET) + ); + expect( + getEntities(lastNestedBlock) + ).toEqual( + getEntities(initialBlock).slice(SPLIT_OFFSET) + ); + }); + + it('must split at the end of a block', () => { + const initialBlock = contentState.getBlockMap().first(); + const end = initialBlock.getLength(); + const selection = selectionState.merge({ + anchorOffset: end, + focusOffset: end, + }); + + const afterSplit = splitBlockWithNestingInContentState(contentState, selection); + const afterBlockMap = afterSplit.getBlockMap(); + expect(afterBlockMap.size).toBe(5); + + const preSplitBlock = afterBlockMap.first(); + expect(preSplitBlock.getKey()).toBe(initialBlock.getKey()); + expect(preSplitBlock.getText()).toBe(''); + expect(getInlineStyles(preSplitBlock)).toEqual([]); + expect(getEntities(preSplitBlock)).toEqual([]); + + const nestedBlocks = afterSplit.getBlockChildren(initialBlock.getKey()); + expect(nestedBlocks.size).toBe(2); + + const firstNestedBlock = nestedBlocks.first(); + const lastNestedBlock = nestedBlocks.last(); + + expect(firstNestedBlock.getKey()).not.toBe(lastNestedBlock.getKey()); + expect(firstNestedBlock.getType()).toBe(lastNestedBlock.getType()); + expect(firstNestedBlock.getType()).toBe('unstyled'); + expect(lastNestedBlock.getKey()).not.toBe(firstNestedBlock.getKey()); + + // First block should contain everything + expect(firstNestedBlock.getText()).toBe(initialBlock.getText()); + expect( + getInlineStyles(firstNestedBlock) + ).toEqual( + getInlineStyles(initialBlock) + ); + expect( + getEntities(firstNestedBlock) + ).toEqual( + getEntities(initialBlock) + ); + + // Second block should be empty + expect(lastNestedBlock.getText()).toBe(''); + expect(getInlineStyles(lastNestedBlock)).toEqual([]); + expect(getEntities(lastNestedBlock)).toEqual([]); + + checkForCharacterList(firstNestedBlock); + checkForCharacterList(lastNestedBlock); + }); +}); diff --git a/src/model/transaction/getContentStateFragment.js b/src/model/transaction/getContentStateFragment.js index f4746b8202..4070992d5f 100644 --- a/src/model/transaction/getContentStateFragment.js +++ b/src/model/transaction/getContentStateFragment.js @@ -13,7 +13,7 @@ 'use strict'; -var generateRandomKey = require('generateRandomKey'); +var randomizeBlockMapKeys = require('randomizeBlockMapKeys'); var removeEntitiesAtEdges = require('removeEntitiesAtEdges'); import type {BlockMap} from 'BlockMap'; @@ -38,41 +38,43 @@ function getContentStateFragment( ); var blockMap = contentWithoutEdgeEntities.getBlockMap(); + + var randomizedBlockMapKeys = randomizeBlockMapKeys(blockMap); + + var randomizedBlockKeys = randomizedBlockMapKeys.keySeq(); var blockKeys = blockMap.keySeq(); + var startIndex = blockKeys.indexOf(startKey); var endIndex = blockKeys.indexOf(endKey) + 1; - var slice = blockMap.slice(startIndex, endIndex).map((block, blockKey) => { - var newKey = generateRandomKey(); + var slice = randomizedBlockMapKeys.slice(startIndex, endIndex).map((block, blockKey) => { + var keyIndex = randomizedBlockKeys.indexOf(blockKey); var text = block.getText(); var chars = block.getCharacterList(); if (startKey === endKey) { return block.merge({ - key: newKey, text: text.slice(startOffset, endOffset), characterList: chars.slice(startOffset, endOffset), }); } - if (blockKey === startKey) { + if (keyIndex === startIndex) { return block.merge({ - key: newKey, text: text.slice(startOffset), characterList: chars.slice(startOffset), }); } - if (blockKey === endKey) { + if (keyIndex === endIndex) { return block.merge({ - key: newKey, text: text.slice(0, endOffset), characterList: chars.slice(0, endOffset), }); } - return block.set('key', newKey); + return block; }); return slice.toOrderedMap(); diff --git a/src/model/transaction/getSampleStateForTestingNestedBlocks.js b/src/model/transaction/getSampleStateForTestingNestedBlocks.js new file mode 100644 index 0000000000..13a49f1f22 --- /dev/null +++ b/src/model/transaction/getSampleStateForTestingNestedBlocks.js @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule getSampleStateForTestingNestedBlocks + * @typechecks + * @flow + */ + +'use strict'; + +const BlockMapBuilder = require('BlockMapBuilder'); +const CharacterMetadata = require('CharacterMetadata'); +const ContentBlock = require('ContentBlock'); +const ContentState = require('ContentState'); +const EditorState = require('EditorState'); +const Immutable = require('immutable'); +const SampleDraftInlineStyle = require('SampleDraftInlineStyle'); +const SelectionState = require('SelectionState'); + +const { + BOLD, + ITALIC +} = SampleDraftInlineStyle; +const ENTITY_KEY = '123'; + +const BLOCKS = [ + new ContentBlock({ + key: 'a', + type: 'header-one', + text: 'Alpha', + characterList: Immutable.List( + Immutable.Repeat(CharacterMetadata.EMPTY, 5) + ), + }), + new ContentBlock({ + key: 'b', + type: 'blockquote', + text: '', + characterList: Immutable.List( + Immutable.Repeat( + CharacterMetadata.create({ + style: BOLD, + entity: ENTITY_KEY + }), + 5 + ) + ), + }), + new ContentBlock({ + key: 'b/c', + type: 'ordered-list-item', + text: 'Charlie', + characterList: Immutable.List( + Immutable.Repeat( + CharacterMetadata.create({ + style: ITALIC, + entity: null + }), + 7 + ) + ), + }), + new ContentBlock({ + key: 'b/d', + type: 'unordered-list-item', + text: '', + characterList: Immutable.List( + Immutable.Repeat( + CharacterMetadata.create({ + style: ITALIC, + entity: null + }), + 7 + ) + ), + }), + new ContentBlock({ + key: 'b/d/e', + type: 'header-one', + text: 'Echo', + characterList: Immutable.List( + Immutable.Repeat( + CharacterMetadata.create({ + style: ITALIC, + entity: null + }), + 7 + ) + ), + }), + new ContentBlock({ + key: 'f', + type: 'blockquote', + text: 'Foxtrot', + characterList: Immutable.List( + Immutable.Repeat( + CharacterMetadata.create({ + style: ITALIC, + entity: null + }), + 7 + ) + ), + }), +]; + +const selectionState = new SelectionState({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'e', + focusOffset: 0, + isBackward: false, + hasFocus: true, +}); + +const blockMap = BlockMapBuilder.createFromArray(BLOCKS); +const contentState = new ContentState({ + blockMap, + selectionBefore: selectionState, + selectionAfter: selectionState, +}); + +const editorState = EditorState.forceSelection( + EditorState.createWithContent(contentState), + selectionState +); + +function getSampleStateForTestingNestedBlocks(): Object { + return { + editorState, + contentState, + selectionState + }; +} + +module.exports = getSampleStateForTestingNestedBlocks; diff --git a/src/model/transaction/insertFragmentIntoContentState.js b/src/model/transaction/insertFragmentIntoContentState.js index 2b03b3f184..77b51fd882 100644 --- a/src/model/transaction/insertFragmentIntoContentState.js +++ b/src/model/transaction/insertFragmentIntoContentState.js @@ -15,10 +15,11 @@ var BlockMapBuilder = require('BlockMapBuilder'); -var generateRandomKey = require('generateRandomKey'); var insertIntoList = require('insertIntoList'); var invariant = require('invariant'); +var randomizeBlockMapKeys = require('randomizeBlockMapKeys'); + import type {BlockMap} from 'BlockMap'; import type ContentState from 'ContentState'; import type SelectionState from 'SelectionState'; @@ -26,7 +27,7 @@ import type SelectionState from 'SelectionState'; function insertFragmentIntoContentState( contentState: ContentState, selectionState: SelectionState, - fragment: BlockMap + fragmentBlockMap: BlockMap ): ContentState { invariant( selectionState.isCollapsed(), @@ -38,6 +39,11 @@ function insertFragmentIntoContentState( var blockMap = contentState.getBlockMap(); + // we need to make sure that the fragment have unique keys + // that would not clash with the blockMap, so we need to + // generate new set of keys for all nested elements + var fragment = randomizeBlockMapKeys(fragmentBlockMap); + var fragmentSize = fragment.size; var finalKey; var finalOffset; @@ -108,7 +114,7 @@ function insertFragmentIntoContentState( // Insert fragment blocks after the head and before the tail. fragment.slice(1, fragmentSize - 1).forEach( fragmentBlock => { - newBlockArr.push(fragmentBlock.set('key', generateRandomKey())); + newBlockArr.push(fragmentBlock); } ); @@ -116,10 +122,9 @@ function insertFragmentIntoContentState( var tailText = text.slice(targetOffset, blockSize); var tailCharacters = chars.slice(targetOffset, blockSize); var prependToTail = fragment.last(); - finalKey = generateRandomKey(); + finalKey = prependToTail.getKey(); var modifiedTail = prependToTail.merge({ - key: finalKey, text: prependToTail.getText() + tailText, characterList: prependToTail .getCharacterList() diff --git a/src/model/transaction/removeRangeFromContentState.js b/src/model/transaction/removeRangeFromContentState.js index ce1feb4c4a..b7748dc5c6 100644 --- a/src/model/transaction/removeRangeFromContentState.js +++ b/src/model/transaction/removeRangeFromContentState.js @@ -35,6 +35,28 @@ function removeRangeFromContentState( var startBlock = blockMap.get(startKey); var endBlock = blockMap.get(endKey); + + var nextBlock = contentState.getBlockAfter(endKey); + var nextBlockKey = nextBlock ? nextBlock.getKey() : ''; + + /* + * when dealing with selection ranges across nested blocks we need to be able to + * identify what is the most common shared parent beteween sibling blocks + * + * example + * + * li > bq > unstyled + * li > bq > header-one + * + * the topMosCommonParentKey would be the key for `li > bq` + */ + var topMostCommonParentKey = nextBlockKey.split('/').reduce((value, key, index) => { + if (endKey.indexOf(key) !== -1) { + value.push(key); + } + return value; + }, []).join('/'); + var characterList; if (startBlock === endBlock) { @@ -62,6 +84,7 @@ function removeRangeFromContentState( .toSeq() .skipUntil((_, k) => k === startKey) .takeUntil((_, k) => k === endKey) + .filterNot((_, k) => topMostCommonParentKey.indexOf(k) !== -1) .concat(Immutable.Map([[endKey, null]])) .map((_, k) => { return k === startKey ? modifiedStart : null; }); diff --git a/src/model/transaction/splitBlockInContentState.js b/src/model/transaction/splitBlockInContentState.js index a2619c540b..62fe6eecd6 100644 --- a/src/model/transaction/splitBlockInContentState.js +++ b/src/model/transaction/splitBlockInContentState.js @@ -13,6 +13,7 @@ 'use strict'; +var generateNestedKey = require('generateNestedKey'); var generateRandomKey = require('generateRandomKey'); var invariant = require('invariant'); @@ -32,6 +33,7 @@ function splitBlockInContentState( var offset = selectionState.getAnchorOffset(); var blockMap = contentState.getBlockMap(); var blockToSplit = blockMap.get(key); + var parentKey = blockToSplit.getParentKey(); var text = blockToSplit.getText(); var chars = blockToSplit.getCharacterList(); @@ -41,7 +43,8 @@ function splitBlockInContentState( characterList: chars.slice(0, offset), }); - var keyBelow = generateRandomKey(); + var keyBelow = parentKey ? generateNestedKey(parentKey) : generateRandomKey(); + var blockBelow = blockAbove.merge({ key: keyBelow, text: text.slice(offset), diff --git a/src/model/transaction/splitBlockWithNestingInContentState.js b/src/model/transaction/splitBlockWithNestingInContentState.js new file mode 100644 index 0000000000..6ee936b6ea --- /dev/null +++ b/src/model/transaction/splitBlockWithNestingInContentState.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule splitBlockWithNestingInContentState + * @typechecks + * @flow + */ + +'use strict'; + +const Immutable = require('immutable'); +const generateNestedKey = require('generateNestedKey'); +const invariant = require('invariant'); +const ContentBlock = require('ContentBlock'); + +import type ContentState from 'ContentState'; +import type SelectionState from 'SelectionState'; + +const { + List +} = Immutable; + +/* + Split a block and create a new nested block, + + If block has no nested blocks, original text from the block is split + between 2 nested blocks + + LI "Hello World" --> LI "" + UNSTYLED "Hello" + UNSTYLED " World" +*/ +function splitBlockWithNestingInContentState( + contentState: ContentState, + selectionState: SelectionState, + blockType:string='unstyled' +): ContentState { + invariant( + selectionState.isCollapsed(), + 'Selection range must be collapsed.' + ); + + const key = selectionState.getAnchorKey(); + const offset = selectionState.getAnchorOffset(); + const blockMap = contentState.getBlockMap(); + const blockToSplit = blockMap.get(key); + + const text = blockToSplit.getText(); + const chars = blockToSplit.getCharacterList(); + + const firstNestedKey = generateNestedKey(key); + const secondNestedKey = generateNestedKey(key); + + const newParentBlock = blockToSplit.merge({ + text: '', + characterList: List() + }); + + const firstNestedBlock = new ContentBlock({ + key: firstNestedKey, + type: blockType, + text: text.slice(0, offset), + characterList: chars.slice(0, offset) + }); + + const secondNestedBlock = new ContentBlock({ + key: secondNestedKey, + type: blockType, + text: text.slice(offset), + characterList: chars.slice(offset) + }); + + const blocksBefore = blockMap.toSeq().takeUntil(v => v === blockToSplit); + const blocksAfter = blockMap.toSeq().skipUntil(v => v === blockToSplit).rest(); + const newBlocks = blocksBefore.concat( + [[newParentBlock.getKey(), newParentBlock], + [firstNestedBlock.getKey(), firstNestedBlock], + [secondNestedBlock.getKey(), secondNestedBlock]], + blocksAfter + ).toOrderedMap(); + + return contentState.merge({ + blockMap: newBlocks, + selectionBefore: selectionState, + selectionAfter: selectionState.merge({ + anchorKey: secondNestedKey, + anchorOffset: 0, + focusKey: secondNestedKey, + focusOffset: 0, + isBackward: false, + }), + }); +} + +module.exports = splitBlockWithNestingInContentState; diff --git a/website/core/metadata.js b/website/core/metadata.js index eb3b7b1d06..c56ffa3b76 100644 --- a/website/core/metadata.js +++ b/website/core/metadata.js @@ -177,7 +177,8 @@ module.exports = { "title": "Modifier", "layout": "docs", "category": "API Reference", - "permalink": "docs/api-reference-modifier.html" + "permalink": "docs/api-reference-modifier.html", + "next": "experimental-nesting" }, { "id": "api-reference-rich-utils", @@ -195,6 +196,14 @@ module.exports = { "next": "api-reference-composite-decorator", "permalink": "docs/api-reference-selection-state.html" }, + { + "id": "experimental-nesting", + "title": "Nesting", + "layout": "docs", + "category": "Experimental", + "next": "api-reference-data-conversion", + "permalink": "docs/experimental-nesting.html" + }, { "id": "getting-started", "title": "Overview",