From f15b91ca9cbdf171448ebc1f23f55cc5b2748262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Sun, 14 Jun 2020 21:26:38 +0300 Subject: [PATCH] RichText: rewrite with hooks (#23132) * RichText: rewrite with hooks * Immediately render content * Adjust undo e2e test * Avoid underscore prefix for duplicate identifiers --- .../src/components/rich-text/index.js | 11 +- .../specs/editor/various/undo.test.js | 8 +- packages/rich-text/src/component/index.js | 1497 ++++++++--------- ...oundary-style.js => use-boundary-style.js} | 5 +- ...nline-warning.js => use-inline-warning.js} | 5 +- 5 files changed, 734 insertions(+), 792 deletions(-) rename packages/rich-text/src/component/{boundary-style.js => use-boundary-style.js} (89%) rename packages/rich-text/src/component/{inline-warning.js => use-inline-warning.js} (82%) diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 71dcadd3ca951..639c4e4a191d9 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -575,7 +575,8 @@ function RichTextWrapper( value, onChange, onFocus, - Editable, + editableProps, + editableTagName: TagName, } ) => ( <> { children && children( { value, onChange, onFocus } ) } @@ -594,7 +595,8 @@ function RichTextWrapper( isSelected={ nestedIsSelected } > { ( { listBoxId, activeId, onKeyDown } ) => ( - { + onKeyDown( event ); + editableProps.onKeyDown( event ); + } } /> ) } diff --git a/packages/e2e-tests/specs/editor/various/undo.test.js b/packages/e2e-tests/specs/editor/various/undo.test.js index 3f2f5d3b752d1..ac9864b981493 100644 --- a/packages/e2e-tests/specs/editor/various/undo.test.js +++ b/packages/e2e-tests/specs/editor/various/undo.test.js @@ -225,8 +225,8 @@ describe( 'undo', () => { expect( await getSelection() ).toEqual( { blockIndex: 2, editableIndex: 0, - startOffset: 0, - endOffset: 0, + startOffset: 'is'.length, + endOffset: 'is'.length, } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd paragraph text. @@ -245,8 +245,8 @@ describe( 'undo', () => { expect( await getSelection() ).toEqual( { blockIndex: 1, editableIndex: 0, - startOffset: 0, - endOffset: 0, + startOffset: 'This'.length, + endOffset: 'This'.length, } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st paragraph text. diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index cd174b2baaabb..7a392dcc956ef 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -7,7 +7,14 @@ import { find, isNil, pickBy, startsWith } from 'lodash'; /** * WordPress dependencies */ -import { Component, forwardRef } from '@wordpress/element'; +import { + forwardRef, + useEffect, + useRef, + useState, + useMemo, + useLayoutEffect, +} from '@wordpress/element'; import { BACKSPACE, DELETE, @@ -17,8 +24,6 @@ import { SPACE, ESCAPE, } from '@wordpress/keycodes'; -import { withSafeTimeout, compose } from '@wordpress/compose'; -import isShallowEqual from '@wordpress/is-shallow-equal'; import deprecated from '@wordpress/deprecated'; /** @@ -38,8 +43,8 @@ import { updateFormats } from '../update-formats'; import { removeLineSeparator } from '../remove-line-separator'; import { isEmptyLine } from '../is-empty'; import withFormatTypes from './with-format-types'; -import { BoundaryStyle } from './boundary-style'; -import { InlineWarning } from './inline-warning'; +import { useBoundaryStyle } from './use-boundary-style'; +import { useInlineWarning } from './use-inline-warning'; import { insert } from '../insert'; /** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */ @@ -132,90 +137,153 @@ function fixPlaceholderSelection( defaultView ) { selection.collapseToStart(); } -/** - * See export statement below. - */ -class RichText extends Component { - constructor( { value, selectionStart, selectionEnd } ) { - super( ...arguments ); - - this.getDocument = this.getDocument.bind( this ); - this.getWindow = this.getWindow.bind( this ); - this.onFocus = this.onFocus.bind( this ); - this.onBlur = this.onBlur.bind( this ); - this.onChange = this.onChange.bind( this ); - this.handleDelete = this.handleDelete.bind( this ); - this.handleEnter = this.handleEnter.bind( this ); - this.handleSpace = this.handleSpace.bind( this ); - this.handleHorizontalNavigation = this.handleHorizontalNavigation.bind( - this - ); - this.onPaste = this.onPaste.bind( this ); - this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); - this.onInput = this.onInput.bind( this ); - this.onCompositionStart = this.onCompositionStart.bind( this ); - this.onCompositionEnd = this.onCompositionEnd.bind( this ); - this.onSelectionChange = this.onSelectionChange.bind( this ); - this.createRecord = this.createRecord.bind( this ); - this.applyRecord = this.applyRecord.bind( this ); - this.valueToFormat = this.valueToFormat.bind( this ); - this.onPointerDown = this.onPointerDown.bind( this ); - this.formatToValue = this.formatToValue.bind( this ); - this.Editable = this.Editable.bind( this ); - - this.onKeyDown = ( event ) => { - if ( event.defaultPrevented ) { - return; - } +function RichText( { + tagName: TagName = 'div', + value = '', + selectionStart, + selectionEnd, + children, + allowedFormats, + withoutInteractiveFormatting, + formatTypes, + style, + className, + placeholder, + disabled, + preserveWhiteSpace, + onPaste, + format = 'string', + onDelete, + onEnter, + onSelectionChange, + onChange, + unstableOnFocus: onFocus, + setFocusedElement, + instanceId, + __unstableMultilineTag: multilineTag, + __unstableMultilineRootTag: multilineRootTag, + __unstableDisableFormats: disableFormats, + __unstableDidAutomaticChange: didAutomaticChange, + __unstableInputRule: inputRule, + __unstableMarkAutomaticChange: markAutomaticChange, + __unstableAllowPrefixTransformations: allowPrefixTransformations, + __unstableUndo: undo, + __unstableIsCaretWithinFormattedText: isCaretWithinFormattedText, + __unstableOnEnterFormattedText: onEnterFormattedText, + __unstableOnExitFormattedText: onExitFormattedText, + __unstableOnCreateUndoLevel: onCreateUndoLevel, + __unstableIsSelected: isSelected, + forwardedRef: ref, + ...remainingProps +} ) { + const [ activeFormats = [], setActiveFormats ] = useState(); + + function getDoc() { + return ref.current.ownerDocument; + } - this.handleDelete( event ); - this.handleEnter( event ); - this.handleSpace( event ); - this.handleHorizontalNavigation( event ); - }; + function getWin() { + return getDoc().defaultView; + } - this.state = {}; - this.lastHistoryValue = value; + /** + * Converts the outside data structure to our internal representation. + * + * @param {*} string The outside value, data type depends on props. + * + * @return {Object} An internal rich-text value. + */ + function formatToValue( string ) { + if ( disableFormats ) { + return { + text: string, + formats: Array( string.length ), + replacements: Array( string.length ), + }; + } - // Internal values are updated synchronously, unlike props and state. - this.value = value; - this.record = this.formatToValue( value ); - this.record.start = selectionStart; - this.record.end = selectionEnd; - } + if ( format !== 'string' ) { + return string; + } - componentWillUnmount() { - this.getDocument().removeEventListener( - 'selectionchange', - this.onSelectionChange + const prepare = createPrepareEditableTree( + remainingProps, + 'format_value_functions' ); - this.getWindow().cancelAnimationFrame( this.rafId ); - } - componentDidMount() { - this.applyRecord( this.record, { domOnly: true } ); + const result = create( { + html: string, + multilineTag, + multilineWrapperTags: + multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, + preserveWhiteSpace, + } ); + + result.formats = prepare( result ); + + return result; } - getDocument() { - return this.props.forwardedRef.current.ownerDocument; + /** + * Removes editor only formats from the value. + * + * Editor only formats are applied using `prepareEditableTree`, so we need to + * remove them before converting the internal state + * + * @param {Object} val The internal rich-text value. + * + * @return {Object} A new rich-text value. + */ + function removeEditorOnlyFormats( val ) { + formatTypes.forEach( ( formatType ) => { + // Remove formats created by prepareEditableTree, because they are editor only. + if ( formatType.__experimentalCreatePrepareEditableTree ) { + val = removeFormat( val, formatType.name, 0, val.text.length ); + } + } ); + + return val; } - getWindow() { - return this.getDocument().defaultView; + /** + * Converts the internal value to the external data format. + * + * @param {Object} val The internal rich-text value. + * + * @return {*} The external data format, data type depends on props. + */ + function valueToFormat( val ) { + if ( disableFormats ) { + return val.text; + } + + val = removeEditorOnlyFormats( val ); + + if ( format !== 'string' ) { + return; + } + + return toHTMLString( { value: val, multilineTag, preserveWhiteSpace } ); } - createRecord() { - const { - __unstableMultilineTag: multilineTag, - forwardedRef, - preserveWhiteSpace, - } = this.props; - const selection = this.getWindow().getSelection(); + // Internal values are updated synchronously, unlike props and state. + const _value = useRef( value ); + const record = useRef( + useMemo( () => { + const initialRecord = formatToValue( value ); + initialRecord.start = selectionStart; + initialRecord.end = selectionEnd; + return initialRecord; + }, [] ) + ); + + function createRecord() { + const selection = getWin().getSelection(); const range = selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null; return create( { - element: forwardedRef.current, + element: ref.current, range, multilineTag, multilineWrapperTags: @@ -225,24 +293,19 @@ class RichText extends Component { } ); } - applyRecord( record, { domOnly } = {} ) { - const { - __unstableMultilineTag: multilineTag, - forwardedRef, - } = this.props; - + function applyRecord( newRecord, { domOnly } = {} ) { apply( { - value: record, - current: forwardedRef.current, + value: newRecord, + current: ref.current, multilineTag, multilineWrapperTags: multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, prepareEditableTree: createPrepareEditableTree( - this.props, + remainingProps, 'format_prepare_functions' ), __unstableDomOnly: domOnly, - placeholder: this.props.placeholder, + placeholder, } ); } @@ -253,15 +316,7 @@ class RichText extends Component { * * @param {ClipboardEvent} event The paste event. */ - onPaste( event ) { - const { - formatTypes, - onPaste, - __unstableIsSelected: isSelected, - __unstableDisableFormats, - } = this.props; - const { activeFormats = [] } = this.state; - + function handlePaste( event ) { if ( ! isSelected ) { event.preventDefault(); return; @@ -301,17 +356,16 @@ class RichText extends Component { window.console.log( 'Received HTML:\n\n', html ); window.console.log( 'Received plain text:\n\n', plainText ); - if ( __unstableDisableFormats ) { - this.onChange( insert( this.record, plainText ) ); + if ( disableFormats ) { + handleChange( insert( record.current, plainText ) ); return; } - const record = this.record; const transformed = formatTypes.reduce( ( accumlator, { __unstablePasteRule } ) => { // Only allow one transform. - if ( __unstablePasteRule && accumlator === record ) { - accumlator = __unstablePasteRule( record, { + if ( __unstablePasteRule && accumlator === record.current ) { + accumlator = __unstablePasteRule( record.current, { html, plainText, } ); @@ -319,11 +373,11 @@ class RichText extends Component { return accumlator; }, - record + record.current ); - if ( transformed !== record ) { - this.onChange( transformed ); + if ( transformed !== record.current ) { + handleChange( transformed ); return; } @@ -349,8 +403,8 @@ class RichText extends Component { } ); onPaste( { - value: this.removeEditorOnlyFormats( record ), - onChange: this.onChange, + value: removeEditorOnlyFormats( record.current ), + onChange: handleChange, html, plainText, files, @@ -360,471 +414,144 @@ class RichText extends Component { } /** - * Handles a focus event on the contenteditable field, calling the - * `unstableOnFocus` prop callback if one is defined. The callback does not - * receive any arguments. - * - * This is marked as a private API and the `unstableOnFocus` prop is not - * documented, as the current requirements where it is used are subject to - * future refactoring following `isSelected` handling. - * - * In contrast with `setFocusedElement`, this is only triggered in response - * to focus within the contenteditable field, whereas `setFocusedElement` - * is triggered on focus within any `RichText` descendent element. - * - * @see setFocusedElement + * Handles delete on keydown: + * - outdent list items, + * - delete content if everything is selected, + * - trigger the onDelete prop when selection is uncollapsed and at an edge. * - * @private + * @param {WPSyntheticEvent} event A synthetic keyboard event. */ - onFocus() { - const { unstableOnFocus } = this.props; + function handleDelete( event ) { + const { keyCode } = event; - if ( unstableOnFocus ) { - unstableOnFocus(); + if ( + keyCode !== DELETE && + keyCode !== BACKSPACE && + keyCode !== ESCAPE + ) { + return; } - if ( ! this.props.__unstableIsSelected ) { - // We know for certain that on focus, the old selection is invalid. It - // will be recalculated on the next mouseup, keyup, or touchend event. - const index = undefined; - const activeFormats = EMPTY_ACTIVE_FORMATS; - - this.record = { - ...this.record, - start: index, - end: index, - activeFormats, - }; - this.props.onSelectionChange( index, index ); - this.setState( { activeFormats } ); - } else { - this.props.onSelectionChange( this.record.start, this.record.end ); - this.setState( { - activeFormats: getActiveFormats( - { - ...this.record, - activeFormats: undefined, - }, - EMPTY_ACTIVE_FORMATS - ), - } ); + if ( didAutomaticChange ) { + event.preventDefault(); + undo(); + return; } - // Update selection as soon as possible, which is at the next animation - // frame. The event listener for selection changes may be added too late - // at this point, but this focus event is still too early to calculate - // the selection. - this.rafId = this.getWindow().requestAnimationFrame( - this.onSelectionChange - ); - - this.getDocument().addEventListener( - 'selectionchange', - this.onSelectionChange - ); - - if ( this.props.setFocusedElement ) { - deprecated( 'wp.blockEditor.RichText setFocusedElement prop', { - alternative: 'selection state from the block editor store.', - } ); - this.props.setFocusedElement( this.props.instanceId ); + if ( keyCode === ESCAPE ) { + return; } - } - onBlur() { - this.getDocument().removeEventListener( - 'selectionchange', - this.onSelectionChange - ); - } + const currentValue = createRecord(); + const { start, end, text } = currentValue; + const isReverse = keyCode === BACKSPACE; - /** - * Handle input on the next selection change event. - * - * @param {WPSyntheticEvent} event Synthetic input event. - */ - onInput( event ) { - // Do not trigger a change if characters are being composed. Browsers - // will usually emit a final `input` event when the characters are - // composed. - // As of December 2019, Safari doesn't support nativeEvent.isComposing. - if ( this.isComposing ) { + // Always handle full content deletion ourselves. + if ( start === 0 && end !== 0 && end === text.length ) { + handleChange( remove( currentValue ) ); + event.preventDefault(); return; } - let inputType; + if ( multilineTag ) { + let newValue; - if ( event ) { - inputType = event.inputType; - } + // Check to see if we should remove the first item if empty. + if ( + isReverse && + currentValue.start === 0 && + currentValue.end === 0 && + isEmptyLine( currentValue ) + ) { + newValue = removeLineSeparator( currentValue, ! isReverse ); + } else { + newValue = removeLineSeparator( currentValue, isReverse ); + } - if ( ! inputType && event && event.nativeEvent ) { - inputType = event.nativeEvent.inputType; + if ( newValue ) { + handleChange( newValue ); + event.preventDefault(); + return; + } } - // The browser formatted something or tried to insert HTML. - // Overwrite it. It will be handled later by the format library if - // needed. + // Only process delete if the key press occurs at an uncollapsed edge. if ( - inputType && - ( inputType.indexOf( 'format' ) === 0 || - INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) ) + ! onDelete || + ! isCollapsed( currentValue ) || + activeFormats.length || + ( isReverse && start !== 0 ) || + ( ! isReverse && end !== text.length ) ) { - this.applyRecord( this.record ); return; } - const value = this.createRecord(); - const { start, activeFormats = [] } = this.record; - - // Update the formats between the last and new caret position. - const change = updateFormats( { - value, - start, - end: value.start, - formats: activeFormats, - } ); - - this.onChange( change, { withoutHistory: true } ); - - const { - __unstableInputRule: inputRule, - __unstableMarkAutomaticChange: markAutomaticChange, - __unstableAllowPrefixTransformations: allowPrefixTransformations, - formatTypes, - setTimeout, - clearTimeout, - } = this.props; - - // Create an undo level when input stops for over a second. - clearTimeout( this.onInput.timeout ); - this.onInput.timeout = setTimeout( this.onCreateUndoLevel, 1000 ); + onDelete( { isReverse, value: currentValue } ); + event.preventDefault(); + } - // Only run input rules when inserting text. - if ( inputType !== 'insertText' ) { + /** + * Triggers the `onEnter` prop on keydown. + * + * @param {WPSyntheticEvent} event A synthetic keyboard event. + */ + function handleEnter( event ) { + if ( event.keyCode !== ENTER ) { return; } - if ( allowPrefixTransformations && inputRule ) { - inputRule( change, this.valueToFormat ); - } - - const transformed = formatTypes.reduce( - ( accumlator, { __unstableInputRule } ) => { - if ( __unstableInputRule ) { - accumlator = __unstableInputRule( accumlator ); - } - - return accumlator; - }, - change - ); + event.preventDefault(); - if ( transformed !== change ) { - this.onCreateUndoLevel(); - this.onChange( { ...transformed, activeFormats } ); - markAutomaticChange(); + if ( ! onEnter ) { + return; } - } - onCompositionStart() { - this.isComposing = true; - // Do not update the selection when characters are being composed as - // this rerenders the component and might distroy internal browser - // editing state. - this.getDocument().removeEventListener( - 'selectionchange', - this.onSelectionChange - ); - } - - onCompositionEnd() { - this.isComposing = false; - // Ensure the value is up-to-date for browsers that don't emit a final - // input event after composition. - this.onInput( { inputType: 'insertText' } ); - // Tracking selection changes can be resumed. - this.getDocument().addEventListener( - 'selectionchange', - this.onSelectionChange - ); + onEnter( { + value: removeEditorOnlyFormats( createRecord() ), + onChange: handleChange, + shiftKey: event.shiftKey, + } ); } /** - * Syncs the selection to local state. A callback for the `selectionchange` - * native events, `keyup`, `mouseup` and `touchend` synthetic events, and - * animation frames after the `focus` event. + * Indents list items on space keydown. * - * @param {Event|WPSyntheticEvent|DOMHighResTimeStamp} event + * @param {WPSyntheticEvent} event A synthetic keyboard event. */ - onSelectionChange( event ) { + function handleSpace( event ) { + const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; + if ( - event.type !== 'selectionchange' && - ! this.props.__unstableIsSelected + // Only override when no modifiers are pressed. + shiftKey || + altKey || + metaKey || + ctrlKey || + keyCode !== SPACE || + multilineTag !== 'li' ) { return; } - if ( this.props.disabled ) { - return; - } + const currentValue = createRecord(); - // In case of a keyboard event, ignore selection changes during - // composition. - if ( this.isComposing ) { + if ( ! isCollapsed( currentValue ) ) { return; } - const { start, end, text } = this.createRecord(); - const value = this.record; + const { text, start } = currentValue; + const characterBefore = text[ start - 1 ]; - // Fallback mechanism for IE11, which doesn't support the input event. - // Any input results in a selection change. - if ( text !== value.text ) { - this.onInput(); + // The caret must be at the start of a line. + if ( characterBefore && characterBefore !== LINE_SEPARATOR ) { return; } - if ( start === value.start && end === value.end ) { - // Sometimes the browser may set the selection on the placeholder - // element, in which case the caret is not visible. We need to set - // the caret before the placeholder if that's the case. - if ( value.text.length === 0 && start === 0 ) { - fixPlaceholderSelection( this.getWindow() ); - } - - return; - } - - const { - __unstableIsCaretWithinFormattedText: isCaretWithinFormattedText, - __unstableOnEnterFormattedText: onEnterFormattedText, - __unstableOnExitFormattedText: onExitFormattedText, - } = this.props; - const newValue = { - ...value, - start, - end, - // Allow `getActiveFormats` to get new `activeFormats`. - activeFormats: undefined, - }; - - const activeFormats = getActiveFormats( - newValue, - EMPTY_ACTIVE_FORMATS - ); - - // Update the value with the new active formats. - newValue.activeFormats = activeFormats; - - if ( ! isCaretWithinFormattedText && activeFormats.length ) { - onEnterFormattedText(); - } else if ( isCaretWithinFormattedText && ! activeFormats.length ) { - onExitFormattedText(); - } - - // It is important that the internal value is updated first, - // otherwise the value will be wrong on render! - this.record = newValue; - this.applyRecord( newValue, { domOnly: true } ); - this.props.onSelectionChange( start, end ); - this.setState( { activeFormats } ); - } - - /** - * Sync the value to global state. The node tree and selection will also be - * updated if differences are found. - * - * @param {Object} record The record to sync and apply. - * @param {Object} $2 Named options. - * @param {boolean} $2.withoutHistory If true, no undo level will be - * created. - */ - onChange( record, { withoutHistory } = {} ) { - if ( this.props.__unstableDisableFormats ) { - record.formats = Array( record.text.length ); - record.replacements = Array( record.text.length ); - } - - this.applyRecord( record ); - - const { start, end, activeFormats = [] } = record; - const changeHandlers = pickBy( this.props, ( v, key ) => - key.startsWith( 'format_on_change_functions_' ) - ); - - Object.values( changeHandlers ).forEach( ( changeHandler ) => { - changeHandler( record.formats, record.text ); - } ); - - this.value = this.valueToFormat( record ); - this.record = record; - // Selection must be updated first, so it is recorded in history when - // the content change happens. - this.props.onSelectionChange( start, end ); - this.props.onChange( this.value ); - this.setState( { activeFormats } ); - - if ( ! withoutHistory ) { - this.onCreateUndoLevel(); - } - } - - onCreateUndoLevel() { - // If the content is the same, no level needs to be created. - if ( this.lastHistoryValue === this.value ) { - return; - } - - this.props.__unstableOnCreateUndoLevel(); - this.lastHistoryValue = this.value; - } - - /** - * Handles delete on keydown: - * - outdent list items, - * - delete content if everything is selected, - * - trigger the onDelete prop when selection is uncollapsed and at an edge. - * - * @param {WPSyntheticEvent} event A synthetic keyboard event. - */ - handleDelete( event ) { - const { keyCode } = event; - - if ( - keyCode !== DELETE && - keyCode !== BACKSPACE && - keyCode !== ESCAPE - ) { - return; - } - - if ( this.props.__unstableDidAutomaticChange ) { - event.preventDefault(); - this.props.__unstableUndo(); - return; - } - - if ( keyCode === ESCAPE ) { - return; - } - - const { onDelete, __unstableMultilineTag: multilineTag } = this.props; - const { activeFormats = [] } = this.state; - const value = this.createRecord(); - const { start, end, text } = value; - const isReverse = keyCode === BACKSPACE; - - // Always handle full content deletion ourselves. - if ( start === 0 && end !== 0 && end === text.length ) { - this.onChange( remove( value ) ); - event.preventDefault(); - return; - } - - if ( multilineTag ) { - let newValue; - - // Check to see if we should remove the first item if empty. - if ( - isReverse && - value.start === 0 && - value.end === 0 && - isEmptyLine( value ) - ) { - newValue = removeLineSeparator( value, ! isReverse ); - } else { - newValue = removeLineSeparator( value, isReverse ); - } - - if ( newValue ) { - this.onChange( newValue ); - event.preventDefault(); - return; - } - } - - // Only process delete if the key press occurs at an uncollapsed edge. - if ( - ! onDelete || - ! isCollapsed( value ) || - activeFormats.length || - ( isReverse && start !== 0 ) || - ( ! isReverse && end !== text.length ) - ) { - return; - } - - onDelete( { isReverse, value } ); - event.preventDefault(); - } - - /** - * Triggers the `onEnter` prop on keydown. - * - * @param {WPSyntheticEvent} event A synthetic keyboard event. - */ - handleEnter( event ) { - if ( event.keyCode !== ENTER ) { - return; - } - - event.preventDefault(); - - const { onEnter } = this.props; - - if ( ! onEnter ) { - return; - } - - onEnter( { - value: this.removeEditorOnlyFormats( this.createRecord() ), - onChange: this.onChange, - shiftKey: event.shiftKey, - } ); - } - - /** - * Indents list items on space keydown. - * - * @param {WPSyntheticEvent} event A synthetic keyboard event. - */ - handleSpace( event ) { - const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; - const { - __unstableMultilineRootTag: multilineRootTag, - __unstableMultilineTag: multilineTag, - } = this.props; - - if ( - // Only override when no modifiers are pressed. - shiftKey || - altKey || - metaKey || - ctrlKey || - keyCode !== SPACE || - multilineTag !== 'li' - ) { - return; - } - - const value = this.createRecord(); - - if ( ! isCollapsed( value ) ) { - return; - } - - const { text, start } = value; - const characterBefore = text[ start - 1 ]; - - // The caret must be at the start of a line. - if ( characterBefore && characterBefore !== LINE_SEPARATOR ) { - return; - } - - this.onChange( indentListItems( value, { type: multilineRootTag } ) ); - event.preventDefault(); - } + handleChange( + indentListItems( currentValue, { type: multilineRootTag } ) + ); + event.preventDefault(); + } /** * Handles horizontal keyboard navigation when no modifiers are pressed. The @@ -833,7 +560,7 @@ class RichText extends Component { * * @param {WPSyntheticEvent} event A synthetic keyboard event. */ - handleHorizontalNavigation( event ) { + function handleHorizontalNavigation( event ) { const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; if ( @@ -847,13 +574,16 @@ class RichText extends Component { return; } - const value = this.record; - const { text, formats, start, end, activeFormats = [] } = value; - const collapsed = isCollapsed( value ); + const { + text, + formats, + start, + end, + activeFormats: currentActiveFormats = [], + } = record.current; + const collapsed = isCollapsed( record.current ); // To do: ideally, we should look at visual position instead. - const { direction } = this.getWindow().getComputedStyle( - this.props.forwardedRef.current - ); + const { direction } = getWin().getComputedStyle( ref.current ); const reverseKey = direction === 'rtl' ? RIGHT : LEFT; const isReverse = event.keyCode === reverseKey; @@ -861,7 +591,7 @@ class RichText extends Component { // navigating backward. // If the selection is collapsed and at the very end, do nothing if // navigating forward. - if ( collapsed && activeFormats.length === 0 ) { + if ( collapsed && currentActiveFormats.length === 0 ) { if ( start === 0 && isReverse ) { return; } @@ -884,7 +614,7 @@ class RichText extends Component { const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS; const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS; - let newActiveFormatsLength = activeFormats.length; + let newActiveFormatsLength = currentActiveFormats.length; let source = formatsAfter; if ( formatsBefore.length > formatsAfter.length ) { @@ -894,338 +624,547 @@ class RichText extends Component { // 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 && activeFormats.length < formatsAfter.length ) { + if ( + ! isReverse && + currentActiveFormats.length < formatsAfter.length + ) { newActiveFormatsLength++; } - if ( isReverse && activeFormats.length > formatsBefore.length ) { + if ( + isReverse && + currentActiveFormats.length > formatsBefore.length + ) { newActiveFormatsLength--; } } else if ( formatsBefore.length > formatsAfter.length ) { - if ( ! isReverse && activeFormats.length > formatsAfter.length ) { + if ( + ! isReverse && + currentActiveFormats.length > formatsAfter.length + ) { newActiveFormatsLength--; } - if ( isReverse && activeFormats.length < formatsBefore.length ) { + if ( + isReverse && + currentActiveFormats.length < formatsBefore.length + ) { newActiveFormatsLength++; } } - if ( newActiveFormatsLength !== activeFormats.length ) { + if ( newActiveFormatsLength !== currentActiveFormats.length ) { const newActiveFormats = source.slice( 0, newActiveFormatsLength ); - const newValue = { ...value, activeFormats: newActiveFormats }; - this.record = newValue; - this.applyRecord( newValue ); - this.setState( { activeFormats: newActiveFormats } ); + const newValue = { + ...record.current, + activeFormats: newActiveFormats, + }; + record.current = newValue; + applyRecord( newValue ); + setActiveFormats( newActiveFormats ); return; } const newPos = start + ( isReverse ? -1 : 1 ); const newActiveFormats = isReverse ? formatsBefore : formatsAfter; const newValue = { - ...value, + ...record.current, start: newPos, end: newPos, activeFormats: newActiveFormats, }; - this.record = newValue; - this.applyRecord( newValue ); - this.props.onSelectionChange( newPos, newPos ); - this.setState( { activeFormats: newActiveFormats } ); + record.current = newValue; + applyRecord( newValue ); + onSelectionChange( newPos, newPos ); + setActiveFormats( newActiveFormats ); } + function handleKeyDown( event ) { + if ( event.defaultPrevented ) { + return; + } + + handleDelete( event ); + handleEnter( event ); + handleSpace( event ); + handleHorizontalNavigation( event ); + } + + const lastHistoryValue = useRef( value ); + + function createUndoLevel() { + // If the content is the same, no level needs to be created. + if ( lastHistoryValue.current === _value.current ) { + return; + } + + onCreateUndoLevel(); + lastHistoryValue.current = _value.current; + } + + const isComposing = useRef( false ); + const timeout = useRef(); + /** - * Select object when they are clicked. The browser will not set any - * selection when clicking e.g. an image. + * Handle input on the next selection change event. * - * @param {WPSyntheticEvent} event Synthetic mousedown or touchstart event. + * @param {WPSyntheticEvent} event Synthetic input event. */ - onPointerDown( event ) { - const { target } = event; + function handleInput( event ) { + // Do not trigger a change if characters are being composed. Browsers + // will usually emit a final `input` event when the characters are + // composed. + // As of December 2019, Safari doesn't support nativeEvent.isComposing. + if ( isComposing.current ) { + return; + } - // If the child element has no text content, it must be an object. + let inputType; + + if ( event ) { + inputType = event.inputType; + } + + if ( ! inputType && event && event.nativeEvent ) { + inputType = event.nativeEvent.inputType; + } + + // The browser formatted something or tried to insert HTML. + // Overwrite it. It will be handled later by the format library if + // needed. if ( - target === this.props.forwardedRef.current || - target.textContent + inputType && + ( inputType.indexOf( 'format' ) === 0 || + INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) ) ) { + applyRecord( record.current ); return; } - const { parentNode } = target; - const index = Array.from( parentNode.childNodes ).indexOf( target ); - const range = this.getDocument().createRange(); - const selection = this.getWindow().getSelection(); + const currentValue = createRecord(); + const { start, activeFormats: oldActiveFormats = [] } = record.current; - range.setStart( target.parentNode, index ); - range.setEnd( target.parentNode, index + 1 ); + // Update the formats between the last and new caret position. + const change = updateFormats( { + value: currentValue, + start, + end: currentValue.start, + formats: oldActiveFormats, + } ); - selection.removeAllRanges(); - selection.addRange( range ); - } + handleChange( change, { withoutHistory: true } ); - componentDidUpdate( prevProps ) { - const { - tagName, - value, - selectionStart, - selectionEnd, - placeholder, - __unstableIsSelected: isSelected, - } = this.props; - - // Check if tag name changed. - let shouldReapply = tagName !== prevProps.tagName; - - // Check if the content changed. - shouldReapply = - shouldReapply || - ( value !== prevProps.value && value !== this.value ); - - const selectionChanged = - ( selectionStart !== prevProps.selectionStart && - selectionStart !== this.record.start ) || - ( selectionEnd !== prevProps.selectionEnd && - selectionEnd !== this.record.end ); - - // Check if the selection changed. - shouldReapply = - shouldReapply || - ( isSelected && ! prevProps.isSelected && selectionChanged ); - - const prefix = 'format_prepare_props_'; - const predicate = ( v, key ) => key.startsWith( prefix ); - const prepareProps = pickBy( this.props, predicate ); - const prevPrepareProps = pickBy( prevProps, predicate ); - - // Check if any format props changed. - shouldReapply = - shouldReapply || ! isShallowEqual( prepareProps, prevPrepareProps ); - - // Rerender if the placeholder changed. - shouldReapply = shouldReapply || placeholder !== prevProps.placeholder; - - if ( shouldReapply ) { - this.value = value; - this.record = this.formatToValue( value ); - this.record.start = selectionStart; - this.record.end = selectionEnd; - this.applyRecord( this.record ); - } else if ( selectionChanged ) { - this.record = { - ...this.record, - start: selectionStart, - end: selectionEnd, - }; + // Create an undo level when input stops for over a second. + getWin().clearTimeout( timeout.current ); + timeout.current = getWin().setTimeout( createUndoLevel, 1000 ); + + // Only run input rules when inserting text. + if ( inputType !== 'insertText' ) { + return; + } + + if ( allowPrefixTransformations && inputRule ) { + inputRule( change, valueToFormat ); + } + + const transformed = formatTypes.reduce( + ( accumlator, { __unstableInputRule } ) => { + if ( __unstableInputRule ) { + accumlator = __unstableInputRule( accumlator ); + } + + return accumlator; + }, + change + ); + + if ( transformed !== change ) { + createUndoLevel(); + handleChange( { ...transformed, activeFormats: oldActiveFormats } ); + markAutomaticChange(); } } + function handleCompositionStart() { + isComposing.current = true; + // Do not update the selection when characters are being composed as + // this rerenders the component and might distroy internal browser + // editing state. + getDoc().removeEventListener( + 'selectionchange', + handleSelectionChange + ); + } + + function handleCompositionEnd() { + isComposing.current = false; + // Ensure the value is up-to-date for browsers that don't emit a final + // input event after composition. + handleInput( { inputType: 'insertText' } ); + // Tracking selection changes can be resumed. + getDoc().addEventListener( 'selectionchange', handleSelectionChange ); + } + + const didMount = useRef( false ); + /** - * Converts the outside data structure to our internal representation. + * Syncs the selection to local state. A callback for the `selectionchange` + * native events, `keyup`, `mouseup` and `touchend` synthetic events, and + * animation frames after the `focus` event. * - * @param {*} value The outside value, data type depends on props. - * @return {Object} An internal rich-text value. + * @param {Event|WPSyntheticEvent|DOMHighResTimeStamp} event */ - formatToValue( value ) { - const { - format, - __unstableMultilineTag: multilineTag, - preserveWhiteSpace, - __unstableDisableFormats: disableFormats, - } = this.props; + function handleSelectionChange( event ) { + if ( ! ref.current ) { + return; + } - if ( disableFormats ) { - return { - text: value, - formats: Array( value.length ), - replacements: Array( value.length ), - }; + if ( document.activeElement !== ref.current ) { + return; } - if ( format !== 'string' ) { - return value; + if ( event.type !== 'selectionchange' && ! isSelected ) { + return; } - const prepare = createPrepareEditableTree( - this.props, - 'format_value_functions' + if ( disabled ) { + return; + } + + // In case of a keyboard event, ignore selection changes during + // composition. + if ( isComposing.current ) { + return; + } + + const { start, end, text } = createRecord(); + const oldRecord = record.current; + + // Fallback mechanism for IE11, which doesn't support the input event. + // Any input results in a selection change. + if ( text !== oldRecord.text ) { + handleInput(); + return; + } + + if ( start === oldRecord.start && end === oldRecord.end ) { + // Sometimes the browser may set the selection on the placeholder + // element, in which case the caret is not visible. We need to set + // the caret before the placeholder if that's the case. + if ( oldRecord.text.length === 0 && start === 0 ) { + fixPlaceholderSelection( getWin() ); + } + + return; + } + + const newValue = { + ...oldRecord, + start, + end, + // Allow `getActiveFormats` to get new `activeFormats`. + activeFormats: undefined, + }; + + const newActiveFormats = getActiveFormats( + newValue, + EMPTY_ACTIVE_FORMATS ); - value = create( { - html: value, - multilineTag, - multilineWrapperTags: - multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, - preserveWhiteSpace, - } ); - value.formats = prepare( value ); + // Update the value with the new active formats. + newValue.activeFormats = newActiveFormats; - return value; + if ( ! isCaretWithinFormattedText && newActiveFormats.length ) { + onEnterFormattedText(); + } else if ( isCaretWithinFormattedText && ! newActiveFormats.length ) { + onExitFormattedText(); + } + + // It is important that the internal value is updated first, + // otherwise the value will be wrong on render! + record.current = newValue; + applyRecord( newValue, { domOnly: true } ); + onSelectionChange( start, end ); + setActiveFormats( newActiveFormats ); } /** - * Removes editor only formats from the value. - * - * Editor only formats are applied using `prepareEditableTree`, so we need to - * remove them before converting the internal state + * Sync the value to global state. The node tree and selection will also be + * updated if differences are found. * - * @param {Object} value The internal rich-text value. - * @return {Object} A new rich-text value. + * @param {Object} newRecord The record to sync and apply. + * @param {Object} $2 Named options. + * @param {boolean} $2.withoutHistory If true, no undo level will be + * created. */ - removeEditorOnlyFormats( value ) { - this.props.formatTypes.forEach( ( formatType ) => { - // Remove formats created by prepareEditableTree, because they are editor only. - if ( formatType.__experimentalCreatePrepareEditableTree ) { - value = removeFormat( - value, - formatType.name, - 0, - value.text.length - ); - } + function handleChange( newRecord, { withoutHistory } = {} ) { + if ( disableFormats ) { + newRecord.formats = Array( newRecord.text.length ); + newRecord.replacements = Array( newRecord.text.length ); + } + + applyRecord( newRecord ); + + const { start, end, activeFormats: newActiveFormats = [] } = newRecord; + const changeHandlers = pickBy( remainingProps, ( v, key ) => + key.startsWith( 'format_on_change_functions_' ) + ); + + Object.values( changeHandlers ).forEach( ( changeHandler ) => { + changeHandler( newRecord.formats, newRecord.text ); } ); - return value; + _value.current = valueToFormat( newRecord ); + record.current = newRecord; + + // Selection must be updated first, so it is recorded in history when + // the content change happens. + onSelectionChange( start, end ); + onChange( _value.current ); + setActiveFormats( newActiveFormats ); + + if ( ! withoutHistory ) { + createUndoLevel(); + } } /** - * Converts the internal value to the external data format. + * Select object when they are clicked. The browser will not set any + * selection when clicking e.g. an image. * - * @param {Object} value The internal rich-text value. - * @return {*} The external data format, data type depends on props. + * @param {WPSyntheticEvent} event Synthetic mousedown or touchstart event. */ - valueToFormat( value ) { - const { - format, - __unstableMultilineTag: multilineTag, - preserveWhiteSpace, - __unstableDisableFormats: disableFormats, - } = this.props; + function handlePointerDown( event ) { + const { target } = event; - if ( disableFormats ) { - return value.text; + // If the child element has no text content, it must be an object. + if ( target === ref.current || target.textContent ) { + return; } - value = this.removeEditorOnlyFormats( value ); + const { parentNode } = target; + const index = Array.from( parentNode.childNodes ).indexOf( target ); + const range = getDoc().createRange(); + const selection = getWin().getSelection(); - if ( format !== 'string' ) { - return; + range.setStart( target.parentNode, index ); + range.setEnd( target.parentNode, index + 1 ); + + selection.removeAllRanges(); + selection.addRange( range ); + } + + const rafId = useRef(); + + /** + * Handles a focus event on the contenteditable field, calling the + * `unstableOnFocus` prop callback if one is defined. The callback does not + * receive any arguments. + * + * This is marked as a private API and the `unstableOnFocus` prop is not + * documented, as the current requirements where it is used are subject to + * future refactoring following `isSelected` handling. + * + * In contrast with `setFocusedElement`, this is only triggered in response + * to focus within the contenteditable field, whereas `setFocusedElement` + * is triggered on focus within any `RichText` descendent element. + * + * @see setFocusedElement + * + * @private + */ + function handleFocus() { + if ( onFocus ) { + onFocus(); + } + + if ( ! isSelected ) { + // We know for certain that on focus, the old selection is invalid. + // It will be recalculated on the next mouseup, keyup, or touchend + // event. + const index = undefined; + + record.current = { + ...record.current, + start: index, + end: index, + activeFormats: EMPTY_ACTIVE_FORMATS, + }; + onSelectionChange( index, index ); + setActiveFormats( EMPTY_ACTIVE_FORMATS ); + } else { + onSelectionChange( record.current.start, record.current.end ); + setActiveFormats( + getActiveFormats( + { + ...record.current, + activeFormats: undefined, + }, + EMPTY_ACTIVE_FORMATS + ) + ); } - return toHTMLString( { value, multilineTag, preserveWhiteSpace } ); + // Update selection as soon as possible, which is at the next animation + // frame. The event listener for selection changes may be added too late + // at this point, but this focus event is still too early to calculate + // the selection. + rafId.current = getWin().requestAnimationFrame( handleSelectionChange ); + + getDoc().addEventListener( 'selectionchange', handleSelectionChange ); + + if ( setFocusedElement ) { + deprecated( 'wp.blockEditor.RichText setFocusedElement prop', { + alternative: 'selection state from the block editor store.', + } ); + setFocusedElement( instanceId ); + } } - Editable( props ) { - const { - tagName: TagName = 'div', - style, - className, - placeholder, - forwardedRef, - disabled, - } = this.props; - const ariaProps = pickBy( this.props, ( value, key ) => - startsWith( key, 'aria-' ) + function handleBlur() { + getDoc().removeEventListener( + 'selectionchange', + handleSelectionChange ); + } - return ( - { - props.onKeyDown( event ); - this.onKeyDown( event ); - } - : this.onKeyDown - } - onFocus={ this.onFocus } - onBlur={ this.onBlur } - onMouseDown={ this.onPointerDown } - onTouchStart={ this.onPointerDown } - // Selection updates must be done at these events as they - // happen before the `selectionchange` event. In some cases, - // the `selectionchange` event may not even fire, for - // example when the window receives focus again on click. - onKeyUp={ this.onSelectionChange } - onMouseUp={ this.onSelectionChange } - onTouchEnd={ this.onSelectionChange } - // Do not set the attribute if disabled. - contentEditable={ disabled ? undefined : true } - suppressContentEditableWarning={ ! disabled } - /> - ); + function applyFromProps() { + _value.current = value; + record.current = formatToValue( value ); + record.current.start = selectionStart; + record.current.end = selectionEnd; + applyRecord( record.current ); } - render() { - const { - __unstableIsSelected: isSelected, - children, - allowedFormats, - withoutInteractiveFormatting, - formatTypes, - forwardedRef, - } = this.props; - const { activeFormats } = this.state; - - const onFocus = () => { - forwardedRef.current.focus(); - this.applyRecord( this.record ); + useEffect( () => { + if ( didMount.current ) { + applyFromProps(); + } + }, [ TagName, placeholder ] ); + + useEffect( () => { + if ( didMount.current && value !== _value.current ) { + applyFromProps(); + } + }, [ value ] ); + + useEffect( () => { + if ( ! didMount.current ) { + return; + } + + if ( + isSelected && + ( selectionStart !== record.current.start || + selectionEnd !== record.current.end ) + ) { + applyFromProps(); + } else { + record.current = { + ...record.current, + start: selectionStart, + end: selectionEnd, + }; + } + }, [ selectionStart, selectionEnd, isSelected ] ); + + const prefix = 'format_prepare_props_'; + const predicate = ( v, key ) => key.startsWith( prefix ); + const prepareProps = pickBy( remainingProps, predicate ); + + useEffect( () => { + if ( didMount.current ) { + applyFromProps(); + } + }, Object.values( prepareProps ) ); + + useLayoutEffect( () => { + applyRecord( record.current, { domOnly: true } ); + + didMount.current = true; + + return () => { + getDoc().removeEventListener( + 'selectionchange', + handleSelectionChange + ); + getWin().cancelAnimationFrame( rafId.current ); + getWin().clearTimeout( timeout.current ); }; + }, [] ); - return ( - <> - - - { isSelected && ( - - ) } - { children && - children( { - isSelected, - value: this.record, - onChange: this.onChange, - onFocus, - Editable: this.Editable, - } ) } - { ! children && } - - ); + function focus() { + ref.current.focus(); + applyRecord( record.current ); } -} -RichText.defaultProps = { - format: 'string', - value: '', -}; + const ariaProps = pickBy( remainingProps, ( v, key ) => + startsWith( key, 'aria-' ) + ); + + const editableProps = { + // Overridable props. + role: 'textbox', + 'aria-multiline': '', + 'aria-label': placeholder, + ...ariaProps, + ref, + style: style ? { ...style, whiteSpace } : defaultStyle, + className: classnames( 'rich-text', className ), + onPaste: handlePaste, + onInput: handleInput, + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onKeyDown: handleKeyDown, + onFocus: handleFocus, + onBlur: handleBlur, + onMouseDown: handlePointerDown, + onTouchStart: handlePointerDown, + // Selection updates must be done at these events as they + // happen before the `selectionchange` event. In some cases, + // the `selectionchange` event may not even fire, for + // example when the window receives focus again on click. + onKeyUp: handleSelectionChange, + onMouseUp: handleSelectionChange, + onTouchEnd: handleSelectionChange, + // Do not set the attribute if disabled. + contentEditable: disabled ? undefined : true, + suppressContentEditableWarning: ! disabled, + }; + + useBoundaryStyle( { ref, activeFormats } ); + useInlineWarning( { ref } ); + + return ( + <> + { isSelected && ( + + ) } + { children && + children( { + isSelected, + value: record.current, + onChange: handleChange, + onFocus: focus, + editableProps, + editableTagName: TagName, + } ) } + { ! children && } + + ); +} -const RichTextWrapper = compose( [ withSafeTimeout, withFormatTypes ] )( - RichText -); +const RichTextWrapper = withFormatTypes( RichText ); /** * Renders a rich content input, providing users with the option to format the diff --git a/packages/rich-text/src/component/boundary-style.js b/packages/rich-text/src/component/use-boundary-style.js similarity index 89% rename from packages/rich-text/src/component/boundary-style.js rename to packages/rich-text/src/component/use-boundary-style.js index 3c17da5369704..2636007521b1e 100644 --- a/packages/rich-text/src/component/boundary-style.js +++ b/packages/rich-text/src/component/use-boundary-style.js @@ -7,7 +7,7 @@ import { useEffect } from '@wordpress/element'; * Calculates and renders the format boundary style when the active formats * change. */ -export function BoundaryStyle( { activeFormats, forwardedRef } ) { +export function useBoundaryStyle( { activeFormats, ref } ) { useEffect( () => { // There's no need to recalculate the boundary styles if no formats are // active, because no boundary styles will be visible. @@ -16,7 +16,7 @@ export function BoundaryStyle( { activeFormats, forwardedRef } ) { } const boundarySelector = '*[data-rich-text-format-boundary]'; - const element = forwardedRef.current.querySelector( boundarySelector ); + const element = ref.current.querySelector( boundarySelector ); if ( ! element ) { return; @@ -45,5 +45,4 @@ export function BoundaryStyle( { activeFormats, forwardedRef } ) { globalStyle.innerHTML = style; } }, [ activeFormats ] ); - return null; } diff --git a/packages/rich-text/src/component/inline-warning.js b/packages/rich-text/src/component/use-inline-warning.js similarity index 82% rename from packages/rich-text/src/component/inline-warning.js rename to packages/rich-text/src/component/use-inline-warning.js index 00598b4d7c544..dfd3ba8de8515 100644 --- a/packages/rich-text/src/component/inline-warning.js +++ b/packages/rich-text/src/component/use-inline-warning.js @@ -3,10 +3,10 @@ */ import { useEffect } from '@wordpress/element'; -export function InlineWarning( { forwardedRef } ) { +export function useInlineWarning( { ref } ) { useEffect( () => { if ( process.env.NODE_ENV === 'development' ) { - const target = forwardedRef.current; + const target = ref.current; const { defaultView } = target.ownerDocument; const computedStyle = defaultView.getComputedStyle( target ); @@ -18,5 +18,4 @@ export function InlineWarning( { forwardedRef } ) { } } }, [] ); - return null; }