From c1150a729080d04014551d1c78292cb05da7cf1b Mon Sep 17 00:00:00 2001 From: mitermayer Date: Wed, 15 Nov 2017 18:44:59 -0800 Subject: [PATCH] Exploration - Feature tree / Rendering tree data format Summary: This PR is part of a series of PR's that will be exploring **tree data block support** in Draft. **Rendering tree data format** This PR adds support for react components to render the tree data structure *** **Note:** This is unstable and not part of the public API and should not be used by production systems. Closes https://github.com/facebook/draft-js/pull/1508 Differential Revision: D6341091 fbshipit-source-id: e285bb04ca51eb55b75c6912a2f5d283a4145d2d --- .../contents/DraftEditorBlock.react.js | 106 ++- .../contents/DraftEditorContents.react.js | 99 ++- .../contents/DraftEditorLeaf.react.js | 2 +- ...t.js => DraftEditorContents.react-test.js} | 3 +- .../exploration/DraftEditorBlockNode.react.js | 378 +++++++++++ .../DraftEditorContentsExperimental.react.js | 193 ++++++ .../DraftEditorDecoratedLeaves.react.js | 91 +++ .../exploration/DraftEditorNode.react.js | 132 ++++ .../DraftEditorBlockNode.react-test.js | 539 ++++++++++++++++ ...ftEditorContentsExperimental.react-test.js | 135 ++++ .../DraftEditorBlockNode.react-test.js.snap | 609 ++++++++++++++++++ ...torContentsExperimental.react-test.js.snap | 159 +++++ src/component/utils/_DraftTestHelper.js | 51 ++ src/model/immutable/ContentBlockNode.js | 3 + 14 files changed, 2388 insertions(+), 112 deletions(-) rename src/component/contents/__tests__/{DraftEditorContent.react-test.js => DraftEditorContents.react-test.js} (96%) create mode 100644 src/component/contents/exploration/DraftEditorBlockNode.react.js create mode 100644 src/component/contents/exploration/DraftEditorContentsExperimental.react.js create mode 100644 src/component/contents/exploration/DraftEditorDecoratedLeaves.react.js create mode 100644 src/component/contents/exploration/DraftEditorNode.react.js create mode 100644 src/component/contents/exploration/__tests__/DraftEditorBlockNode.react-test.js create mode 100644 src/component/contents/exploration/__tests__/DraftEditorContentsExperimental.react-test.js create mode 100644 src/component/contents/exploration/__tests__/__snapshots__/DraftEditorBlockNode.react-test.js.snap create mode 100644 src/component/contents/exploration/__tests__/__snapshots__/DraftEditorContentsExperimental.react-test.js.snap create mode 100644 src/component/utils/_DraftTestHelper.js diff --git a/src/component/contents/DraftEditorBlock.react.js b/src/component/contents/DraftEditorBlock.react.js index 102ba14384..2c62b8df54 100644 --- a/src/component/contents/DraftEditorBlock.react.js +++ b/src/component/contents/DraftEditorBlock.react.js @@ -16,6 +16,7 @@ import type {BlockNodeRecord} from 'BlockNodeRecord'; import type ContentState from 'ContentState'; import type {DraftDecoratorType} from 'DraftDecoratorType'; +import type {DraftInlineStyle} from 'DraftInlineStyle'; import type SelectionState from 'SelectionState'; import type {BidiDirection} from 'UnicodeBidiDirection'; import type {List} from 'immutable'; @@ -39,18 +40,29 @@ const nullthrows = require('nullthrows'); const SCROLL_BUFFER = 10; type Props = { - contentState: ContentState, block: BlockNodeRecord, + blockProps?: Object, + blockStyleFn: (block: BlockNodeRecord) => string, + contentState: ContentState, + customStyleFn: (style: DraftInlineStyle, block: BlockNodeRecord) => ?Object, customStyleMap: Object, - customStyleFn: Function, - tree: List, - selection: SelectionState, - decorator: DraftDecoratorType, - forceSelection: boolean, + decorator: ?DraftDecoratorType, direction: BidiDirection, - blockProps?: Object, + forceSelection: boolean, + offsetKey: string, + selection: SelectionState, startIndent?: boolean, - blockStyleFn: Function, + tree: List, +}; + +/** + * Return whether a block overlaps with either edge of the `SelectionState`. + */ +const isBlockOnSelectionEdge = ( + selection: SelectionState, + key: string, +): boolean => { + return selection.getAnchorKey() === key || selection.getFocusKey() === key; }; /** @@ -83,21 +95,21 @@ class DraftEditorBlock extends React.Component { * scroll parent. */ componentDidMount(): void { - var selection = this.props.selection; - var endKey = selection.getEndKey(); + const selection = this.props.selection; + const endKey = selection.getEndKey(); if (!selection.getHasFocus() || endKey !== this.props.block.getKey()) { return; } - var blockNode = ReactDOM.findDOMNode(this); - var scrollParent = Style.getScrollParent(blockNode); - var scrollPosition = getScrollPosition(scrollParent); - var scrollDelta; + const blockNode = ReactDOM.findDOMNode(this); + const scrollParent = Style.getScrollParent(blockNode); + const scrollPosition = getScrollPosition(scrollParent); + let scrollDelta; if (scrollParent === window) { - var nodePosition = getElementPosition(blockNode); - var nodeBottom = nodePosition.y + nodePosition.height; - var viewportHeight = getViewportDimensions().height; + const nodePosition = getElementPosition(blockNode); + const nodeBottom = nodePosition.y + nodePosition.height; + const viewportHeight = getViewportDimensions().height; scrollDelta = nodeBottom - viewportHeight; if (scrollDelta > 0) { window.scrollTo( @@ -110,8 +122,8 @@ class DraftEditorBlock extends React.Component { blockNode instanceof HTMLElement, 'blockNode is not an HTMLElement', ); - var blockBottom = blockNode.offsetHeight + blockNode.offsetTop; - var scrollBottom = scrollParent.offsetHeight + scrollPosition.y; + const blockBottom = blockNode.offsetHeight + blockNode.offsetTop; + const scrollBottom = scrollParent.offsetHeight + scrollPosition.y; scrollDelta = blockBottom - scrollBottom; if (scrollDelta > 0) { Scroll.setTop( @@ -123,32 +135,28 @@ class DraftEditorBlock extends React.Component { } _renderChildren(): Array> { - var block = this.props.block; - var blockKey = block.getKey(); - var text = block.getText(); - var lastLeafSet = this.props.tree.size - 1; - var hasSelection = isBlockOnSelectionEdge(this.props.selection, blockKey); + const block = this.props.block; + const blockKey = block.getKey(); + const text = block.getText(); + const lastLeafSet = this.props.tree.size - 1; + const hasSelection = isBlockOnSelectionEdge(this.props.selection, blockKey); return this.props.tree .map((leafSet, ii) => { - var leavesForLeafSet = leafSet.get('leaves'); - var lastLeaf = leavesForLeafSet.size - 1; - var leaves = leavesForLeafSet + const leavesForLeafSet = leafSet.get('leaves'); + const lastLeaf = leavesForLeafSet.size - 1; + const leaves = leavesForLeafSet .map((leaf, jj) => { - var offsetKey = DraftOffsetKey.encode(blockKey, ii, jj); - var start = leaf.get('start'); - var end = leaf.get('end'); + const offsetKey = DraftOffsetKey.encode(blockKey, ii, jj); + const start = leaf.get('start'); + const end = leaf.get('end'); return ( - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an - * error when upgrading Flow's support for React. Common errors found - * when upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ { }) .toArray(); - var decoratorKey = leafSet.get('decoratorKey'); + const decoratorKey = leafSet.get('decoratorKey'); if (decoratorKey == null) { return leaves; } @@ -169,23 +177,23 @@ class DraftEditorBlock extends React.Component { return leaves; } - var decorator = nullthrows(this.props.decorator); + const decorator = nullthrows(this.props.decorator); - var DecoratorComponent = decorator.getComponentForKey(decoratorKey); + const DecoratorComponent = decorator.getComponentForKey(decoratorKey); if (!DecoratorComponent) { return leaves; } - var decoratorProps = decorator.getPropsForKey(decoratorKey); - var decoratorOffsetKey = DraftOffsetKey.encode(blockKey, ii, 0); - var decoratedText = text.slice( + const decoratorProps = decorator.getPropsForKey(decoratorKey); + const decoratorOffsetKey = DraftOffsetKey.encode(blockKey, ii, 0); + const decoratedText = text.slice( leavesForLeafSet.first().get('start'), leavesForLeafSet.last().get('end'), ); // Resetting dir to the same value on a child node makes Chrome/Firefox // confused on cursor movement. See http://jsfiddle.net/d157kLck/3/ - var dir = UnicodeBidiDirection.getHTMLDirIfDifferent( + const dir = UnicodeBidiDirection.getHTMLDirIfDifferent( UnicodeBidi.getDirection(decoratedText), this.props.direction, ); @@ -207,10 +215,6 @@ class DraftEditorBlock extends React.Component { } render(): React.Node { - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an error - * when upgrading Flow's support for React. Common errors found when - * upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ const {direction, offsetKey} = this.props; const className = cx({ 'public/DraftStyleDefault/block': true, @@ -226,14 +230,4 @@ class DraftEditorBlock extends React.Component { } } -/** - * Return whether a block overlaps with either edge of the `SelectionState`. - */ -function isBlockOnSelectionEdge( - selection: SelectionState, - key: string, -): boolean { - return selection.getAnchorKey() === key || selection.getFocusKey() === key; -} - module.exports = DraftEditorBlock; diff --git a/src/component/contents/DraftEditorContents.react.js b/src/component/contents/DraftEditorContents.react.js index e1644542f5..4211a43b2a 100644 --- a/src/component/contents/DraftEditorContents.react.js +++ b/src/component/contents/DraftEditorContents.react.js @@ -14,6 +14,8 @@ 'use strict'; import type {BlockNodeRecord} from 'BlockNodeRecord'; +import type {DraftBlockRenderMap} from 'DraftBlockRenderMap'; +import type {DraftInlineStyle} from 'DraftInlineStyle'; import type {BidiDirection} from 'UnicodeBidiDirection'; const DraftEditorBlock = require('DraftEditorBlock.react'); @@ -26,12 +28,43 @@ const joinClasses = require('joinClasses'); const nullthrows = require('nullthrows'); type Props = { - blockRendererFn: Function, - blockStyleFn: (block: BlockNodeRecord) => string, + blockRenderMap: DraftBlockRenderMap, + blockRendererFn: (block: BlockNodeRecord) => ?Object, + blockStyleFn?: (block: BlockNodeRecord) => string, + customStyleFn?: (style: DraftInlineStyle, block: BlockNodeRecord) => ?Object, + customStyleMap?: Object, + editorKey?: string, editorState: EditorState, textDirectionality?: BidiDirection, }; +/** + * 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. + */ +const 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', + }); +}; + /** * `DraftEditorContents` is the container component for all block components * rendered for a `DraftEditor`. It is optimized to aggressively avoid @@ -91,23 +124,14 @@ class DraftEditorContents extends React.Component { render(): React.Node { const { - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an error - * when upgrading Flow's support for React. Common errors found when - * upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ blockRenderMap, blockRendererFn, - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an error - * when upgrading Flow's support for React. Common errors found when - * upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ + blockStyleFn, customStyleMap, - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an error - * when upgrading Flow's support for React. Common errors found when - * upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ customStyleFn, editorState, + editorKey, + textDirectionality, } = this.props; const content = editorState.getCurrentContent(); @@ -118,6 +142,7 @@ class DraftEditorContents extends React.Component { const blocksAsArray = content.getBlocksAsArray(); const processedBlocks = []; + let currentDepth = null; let lastWrapperTemplate = null; @@ -134,7 +159,6 @@ class DraftEditorContents extends React.Component { customEditable = customRenderer.editable; } - const {textDirectionality} = this.props; const direction = textDirectionality ? textDirectionality : directionMap.get(key); @@ -143,6 +167,7 @@ class DraftEditorContents extends React.Component { contentState: content, block, blockProps: customProps, + blockStyleFn, customStyleMap, customStyleFn, decorator, @@ -162,7 +187,10 @@ class DraftEditorContents extends React.Component { configForType.element || blockRenderMap.get('unstyled').element; const depth = block.getDepth(); - let className = this.props.blockStyleFn(block); + let className = ''; + if (blockStyleFn) { + className = blockStyleFn(block); + } // List items are special snowflakes, since we handle nesting and // counters manually. @@ -181,11 +209,7 @@ class DraftEditorContents extends React.Component { let childProps = { className, 'data-block': true, - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an - * error when upgrading Flow's support for React. Common errors found - * when upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ - 'data-editor': this.props.editorKey, + 'data-editor': editorKey, 'data-offset-key': offsetKey, key, }; @@ -200,10 +224,6 @@ class DraftEditorContents extends React.Component { const child = React.createElement( Element, childProps, - /* $FlowFixMe(>=0.53.0 site=www,mobile) This comment suppresses an - * error when upgrading Flow's support for React. Common errors found - * when upgrading Flow's React support are documented at - * https://fburl.com/eq7bs81w */ , ); @@ -225,7 +245,7 @@ class DraftEditorContents extends React.Component { // Group contiguous runs of blocks that have the same wrapperTemplate const outputBlocks = []; for (let ii = 0; ii < processedBlocks.length; ) { - const info = processedBlocks[ii]; + const info: any = processedBlocks[ii]; if (info.wrapperTemplate) { const blocks = []; do { @@ -254,31 +274,4 @@ class DraftEditorContents extends React.Component { } } -/** - * 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/DraftEditorLeaf.react.js b/src/component/contents/DraftEditorLeaf.react.js index a7ff274e8f..f237aae4d6 100644 --- a/src/component/contents/DraftEditorLeaf.react.js +++ b/src/component/contents/DraftEditorLeaf.react.js @@ -44,7 +44,7 @@ type Props = { // The current `SelectionState`, used to represent a selection range in the // editor - selection: SelectionState, + selection: ?SelectionState, // The offset of this string within its block. start: number, diff --git a/src/component/contents/__tests__/DraftEditorContent.react-test.js b/src/component/contents/__tests__/DraftEditorContents.react-test.js similarity index 96% rename from src/component/contents/__tests__/DraftEditorContent.react-test.js rename to src/component/contents/__tests__/DraftEditorContents.react-test.js index 54b045a6be..abf02f1fc9 100644 --- a/src/component/contents/__tests__/DraftEditorContent.react-test.js +++ b/src/component/contents/__tests__/DraftEditorContents.react-test.js @@ -85,8 +85,7 @@ test('defaults to "unstyled" block type for unknown block types', () => { } const block = ReactTestRenderer.create(); - const blockInstance = block.root; - const editorInstace = blockInstance._fiber.stateNode; + const editorInstace = block.getInstance(); expect(() => { editorInstace.toggleCustomBlock( diff --git a/src/component/contents/exploration/DraftEditorBlockNode.react.js b/src/component/contents/exploration/DraftEditorBlockNode.react.js new file mode 100644 index 0000000000..ad2932d139 --- /dev/null +++ b/src/component/contents/exploration/DraftEditorBlockNode.react.js @@ -0,0 +1,378 @@ +/** + * 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 DraftEditorBlockNode.react + * @format + * @flow + * + * This file is a fork of DraftEditorBlock.react.js and DraftEditorContents.react.js + * + * This is unstable and not part of the public API and should not be used by + * production systems. This file may be update/removed without notice. + */ + +'use strict'; + +import type {BlockNodeRecord} from 'BlockNodeRecord'; +import type ContentState from 'ContentState'; +import type {DraftBlockRenderMap} from 'DraftBlockRenderMap'; +import type {DraftDecoratorType} from 'DraftDecoratorType'; +import type {DraftInlineStyle} from 'DraftInlineStyle'; +import type SelectionState from 'SelectionState'; +import type {BidiDirection} from 'UnicodeBidiDirection'; + +const DraftEditorNode = require('DraftEditorNode.react'); +const DraftOffsetKey = require('DraftOffsetKey'); +const EditorState = require('EditorState'); +const Immutable = require('immutable'); +const React = require('React'); +const ReactDOM = require('ReactDOM'); +const Scroll = require('Scroll'); +const Style = require('Style'); + +const getElementPosition = require('getElementPosition'); +const getScrollPosition = require('getScrollPosition'); +const getViewportDimensions = require('getViewportDimensions'); +const invariant = require('invariant'); + +const SCROLL_BUFFER = 10; + +const {List} = Immutable; + +// we should harden up the bellow flow types to make them more strict +type CustomRenderConfig = Object; +type DraftRenderConfig = Object; +type BlockRenderFn = (block: BlockNodeRecord) => ?Object; +type BlockStyleFn = (block: BlockNodeRecord) => string; + +type Props = { + block: BlockNodeRecord, + blockProps?: Object, + blockRenderMap: DraftBlockRenderMap, + blockRendererFn: BlockRenderFn, + blockStyleFn: BlockStyleFn, + contentState: ContentState, + customStyleFn: (style: DraftInlineStyle, block: BlockNodeRecord) => ?Object, + customStyleMap: Object, + decorator: ?DraftDecoratorType, + direction: BidiDirection, + editorKey: string, + editorState: EditorState, + forceSelection: boolean, + selection: SelectionState, + startIndent?: boolean, + tree: List, +}; + +/** + * Return whether a block overlaps with either edge of the `SelectionState`. + */ +const isBlockOnSelectionEdge = ( + selection: SelectionState, + key: string, +): boolean => { + return selection.getAnchorKey() === key || selection.getFocusKey() === key; +}; + +/** + * We will use this helper to identify blocks that need to be wrapped but have siblings that + * also share the same wrapper element, this way we can do the wrapping once the last sibling + * is added. + */ +const shouldNotAddWrapperElement = ( + block: BlockNodeRecord, + contentState: ContentState, +): boolean => { + const nextSiblingKey = block.getNextSiblingKey(); + + return nextSiblingKey + ? contentState.getBlockForKey(nextSiblingKey).getType() === block.getType() + : false; +}; + +const applyWrapperElementToSiblings = ( + wrapperTemplate: *, + Element: string, + nodes: Array, +): Array => { + const wrappedSiblings = []; + + // we check back until we find a sibbling that does not have same wrapper + for (const sibling: any of nodes.reverse()) { + if (sibling.type !== Element) { + break; + } + wrappedSiblings.push(sibling); + } + + // we now should remove from acc the wrappedSiblings and add them back under same wrap + nodes.splice(nodes.indexOf(wrappedSiblings[0]), wrappedSiblings.length + 1); + + const childrenIs = wrappedSiblings.reverse(); + + const key = childrenIs[0].key; + + nodes.push( + React.cloneElement( + wrapperTemplate, + { + key: `${key}-wrap`, + 'data-offset-key': DraftOffsetKey.encode(key, 0, 0), + }, + childrenIs, + ), + ); + + return nodes; +}; + +const getDraftRenderConfig = ( + block: BlockNodeRecord, + blockRenderMap: DraftBlockRenderMap, +): DraftRenderConfig => { + const configForType = + blockRenderMap.get(block.getType()) || blockRenderMap.get('unstyled'); + + const wrapperTemplate = configForType.wrapper; + const Element = + configForType.element || blockRenderMap.get('unstyled').element; + + return { + Element, + wrapperTemplate, + }; +}; + +const getCustomRenderConfig = ( + block: BlockNodeRecord, + blockRendererFn: BlockRenderFn, +): CustomRenderConfig => { + const customRenderer = blockRendererFn(block); + + if (!customRenderer) { + return {}; + } + + const { + component: CustomComponent, + props: customProps, + editable: customEditable, + } = customRenderer; + + return { + CustomComponent, + customProps, + customEditable, + }; +}; + +const getElementPropsConfig = ( + block: BlockNodeRecord, + editorKey: string, + offsetKey: string, + blockStyleFn: BlockStyleFn, + customConfig: *, +): Object => { + let elementProps: Object = { + 'data-block': true, + 'data-editor': editorKey, + 'data-offset-key': offsetKey, + key: block.getKey(), + }; + const customClass = blockStyleFn(block); + + if (customClass) { + elementProps.className = customClass; + } + + if (customConfig.customEditable !== undefined) { + elementProps = { + ...elementProps, + contentEditable: customConfig.customEditable, + suppressContentEditableWarning: true, + }; + } + + return elementProps; +}; + +class DraftEditorBlockNode extends React.Component { + shouldComponentUpdate(nextProps: Props): boolean { + const {block, direction, tree} = this.props; + const isContainerNode = !block.getChildKeys.isEmpty(); + const blockHasChanged = + block !== nextProps.block || + tree !== nextProps.tree || + direction !== nextProps.direction || + (isBlockOnSelectionEdge(nextProps.selection, nextProps.block.getKey()) && + nextProps.forceSelection); + + // if we have children at this stage we always re-render container nodes + // else if its a root node we avoid re-rendering by checking for block updates + return isContainerNode || blockHasChanged; + } + + /** + * When a block is mounted and overlaps the selection state, we need to make + * sure that the cursor is visible to match native behavior. This may not + * be the case if the user has pressed `RETURN` or pasted some content, since + * programatically creating these new blocks and setting the DOM selection + * will miss out on the browser natively scrolling to that position. + * + * To replicate native behavior, if the block overlaps the selection state + * on mount, force the scroll position. Check the scroll state of the scroll + * parent, and adjust it to align the entire block to the bottom of the + * scroll parent. + */ + componentDidMount(): void { + const selection = this.props.selection; + const endKey = selection.getEndKey(); + if (!selection.getHasFocus() || endKey !== this.props.block.getKey()) { + return; + } + + const blockNode = ReactDOM.findDOMNode(this); + const scrollParent = Style.getScrollParent(blockNode); + const scrollPosition = getScrollPosition(scrollParent); + let scrollDelta; + + if (scrollParent === window) { + const nodePosition = getElementPosition(blockNode); + const nodeBottom = nodePosition.y + nodePosition.height; + const viewportHeight = getViewportDimensions().height; + scrollDelta = nodeBottom - viewportHeight; + if (scrollDelta > 0) { + window.scrollTo( + scrollPosition.x, + scrollPosition.y + scrollDelta + SCROLL_BUFFER, + ); + } + } else { + invariant( + blockNode instanceof HTMLElement, + 'blockNode is not an HTMLElement', + ); + const blockBottom = blockNode.offsetHeight + blockNode.offsetTop; + const scrollBottom = scrollParent.offsetHeight + scrollPosition.y; + scrollDelta = blockBottom - scrollBottom; + if (scrollDelta > 0) { + Scroll.setTop( + scrollParent, + Scroll.getTop(scrollParent) + scrollDelta + SCROLL_BUFFER, + ); + } + } + } + + render(): React.Node { + const { + block, + blockRenderMap, + blockRendererFn, + blockStyleFn, + contentState, + decorator, + editorKey, + editorState, + customStyleFn, + customStyleMap, + direction, + forceSelection, + selection, + tree, + } = this.props; + + let children = null; + + if (block.children.size) { + children = block.children.reduce((acc, key) => { + const offsetKey = DraftOffsetKey.encode(key, 0, 0); + const child = contentState.getBlockForKey(key); + const customConfig = getCustomRenderConfig(child, blockRendererFn); + const Component = customConfig.CustomComponent || DraftEditorBlockNode; + const {Element, wrapperTemplate} = getDraftRenderConfig( + child, + blockRenderMap, + ); + const elementProps = getElementPropsConfig( + child, + editorKey, + offsetKey, + blockStyleFn, + customConfig, + ); + const childProps = { + ...this.props, + tree: editorState.getBlockTree(key), + blockProps: customConfig.customProps, + offsetKey, + block: child, + }; + + acc.push( + React.createElement( + Element, + elementProps, + , + ), + ); + + if ( + !wrapperTemplate || + shouldNotAddWrapperElement(child, contentState) + ) { + return acc; + } + + // if we are here it means we are the last block + // that has a wrapperTemplate so we should wrap itself + // and all other previous siblings that share the same wrapper + applyWrapperElementToSiblings(wrapperTemplate, Element, acc); + + return acc; + }, []); + } + + const blockKey = block.getKey(); + const offsetKey = DraftOffsetKey.encode(blockKey, 0, 0); + + const blockNode = ( + + ); + + if (block.getParentKey()) { + return blockNode; + } + + const {Element} = getDraftRenderConfig(block, blockRenderMap); + const elementProps = getElementPropsConfig( + block, + editorKey, + offsetKey, + blockStyleFn, + getCustomRenderConfig(block, blockRendererFn), + ); + + // root block nodes needs to be wrapped + return React.createElement(Element, elementProps, blockNode); + } +} + +module.exports = DraftEditorBlockNode; diff --git a/src/component/contents/exploration/DraftEditorContentsExperimental.react.js b/src/component/contents/exploration/DraftEditorContentsExperimental.react.js new file mode 100644 index 0000000000..353ec6d841 --- /dev/null +++ b/src/component/contents/exploration/DraftEditorContentsExperimental.react.js @@ -0,0 +1,193 @@ +/** + * 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 DraftEditorContentsExperimental.react + * @format + * @flow + * + * This file is a fork of DraftEditorContents.react.js for tree nodes + * + * This is unstable and not part of the public API and should not be used by + * production systems. This file may be update/removed without notice. + */ + +'use strict'; + +import type {BlockNodeRecord} from 'BlockNodeRecord'; +import type {DraftBlockRenderMap} from 'DraftBlockRenderMap'; +import type {DraftInlineStyle} from 'DraftInlineStyle'; +import type {BidiDirection} from 'UnicodeBidiDirection'; + +const DraftEditorBlockNode = require('DraftEditorBlockNode.react'); +const DraftOffsetKey = require('DraftOffsetKey'); +const EditorState = require('EditorState'); +const React = require('React'); + +const nullthrows = require('nullthrows'); + +type Props = { + blockRenderMap: DraftBlockRenderMap, + blockRendererFn: (block: BlockNodeRecord) => ?Object, + blockStyleFn: (block: BlockNodeRecord) => string, + customStyleFn: (style: DraftInlineStyle, block: BlockNodeRecord) => ?Object, + customStyleMap: Object, + editorKey: string, + editorState: EditorState, + textDirectionality?: BidiDirection, +}; + +/** + * `DraftEditorContents` 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 DraftEditorContentsExperimental extends React.Component { + shouldComponentUpdate(nextProps: Props): boolean { + const prevEditorState = this.props.editorState; + const nextEditorState = nextProps.editorState; + + const prevDirectionMap = prevEditorState.getDirectionMap(); + const nextDirectionMap = nextEditorState.getDirectionMap(); + + // Text direction has changed for one or more blocks. We must re-render. + if (prevDirectionMap !== nextDirectionMap) { + return true; + } + + const didHaveFocus = prevEditorState.getSelection().getHasFocus(); + const nowHasFocus = nextEditorState.getSelection().getHasFocus(); + + if (didHaveFocus !== nowHasFocus) { + return true; + } + + const nextNativeContent = nextEditorState.getNativelyRenderedContent(); + + const wasComposing = prevEditorState.isInCompositionMode(); + const nowComposing = nextEditorState.isInCompositionMode(); + + // If the state is unchanged or we're currently rendering a natively + // rendered state, there's nothing new to be done. + if ( + prevEditorState === nextEditorState || + (nextNativeContent !== null && + nextEditorState.getCurrentContent() === nextNativeContent) || + (wasComposing && nowComposing) + ) { + return false; + } + + const prevContent = prevEditorState.getCurrentContent(); + const nextContent = nextEditorState.getCurrentContent(); + const prevDecorator = prevEditorState.getDecorator(); + const nextDecorator = nextEditorState.getDecorator(); + return ( + wasComposing !== nowComposing || + prevContent !== nextContent || + prevDecorator !== nextDecorator || + nextEditorState.mustForceSelection() + ); + } + + render(): React.Node { + const { + blockRenderMap, + blockRendererFn, + blockStyleFn, + customStyleMap, + customStyleFn, + editorState, + editorKey, + textDirectionality, + } = this.props; + + const content = editorState.getCurrentContent(); + const selection = editorState.getSelection(); + const forceSelection = editorState.mustForceSelection(); + const decorator = editorState.getDecorator(); + const directionMap = nullthrows(editorState.getDirectionMap()); + + const blocksAsArray = content.getBlocksAsArray(); + const rootBlock = blocksAsArray[0]; + const processedBlocks = []; + + let nodeBlock = rootBlock; + + while (nodeBlock) { + const blockKey = nodeBlock.getKey(); + const blockProps = { + blockRenderMap, + blockRendererFn, + blockStyleFn, + contentState: content, + customStyleFn, + customStyleMap, + decorator, + editorKey, + editorState, + forceSelection, + selection, + block: nodeBlock, + direction: textDirectionality + ? textDirectionality + : directionMap.get(blockKey), + tree: editorState.getBlockTree(blockKey), + }; + + const configForType = + blockRenderMap.get(nodeBlock.getType()) || + blockRenderMap.get('unstyled'); + const wrapperTemplate = configForType.wrapper; + processedBlocks.push({ + block: , + wrapperTemplate, + key: blockKey, + offsetKey: DraftOffsetKey.encode(blockKey, 0, 0), + }); + + const nextBlockKey = nodeBlock.getNextSiblingKey(); + nodeBlock = nextBlockKey ? content.getBlockForKey(nextBlockKey) : null; + } + + // Group contiguous runs of blocks that have the same wrapperTemplate + const outputBlocks = []; + for (let ii = 0; ii < processedBlocks.length; ) { + const info: any = processedBlocks[ii]; + if (info.wrapperTemplate) { + const blocks = []; + do { + blocks.push(processedBlocks[ii].block); + ii++; + } while ( + ii < processedBlocks.length && + processedBlocks[ii].wrapperTemplate === info.wrapperTemplate + ); + const wrapperElement = React.cloneElement( + info.wrapperTemplate, + { + key: info.key + '-wrap', + 'data-offset-key': info.offsetKey, + }, + blocks, + ); + outputBlocks.push(wrapperElement); + } else { + outputBlocks.push(info.block); + ii++; + } + } + + return
{outputBlocks}
; + } +} + +module.exports = DraftEditorContentsExperimental; diff --git a/src/component/contents/exploration/DraftEditorDecoratedLeaves.react.js b/src/component/contents/exploration/DraftEditorDecoratedLeaves.react.js new file mode 100644 index 0000000000..51b171df80 --- /dev/null +++ b/src/component/contents/exploration/DraftEditorDecoratedLeaves.react.js @@ -0,0 +1,91 @@ +/** + * 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 DraftEditorDecoratedLeaves.react + * @format + * @flow + * + * This is unstable and not part of the public API and should not be used by + * production systems. This file may be update/removed without notice. + */ + +'use strict'; + +import type {BlockNodeRecord} from 'BlockNodeRecord'; +import type ContentState from 'ContentState'; +import type {DraftDecoratorType} from 'DraftDecoratorType'; +import type {BidiDirection} from 'UnicodeBidiDirection'; +import type {Set} from 'immutable'; + +const DraftOffsetKey = require('DraftOffsetKey'); +const React = require('React'); +const UnicodeBidi = require('UnicodeBidi'); +const UnicodeBidiDirection = require('UnicodeBidiDirection'); + +type Props = { + block: BlockNodeRecord, + children: ?Array, + contentState: ContentState, + decorator: DraftDecoratorType, + decoratorKey: string, + direction: BidiDirection, + text: string, + leafSet: Set, +}; + +class DraftEditorDecoratedLeaves extends React.Component { + render(): React.Node { + const { + block, + children, + contentState, + decorator, + decoratorKey, + direction, + leafSet, + text, + } = this.props; + + const blockKey = block.getKey(); + const leavesForLeafSet = leafSet.get('leaves'); + const DecoratorComponent = decorator.getComponentForKey(decoratorKey); + const decoratorProps = decorator.getPropsForKey(decoratorKey); + const decoratorOffsetKey = DraftOffsetKey.encode( + blockKey, + parseInt(decoratorKey, 10), + 0, + ); + + const decoratedText = text.slice( + leavesForLeafSet.first().get('start'), + leavesForLeafSet.last().get('end'), + ); + + // Resetting dir to the same value on a child node makes Chrome/Firefox + // confused on cursor movement. See http://jsfiddle.net/d157kLck/3/ + const dir = UnicodeBidiDirection.getHTMLDirIfDifferent( + UnicodeBidi.getDirection(decoratedText), + direction, + ); + + return ( + + {children} + + ); + } +} + +module.exports = DraftEditorDecoratedLeaves; diff --git a/src/component/contents/exploration/DraftEditorNode.react.js b/src/component/contents/exploration/DraftEditorNode.react.js new file mode 100644 index 0000000000..6e09d36e50 --- /dev/null +++ b/src/component/contents/exploration/DraftEditorNode.react.js @@ -0,0 +1,132 @@ +/** + * 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 DraftEditorNode.react + * @format + * @flow + * + * This is unstable and not part of the public API and should not be used by + * production systems. This file may be update/removed without notice. + */ + +'use strict'; + +import type {BlockNodeRecord} from 'BlockNodeRecord'; +import type ContentState from 'ContentState'; +import type {DraftDecoratorType} from 'DraftDecoratorType'; +import type SelectionState from 'SelectionState'; +import type {BidiDirection} from 'UnicodeBidiDirection'; + +const DraftEditorDecoratedLeaves = require('DraftEditorDecoratedLeaves.react'); +const DraftEditorLeaf = require('DraftEditorLeaf.react'); +const DraftOffsetKey = require('DraftOffsetKey'); +const Immutable = require('immutable'); +const React = require('React'); + +const cx = require('cx'); + +const {List} = Immutable; + +type Props = { + block: BlockNodeRecord, + children: ?Array, + contentState: ContentState, + customStyleFn: Function, + customStyleMap: Object, + decorator: ?DraftDecoratorType, + direction: BidiDirection, + forceSelection: boolean, + hasSelection: boolean, + selection: SelectionState, + tree: List, +}; + +class DraftEditorNode extends React.Component { + render(): React.Node { + const { + block, + contentState, + customStyleFn, + customStyleMap, + decorator, + direction, + forceSelection, + hasSelection, + selection, + tree, + } = this.props; + + const blockKey = block.getKey(); + const text = block.getText(); + const lastLeafSet = tree.size - 1; + + const children = + this.props.children || + tree + .map((leafSet, ii) => { + const decoratorKey = leafSet.get('decoratorKey'); + const leavesForLeafSet = leafSet.get('leaves'); + const lastLeaf = leavesForLeafSet.size - 1; + const Leaves = leavesForLeafSet + .map((leaf, jj) => { + const offsetKey = DraftOffsetKey.encode(blockKey, ii, jj); + const start = leaf.get('start'); + const end = leaf.get('end'); + return ( + + ); + }) + .toArray(); + + if (!decoratorKey || !decorator) { + return Leaves; + } + + return ( + + ); + }) + .toArray(); + + return ( +
+ {children} +
+ ); + } +} + +module.exports = DraftEditorNode; diff --git a/src/component/contents/exploration/__tests__/DraftEditorBlockNode.react-test.js b/src/component/contents/exploration/__tests__/DraftEditorBlockNode.react-test.js new file mode 100644 index 0000000000..a7a1681466 --- /dev/null +++ b/src/component/contents/exploration/__tests__/DraftEditorBlockNode.react-test.js @@ -0,0 +1,539 @@ +/** + * 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 + * @format + */ + +'use strict'; + +jest + .disableAutomock() + .mock('Style') + .mock('getElementPosition') + .mock('getScrollPosition') + .mock('getViewportDimensions'); + +const BlockTree = require('BlockTree'); +const CompositeDraftDecorator = require('CompositeDraftDecorator'); +const ContentBlockNode = require('ContentBlockNode'); +const ContentState = require('ContentState'); +const DefaultDraftBlockRenderMap = require('DefaultDraftBlockRenderMap'); +const DraftEditorBlockNode = require('DraftEditorBlockNode.react'); +const EditorState = require('EditorState'); +const Immutable = require('immutable'); +const React = require('React'); +const SelectionState = require('SelectionState'); +const Style = require('Style'); +const UnicodeBidiDirection = require('UnicodeBidiDirection'); + +const TestHelper = require('_DraftTestHelper'); +const getElementPosition = require('getElementPosition'); +const getScrollPosition = require('getScrollPosition'); +const getViewportDimensions = require('getViewportDimensions'); +const ReactTestRenderer = require('react-test-renderer'); + +const {List} = Immutable; + +const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B', 'C', 'D']), +}); + +const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + nextSibling: 'C', + type: 'header-three', + key: 'B', + text: 'Left', + children: List([]), + }), + new ContentBlockNode({ + parent: 'A', + type: 'header-one', + prevSibling: 'B', + nextSibling: 'D', + key: 'C', + text: 'Middle', + children: List([]), + }), + new ContentBlockNode({ + parent: 'A', + prevSibling: 'C', + type: 'header-two', + key: 'D', + text: 'Right', + children: List([]), + }), +]); + +const selectionState = new SelectionState({ + anchorKey: 'A', + anchorOffset: 0, + focusKey: 'A', + focusOffset: 0, + isBackward: false, + hasFocus: false, +}); + +const PROPS = { + block: rootBlock, + blockRenderMap: DefaultDraftBlockRenderMap, + blockRendererFn: block => null, + blockStyleFn: block => '', + contentState, + customStyleFn: (style, block) => null, + customStyleMap: {}, + decorator: null, + direction: UnicodeBidiDirection.LTR, + editorKey: 'editor', + editorState: EditorState.createWithContent(contentState, null), + forceSelection: false, + offsetKey: 'A-0-0', + selection: selectionState, + tree: BlockTree.generate(contentState, rootBlock, null), +}; + +const setupDomMocks = () => { + Style.getScrollParent.mockReturnValue(window); + window.scrollTo = jest.fn(); + getElementPosition.mockReturnValue({ + x: 0, + y: 600, + width: 500, + height: 16, + }); + getScrollPosition.mockReturnValue({x: 0, y: 0}); + getViewportDimensions.mockReturnValue({width: 1200, height: 800}); +}; + +const assertDraftEditorBlockRendering = props => { + const childProps = { + ...props, + editorState: EditorState.createWithContent( + props.contentState, + props.decorator, + ), + }; + + const blockNode = ReactTestRenderer.create( + , + ); + + expect( + TestHelper.transformSnapshotProps(blockNode.toJSON()), + ).toMatchSnapshot(); +}; + +beforeEach(() => { + setupDomMocks(); +}); + +test('renders block with no children', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: 'some text', + type: 'header-one', + }); + + const contentState = ContentState.createFromBlockArray([rootBlock]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with children', () => { + assertDraftEditorBlockRendering(PROPS); +}); + +test('renders block with child that have wrapperElement', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with children that have same wrapperElement', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B', 'C']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + nextSibling: 'C', + }), + new ContentBlockNode({ + parent: 'A', + key: 'C', + text: 'second list item', + type: 'ordered-list-item', + prevSibling: 'B', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested child that have same wrapperElement', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: '', + type: 'unordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'deeply nested list', + type: 'unordered-list-item', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested child that is of different block type', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested child that have different wrapperElement', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: '', + type: 'unordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'deeply nested list', + type: 'ordered-list-item', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested children with decorator', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + ]); + + const DecoratedComponent = props => { + return {props.children}; + }; + + const decorator = new CompositeDraftDecorator([ + { + strategy: (block, callback, contentState) => { + if (block.getType() === 'header-one') { + callback(0, block.getLength()); + } + }, + component: DecoratedComponent, + }, + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + decorator, + block: rootBlock, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested children with different direction', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + direction: UnicodeBidiDirection.RTL, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested children with custom component', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + ]); + + const CustomBlock = props => { + return
Custom Component
; + }; + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + blockRendererFn: block => { + if (block.getType() === 'header-one') { + return { + component: CustomBlock, + }; + } + return null; + }, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested children with custom component and editable prop', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + ]); + + const CustomBlock = props => { + return ( +
+ Custom Component {props.blockProps.foo} +
+ ); + }; + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + blockRendererFn: block => { + if (block.getType() === 'header-one') { + return { + component: CustomBlock, + editable: false, + props: { + foo: 'bar', + }, + }; + } + return null; + }, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); + +test('renders block with nested children with blockStyleFn', () => { + const rootBlock = new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + }); + + const contentState = ContentState.createFromBlockArray([ + rootBlock, + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + ]); + + assertDraftEditorBlockRendering({ + ...PROPS, + block: rootBlock, + blockStyleFn: block => { + if (block.getType() === 'header-one') { + return 'My-fancy-custom-class'; + } + return null; + }, + contentState, + tree: BlockTree.generate(contentState, rootBlock, null), + }); +}); diff --git a/src/component/contents/exploration/__tests__/DraftEditorContentsExperimental.react-test.js b/src/component/contents/exploration/__tests__/DraftEditorContentsExperimental.react-test.js new file mode 100644 index 0000000000..1ed5a75e5e --- /dev/null +++ b/src/component/contents/exploration/__tests__/DraftEditorContentsExperimental.react-test.js @@ -0,0 +1,135 @@ +/** + * 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 + * @format + */ + +'use strict'; + +jest.disableAutomock(); + +const ContentBlockNode = require('ContentBlockNode'); +const ContentState = require('ContentState'); +const DefaultDraftBlockRenderMap = require('DefaultDraftBlockRenderMap'); +const DraftEditorContents = require('DraftEditorContentsExperimental.react'); +const EditorState = require('EditorState'); +const Immutable = require('immutable'); +const React = require('React'); +const SelectionState = require('SelectionState'); + +const TestHelper = require('_DraftTestHelper'); +const ReactTestRenderer = require('react-test-renderer'); + +const {List} = Immutable; + +const selectionState = new SelectionState({ + anchorKey: 'A', + anchorOffset: 0, + focusKey: 'A', + focusOffset: 0, + isBackward: false, + hasFocus: true, +}); + +const contentState = ContentState.createFromBlockArray([ + new ContentBlockNode({ + key: 'A', + text: 'Alpha', + type: 'blockquote', + children: List(), + }), +]); + +const PROPS = { + blockRenderMap: DefaultDraftBlockRenderMap, + blockRendererFn: block => null, + blockStyleFn: block => '', + contentState, + customStyleFn: (style, block) => null, + editorKey: 'editor', + editorState: EditorState.createWithContent(contentState), + selection: selectionState, +}; + +const assertDraftEditorContentsRendering = props => { + const childProps = { + ...props, + editorState: EditorState.createWithContent(props.contentState), + }; + + const blockNode = ReactTestRenderer.create( + , + ); + + expect( + TestHelper.transformSnapshotProps(blockNode.toJSON()), + ).toMatchSnapshot(); +}; + +test('renders ContentBlockNode', () => { + assertDraftEditorContentsRendering(PROPS); +}); + +test('renders ContentBlockNodes', () => { + const contentState = ContentState.createFromBlockArray([ + new ContentBlockNode({ + key: 'A', + text: '', + type: 'blockquote', + children: List(['B']), + nextSibling: 'D', + }), + new ContentBlockNode({ + parent: 'A', + key: 'B', + text: 'fist list item', + type: 'ordered-list-item', + children: List(['C']), + }), + new ContentBlockNode({ + parent: 'B', + key: 'C', + text: 'header inside list', + type: 'header-one', + }), + new ContentBlockNode({ + key: 'D', + prevSibling: 'A', + text: 'header two', + type: 'header-two', + }), + ]); + + assertDraftEditorContentsRendering({ + ...PROPS, + contentState, + }); +}); + +test('renders ContentBlockNodes with root blocks that have wrapperTemplate', () => { + const contentState = ContentState.createFromBlockArray([ + new ContentBlockNode({ + key: 'A', + text: 'list one', + type: 'ordered-list-item', + nextSibling: 'B', + }), + new ContentBlockNode({ + key: 'B', + text: 'list two', + type: 'ordered-list-item', + prevSibling: 'A', + }), + ]); + + assertDraftEditorContentsRendering({ + ...PROPS, + contentState, + }); +}); diff --git a/src/component/contents/exploration/__tests__/__snapshots__/DraftEditorBlockNode.react-test.js.snap b/src/component/contents/exploration/__tests__/__snapshots__/DraftEditorBlockNode.react-test.js.snap new file mode 100644 index 0000000000..4855a45c9b --- /dev/null +++ b/src/component/contents/exploration/__tests__/__snapshots__/DraftEditorBlockNode.react-test.js.snap @@ -0,0 +1,609 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders block with child that have wrapperElement 1`] = ` +
+
+
    +
  1. +
    + + + fist list item + + +
    +
  2. +
