diff --git a/blocks/rich-text/README.md b/blocks/rich-text/README.md
index 74db6896146a39..02f95ada8403b7 100644
--- a/blocks/rich-text/README.md
+++ b/blocks/rich-text/README.md
@@ -10,11 +10,17 @@ a traditional `input` field, usually when the user exits the field.
## Properties
-### `value: Array`
+### `format: String`
-*Required.* Array of React DOM to make editable. The rendered HTML should be valid, and valid with respect to the `tagName` and `inline` property.
+*Optional.* Format of the RichText provided value prop. It can be `element` or `string`.
-### `onChange( value: Array ): Function`
+*Default: `element`*.
+
+### `value: Array|String`
+
+*Required.* Depending on the format prop, this value could be an array of React DOM to make editable or an HTML string. The rendered HTML should be valid, and valid with respect to the `tagName` and `inline` property.
+
+### `onChange( value: Array|String ): Function`
*Required.* Called when the value changes.
@@ -31,7 +37,7 @@ a traditional `input` field, usually when the user exits the field.
*Optional.* By default, a line break will be inserted on Enter. If the editable field can contain multiple paragraphs, this property can be set to `p` to create new paragraphs on Enter.
-### `onSplit( before: Array, after: Array, ...blocks: Object ): Function`
+### `onSplit( before: Array|String, after: Array|String, ...blocks: Object ): Function`
*Optional.* Called when the content can be split with `before` and `after`. There might be blocks present, which should be inserted in between.
diff --git a/blocks/rich-text/format.js b/blocks/rich-text/format.js
new file mode 100644
index 00000000000000..595bff937aa8d7
--- /dev/null
+++ b/blocks/rich-text/format.js
@@ -0,0 +1,127 @@
+/**
+ * External dependencies
+ */
+import { omitBy } from 'lodash';
+import { nodeListToReact } from 'dom-react';
+
+/**
+ * WordPress dependencies
+ */
+import { createElement, renderToString } from '@wordpress/element';
+
+/**
+ * Transforms a WP Element to its corresponding HTML string.
+ *
+ * @param {WPElement} value Element.
+ *
+ * @return {string} HTML.
+ */
+export function elementToString( value ) {
+ return renderToString( value );
+}
+
+/**
+ * Transforms a value in a given format into string.
+ *
+ * @param {Array|string?} value DOM Elements.
+ * @param {string} format Output format (string or element)
+ *
+ * @return {string} HTML output as string.
+ */
+export function valueToString( value, format ) {
+ switch ( format ) {
+ case 'string':
+ return value || '';
+ default:
+ return elementToString( value );
+ }
+}
+
+/**
+ * Strips out TinyMCE specific attributes and nodes from a WPElement
+ *
+ * @param {string} type Element type
+ * @param {Object} props Element Props
+ * @param {Array} children Element Children
+ *
+ * @return {Element} WPElement.
+ */
+export 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, ( _, key ) => key.indexOf( 'data-mce-' ) === 0 ),
+ ...children
+ );
+}
+
+/**
+ * Transforms an array of DOM Elements to their corresponding WP element.
+ *
+ * @param {Array} value DOM Elements.
+ *
+ * @return {WPElement} WP Element.
+ */
+export function domToElement( value ) {
+ return nodeListToReact( value || [], createTinyMCEElement );
+}
+
+/**
+ * Transforms an array of DOM Elements to their corresponding HTML string output.
+ *
+ * @param {Array} value DOM Elements.
+ * @param {Editor} editor TinyMCE editor instance.
+ *
+ * @return {string} HTML.
+ */
+export function domToString( value, editor ) {
+ const doc = document.implementation.createHTMLDocument( '' );
+
+ Array.from( value ).forEach( ( child ) => {
+ doc.body.appendChild( child );
+ } );
+
+ return editor ? editor.serializer.serialize( doc.body ) : doc.body.innerHTML;
+}
+
+/**
+ * Transforms an array of DOM Elements to the given format.
+ *
+ * @param {Array} value DOM Elements.
+ * @param {string} format Output format (string or element)
+ * @param {Editor} editor TinyMCE editor instance.
+ *
+ * @return {*} Output.
+ */
+export function domToFormat( value, format, editor ) {
+ switch ( format ) {
+ case 'string':
+ return domToString( value, editor );
+ default:
+ return domToElement( value );
+ }
+}
+
+/**
+ * Checks whether the value is empty or not
+ *
+ * @param {Array|string} value Value.
+ * @param {string} format Format (string or element)
+ *
+ * @return {boolean} Is value empty.
+ */
+export function isEmpty( value, format ) {
+ switch ( format ) {
+ case 'string':
+ return value === '';
+ default:
+ return ! value.length;
+ }
+}
diff --git a/blocks/rich-text/index.js b/blocks/rich-text/index.js
index e4867d969a82f7..9f3d982dc16787 100644
--- a/blocks/rich-text/index.js
+++ b/blocks/rich-text/index.js
@@ -5,7 +5,6 @@ import classnames from 'classnames';
import {
last,
isEqual,
- omitBy,
forEach,
merge,
identity,
@@ -14,13 +13,12 @@ import {
noop,
reject,
} from 'lodash';
-import { nodeListToReact } from 'dom-react';
import 'element-closest';
/**
* WordPress dependencies
*/
-import { createElement, Component, renderToString, Fragment, compose } from '@wordpress/element';
+import { Component, Fragment, compose } from '@wordpress/element';
import { keycodes, createBlobURL, isHorizontalEdge, getRectangleFromRange, getScrollContainer } from '@wordpress/utils';
import { withSafeTimeout, Slot } from '@wordpress/components';
import { withSelect } from '@wordpress/data';
@@ -38,25 +36,10 @@ import { pickAriaProps } from './aria';
import patterns from './patterns';
import { EVENTS } from './constants';
import { withBlockEditContext } from '../block-edit/context';
+import { domToFormat, valueToString, isEmpty } from './format';
const { BACKSPACE, DELETE, ENTER } = keycodes;
-export 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
- );
-}
-
/**
* Returns true if the node is the inline node boundary. This is used in node
* filtering prevent the inline boundary from being included in the split which
@@ -115,21 +98,9 @@ export function getFormatProperties( formatName, parents ) {
const DEFAULT_FORMATS = [ 'bold', 'italic', 'strikethrough', 'link' ];
export class RichText extends Component {
- constructor( props ) {
+ constructor( { value } ) {
super( ...arguments );
- const { value } = props;
- if ( 'production' !== process.env.NODE_ENV && undefined !== value &&
- ! Array.isArray( value ) ) {
- // eslint-disable-next-line no-console
- console.error(
- `Invalid value of type ${ typeof value } passed to RichText ` +
- '(expected array). Attribute values should be sourced using ' +
- 'the `children` source when used with RichText.\n\n' +
- 'See: https://wordpress.org/gutenberg/handbook/block-api/attributes/#children'
- );
- }
-
this.onInit = this.onInit.bind( this );
this.getSettings = this.getSettings.bind( this );
this.onSetup = this.onSetup.bind( this );
@@ -293,7 +264,7 @@ export class RichText extends Component {
if ( item && ! HTML ) {
const blob = item.getAsFile ? item.getAsFile() : item;
const rootNode = this.editor.getBody();
- const isEmpty = this.editor.dom.isEmpty( rootNode );
+ const isEmptyEditor = this.editor.dom.isEmpty( rootNode );
const content = rawHandler( {
HTML: ``,
mode: 'BLOCKS',
@@ -303,7 +274,7 @@ export class RichText extends Component {
// Allows us to ask for this information when we get a report.
window.console.log( 'Received item:\n\n', blob );
- if ( isEmpty && this.props.onReplace ) {
+ if ( isEmptyEditor && this.props.onReplace ) {
// Necessary to allow the paste bin to be removed without errors.
this.props.setTimeout( () => this.props.onReplace( content ) );
} else if ( this.props.onSplit ) {
@@ -357,11 +328,11 @@ export class RichText extends Component {
}
const rootNode = this.editor.getBody();
- const isEmpty = this.editor.dom.isEmpty( rootNode );
+ const isEmptyEditor = this.editor.dom.isEmpty( rootNode );
let mode = 'INLINE';
- if ( isEmpty && this.props.onReplace ) {
+ if ( isEmptyEditor && this.props.onReplace ) {
mode = 'BLOCKS';
} else if ( this.props.onSplit ) {
mode = 'AUTO';
@@ -399,8 +370,8 @@ export class RichText extends Component {
*/
onChange() {
- this.isEmpty = this.editor.dom.isEmpty( this.editor.getBody() );
- this.savedContent = this.isEmpty ? [] : this.getContent();
+ this.savedContent = this.getContent();
+ this.isEmpty = isEmpty( this.savedContent, this.props.format );
this.props.onChange( this.savedContent );
}
@@ -503,10 +474,12 @@ export class RichText extends Component {
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.restoreContentAndSplit( beforeElement, afterElement );
+ const { format } = this.props;
+ const before = domToFormat( beforeNodes, format, this.editor );
+ const after = domToFormat( afterNodes, format, this.editor );
+
+ this.restoreContentAndSplit( before, after );
} else {
event.preventDefault();
this.onCreateUndoLevel();
@@ -597,10 +570,11 @@ export class RichText extends Component {
const beforeFragment = beforeRange.extractContents();
const afterFragment = afterRange.extractContents();
- const beforeElement = nodeListToReact( beforeFragment.childNodes, createTinyMCEElement );
- const afterElement = nodeListToReact( filterEmptyNodes( afterFragment.childNodes ), createTinyMCEElement );
+ const { format } = this.props;
+ const before = domToFormat( beforeFragment.childNodes, format, this.editor );
+ const after = domToFormat( filterEmptyNodes( afterFragment.childNodes ), format, this.editor );
- this.restoreContentAndSplit( beforeElement, afterElement, blocks );
+ this.restoreContentAndSplit( before, after, blocks );
} else {
this.restoreContentAndSplit( [], [], blocks );
}
@@ -648,9 +622,10 @@ export class RichText extends Component {
// Splitting into two blocks
this.setContent( this.props.value );
+ const { format } = this.props;
this.restoreContentAndSplit(
- nodeListToReact( before, createTinyMCEElement ),
- nodeListToReact( after, createTinyMCEElement )
+ domToFormat( before, format, this.editor ),
+ domToFormat( after, format, this.editor )
);
}
@@ -702,12 +677,22 @@ export class RichText extends Component {
} );
}
- setContent( content = '' ) {
- this.editor.setContent( renderToString( content ) );
+ setContent( content ) {
+ const { format } = this.props;
+ this.editor.setContent( valueToString( content, format ) );
}
getContent() {
- return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement );
+ const { format } = this.props;
+
+ switch ( format ) {
+ case 'string':
+ return this.editor.getContent();
+ default:
+ return this.editor.dom.isEmpty( this.editor.getBody() ) ?
+ [] :
+ domToFormat( this.editor.getBody().childNodes || [], 'element', this.editor );
+ }
}
componentDidUpdate( prevProps ) {
@@ -806,6 +791,7 @@ export class RichText extends Component {
isSelected,
formatters,
autocompleters,
+ format,
} = this.props;
const ariaProps = { ...pickAriaProps( this.props ), 'aria-multiline': !! MultilineTag };
@@ -849,6 +835,7 @@ export class RichText extends Component {
onSetup={ this.onSetup }
style={ style }
defaultValue={ value }
+ format={ format }
isPlaceholderVisible={ isPlaceholderVisible }
aria-label={ placeholder }
aria-autocomplete="list"
@@ -886,6 +873,7 @@ RichText.contextTypes = {
RichText.defaultProps = {
formattingControls: DEFAULT_FORMATS,
formatters: [],
+ format: 'element',
};
export default compose( [
diff --git a/blocks/rich-text/test/__snapshots__/format.js.snap b/blocks/rich-text/test/__snapshots__/format.js.snap
new file mode 100644
index 00000000000000..731d9765c970d5
--- /dev/null
+++ b/blocks/rich-text/test/__snapshots__/format.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createTinyMCEElement should render a TinyMCE element 1`] = `
+
+ Child +
+-
- Child -
- -`; diff --git a/blocks/rich-text/test/format.js b/blocks/rich-text/test/format.js new file mode 100644 index 00000000000000..1a42aa9e39f051 --- /dev/null +++ b/blocks/rich-text/test/format.js @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + createTinyMCEElement, + elementToString, + domToElement, + domToString, +} from '../format'; + +describe( 'createTinyMCEElement', () => { + const type = 'div'; + const children =Child
; + + test( 'should return null', () => { + const props = { + 'data-mce-bogus': 'all', + }; + + expect( createTinyMCEElement( type, props, children ) ).toBeNull(); + } ); + + test( 'should return children', () => { + const props = { + 'data-mce-bogus': '', + }; + + const wrapper = createTinyMCEElement( type, props, children ); + expect( wrapper ).toEqual( [ children ] ); + } ); + + test( 'should render a TinyMCE element', () => { + const props = { + 'data-prop': 'hi', + }; + + const wrapper = shallow( createTinyMCEElement( type, props, children ) ); + expect( wrapper ).toMatchSnapshot(); + } ); +} ); + +describe( 'elementToString', () => { + test( 'should return an empty string for null element', () => { + expect( elementToString( null ) ).toBe( '' ); + } ); + + test( 'should return an empty string for an empty array', () => { + expect( elementToString( [] ) ).toBe( '' ); + } ); + + test( 'should return the HTML content ', () => { + const element = createElement( 'div', { className: 'container' }, + createElement( 'strong', {}, 'content' ) + ); + expect( elementToString( element ) ).toBe( 'Child
; - - test( 'should return null', () => { - const props = { - 'data-mce-bogus': 'all', - }; - - expect( createTinyMCEElement( type, props, children ) ).toBeNull(); - } ); - - test( 'should return children', () => { - const props = { - 'data-mce-bogus': '', - }; - - const wrapper = createTinyMCEElement( type, props, children ); - expect( wrapper ).toEqual( [ children ] ); - } ); - - test( 'should render a TinyMCE element', () => { - const props = { - 'a-prop': 'hi', - }; - - const wrapper = shallow( createTinyMCEElement( type, props, children ) ); - expect( wrapper ).toMatchSnapshot(); - } ); -} ); - describe( 'isEmptyInlineBoundary', () => { describe( 'link', () => { const node = document.createElement( 'a' ); @@ -234,37 +202,6 @@ describe( 'RichText', () => { } ); } ); - describe( '.propTypes', () => { - /* eslint-disable no-console */ - let consoleError; - beforeEach( () => { - consoleError = console.error; - console.error = jest.fn(); - } ); - - afterEach( () => { - console.error = consoleError; - } ); - - it( 'should warn when rendered with string value', () => { - shallow(