Skip to content

Commit 29c3903

Browse files
youknowriadnuzzio
authored andcommitted
RichText: Add a format prop to allow HTML string values to be used in RichText components (WordPress#6034)
* RichText: Add a format prop to allow HTML string values to be used in RichText components * Don't align params and returns docs * Add domToFormat function to factorize ternaries * Remove useless stringToElement * Add valueToString to avoid ternaries when converting to strings * Avoid double isEmpty check * Use valid HTML in unit tests * Fix splitting
1 parent 5bbabf0 commit 29c3903

File tree

8 files changed

+295
-134
lines changed

8 files changed

+295
-134
lines changed

blocks/rich-text/README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@ a traditional `input` field, usually when the user exits the field.
1010

1111
## Properties
1212

13-
### `value: Array`
13+
### `format: String`
1414

15-
*Required.* Array of React DOM to make editable. The rendered HTML should be valid, and valid with respect to the `tagName` and `inline` property.
15+
*Optional.* Format of the RichText provided value prop. It can be `element` or `string`.
1616

17-
### `onChange( value: Array ): Function`
17+
*Default: `element`*.
18+
19+
### `value: Array|String`
20+
21+
*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.
22+
23+
### `onChange( value: Array|String ): Function`
1824

1925
*Required.* Called when the value changes.
2026

@@ -31,7 +37,7 @@ a traditional `input` field, usually when the user exits the field.
3137

3238
*Optional.* By default, a line break will be inserted on <kbd>Enter</kbd>. If the editable field can contain multiple paragraphs, this property can be set to `p` to create new paragraphs on <kbd>Enter</kbd>.
3339

34-
### `onSplit( before: Array, after: Array, ...blocks: Object ): Function`
40+
### `onSplit( before: Array|String, after: Array|String, ...blocks: Object ): Function`
3541

3642
*Optional.* Called when the content can be split with `before` and `after`. There might be blocks present, which should be inserted in between.
3743

blocks/rich-text/format.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { omitBy } from 'lodash';
5+
import { nodeListToReact } from 'dom-react';
6+
7+
/**
8+
* WordPress dependencies
9+
*/
10+
import { createElement, renderToString } from '@wordpress/element';
11+
12+
/**
13+
* Transforms a WP Element to its corresponding HTML string.
14+
*
15+
* @param {WPElement} value Element.
16+
*
17+
* @return {string} HTML.
18+
*/
19+
export function elementToString( value ) {
20+
return renderToString( value );
21+
}
22+
23+
/**
24+
* Transforms a value in a given format into string.
25+
*
26+
* @param {Array|string?} value DOM Elements.
27+
* @param {string} format Output format (string or element)
28+
*
29+
* @return {string} HTML output as string.
30+
*/
31+
export function valueToString( value, format ) {
32+
switch ( format ) {
33+
case 'string':
34+
return value || '';
35+
default:
36+
return elementToString( value );
37+
}
38+
}
39+
40+
/**
41+
* Strips out TinyMCE specific attributes and nodes from a WPElement
42+
*
43+
* @param {string} type Element type
44+
* @param {Object} props Element Props
45+
* @param {Array} children Element Children
46+
*
47+
* @return {Element} WPElement.
48+
*/
49+
export function createTinyMCEElement( type, props, ...children ) {
50+
if ( props[ 'data-mce-bogus' ] === 'all' ) {
51+
return null;
52+
}
53+
54+
if ( props.hasOwnProperty( 'data-mce-bogus' ) ) {
55+
return children;
56+
}
57+
58+
return createElement(
59+
type,
60+
omitBy( props, ( _, key ) => key.indexOf( 'data-mce-' ) === 0 ),
61+
...children
62+
);
63+
}
64+
65+
/**
66+
* Transforms an array of DOM Elements to their corresponding WP element.
67+
*
68+
* @param {Array} value DOM Elements.
69+
*
70+
* @return {WPElement} WP Element.
71+
*/
72+
export function domToElement( value ) {
73+
return nodeListToReact( value || [], createTinyMCEElement );
74+
}
75+
76+
/**
77+
* Transforms an array of DOM Elements to their corresponding HTML string output.
78+
*
79+
* @param {Array} value DOM Elements.
80+
* @param {Editor} editor TinyMCE editor instance.
81+
*
82+
* @return {string} HTML.
83+
*/
84+
export function domToString( value, editor ) {
85+
const doc = document.implementation.createHTMLDocument( '' );
86+
87+
Array.from( value ).forEach( ( child ) => {
88+
doc.body.appendChild( child );
89+
} );
90+
91+
return editor ? editor.serializer.serialize( doc.body ) : doc.body.innerHTML;
92+
}
93+
94+
/**
95+
* Transforms an array of DOM Elements to the given format.
96+
*
97+
* @param {Array} value DOM Elements.
98+
* @param {string} format Output format (string or element)
99+
* @param {Editor} editor TinyMCE editor instance.
100+
*
101+
* @return {*} Output.
102+
*/
103+
export function domToFormat( value, format, editor ) {
104+
switch ( format ) {
105+
case 'string':
106+
return domToString( value, editor );
107+
default:
108+
return domToElement( value );
109+
}
110+
}
111+
112+
/**
113+
* Checks whether the value is empty or not
114+
*
115+
* @param {Array|string} value Value.
116+
* @param {string} format Format (string or element)
117+
*
118+
* @return {boolean} Is value empty.
119+
*/
120+
export function isEmpty( value, format ) {
121+
switch ( format ) {
122+
case 'string':
123+
return value === '';
124+
default:
125+
return ! value.length;
126+
}
127+
}

blocks/rich-text/index.js

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import classnames from 'classnames';
55
import {
66
last,
77
isEqual,
8-
omitBy,
98
forEach,
109
merge,
1110
identity,
@@ -14,13 +13,12 @@ import {
1413
noop,
1514
reject,
1615
} from 'lodash';
17-
import { nodeListToReact } from 'dom-react';
1816
import 'element-closest';
1917

2018
/**
2119
* WordPress dependencies
2220
*/
23-
import { createElement, Component, renderToString, Fragment, compose } from '@wordpress/element';
21+
import { Component, Fragment, compose } from '@wordpress/element';
2422
import { keycodes, createBlobURL, isHorizontalEdge, getRectangleFromRange, getScrollContainer } from '@wordpress/utils';
2523
import { withSafeTimeout, Slot } from '@wordpress/components';
2624
import { withSelect } from '@wordpress/data';
@@ -38,25 +36,10 @@ import { pickAriaProps } from './aria';
3836
import patterns from './patterns';
3937
import { EVENTS } from './constants';
4038
import { withBlockEditContext } from '../block-edit/context';
39+
import { domToFormat, valueToString, isEmpty } from './format';
4140

4241
const { BACKSPACE, DELETE, ENTER } = keycodes;
4342

44-
export function createTinyMCEElement( type, props, ...children ) {
45-
if ( props[ 'data-mce-bogus' ] === 'all' ) {
46-
return null;
47-
}
48-
49-
if ( props.hasOwnProperty( 'data-mce-bogus' ) ) {
50-
return children;
51-
}
52-
53-
return createElement(
54-
type,
55-
omitBy( props, ( value, key ) => key.indexOf( 'data-mce-' ) === 0 ),
56-
...children
57-
);
58-
}
59-
6043
/**
6144
* Returns true if the node is the inline node boundary. This is used in node
6245
* filtering prevent the inline boundary from being included in the split which
@@ -115,21 +98,9 @@ export function getFormatProperties( formatName, parents ) {
11598
const DEFAULT_FORMATS = [ 'bold', 'italic', 'strikethrough', 'link' ];
11699

117100
export class RichText extends Component {
118-
constructor( props ) {
101+
constructor( { value } ) {
119102
super( ...arguments );
120103

121-
const { value } = props;
122-
if ( 'production' !== process.env.NODE_ENV && undefined !== value &&
123-
! Array.isArray( value ) ) {
124-
// eslint-disable-next-line no-console
125-
console.error(
126-
`Invalid value of type ${ typeof value } passed to RichText ` +
127-
'(expected array). Attribute values should be sourced using ' +
128-
'the `children` source when used with RichText.\n\n' +
129-
'See: https://wordpress.org/gutenberg/handbook/block-api/attributes/#children'
130-
);
131-
}
132-
133104
this.onInit = this.onInit.bind( this );
134105
this.getSettings = this.getSettings.bind( this );
135106
this.onSetup = this.onSetup.bind( this );
@@ -293,7 +264,7 @@ export class RichText extends Component {
293264
if ( item && ! HTML ) {
294265
const blob = item.getAsFile ? item.getAsFile() : item;
295266
const rootNode = this.editor.getBody();
296-
const isEmpty = this.editor.dom.isEmpty( rootNode );
267+
const isEmptyEditor = this.editor.dom.isEmpty( rootNode );
297268
const content = rawHandler( {
298269
HTML: `<img src="${ createBlobURL( blob ) }">`,
299270
mode: 'BLOCKS',
@@ -303,7 +274,7 @@ export class RichText extends Component {
303274
// Allows us to ask for this information when we get a report.
304275
window.console.log( 'Received item:\n\n', blob );
305276

306-
if ( isEmpty && this.props.onReplace ) {
277+
if ( isEmptyEditor && this.props.onReplace ) {
307278
// Necessary to allow the paste bin to be removed without errors.
308279
this.props.setTimeout( () => this.props.onReplace( content ) );
309280
} else if ( this.props.onSplit ) {
@@ -357,11 +328,11 @@ export class RichText extends Component {
357328
}
358329

359330
const rootNode = this.editor.getBody();
360-
const isEmpty = this.editor.dom.isEmpty( rootNode );
331+
const isEmptyEditor = this.editor.dom.isEmpty( rootNode );
361332

362333
let mode = 'INLINE';
363334

364-
if ( isEmpty && this.props.onReplace ) {
335+
if ( isEmptyEditor && this.props.onReplace ) {
365336
mode = 'BLOCKS';
366337
} else if ( this.props.onSplit ) {
367338
mode = 'AUTO';
@@ -399,8 +370,8 @@ export class RichText extends Component {
399370
*/
400371

401372
onChange() {
402-
this.isEmpty = this.editor.dom.isEmpty( this.editor.getBody() );
403-
this.savedContent = this.isEmpty ? [] : this.getContent();
373+
this.savedContent = this.getContent();
374+
this.isEmpty = isEmpty( this.savedContent, this.props.format );
404375
this.props.onChange( this.savedContent );
405376
}
406377

@@ -503,10 +474,12 @@ export class RichText extends Component {
503474
const index = dom.nodeIndex( selectedNode );
504475
const beforeNodes = childNodes.slice( 0, index );
505476
const afterNodes = childNodes.slice( index + 1 );
506-
const beforeElement = nodeListToReact( beforeNodes, createTinyMCEElement );
507-
const afterElement = nodeListToReact( afterNodes, createTinyMCEElement );
508477

509-
this.restoreContentAndSplit( beforeElement, afterElement );
478+
const { format } = this.props;
479+
const before = domToFormat( beforeNodes, format, this.editor );
480+
const after = domToFormat( afterNodes, format, this.editor );
481+
482+
this.restoreContentAndSplit( before, after );
510483
} else {
511484
event.preventDefault();
512485
this.onCreateUndoLevel();
@@ -597,10 +570,11 @@ export class RichText extends Component {
597570
const beforeFragment = beforeRange.extractContents();
598571
const afterFragment = afterRange.extractContents();
599572

600-
const beforeElement = nodeListToReact( beforeFragment.childNodes, createTinyMCEElement );
601-
const afterElement = nodeListToReact( filterEmptyNodes( afterFragment.childNodes ), createTinyMCEElement );
573+
const { format } = this.props;
574+
const before = domToFormat( beforeFragment.childNodes, format, this.editor );
575+
const after = domToFormat( filterEmptyNodes( afterFragment.childNodes ), format, this.editor );
602576

603-
this.restoreContentAndSplit( beforeElement, afterElement, blocks );
577+
this.restoreContentAndSplit( before, after, blocks );
604578
} else {
605579
this.restoreContentAndSplit( [], [], blocks );
606580
}
@@ -648,9 +622,10 @@ export class RichText extends Component {
648622
// Splitting into two blocks
649623
this.setContent( this.props.value );
650624

625+
const { format } = this.props;
651626
this.restoreContentAndSplit(
652-
nodeListToReact( before, createTinyMCEElement ),
653-
nodeListToReact( after, createTinyMCEElement )
627+
domToFormat( before, format, this.editor ),
628+
domToFormat( after, format, this.editor )
654629
);
655630
}
656631

@@ -702,12 +677,22 @@ export class RichText extends Component {
702677
} );
703678
}
704679

705-
setContent( content = '' ) {
706-
this.editor.setContent( renderToString( content ) );
680+
setContent( content ) {
681+
const { format } = this.props;
682+
this.editor.setContent( valueToString( content, format ) );
707683
}
708684

709685
getContent() {
710-
return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement );
686+
const { format } = this.props;
687+
688+
switch ( format ) {
689+
case 'string':
690+
return this.editor.getContent();
691+
default:
692+
return this.editor.dom.isEmpty( this.editor.getBody() ) ?
693+
[] :
694+
domToFormat( this.editor.getBody().childNodes || [], 'element', this.editor );
695+
}
711696
}
712697

713698
componentDidUpdate( prevProps ) {
@@ -806,6 +791,7 @@ export class RichText extends Component {
806791
isSelected,
807792
formatters,
808793
autocompleters,
794+
format,
809795
} = this.props;
810796

811797
const ariaProps = { ...pickAriaProps( this.props ), 'aria-multiline': !! MultilineTag };
@@ -849,6 +835,7 @@ export class RichText extends Component {
849835
onSetup={ this.onSetup }
850836
style={ style }
851837
defaultValue={ value }
838+
format={ format }
852839
isPlaceholderVisible={ isPlaceholderVisible }
853840
aria-label={ placeholder }
854841
aria-autocomplete="list"
@@ -886,6 +873,7 @@ RichText.contextTypes = {
886873
RichText.defaultProps = {
887874
formattingControls: DEFAULT_FORMATS,
888875
formatters: [],
876+
format: 'element',
889877
};
890878

891879
export default compose( [
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`createTinyMCEElement should render a TinyMCE element 1`] = `
4+
<div
5+
data-prop="hi"
6+
>
7+
<p>
8+
Child
9+
</p>
10+
</div>
11+
`;
12+
13+
exports[`domToElement should return the corresponding element 1`] = `
14+
Array [
15+
<div
16+
className="container"
17+
>
18+
<strong>
19+
content
20+
</strong>
21+
</div>,
22+
]
23+
`;

0 commit comments

Comments
 (0)