+
+
+`; + +exports[`renders block with children 1`] = ` +
+
+

+
+ + + Left + + +
+

+

+
+ + + Middle + + +
+

+

+
+ + + Right + + +
+

+
+
+`; + +exports[`renders block with children that have same wrapperElement 1`] = ` +
+
+
    +
  1. +
    + + + fist list item + + +
    +
  2. +
  3. +
    + + + second list item + + +
    +
  4. +
+
+
+`; + +exports[`renders block with nested child that have different wrapperElement 1`] = ` +
+
+
    +
  • +
    +
      +
    1. +
      + + + deeply nested list + + +
      +
    2. +
    +
    +
  • +
+
+
+`; + +exports[`renders block with nested child that have same wrapperElement 1`] = ` +
+
+
    +
  • +
    +
      +
    • +
      + + + deeply nested list + + +
      +
    • +
    +
    +
  • +
+
+
+`; + +exports[`renders block with nested child that is of different block type 1`] = ` +
+
+
    +
  1. +
    +

    +
    + + + header inside list + + +
    +

    +
    +
  2. +
+
+
+`; + +exports[`renders block with nested children with blockStyleFn 1`] = ` +
+
+
    +
  1. +
    +

    +
    + + + header inside list + + +
    +

    +
    +
  2. +
