diff --git a/blocks/api/matchers.js b/blocks/api/matchers.js index 0ccf4cf8d8832..be3c23f01ab86 100644 --- a/blocks/api/matchers.js +++ b/blocks/api/matchers.js @@ -6,7 +6,7 @@ import { createElement } from '@wordpress/element'; /** * External dependencies */ -import { nodeListToReact, nodeToReact } from 'dom-react'; +import { domreact } from '@wordpress/utils'; export { attr, prop, html, text, query } from 'hpq'; export const children = ( selector ) => { @@ -18,7 +18,7 @@ export const children = ( selector ) => { } if ( match ) { - return nodeListToReact( match.childNodes || [], createElement ); + return domreact.nodeListToReact( match.childNodes || [], createElement ); } return []; @@ -33,6 +33,6 @@ export const node = ( selector ) => { match = domNode.querySelector( selector ); } - return nodeToReact( match, createElement ); + return domreact.nodeToReact( match, createElement ); }; }; diff --git a/blocks/editable/content.js b/blocks/editable/content.js new file mode 100644 index 0000000000000..4f6ef60a4c50f --- /dev/null +++ b/blocks/editable/content.js @@ -0,0 +1,117 @@ +import { renderToString } from '@wordpress/element'; +import { last } from 'lodash'; +import { childrenToReact } from './tree'; + +export function nodeListToReact( editor, nodeList ) { + const fragment = editor.getDoc().createDocumentFragment(); + + nodeList.forEach( function( node ) { + fragment.appendChild( node.cloneNode( true ) ); + } ); + + return childrenToReact( editor.serializer.serialize( fragment, { format: 'tree' } ) ); +} + +function splitResult( before, after ) { + return { before: before, after: after }; +} + +export function setContent( editor, content ) { + if ( ! content ) { + content = ''; + } + + content = renderToString( content ); + editor.setContent( content, { format: 'raw' } ); +} + +export function getContent( editor ) { + return childrenToReact( editor.getContent( { format: 'tree' } ) ); +} + +export function getSplitAtLine( editor ) { + const rootNode = editor.getBody(); + const selectedNode = editor.selection.getNode(); + + if ( selectedNode.parentNode !== rootNode ) { + return null; + } + + const dom = editor.dom; + + if ( ! dom.isEmpty( selectedNode ) ) { + return null; + } + + const childNodes = Array.from( rootNode.childNodes ); + const index = dom.nodeIndex( selectedNode ); + const beforeNodes = childNodes.slice( 0, index ); + const afterNodes = childNodes.slice( index + 1 ); + const beforeElement = nodeListToReact( editor, beforeNodes ); + const afterElement = nodeListToReact( editor, afterNodes ); + + return splitResult( beforeElement, afterElement ); +} + +export function splitAtCaret( editor ) { + const { dom } = editor; + const rootNode = editor.getBody(); + const beforeRange = dom.createRng(); + const afterRange = dom.createRng(); + const selectionRange = editor.selection.getRng(); + + beforeRange.setStart( rootNode, 0 ); + beforeRange.setEnd( selectionRange.startContainer, selectionRange.startOffset ); + + afterRange.setStart( selectionRange.endContainer, selectionRange.endOffset ); + afterRange.setEnd( rootNode, dom.nodeIndex( rootNode.lastChild ) + 1 ); + + const beforeFragment = beforeRange.cloneContents(); + const afterFragment = afterRange.cloneContents(); + + const beforeElement = nodeListToReact( editor, beforeFragment.childNodes ); + const afterElement = nodeListToReact( editor, afterFragment.childNodes ); + + return splitResult( beforeElement, afterElement ); +} + +export function splitAtBlock( editor ) { + // Getting the content before and after the cursor + const childNodes = Array.from( editor.getBody().childNodes ); + let selectedChild = editor.selection.getStart(); + while ( childNodes.indexOf( selectedChild ) === -1 && selectedChild.parentNode ) { + selectedChild = selectedChild.parentNode; + } + const splitIndex = childNodes.indexOf( selectedChild ); + if ( splitIndex === -1 ) { + return null; + } + const beforeNodes = childNodes.slice( 0, splitIndex ); + const lastNodeBeforeCursor = last( beforeNodes ); + // Avoid splitting on single enter + if ( + ! lastNodeBeforeCursor || + beforeNodes.length < 2 || + !! lastNodeBeforeCursor.textContent + ) { + return null; + } + + const before = beforeNodes.slice( 0, beforeNodes.length - 1 ); + + // Removing empty nodes from the beginning of the "after" + // avoids empty paragraphs at the beginning of newly created blocks. + const after = childNodes.slice( splitIndex ).reduce( ( memo, node ) => { + if ( ! memo.length && ! node.textContent ) { + return memo; + } + + memo.push( node ); + return memo; + }, [] ); + + const beforeElement = nodeListToReact( editor, before ); + const afterElement = nodeListToReact( editor, after ); + + return splitResult( beforeElement, afterElement ); +} diff --git a/blocks/editable/index.js b/blocks/editable/index.js index 69b43929c1b0d..ea56abc0037f1 100644 --- a/blocks/editable/index.js +++ b/blocks/editable/index.js @@ -4,9 +4,7 @@ import tinymce from 'tinymce'; import classnames from 'classnames'; import { - last, isEqual, - omitBy, forEach, merge, identity, @@ -14,13 +12,12 @@ import { defer, noop, } from 'lodash'; -import { nodeListToReact } from 'dom-react'; import 'element-closest'; /** * WordPress dependencies */ -import { createElement, Component, renderToString } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { keycodes, createBlobURL } from '@wordpress/utils'; import { Slot, Fill } from '@wordpress/components'; @@ -34,31 +31,10 @@ import TinyMCE from './tinymce'; import { pickAriaProps } from './aria'; import patterns from './patterns'; import { EVENTS } from './constants'; +import { getContent, setContent, getSplitAtLine, splitAtCaret, splitAtBlock } from './content'; const { BACKSPACE, DELETE, ENTER } = keycodes; -function createTinyMCEElement( type, props, ...children ) { - if ( props[ 'data-mce-bogus' ] === 'all' ) { - return null; - } - - if ( props.hasOwnProperty( 'data-mce-bogus' ) ) { - return children; - } - - return createElement( - type, - omitBy( props, ( value, key ) => key.indexOf( 'data-mce-' ) === 0 ), - ...children - ); -} - -function isLinkBoundary( fragment ) { - return fragment.childNodes && fragment.childNodes.length === 1 && - fragment.childNodes[ 0 ].nodeName === 'A' && fragment.childNodes[ 0 ].text.length === 1 && - fragment.childNodes[ 0 ].text[ 0 ] === '\uFEFF'; -} - function getFormatProperties( formatName, parents ) { switch ( formatName ) { case 'link' : { @@ -524,30 +500,13 @@ export default class Editable extends Component { return; } - const rootNode = this.editor.getBody(); - const selectedNode = this.editor.selection.getNode(); + const split = getSplitAtLine( this.editor ); - if ( selectedNode.parentNode !== rootNode ) { - return; + if ( split ) { + event.preventDefault(); + this.setContent( split.before ); + this.props.onSplit( split.before, split.after ); } - - const dom = this.editor.dom; - - if ( ! dom.isEmpty( selectedNode ) ) { - return; - } - - event.preventDefault(); - - const childNodes = Array.from( rootNode.childNodes ); - const index = dom.nodeIndex( selectedNode ); - const beforeNodes = childNodes.slice( 0, index ); - const afterNodes = childNodes.slice( index + 1 ); - const beforeElement = nodeListToReact( beforeNodes, createTinyMCEElement ); - const afterElement = nodeListToReact( afterNodes, createTinyMCEElement ); - - this.setContent( beforeElement ); - this.props.onSplit( beforeElement, afterElement ); } else { event.preventDefault(); @@ -581,27 +540,12 @@ export default class Editable extends Component { * @param {Array} blocks The blocks to add after the split point. */ splitContent( blocks = [] ) { - const { dom } = this.editor; const rootNode = this.editor.getBody(); - const beforeRange = dom.createRng(); - const afterRange = dom.createRng(); - const selectionRange = this.editor.selection.getRng(); if ( rootNode.childNodes.length ) { - beforeRange.setStart( rootNode, 0 ); - beforeRange.setEnd( selectionRange.startContainer, selectionRange.startOffset ); - - afterRange.setStart( selectionRange.endContainer, selectionRange.endOffset ); - afterRange.setEnd( rootNode, dom.nodeIndex( rootNode.lastChild ) + 1 ); - - const beforeFragment = beforeRange.extractContents(); - const afterFragment = afterRange.extractContents(); - - const beforeElement = nodeListToReact( beforeFragment.childNodes, createTinyMCEElement ); - const afterElement = isLinkBoundary( afterFragment ) ? [] : nodeListToReact( afterFragment.childNodes, createTinyMCEElement ); - - this.setContent( beforeElement ); - this.props.onSplit( beforeElement, afterElement, ...blocks ); + const split = splitAtCaret( this.editor ); + this.setContent( split.before ); + this.props.onSplit( split.before, split.after, ...blocks ); } else { this.setContent( [] ); this.props.onSplit( [], [], ...blocks ); @@ -613,47 +557,16 @@ export default class Editable extends Component { return; } - // Getting the content before and after the cursor - const childNodes = Array.from( this.editor.getBody().childNodes ); - let selectedChild = this.editor.selection.getStart(); - while ( childNodes.indexOf( selectedChild ) === -1 && selectedChild.parentNode ) { - selectedChild = selectedChild.parentNode; - } - const splitIndex = childNodes.indexOf( selectedChild ); - if ( splitIndex === -1 ) { - return; - } - const beforeNodes = childNodes.slice( 0, splitIndex ); - const lastNodeBeforeCursor = last( beforeNodes ); - // Avoid splitting on single enter - if ( - ! lastNodeBeforeCursor || - beforeNodes.length < 2 || - !! lastNodeBeforeCursor.textContent - ) { - return; - } - - const before = beforeNodes.slice( 0, beforeNodes.length - 1 ); + const split = splitAtBlock( this.editor ); - // Removing empty nodes from the beginning of the "after" - // avoids empty paragraphs at the beginning of newly created blocks. - const after = childNodes.slice( splitIndex ).reduce( ( memo, node ) => { - if ( ! memo.length && ! node.textContent ) { - return memo; - } - - memo.push( node ); - return memo; - }, [] ); - - // Splitting into two blocks - this.setContent( this.props.value ); - - this.props.onSplit( - nodeListToReact( before, createTinyMCEElement ), - nodeListToReact( after, createTinyMCEElement ) - ); + if ( split ) { + // Splitting into two blocks + this.setContent( this.props.value ); + this.props.onSplit( + split.before, + split.after + ); + } } onNodeChange( { parents } ) { @@ -683,16 +596,11 @@ export default class Editable extends Component { } setContent( content ) { - if ( ! content ) { - content = ''; - } - - content = renderToString( content ); - this.editor.setContent( content, { format: 'raw' } ); + setContent( this.editor, content ); } getContent() { - return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement ); + return getContent( this.editor ); } updateFocus() { diff --git a/blocks/editable/tree.js b/blocks/editable/tree.js new file mode 100644 index 0000000000000..391b7067c6c8b --- /dev/null +++ b/blocks/editable/tree.js @@ -0,0 +1,46 @@ +import { createElement } from '@wordpress/element'; +import { domreact } from '@wordpress/utils'; + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; + +function attributesToReact( attributes ) { + const reactAttrs = {}; + + attributes.forEach( ( { name, value } ) => { + const canonicalKey = domreact.toCanonical( name ); + const key = canonicalKey ? canonicalKey : name; + reactAttrs[ key ] = key === 'style' ? domreact.styleStringToJSON( value ) : value; + } ); + + return reactAttrs; +} + +function elementToReact( node ) { + const props = node.attributes ? attributesToReact( node.attributes ) : {}; + const children = node.firstChild ? childrenToReact( node ) : []; + + return createElement( node.name, props, ...children ); +} + +function nodeToReact( node ) { + if ( ! node ) { + return null; + } else if ( node.type === ELEMENT_NODE ) { + return elementToReact( node ); + } else if ( node.type === TEXT_NODE ) { + return node.value; + } + + return null; +} + +export function childrenToReact( node ) { + const children = []; + + for ( let child = node.firstChild; child; child = child.next ) { + children.push( nodeToReact( child ) ); + } + + return children; +} diff --git a/lib/client-assets.php b/lib/client-assets.php index 40103dc4e0c5e..5256441df179f 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -241,7 +241,7 @@ function gutenberg_register_vendor_scripts() { 'https://unpkg.com/moment@2.18.1/' . $moment_script, array( 'react' ) ); - $tinymce_version = '4.7.2'; + $tinymce_version = '4.7.3'; gutenberg_register_vendor_script( 'tinymce-latest', 'https://fiddle.azurewebsites.net/tinymce/' . $tinymce_version . '/tinymce' . $suffix . '.js' diff --git a/package.json b/package.json index f817f345bc6c4..b16cd41b81549 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "@wordpress/url": "0.1.0-beta.1", "classnames": "2.2.5", "clipboard": "1.7.1", - "dom-react": "2.2.0", "dom-scroll-into-view": "1.2.1", "element-closest": "2.0.2", "escape-string-regexp": "1.0.5", @@ -90,7 +89,7 @@ "sass-variables-loader": "0.1.3", "sprintf-js": "1.1.1", "style-loader": "0.18.2", - "tinymce": "4.7.2", + "tinymce": "4.7.3", "webpack": "3.8.1" }, "jest": { diff --git a/utils/domreact/attrs.js b/utils/domreact/attrs.js new file mode 100644 index 0000000000000..1502dcb556b66 --- /dev/null +++ b/utils/domreact/attrs.js @@ -0,0 +1,84 @@ +const HTML_ATTRIBUTES = [ + 'accept', 'acceptCharset', 'accessKey', 'action', 'allowFullScreen', 'allowTransparency', + 'alt', 'async', 'autoComplete', 'autoFocus', 'autoPlay', 'capture', 'cellPadding', + 'cellSpacing', 'challenge', 'charSet', 'checked', 'cite', 'classID', 'className', + 'colSpan', 'cols', 'content', 'contentEditable', 'contextMenu', 'controls', 'coords', + 'crossOrigin', 'data', 'dateTime', 'default', 'defer', 'dir', 'disabled', 'download', + 'draggable', 'encType', 'form', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', + 'formTarget', 'frameBorder', 'headers', 'height', 'hidden', 'high', 'href', 'hrefLang', + 'htmlFor', 'httpEquiv', 'icon', 'id', 'inputMode', 'integrity', 'is', 'keyParams', 'keyType', + 'kind', 'label', 'lang', 'list', 'loop', 'low', 'manifest', 'marginHeight', 'marginWidth', + 'max', 'maxLength', 'media', 'mediaGroup', 'method', 'min', 'minLength', 'multiple', 'muted', + 'name', 'noValidate', 'nonce', 'open', 'optimum', 'pattern', 'placeholder', 'poster', + 'preload', 'profile', 'radioGroup', 'readOnly', 'rel', 'required', 'reversed', 'role', + 'rowSpan', 'rows', 'sandbox', 'scope', 'scoped', 'scrolling', 'seamless', 'selected', + 'shape', 'size', 'sizes', 'span', 'spellCheck', 'src', 'srcDoc', 'srcLang', 'srcSet', 'start', + 'step', 'style', 'summary', 'tabIndex', 'target', 'title', 'type', 'useMap', 'value', 'width', + 'wmode', 'wrap', +]; + +const NON_STANDARD_ATTRIBUTES = [ + 'autoCapitalize', 'autoCorrect', 'color', 'itemProp', 'itemScope', 'itemType', 'itemRef', + 'itemID', 'security', 'unselectable', 'results', 'autoSave', +]; + +const SVG_ATTRIBUTES = [ + 'accentHeight', 'accumulate', 'additive', 'alignmentBaseline', 'allowReorder', 'alphabetic', + 'amplitude', 'arabicForm', 'ascent', 'attributeName', 'attributeType', 'autoReverse', + 'azimuth', 'baseFrequency', 'baseProfile', 'baselineShift', 'bbox', 'begin', 'bias', 'by', + 'calcMode', 'capHeight', 'clip', 'clipPath', 'clipPathUnits', 'clipRule', 'colorInterpolation', + 'colorInterpolationFilters', 'colorProfile', 'colorRendering', 'contentScriptType', + 'contentStyleType', 'cursor', 'cx', 'cy', 'd', 'decelerate', 'descent', 'diffuseConstant', + 'direction', 'display', 'divisor', 'dominantBaseline', 'dur', 'dx', 'dy', 'edgeMode', + 'elevation', 'enableBackground', 'end', 'exponent', 'externalResourcesRequired', 'fill', + 'fillOpacity', 'fillRule', 'filter', 'filterRes', 'filterUnits', 'floodColor', 'floodOpacity', + 'focusable', 'fontFamily', 'fontSize', 'fontSizeAdjust', 'fontStretch', 'fontStyle', + 'fontVariant', 'fontWeight', 'format', 'from', 'fx', 'fy', 'g1', 'g2', 'glyphName', + 'glyphOrientationHorizontal', 'glyphOrientationVertical', 'glyphRef', 'gradientTransform', + 'gradientUnits', 'hanging', 'horizAdvX', 'horizOriginX', 'ideographic', 'imageRendering', + 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kernelMatrix', 'kernelUnitLength', + 'kerning', 'keyPoints', 'keySplines', 'keyTimes', 'lengthAdjust', 'letterSpacing', + 'lightingColor', 'limitingConeAngle', 'local', 'markerEnd', 'markerHeight', 'markerMid', + 'markerStart', 'markerUnits', 'markerWidth', 'mask', 'maskContentUnits', 'maskUnits', + 'mathematical', 'mode', 'numOctaves', 'offset', 'opacity', 'operator', 'order', + 'orient', 'orientation', 'origin', 'overflow', 'overlinePosition', 'overlineThickness', + 'paintOrder', 'panose1', 'pathLength', 'patternContentUnits', 'patternTransform', + 'patternUnits', 'pointerEvents', 'points', 'pointsAtX', 'pointsAtY', 'pointsAtZ', + 'preserveAlpha', 'preserveAspectRatio', 'primitiveUnits', 'r', 'radius', 'refX', 'refY', + 'renderingIntent', 'repeatCount', 'repeatDur', 'requiredExtensions', 'requiredFeatures', + 'restart', 'result', 'rotate', 'rx', 'ry', 'scale', 'seed', 'shapeRendering', 'slope', + 'spacing', 'specularConstant', 'specularExponent', 'speed', 'spreadMethod', 'startOffset', + 'stdDeviation', 'stemh', 'stemv', 'stitchTiles', 'stopColor', 'stopOpacity', + 'strikethroughPosition', 'strikethroughThickness', 'string', 'stroke', 'strokeDasharray', + 'strokeDashoffset', 'strokeLinecap', 'strokeLinejoin', 'strokeMiterlimit', + 'strokeOpacity', 'strokeWidth', 'surfaceScale', 'systemLanguage', 'tableValues', 'targetX', + 'targetY', 'textAnchor', 'textDecoration', 'textLength', 'textRendering', 'to', 'transform', + 'u1', 'u2', 'underlinePosition', 'underlineThickness', 'unicode', 'unicodeBidi', + 'unicodeRange', 'unitsPerEm', 'vAlphabetic', 'vHanging', 'vIdeographic', 'vMathematical', + 'values', 'vectorEffect', 'version', 'vertAdvY', 'vertOriginX', 'vertOriginY', 'viewBox', + 'viewTarget', 'visibility', 'widths', 'wordSpacing', 'writingMode', 'x', 'x1', 'x2', + 'xChannelSelector', 'xHeight', 'xlinkActuate', 'xlinkArcrole', 'xlinkHref', 'xlinkRole', + 'xlinkShow', 'xlinkTitle', 'xlinkType', 'xmlBase', 'xmlLang', 'xmlSpace', 'y', 'y1', 'y2', + 'yChannelSelector', 'z', 'zoomAndPan', +]; + +const attributeMap = [ + ...HTML_ATTRIBUTES, + ...NON_STANDARD_ATTRIBUTES, + ...SVG_ATTRIBUTES, +].reduce( ( accumulator, attribute ) => { + const lowerCase = attribute.toLowerCase(); + + if ( attribute !== lowerCase ) { + accumulator[ lowerCase ] = attribute; + } + + return accumulator; +}, {} ); + +attributeMap.class = 'className'; + +export function toCanonical( name ) { + const normalizedName = name.toLowerCase().replace( /[-:]/, '' ); + return attributeMap[ normalizedName ]; +} diff --git a/utils/domreact/index.js b/utils/domreact/index.js new file mode 100644 index 0000000000000..be2f0026f01fb --- /dev/null +++ b/utils/domreact/index.js @@ -0,0 +1,88 @@ +import { toCanonical } from './attrs'; + +function camelCase( string ) { + return string.toLowerCase().replace( /-([a-z])/g, ( match, $1 ) => $1.toUpperCase() ); +} + +export function styleStringToJSON( string = '' ) { + return string.split( ';' ).reduce( ( accumulator, piece ) => { + const pair = piece.split( ':' ); + const key = camelCase( pair[ 0 ] || '' ).trim(); + const value = ( pair[ 1 ] || '' ).trim(); + + if ( key && value ) { + accumulator[ key ] = value; + } + + return accumulator; + }, {} ); +} + +export function attributeListToReact( attributeList ) { + return [ ...attributeList ].reduce( ( accumulator, { name, value } ) => { + const key = toCanonical( name ) || name; + + if ( key === 'style' ) { + value = styleStringToJSON( value ); + } + + accumulator[ key ] = value; + + return accumulator; + }, {} ); +} + +let keyCounter = 0; + +export function nodeListToReact( nodeList, createElement ) { + return [ ...nodeList ].reduce( ( accumulator, node ) => { + if ( ! node._domReactKey ) { + node._domReactKey = '_domReact' + String( keyCounter++ ); + } + + const child = nodeToReact( node, createElement ); + + if ( Array.isArray( child ) ) { + accumulator.push( ...child ); + } else { + accumulator.push( child ); + } + + return accumulator; + }, [] ); +} + +export function nodeToReact( node, createElement ) { + if ( ! node ) { + return null; + } + + if ( node.nodeType === 3 ) { + return node.nodeValue; + } + + if ( node.nodeType !== 1 ) { + return null; + } + + const type = node.nodeName.toLowerCase(); + + let props = {}; + let children = []; + + if ( node.hasAttributes() ) { + props = attributeListToReact( node.attributes ); + } + + if ( node._domReactKey ) { + props.key = node._domReactKey; + } + + if ( node.hasChildNodes() ) { + children = nodeListToReact( node.childNodes, createElement ); + } + + return createElement( type, props, ...children ); +} + +export { toCanonical }; diff --git a/utils/domreact/test/index.js b/utils/domreact/test/index.js new file mode 100644 index 0000000000000..b3da795eb5dfa --- /dev/null +++ b/utils/domreact/test/index.js @@ -0,0 +1,118 @@ +import { createElement, renderToString } from '@wordpress/element'; +import { nodeListToReact, nodeToReact } from '../index'; + +describe( 'nodeToReact()', () => { + [ // Empty + { + description: 'should return null for null', + value: null, + }, + { + description: 'should return null for non-node', + value: 'not a node', + }, + ].forEach( ( { description, text } ) => { + it( description, () => { + expect( nodeToReact( text, createElement ) ).toEqual( null ); + } ); + } ); + + [ // Text + { + description: 'should return empty string for empty text node', + text: '', + }, + { + description: 'should return string for text node', + text: 'test', + }, + ].forEach( ( { description, text } ) => { + it( description, () => { + expect( nodeToReact( document.createTextNode( text ), createElement ) ).toEqual( text ); + } ); + } ); + + [ // Elements + { + description: 'should return React element for DOM element', + HTML: '

test

', + }, + { + description: 'should return React element with props for DOM element with attributes', + HTML: '

test

', + }, + { + description: 'should return React element with children for DOM element with children', + HTML: '

test test

', + }, + { + description: 'should return React element with children for DOM element with children', + HTML: '

test test

', + }, + { + description: 'should return React element with style for DOM element with style', + HTML: '

test

', + }, + ].forEach( ( { description, HTML } ) => { + it( description, () => { + document.body.innerHTML = HTML; + expect( renderToString( nodeToReact( document.body.firstChild, createElement ) ) ).toEqual( HTML ); + } ); + } ); + + it( 'should return React element without filtered attribute', () => { + document.body.innerHTML = '

test

'; + expect( renderToString( nodeToReact( document.body.firstChild, ( type, props, ...children ) => { + delete props[ 'data-test' ]; + return createElement( type, props, ...children ); + } ) ) ).toEqual( '

test

' ); + } ); + + it( 'should return React element without filtered child', () => { + document.body.innerHTML = '

test test test

'; + expect( renderToString( nodeToReact( document.body.firstChild, ( type, props, ...children ) => { + if ( ! props[ 'data-test' ] ) { + return createElement( type, props, ...children ); + } + } ) ) ).toEqual( '

test

' ); + } ); + + it( 'should return React element without filtered tag', () => { + document.body.innerHTML = '

test test test

'; + expect( renderToString( nodeToReact( document.body.firstChild, ( type, props, ...children ) => { + if ( ! props[ 'data-test' ] ) { + return createElement( type, props, ...children ); + } + return children; + } ) ) ).toEqual( '

test test test

' ); + } ); +} ); + +describe( 'nodeListToReact', () => { + const rxKey = /^_domReact\d+$/; + function assertKey( key ) { + expect( rxKey.test( key ) ).toEqual( true ); + } + + it( 'should return array of React element with key assigned by child index', () => { + document.body.innerHTML = '

test test

test'; + const elements = nodeListToReact( document.body.childNodes, createElement ); + + assertKey( elements[ 0 ].key ); + expect( typeof elements[ 0 ].props.children[ 0 ] ).toEqual( 'string' ); + assertKey( elements[ 0 ].props.children[ 1 ].key ); + assertKey( elements[ 1 ].key ); + } ); + + it( 'should reuse assigned key for same elements reference', () => { + document.body.innerHTML = ''; + const list = document.body.firstChild; + const before = nodeListToReact( list.childNodes, createElement ); + + // Rearrange second list item before first + list.insertBefore( list.lastChild, list.firstChild ); + + const after = nodeListToReact( list.childNodes, createElement ); + expect( before.map( ( { key } ) => key ) ).toEqual( after.map( ( { key } ) => key ).reverse() ); + } ); +} ); diff --git a/utils/index.js b/utils/index.js index 5d2a52931f7c3..6221eb92892a7 100644 --- a/utils/index.js +++ b/utils/index.js @@ -1,11 +1,13 @@ import * as focus from './focus'; import * as keycodes from './keycodes'; import * as viewPort from './viewport'; +import * as domreact from './domreact'; import { decodeEntities } from './entities'; export { focus }; export { keycodes }; export { decodeEntities }; +export { domreact }; export * from './blob-cache'; export * from './mediaupload';