diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index b49b05f63ad7c..575c9796c87e8 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -42,6 +42,8 @@ import { isCollapsed, LINE_SEPARATOR, indentListItems, + __unstableGetActiveFormats, + __unstableUpdateFormats, } from '@wordpress/rich-text'; import { decodeEntities } from '@wordpress/html-entities'; import { withFilters, IsolatedEventContainer } from '@wordpress/components'; @@ -183,9 +185,9 @@ export class RichText extends Component { */ getRecord() { const { formats, replacements, text } = this.formatToValue( this.props.value ); - const { start, end, selectedFormat } = this.state; + const { start, end, activeFormats } = this.state; - return { formats, replacements, text, start, end, selectedFormat }; + return { formats, replacements, text, start, end, activeFormats }; } createRecord() { @@ -400,40 +402,19 @@ export class RichText extends Component { } } - let { selectedFormat } = this.state; - const { formats, replacements, text, start, end } = this.createRecord(); - - if ( this.formatPlaceholder ) { - selectedFormat = this.formatPlaceholder.length; - - if ( selectedFormat > 0 ) { - formats[ this.state.start ] = this.formatPlaceholder; - } else { - delete formats[ this.state.start ]; - } - } else if ( selectedFormat > 0 ) { - const formatsBefore = formats[ start - 1 ] || []; - const formatsAfter = formats[ start ] || []; - - let source = formatsBefore; - - if ( formatsAfter.length > formatsBefore.length ) { - source = formatsAfter; - } - - source = source.slice( 0, selectedFormat ); - - formats[ this.state.start ] = source; - } else { - delete formats[ this.state.start ]; - } - - const change = { formats, replacements, text, start, end, selectedFormat }; + const value = this.createRecord(); + const { activeFormats = [], start } = this.state; - this.onChange( change, { - withoutHistory: true, + // Update the formats between the last and new caret position. + const change = __unstableUpdateFormats( { + value, + start, + end: value.start, + formats: activeFormats, } ); + this.onChange( change, { withoutHistory: true } ); + const transformed = this.patterns.reduce( ( accumlator, transform ) => transform( accumlator ), change @@ -441,7 +422,7 @@ export class RichText extends Component { if ( transformed !== change ) { this.onCreateUndoLevel(); - this.onChange( { ...transformed, selectedFormat } ); + this.onChange( { ...transformed, activeFormats } ); } // Create an undo level when input stops for over a second. @@ -461,39 +442,23 @@ export class RichText extends Component { * Handles the `selectionchange` event: sync the selection to local state. */ onSelectionChange() { - if ( this.ignoreSelectionChange ) { - delete this.ignoreSelectionChange; - return; - } - const value = this.createRecord(); - const { start, end, formats } = value; + const { start, end } = value; if ( start !== this.state.start || end !== this.state.end ) { - const isCaretWithinFormattedText = this.props.isCaretWithinFormattedText; + const { isCaretWithinFormattedText } = this.props; + const activeFormats = __unstableGetActiveFormats( value ); - if ( ! isCaretWithinFormattedText && formats[ start ] ) { + if ( ! isCaretWithinFormattedText && activeFormats.length ) { this.props.onEnterFormattedText(); - } else if ( isCaretWithinFormattedText && ! formats[ start ] ) { + } else if ( isCaretWithinFormattedText && ! activeFormats.length ) { this.props.onExitFormattedText(); } - let selectedFormat; - const formatsAfter = formats[ start ] || []; - const collapsed = isCollapsed( value ); - - if ( collapsed ) { - const formatsBefore = formats[ start - 1 ] || []; - - selectedFormat = Math.min( formatsBefore.length, formatsAfter.length ); - } - - this.setState( { start, end, selectedFormat } ); - this.applyRecord( { ...value, selectedFormat }, { domOnly: true } ); - - delete this.formatPlaceholder; + this.setState( { start, end, activeFormats } ); + this.applyRecord( { ...value, activeFormats }, { domOnly: true } ); - if ( collapsed ? selectedFormat > 0 : formatsAfter.length > 0 ) { + if ( activeFormats.length > 0 ) { this.recalculateBoundaryStyle(); } } @@ -538,14 +503,12 @@ export class RichText extends Component { onChange( record, { withoutHistory } = {} ) { this.applyRecord( record ); - const { start, end, formatPlaceholder, selectedFormat } = record; + const { start, end, activeFormats = [] } = record; - this.formatPlaceholder = formatPlaceholder; this.onChangeEditableValue( record ); - this.savedContent = this.valueToFormat( record ); this.props.onChange( this.savedContent ); - this.setState( { start, end, selectedFormat } ); + this.setState( { start, end, activeFormats } ); if ( ! withoutHistory ) { this.onCreateUndoLevel(); @@ -761,17 +724,15 @@ export class RichText extends Component { handleHorizontalNavigation( event ) { const value = this.createRecord(); const { formats, text, start, end } = value; - const { selectedFormat } = this.state; + const { activeFormats = [] } = this.state; const collapsed = isCollapsed( value ); const isReverse = event.keyCode === LEFT; - delete this.formatPlaceholder; - // If the selection is collapsed and at the very start, do nothing if // navigating backward. // If the selection is collapsed and at the very end, do nothing if // navigating forward. - if ( collapsed && selectedFormat === 0 ) { + if ( collapsed && activeFormats.length === 0 ) { if ( start === 0 && isReverse ) { return; } @@ -791,41 +752,43 @@ export class RichText extends Component { // In all other cases, prevent default behaviour. event.preventDefault(); - // Ignore the selection change handler when setting selection, all state - // will be set here. - this.ignoreSelectionChange = true; - const formatsBefore = formats[ start - 1 ] || []; const formatsAfter = formats[ start ] || []; - let newSelectedFormat = selectedFormat; + let newActiveFormatsLength = activeFormats.length; + let source = formatsAfter; + + if ( formatsBefore.length > formatsAfter.length ) { + source = formatsBefore; + } // If the amount of formats before the caret and after the caret is // different, the caret is at a format boundary. if ( formatsBefore.length < formatsAfter.length ) { - if ( ! isReverse && selectedFormat < formatsAfter.length ) { - newSelectedFormat++; + if ( ! isReverse && activeFormats.length < formatsAfter.length ) { + newActiveFormatsLength++; } - if ( isReverse && selectedFormat > formatsBefore.length ) { - newSelectedFormat--; + if ( isReverse && activeFormats.length > formatsBefore.length ) { + newActiveFormatsLength--; } } else if ( formatsBefore.length > formatsAfter.length ) { - if ( ! isReverse && selectedFormat > formatsAfter.length ) { - newSelectedFormat--; + if ( ! isReverse && activeFormats.length > formatsAfter.length ) { + newActiveFormatsLength--; } - if ( isReverse && selectedFormat < formatsBefore.length ) { - newSelectedFormat++; + if ( isReverse && activeFormats.length < formatsBefore.length ) { + newActiveFormatsLength++; } } // Wait for boundary class to be added. setTimeout( () => this.recalculateBoundaryStyle() ); - if ( newSelectedFormat !== selectedFormat ) { - this.applyRecord( { ...value, selectedFormat: newSelectedFormat } ); - this.setState( { selectedFormat: newSelectedFormat } ); + if ( newActiveFormatsLength !== activeFormats.length ) { + const newActiveFormats = source.slice( 0, newActiveFormatsLength ); + this.applyRecord( { ...value, activeFormats: newActiveFormats } ); + this.setState( { activeFormats: newActiveFormats } ); return; } @@ -836,7 +799,7 @@ export class RichText extends Component { ...value, start: newPos, end: newPos, - selectedFormat: isReverse ? formatsBefore.length : formatsAfter.length, + activeFormats: isReverse ? formatsBefore : formatsAfter, } ); } diff --git a/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap b/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap index f5c79ddbd33ac..bf5fefd31a7e3 100644 --- a/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap @@ -18,6 +18,12 @@ exports[`RichText should apply formatting with primary shortcut 1`] = ` " `; +exports[`RichText should apply multiple formats when selection is collapsed 1`] = ` +" +

1.

+" +`; + exports[`RichText should handle change in tag name gracefully 1`] = ` "

diff --git a/packages/e2e-tests/specs/rich-text.test.js b/packages/e2e-tests/specs/rich-text.test.js index 5153032953899..4ebe263fedc34 100644 --- a/packages/e2e-tests/specs/rich-text.test.js +++ b/packages/e2e-tests/specs/rich-text.test.js @@ -58,6 +58,18 @@ describe( 'RichText', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should apply multiple formats when selection is collapsed', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'i' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'i' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'should return focus when pressing formatting button', async () => { await clickBlockAppender(); await page.keyboard.type( 'Some ' ); diff --git a/packages/rich-text/src/apply-format.js b/packages/rich-text/src/apply-format.js index c9a59b96e6a65..14402735b1e60 100644 --- a/packages/rich-text/src/apply-format.js +++ b/packages/rich-text/src/apply-format.js @@ -28,7 +28,8 @@ export function applyFormat( startIndex = value.start, endIndex = value.end ) { - const newFormats = value.formats.slice( 0 ); + const { formats, activeFormats = [] } = value; + const newFormats = formats.slice(); // The selection is collapsed. if ( startIndex === endIndex ) { @@ -51,11 +52,9 @@ export function applyFormat( // Otherwise, insert a placeholder with the format so new input appears // with the format applied. } else { - const previousFormat = newFormats[ startIndex - 1 ] || []; - return { ...value, - formatPlaceholder: [ ...previousFormat, format ], + activeFormats: [ ...activeFormats, format ], }; } } else { diff --git a/packages/rich-text/src/get-active-formats.js b/packages/rich-text/src/get-active-formats.js index fd0f869877bfb..9988c4501c8ed 100644 --- a/packages/rich-text/src/get-active-formats.js +++ b/packages/rich-text/src/get-active-formats.js @@ -5,19 +5,26 @@ * * @return {?Object} Active format objects. */ -export function getActiveFormats( { formats, start, selectedFormat } ) { +export function getActiveFormats( { formats, start, end, activeFormats } ) { if ( start === undefined ) { return []; } - const formatsBefore = formats[ start - 1 ] || []; - const formatsAfter = formats[ start ] || []; + if ( start === end ) { + // For a collapsed caret, it is possible to override the active formats. + if ( activeFormats ) { + return activeFormats; + } - let source = formatsAfter; + const formatsBefore = formats[ start - 1 ] || []; + const formatsAfter = formats[ start ] || []; - if ( formatsBefore.length > formatsAfter.length ) { - source = formatsBefore; + if ( formatsBefore.length < formatsAfter.length ) { + return formatsBefore; + } + + return formatsAfter; } - return source.slice( 0, selectedFormat ); + return formats[ start ] || []; } diff --git a/packages/rich-text/src/index.js b/packages/rich-text/src/index.js index 9d9f462021287..63da636257619 100644 --- a/packages/rich-text/src/index.js +++ b/packages/rich-text/src/index.js @@ -33,3 +33,5 @@ export { unregisterFormatType } from './unregister-format-type'; export { indentListItems } from './indent-list-items'; export { outdentListItems } from './outdent-list-items'; export { changeListType } from './change-list-type'; +export { updateFormats as __unstableUpdateFormats } from './update-formats'; +export { getActiveFormats as __unstableGetActiveFormats } from './get-active-formats'; diff --git a/packages/rich-text/src/normalise-formats.js b/packages/rich-text/src/normalise-formats.js index 72c04f818f9a4..9dbb2970435c4 100644 --- a/packages/rich-text/src/normalise-formats.js +++ b/packages/rich-text/src/normalise-formats.js @@ -1,9 +1,3 @@ -/** - * External dependencies - */ - -import { find } from 'lodash'; - /** * Internal dependencies */ @@ -18,22 +12,28 @@ import { isFormatEqual } from './is-format-equal'; * @return {Object} New value with normalised formats. */ export function normaliseFormats( value ) { - const refs = []; - const newFormats = value.formats.map( ( formatsAtIndex ) => - formatsAtIndex.map( ( format ) => { - const equalRef = find( refs, ( ref ) => - isFormatEqual( ref, format ) - ); + const newFormats = value.formats.slice(); + + newFormats.forEach( ( formatsAtIndex, index ) => { + const formatsAtPreviousIndex = newFormats[ index - 1 ]; + + if ( formatsAtPreviousIndex ) { + const newFormatsAtIndex = formatsAtIndex.slice(); - if ( equalRef ) { - return equalRef; - } + newFormatsAtIndex.forEach( ( format, formatIndex ) => { + const previousFormat = formatsAtPreviousIndex[ formatIndex ]; - refs.push( format ); + if ( isFormatEqual( format, previousFormat ) ) { + newFormatsAtIndex[ formatIndex ] = previousFormat; + } + } ); - return format; - } ) - ); + newFormats[ index ] = newFormatsAtIndex; + } + } ); - return { ...value, formats: newFormats }; + return { + ...value, + formats: newFormats, + }; } diff --git a/packages/rich-text/src/remove-format.js b/packages/rich-text/src/remove-format.js index 405cb6265e1e0..4a4c9c820f900 100644 --- a/packages/rich-text/src/remove-format.js +++ b/packages/rich-text/src/remove-format.js @@ -28,7 +28,8 @@ export function removeFormat( startIndex = value.start, endIndex = value.end ) { - const newFormats = value.formats.slice( 0 ); + const { formats, activeFormats } = value; + const newFormats = formats.slice(); // If the selection is collapsed, expand start and end to the edges of the // format. @@ -50,10 +51,7 @@ export function removeFormat( } else { return { ...value, - formatPlaceholder: reject( - newFormats[ startIndex - 1 ] || [], - { type: formatType } - ), + activeFormats: reject( activeFormats, { type: formatType } ), }; } } else { diff --git a/packages/rich-text/src/test/apply-format.js b/packages/rich-text/src/test/apply-format.js index b8cff3bc47620..b75dc7ee5de00 100644 --- a/packages/rich-text/src/test/apply-format.js +++ b/packages/rich-text/src/test/apply-format.js @@ -61,7 +61,7 @@ describe( 'applyFormat', () => { }; const expected = { ...record, - formatPlaceholder: [ a2 ], + activeFormats: [ a2 ], }; const result = applyFormat( deepFreeze( record ), a2 ); diff --git a/packages/rich-text/src/test/get-active-format.js b/packages/rich-text/src/test/get-active-format.js index 4fa0ddaa3e3ec..eba352783c39a 100644 --- a/packages/rich-text/src/test/get-active-format.js +++ b/packages/rich-text/src/test/get-active-format.js @@ -6,39 +6,82 @@ import { getActiveFormat } from '../get-active-format'; describe( 'getActiveFormat', () => { const em = { type: 'em' }; + const strong = { type: 'strong' }; - it( 'should get format by selection', () => { + it( 'should return undefined if there is no selection', () => { const record = { - formats: [ [ em ], , , ], + formats: [ [ em ], [ em ], [ em ] ], + text: 'one', + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); + + it( 'should return format at first character for uncollapsed selection', () => { + const record = { + formats: [ [ em ], [ strong ], , ], text: 'one', start: 0, - end: 0, + end: 2, }; - expect( getActiveFormat( record, 'em' ) ).toEqual( em ); + expect( getActiveFormat( record, 'em' ) ).toBe( em ); } ); - it( 'should not get any format if outside boundary position', () => { + it( 'should return undefined if at the boundary before', () => { + const record = { + formats: [ [ em ], , [ em ] ], + text: 'one', + start: 3, + end: 3, + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); + + it( 'should return undefined if at the boundary after', () => { const record = { formats: [ [ em ], , [ em ] ], text: 'one', start: 1, end: 1, - selectedFormat: 0, }; expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); } ); - it( 'should get format if inside boundary position', () => { + it( 'should return format if inside format', () => { + const record = { + formats: [ [ em ], [ em ], [ em ] ], + text: 'one', + start: 1, + end: 1, + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( em ); + } ); + + it( 'should return activeFormats', () => { const record = { formats: [ [ em ], , [ em ] ], text: 'one', start: 1, end: 1, - selectedFormat: 1, + activeFormats: [ em ], }; expect( getActiveFormat( record, 'em' ) ).toBe( em ); } ); + + it( 'should not return activeFormats for uncollapsed selection', () => { + const record = { + formats: [ [ em ], , [ em ] ], + text: 'one', + start: 1, + end: 2, + activeFormats: [ em ], + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); } ); diff --git a/packages/rich-text/src/test/normalise-formats.js b/packages/rich-text/src/test/normalise-formats.js index 25525625f18b5..c71b8a35ed634 100644 --- a/packages/rich-text/src/test/normalise-formats.js +++ b/packages/rich-text/src/test/normalise-formats.js @@ -26,7 +26,7 @@ describe( 'normaliseFormats', () => { expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 2 ][ 0 ] ); expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 3 ][ 0 ] ); - expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 5 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).not.toBe( result.formats[ 5 ][ 0 ] ); expect( result.formats[ 2 ][ 1 ] ).toBe( result.formats[ 3 ][ 1 ] ); } ); } ); diff --git a/packages/rich-text/src/test/update-formats.js b/packages/rich-text/src/test/update-formats.js new file mode 100644 index 0000000000000..e5c3c97a85bb7 --- /dev/null +++ b/packages/rich-text/src/test/update-formats.js @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ + +import { updateFormats } from '../update-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'updateFormats', () => { + const em = { type: 'em' }; + + it( 'should update formats with empty array', () => { + const value = { + formats: [ [ em ] ], + text: '1', + }; + const expected = { + ...value, + activeFormats: [], + formats: [ , ], + }; + const result = updateFormats( { + value, + start: 0, + end: 1, + formats: [], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should update formats and update references', () => { + const value = { + formats: [ [ em ], , ], + text: '123', + }; + const expected = { + ...value, + activeFormats: [ em ], + formats: [ [ em ], [ em ] ], + }; + const result = updateFormats( { + value, + start: 1, + end: 2, + formats: [ { ...em } ], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( result.formats[ 1 ][ 0 ] ).toBe( em ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index ef15fe7524b6e..c76eafc803b92 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -69,17 +69,6 @@ function fromFormat( { type, attributes, unregisteredAttributes, object, boundar }; } -function getDeepestActiveFormat( value ) { - const activeFormats = getActiveFormats( value ); - const { selectedFormat } = value; - - if ( selectedFormat === undefined ) { - return activeFormats[ activeFormats.length - 1 ]; - } - - return activeFormats[ selectedFormat - 1 ]; -} - const padding = { type: 'br', attributes: { @@ -107,7 +96,8 @@ export function toTree( { const formatsLength = formats.length + 1; const tree = createEmpty(); const multilineFormat = { type: multilineTag }; - const deepestActiveFormat = getDeepestActiveFormat( value ); + const activeFormats = getActiveFormats( value ); + const deepestActiveFormat = activeFormats[ activeFormats.length - 1 ]; let lastSeparatorFormats; let lastCharacterFormats; diff --git a/packages/rich-text/src/update-formats.js b/packages/rich-text/src/update-formats.js new file mode 100644 index 0000000000000..bc99c9ba0e5db --- /dev/null +++ b/packages/rich-text/src/update-formats.js @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from './is-format-equal'; + +/** + * Efficiently updates all the formats from `start` (including) until `end` + * (excluding) with the active formats. Mutates `value`. + * + * @param {Object} $1 Named paramentes. + * @param {Object} $1.value Value te update. + * @param {number} $1.start Index to update from. + * @param {number} $1.end Index to update until. + * @param {Array} $1.formats Replacement formats. + * + * @return {Object} Mutated value. + */ +export function updateFormats( { value, start, end, formats } ) { + const formatsBefore = value.formats[ start - 1 ] || []; + const formatsAfter = value.formats[ end ] || []; + + // First, fix the references. If any format right before or after are + // equal, the replacement format should use the same reference. + value.activeFormats = formats.map( ( format, index ) => { + if ( formatsBefore[ index ] ) { + if ( isFormatEqual( format, formatsBefore[ index ] ) ) { + return formatsBefore[ index ]; + } + } else if ( formatsAfter[ index ] ) { + if ( isFormatEqual( format, formatsAfter[ index ] ) ) { + return formatsAfter[ index ]; + } + } + + return format; + } ); + + while ( --end >= start ) { + if ( value.activeFormats.length > 0 ) { + value.formats[ end ] = value.activeFormats; + } else { + delete value.formats[ end ]; + } + } + + return value; +}