+
+
+`; + +exports[`renders block with nested children with custom component 1`] = ` +
+
+
    +
  1. +
    +

    +
    + Custom Component +
    +

    +
    +
  2. +
+
+
+`; + +exports[`renders block with nested children with custom component and editable prop 1`] = ` +
+
+
    +
  1. +
    +

    +
    + Custom Component + bar +
    +

    +
    +
  2. +
+
+
+`; + +exports[`renders block with nested children with decorator 1`] = ` +
+
+
    +
  1. +
    +

    +
    + + + + header inside list + + + +
    +

    +
    +
  2. +
+
+
+`; + +exports[`renders block with nested children with different direction 1`] = ` +
+
+
    +
  1. +
    +

    +
    + + + header inside list + + +
    +

    +
    +
  2. +
+
+
+`; + +exports[`renders block with no children 1`] = ` +

+
+ + + some text + + +
+

+`; diff --git a/src/component/contents/exploration/__tests__/__snapshots__/DraftEditorContentsExperimental.react-test.js.snap b/src/component/contents/exploration/__tests__/__snapshots__/DraftEditorContentsExperimental.react-test.js.snap new file mode 100644 index 0000000000..12aa3d20c7 --- /dev/null +++ b/src/component/contents/exploration/__tests__/__snapshots__/DraftEditorContentsExperimental.react-test.js.snap @@ -0,0 +1,159 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ContentBlockNode 1`] = ` +
+
+
+ + + Alpha + + +
+
+
+`; + +exports[`renders ContentBlockNodes 1`] = ` +
+
+
+
    +
  1. +
    +

    +
    + + + header inside list + + +
    +

    +
    +
  2. +
+
+
+

+
+ + + header two + + +
+

+
+`; + +exports[`renders ContentBlockNodes with root blocks that have wrapperTemplate 1`] = ` +
+
    +
  1. +
    + + + list one + + +
    +
  2. +
  3. +
    + + + list two + + +
    +
  4. +
+
+`; diff --git a/src/component/utils/_DraftTestHelper.js b/src/component/utils/_DraftTestHelper.js new file mode 100644 index 0000000000..5630152c24 --- /dev/null +++ b/src/component/utils/_DraftTestHelper.js @@ -0,0 +1,51 @@ +/** + * 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 _DraftTestHelper + * @flow + * @format + */ + +const BLACK_LIST_PROPS = ['data-reactroot']; +const transformSnapshotProps = ( + node: any, + blackList: Array = BLACK_LIST_PROPS, +) => { + const stack = [node]; + while (stack.length) { + const node = stack.pop(); + if (node.props) { + if (node.props.className) { + node.props.className = node.props.className.replace(/-/g, '__'); + } + BLACK_LIST_PROPS.forEach(prop => delete node.props[prop]); + } + if (Array.isArray(node.children)) { + stack.push(...node.children); + } + } + return node; +}; + +const DraftTestHelper = { + /** + * This is meant to be used in combination with ReactTestRenderer + * to ensure compatibility with running our snapshot tests internally + * + * usage example: + * + * const blockNode = ReactTestRenderer.create( + * , + * ); + * + * expect(transformSnapshotProps(blockNode.toJSON())).toMatchSnapshot(); + */ + transformSnapshotProps, +}; + +module.exports = DraftTestHelper; diff --git a/src/model/immutable/ContentBlockNode.js b/src/model/immutable/ContentBlockNode.js index 3a20babe60..f60f9fb1f7 100644 --- a/src/model/immutable/ContentBlockNode.js +++ b/src/model/immutable/ContentBlockNode.js @@ -10,6 +10,9 @@ * @format * @flow * + * This file is a fork of ContentBlock adding support for nesting references by + * providing links to children, parent, prevSibling, and nextSibling. + * * This is unstable and not part of the public API and should not be used by * production systems. This file may be update/removed without notice. */