diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index e6754796ba165..21687c186d777 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -9,7 +9,6 @@ import { omit, pickBy, } from 'lodash'; -import memize from 'memize'; /** * WordPress dependencies @@ -40,7 +39,6 @@ import { __UNSTABLE_LINE_SEPARATOR as LINE_SEPARATOR, __unstableIndentListItems as indentListItems, __unstableGetActiveFormats as getActiveFormats, - __unstableUpdateFormats as updateFormats, } from '@wordpress/rich-text'; import { decodeEntities } from '@wordpress/html-entities'; import { withFilters, IsolatedEventContainer } from '@wordpress/components'; @@ -147,12 +145,8 @@ export class RichText extends Component { this.handleHorizontalNavigation = this.handleHorizontalNavigation.bind( this ); this.onPointerDown = this.onPointerDown.bind( this ); - this.formatToValue = memize( - this.formatToValue.bind( this ), - { maxSize: 1 } - ); - this.savedContent = value; + this.rawValue = this.formatToValue( value ); this.patterns = getPatterns( { onReplace, valueToFormat: this.valueToFormat, @@ -199,7 +193,7 @@ export class RichText extends Component { * @return {Object} The current record (value and selection). */ getRecord() { - const { formats, replacements, text } = this.formatToValue( this.props.value ); + const { formats, replacements, text } = this.rawValue; const { start, end, activeFormats } = this.state; return { formats, replacements, text, start, end, activeFormats }; @@ -231,7 +225,7 @@ export class RichText extends Component { } isEmpty() { - return isEmpty( this.formatToValue( this.props.value ) ); + return isEmpty( this.rawValue ); } /** @@ -380,6 +374,7 @@ export class RichText extends Component { } this.recalculateBoundaryStyle(); + this.onSelectionChange(); document.addEventListener( 'selectionchange', this.onSelectionChange ); } @@ -419,16 +414,33 @@ export class RichText extends Component { } } + const record = this.getRecord(); + const { activeFormats = [], start, end } = record; + const formats = createPrepareEditableTree( this.props )( record ); const value = this.createRecord(); - const { activeFormats = [], start } = this.state; + + let newFormats; // Update the formats between the last and new caret position. - const change = updateFormats( { - value, - start, - end: value.start, - formats: activeFormats, - } ); + if ( value.start <= start ) { // Deletion + const beforeFormats = formats.slice( 0, value.start ); + const afterFormats = formats.slice( end ); + + newFormats = beforeFormats.concat( afterFormats ); + } else { // Insertion + const length = value.start - start; + const formatsToInsert = Array.from( Array( length ), () => activeFormats ); + const beforeFormats = formats.slice( 0, start ); + const afterFormats = formats.slice( end ); + + newFormats = beforeFormats.concat( formatsToInsert, afterFormats ); + } + + const change = { + ...value, + formats: newFormats, + activeFormats, + }; this.onChange( change, { withoutHistory: true } ); @@ -459,12 +471,18 @@ export class RichText extends Component { * Handles the `selectionchange` event: sync the selection to local state. */ onSelectionChange() { - const value = this.createRecord(); - const { start, end } = value; + const { start, end } = this.createRecord(); if ( start !== this.state.start || end !== this.state.end ) { const { isCaretWithinFormattedText } = this.props; - const activeFormats = getActiveFormats( value ); + const value = this.getRecord(); + const newValue = { + ...value, + start, + end, + }; + delete newValue.activeFormats; + const activeFormats = getActiveFormats( newValue ); if ( ! isCaretWithinFormattedText && activeFormats.length ) { this.props.onEnterFormattedText(); @@ -473,7 +491,7 @@ export class RichText extends Component { } this.setState( { start, end, activeFormats } ); - this.applyRecord( { ...value, activeFormats }, { domOnly: true } ); + this.applyRecord( { ...newValue, activeFormats }, { domOnly: true } ); if ( activeFormats.length > 0 ) { this.recalculateBoundaryStyle(); @@ -517,6 +535,7 @@ export class RichText extends Component { changeHandler( record.formats, record.text ); } ); + this.rawValue = record; this.savedContent = this.valueToFormat( record ); this.props.onChange( this.savedContent ); this.setState( { start, end, activeFormats } ); @@ -733,8 +752,8 @@ export class RichText extends Component { * @param {SyntheticEvent} event A synthetic keyboard event. */ handleHorizontalNavigation( event ) { - const value = this.createRecord(); - const { formats, text, start, end } = value; + const value = this.getRecord(); + const { text, start, end } = value; const { activeFormats = [] } = this.state; const collapsed = isCollapsed( value ); const isReverse = event.keyCode === LEFT; @@ -763,6 +782,7 @@ export class RichText extends Component { // In all other cases, prevent default behaviour. event.preventDefault(); + const formats = createPrepareEditableTree( this.props )( value ); const formatsBefore = formats[ start - 1 ] || []; const formatsAfter = formats[ start ] || []; @@ -915,6 +935,7 @@ export class RichText extends Component { } this.applyRecord( record ); + this.rawValue = record; this.savedContent = value; } diff --git a/packages/components/src/positioned-at-selection/index.js b/packages/components/src/positioned-at-selection/index.js index 37c039d77777d..5fe3bc8082a7d 100644 --- a/packages/components/src/positioned-at-selection/index.js +++ b/packages/components/src/positioned-at-selection/index.js @@ -9,9 +9,12 @@ import { getOffsetParent, getRectangleFromRange } from '@wordpress/dom'; * relative to the bottom-center of the current selection. Includes `top` and * `left` style properties. * + * @param {string} selector Use the position of the closest element that matches + * the selector instead of the position of the caret. + * * @return {Object} Style object. */ -function getCurrentCaretPositionStyle() { +function getCurrentCaretPositionStyle( selector ) { const selection = window.getSelection(); // Unlikely, but in the case there is no selection, return empty styles so @@ -20,8 +23,32 @@ function getCurrentCaretPositionStyle() { return {}; } + const range = selection.getRangeAt( 0 ); + // Get position relative viewport. - const rect = getRectangleFromRange( selection.getRangeAt( 0 ) ); + let rect; + + if ( selector ) { + let element = range.startContainer; + + // If the caret is right before the element, select the next element. + element = element.nextElementSibling || element; + + while ( element.nodeType !== window.Node.ELEMENT_NODE ) { + element = element.parentNode; + } + + const closest = element.closest( selector ); + + if ( closest ) { + rect = closest.getBoundingClientRect(); + } + } + + if ( ! rect ) { + rect = getRectangleFromRange( range ); + } + let top = rect.top + rect.height; let left = rect.left + ( rect.width / 2 ); @@ -44,11 +71,11 @@ function getCurrentCaretPositionStyle() { * @type {WPComponent} */ export default class PositionedAtSelection extends Component { - constructor() { + constructor( { selector } ) { super( ...arguments ); this.state = { - style: getCurrentCaretPositionStyle(), + style: getCurrentCaretPositionStyle( selector ), }; } diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index 58b38bed2c64e..40d1adefede53 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -24,6 +24,7 @@ import { applyFormat, getTextContent, slice, + getActiveFormat, } from '@wordpress/rich-text'; import { URLInput, URLPopover } from '@wordpress/block-editor'; @@ -108,23 +109,36 @@ class InlineLinkUI extends Component { this.state = { opensInNewWindow: false, inputValue: '', + key: 0, }; } static getDerivedStateFromProps( props, state ) { - const { activeAttributes: { url, target } } = props; + const { activeAttributes: { url, target }, value } = props; const opensInNewWindow = target === '_blank'; + const activeFormat = getActiveFormat( value, 'core/link' ); + + const newState = {}; if ( ! isShowingInput( props, state ) ) { if ( url !== state.inputValue ) { - return { inputValue: url }; + newState.inputValue = url; } if ( opensInNewWindow !== state.opensInNewWindow ) { - return { opensInNewWindow }; + newState.opensInNewWindow = opensInNewWindow; } } + if ( activeFormat && activeFormat !== state.activeFormat ) { + newState.activeFormat = activeFormat; + newState.key = state.key + 1; + } + + if ( Object.keys( newState ).length ) { + return newState; + } + return null; } @@ -211,7 +225,7 @@ class InlineLinkUI extends Component { } render() { - const { isActive, activeAttributes: { url }, addingLink, value } = this.props; + const { isActive, activeAttributes: { url }, addingLink } = this.props; if ( ! isActive && ! addingLink ) { return null; @@ -222,7 +236,12 @@ class InlineLinkUI extends Component { return ( { - 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/update-formats.js b/packages/rich-text/src/update-formats.js deleted file mode 100644 index bc99c9ba0e5db..0000000000000 --- a/packages/rich-text/src/update-formats.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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; -}