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 = '';
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.
+
+ `;
+
+ 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.
+
+ `;
+
+ 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",