From 45da3cf1948aa2723bfb649dae37287ca2495d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Est=C3=AAv=C3=A3o?= Date: Fri, 5 Apr 2019 11:45:53 +0100 Subject: [PATCH] Implement list block in React Native (#14636) * Make sure multiline property is filtered out of props on save. * Send block edit parameters using the context. * Add multiline variables to allow proper parsing and saving of properties. * Add list edit toolbar options * Add multiline property. * Add list block to mobile gb. * Move list-edit.native.js to new location. * Make block edit send down the onFocus property. * Handle case where unstableSplit is passed has prop. * Pass multiline tags to serialiser. * Use the format-lib for handling "Enter" in lists * Force selection reset on split * Add multiline wrapper tags to formatToValue. * Remove unnecessary code. * Force rich-text text update on list type change * Disable indent and outdent. * Enable toggling list type of nested lists * Update list type toolbar button on native mobile * Include diff missed by previous commit * Rename to denote that it's about lines * Split into separate functions and mark unstable * Add missing JSDoc param * Update snapshot for BlockControls * Move isActiveListType, isListRootSelected to rich-text package * Remove excess empty line --- .../test/__snapshots__/index.js.snap | 1 + .../src/components/block-edit/index.js | 3 +- .../src/components/rich-text/index.native.js | 123 +++++++++++++++--- .../components/rich-text/list-edit.native.js | 55 ++++++++ packages/block-library/src/index.native.js | 1 + .../rich-text/src/get-line-list-formats.js | 19 +++ packages/rich-text/src/index.js | 2 + packages/rich-text/src/is-active-list-type.js | 26 ++++ .../rich-text/src/is-list-root-selected.js | 18 +++ 9 files changed, 230 insertions(+), 18 deletions(-) create mode 100644 packages/block-editor/src/components/rich-text/list-edit.native.js create mode 100644 packages/rich-text/src/get-line-list-formats.js create mode 100644 packages/rich-text/src/is-active-list-type.js create mode 100644 packages/rich-text/src/is-list-root-selected.js diff --git a/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap index 681c33a42d9976..ba44cc9445f656 100644 --- a/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap +++ b/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap @@ -8,6 +8,7 @@ exports[`BlockControls should render a dynamic toolbar of controls 1`] = ` "focusedElement": null, "isSelected": true, "name": undefined, + "onFocus": undefined, "setFocusedElement": [Function], } } diff --git a/packages/block-editor/src/components/block-edit/index.js b/packages/block-editor/src/components/block-edit/index.js index c4c78f906aef91..582e3a9f3ed17c 100644 --- a/packages/block-editor/src/components/block-edit/index.js +++ b/packages/block-editor/src/components/block-edit/index.js @@ -31,12 +31,13 @@ class BlockEdit extends Component { } static getDerivedStateFromProps( props ) { - const { clientId, name, isSelected } = props; + const { clientId, name, isSelected, onFocus } = props; return { name, isSelected, clientId, + onFocus, }; } diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index a6d2ef19a86a56..fe7e7a33170352 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -19,6 +19,9 @@ import { split, toHTMLString, insert, + insertLineSeparator, + insertLineBreak, + isEmptyLine, isCollapsed, } from '@wordpress/rich-text'; import { decodeEntities } from '@wordpress/html-entities'; @@ -31,6 +34,8 @@ import { isURL } from '@wordpress/url'; */ import FormatEdit from './format-edit'; import FormatToolbar from './format-toolbar'; +import { withBlockEditContext } from '../block-edit/context'; +import { ListEdit } from './list-edit'; import styles from './style.scss'; @@ -70,14 +75,32 @@ const gutenbergFormatNamesToAztec = { }; export class RichText extends Component { - constructor() { + constructor( { multiline } ) { super( ...arguments ); + + this.isMultiline = false; + if ( multiline === true || multiline === 'p' || multiline === 'li' ) { + this.multilineTag = multiline === true ? 'p' : multiline; + this.isMultiline = true; + } + + if ( this.multilineTag === 'li' ) { + this.multilineWrapperTags = [ 'ul', 'ol' ]; + } + + if ( this.props.onSplit ) { + this.onSplit = this.props.onSplit; + } else if ( this.props.unstableOnSplit ) { + this.onSplit = this.props.unstableOnSplit; + } + this.isIOS = Platform.OS === 'ios'; this.onChange = this.onChange.bind( this ); this.onEnter = this.onEnter.bind( this ); this.onBackspace = this.onBackspace.bind( this ); this.onPaste = this.onPaste.bind( this ); this.onContentSizeChange = this.onContentSizeChange.bind( this ); + this.onFormatChangeForceChild = this.onFormatChangeForceChild.bind( this ); this.onFormatChange = this.onFormatChange.bind( this ); // This prevents a bug in Aztec which triggers onSelectionChange twice on format change this.onSelectionChange = this.onSelectionChange.bind( this ); @@ -120,9 +143,7 @@ export class RichText extends Component { * */ splitContent( currentRecord, blocks = [], isPasted = false ) { - const { onSplit } = this.props; - - if ( ! onSplit ) { + if ( ! this.onSplit ) { return; } @@ -161,7 +182,7 @@ export class RichText extends Component { // always update when provided with new content. this.lastEventCount = undefined; - onSplit( before, after, ...blocks ); + this.onSplit( before, after, ...blocks ); } valueToFormat( value ) { @@ -182,7 +203,11 @@ export class RichText extends Component { } ).map( ( name ) => gutenbergFormatNamesToAztec[ name ] ).filter( Boolean ); } - onFormatChange( record ) { + onFormatChangeForceChild( record ) { + this.onFormatChange( record, true ); + } + + onFormatChange( record, doUpdateChild ) { let newContent; // valueToFormat might throw when converting the record to a tree structure // let's ignore the event for now and force a render update so we're still in sync @@ -204,9 +229,13 @@ export class RichText extends Component { needsSelectionUpdate: record.needsSelectionUpdate, } ); } else { - // make sure the component rerenders without refreshing the text on gutenberg - // (this can trigger other events that might update the active formats on aztec) - this.lastEventCount = 0; + if ( doUpdateChild ) { + this.lastEventCount = undefined; + } else { + // make sure the component rerenders without refreshing the text on gutenberg + // (this can trigger other events that might update the active formats on aztec) + this.lastEventCount = 0; + } this.forceUpdate(); } } @@ -255,17 +284,31 @@ export class RichText extends Component { // eslint-disable-next-line no-unused-vars onEnter( event ) { this.lastEventCount = event.nativeEvent.eventCount; - if ( ! this.props.onSplit ) { - // TODO: insert the \n char instead? - return; - } const currentRecord = this.createRecord( { ...event.nativeEvent, currentContent: unescapeSpaces( event.nativeEvent.text ), } ); - this.splitContent( currentRecord ); + if ( this.multilineTag ) { + if ( event.shiftKey ) { + const insertedLineBreak = { needsSelectionUpdate: true, ...insertLineBreak( currentRecord ) }; + this.onFormatChangeForceChild( insertedLineBreak ); + } else if ( this.onSplit && isEmptyLine( currentRecord ) ) { + this.setState( { + needsSelectionUpdate: false, + } ); + this.onSplit( ...split( currentRecord ).map( this.valueToFormat ) ); + } else { + const insertedLineSeparator = { needsSelectionUpdate: true, ...insertLineSeparator( currentRecord ) }; + this.onFormatChangeForceChild( insertedLineSeparator ); + } + } else if ( event.shiftKey || ! this.onSplit ) { + const insertedLineBreak = { needsSelectionUpdate: true, ...insertLineBreak( currentRecord ) }; + this.onFormatChangeForceChild( insertedLineBreak ); + } else { + this.splitContent( currentRecord ); + } } // eslint-disable-next-line no-unused-vars @@ -446,7 +489,8 @@ export class RichText extends Component { ...create( { html: innerContent, range: null, - multilineTag: false, + multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, } ), }; @@ -459,6 +503,7 @@ export class RichText extends Component { return create( { html: children.toHTML( value ), multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, } ); } @@ -466,6 +511,7 @@ export class RichText extends Component { return create( { html: value, multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, } ); } @@ -525,6 +571,7 @@ export class RichText extends Component { style, formattingControls, isSelected, + onTagNameChange, } = this.props; const record = this.getRecord(); @@ -546,6 +593,14 @@ export class RichText extends Component { return ( + { isSelected && this.multilineTag === 'li' && ( + + ) } { isSelected && ( @@ -585,6 +640,7 @@ export class RichText extends Component { fontWeight={ this.props.fontWeight } fontStyle={ this.props.fontStyle } disableEditingMenu={ this.props.disableEditingMenu } + isMultiline={ this.isMultiline } /> { isSelected && } @@ -606,13 +662,46 @@ const RichTextContainer = compose( [ formatTypes: getFormatTypes(), }; } ), + withBlockEditContext( ( context, ownProps ) => { + // When explicitly set as not selected, do nothing. + if ( ownProps.isSelected === false ) { + return { + clientId: context.clientId, + }; + } + // When explicitly set as selected, use the value stored in the context instead. + if ( ownProps.isSelected === true ) { + return { + isSelected: context.isSelected, + clientId: context.clientId, + }; + } + + // Ensures that only one RichText component can be focused. + return { + clientId: context.clientId, + isSelected: context.isSelected, + onFocus: context.onFocus, + }; + } ), ] )( RichText ); -RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => { +RichTextContainer.Content = ( { value, format, tagName: Tag, multiline, ...props } ) => { let content; + let html = value; + let MultilineTag; + + if ( multiline === true || multiline === 'p' || multiline === 'li' ) { + MultilineTag = multiline === true ? 'p' : multiline; + } + + if ( ! html && MultilineTag ) { + html = `<${ MultilineTag }>`; + } + switch ( format ) { case 'string': - content = { value }; + content = { html }; break; } diff --git a/packages/block-editor/src/components/rich-text/list-edit.native.js b/packages/block-editor/src/components/rich-text/list-edit.native.js new file mode 100644 index 00000000000000..27b3d56ac9fdf7 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/list-edit.native.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ + +import { Toolbar } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + changeListType, + __unstableIsListRootSelected, + __unstableIsActiveListType, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ + +import BlockFormatControls from '../block-format-controls'; + +export const ListEdit = ( { + onTagNameChange, + tagName, + value, + onChange, +} ) => ( + + + +); diff --git a/packages/block-library/src/index.native.js b/packages/block-library/src/index.native.js index 0326be07c92ca4..19f3ee31e231ea 100644 --- a/packages/block-library/src/index.native.js +++ b/packages/block-library/src/index.native.js @@ -110,6 +110,7 @@ export const registerCoreBlocks = () => { more, image, nextpage, + list, ].forEach( ( { name, settings } ) => { registerBlockType( name, settings ); } ); diff --git a/packages/rich-text/src/get-line-list-formats.js b/packages/rich-text/src/get-line-list-formats.js new file mode 100644 index 00000000000000..7eff9df12e13ff --- /dev/null +++ b/packages/rich-text/src/get-line-list-formats.js @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ + +import { getLineIndex } from './get-line-index'; + +/** + * Returns the list format of the line at the selection start position. + * + * @param {Object} value The rich-text value + * + * @return {Array} Array of the list formats on the selected line. + */ +export function getLineListFormats( value ) { + const { replacements, start } = value; + const startingLineIndex = getLineIndex( value, start ); + const startLineFormats = replacements[ startingLineIndex ] || []; + return startLineFormats; +} diff --git a/packages/rich-text/src/index.js b/packages/rich-text/src/index.js index 63da636257619d..53acd1179f6f67 100644 --- a/packages/rich-text/src/index.js +++ b/packages/rich-text/src/index.js @@ -12,6 +12,8 @@ export { getActiveObject } from './get-active-object'; export { getSelectionEnd } from './get-selection-end'; export { getSelectionStart } from './get-selection-start'; export { getTextContent } from './get-text-content'; +export { isListRootSelected as __unstableIsListRootSelected } from './is-list-root-selected'; +export { isActiveListType as __unstableIsActiveListType } from './is-active-list-type'; export { isCollapsed } from './is-collapsed'; export { isEmpty, isEmptyLine } from './is-empty'; export { join } from './join'; diff --git a/packages/rich-text/src/is-active-list-type.js b/packages/rich-text/src/is-active-list-type.js new file mode 100644 index 00000000000000..56efa78b3c7e72 --- /dev/null +++ b/packages/rich-text/src/is-active-list-type.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ + +import { getLineListFormats } from './get-line-list-formats'; + +/** + * Wether or not the selected list has the given tag name. + * + * @param {string} tagName The tag name the list should have. + * @param {string} rootTagName The current root tag name, to compare with in + * case nothing is selected. + * @param {Object} value The internal rich-text value. + * + * @return {boolean} [description] + */ +export function isActiveListType( tagName, rootTagName, value ) { + const startLineFormats = getLineListFormats( value ); + const [ deepestListFormat ] = startLineFormats.slice( -1 ); + + if ( ! deepestListFormat || ! deepestListFormat.type ) { + return tagName === rootTagName; + } + + return deepestListFormat.type.toLowerCase() === tagName; +} diff --git a/packages/rich-text/src/is-list-root-selected.js b/packages/rich-text/src/is-list-root-selected.js new file mode 100644 index 00000000000000..ba823ebbf60d7c --- /dev/null +++ b/packages/rich-text/src/is-list-root-selected.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ + +import { getLineListFormats } from './get-line-list-formats'; + +/** + * Whether or not the root list is selected. + * + * @param {Object} value The internal rich-text value. + * + * @return {boolean} True if the root list or nothing is selected, false if an + * inner list is selected. + */ +export function isListRootSelected( value ) { + const startLineFormats = getLineListFormats( value ); + return startLineFormats.length < 1; +}