UNPKG

@gechiui/block-editor

Version:
752 lines (685 loc) 20.1 kB
/** * External dependencies */ import classnames from 'classnames'; import { omit } from 'lodash'; /** * GeChiUI dependencies */ import { RawHTML, Platform, useRef, useCallback, forwardRef, } from '@gechiui/element'; import { useDispatch, useSelect } from '@gechiui/data'; import { pasteHandler, children as childrenSource, getBlockTransforms, findTransform, isUnmodifiedDefaultBlock, } from '@gechiui/blocks'; import { useInstanceId, useMergeRefs } from '@gechiui/compose'; import { __experimentalRichText as RichText, __unstableCreateElement, isEmpty, __unstableIsEmptyLine as isEmptyLine, insert, __unstableInsertLineSeparator as insertLineSeparator, create, replace, split, __UNSTABLE_LINE_SEPARATOR as LINE_SEPARATOR, toHTMLString, slice, } from '@gechiui/rich-text'; import deprecated from '@gechiui/deprecated'; import { isURL } from '@gechiui/url'; /** * Internal dependencies */ import Autocomplete from '../autocomplete'; import { useBlockEditContext } from '../block-edit'; import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; import { filePasteHandler } from './file-paste-handler'; import FormatToolbarContainer from './format-toolbar-container'; import { useNativeProps } from './use-native-props'; import { store as blockEditorStore } from '../../store'; import { addActiveFormats, getMultilineTag, getAllowedFormats, isShortcode, createLinkInParagraph, } from './utils'; import EmbedHandlerPicker from './embed-handler-picker'; const wrapperClasses = 'block-editor-rich-text'; const classes = 'block-editor-rich-text__editable'; function RichTextWrapper( { children, tagName, value: originalValue, onChange: originalOnChange, isSelected: originalIsSelected, multiline, inlineToolbar, wrapperClassName, autocompleters, onReplace, placeholder, allowedFormats, formattingControls, withoutInteractiveFormatting, onRemove, onMerge, onSplit, __unstableOnSplitAtEnd: onSplitAtEnd, __unstableOnSplitMiddle: onSplitMiddle, identifier, preserveWhiteSpace, __unstablePastePlainText: pastePlainText, __unstableEmbedURLOnPaste, __unstableDisableFormats: disableFormats, disableLineBreaks, unstableOnFocus, __unstableAllowPrefixTransformations, __unstableMultilineRootTag, // Native props. __unstableMobileNoFocusOnMount, deleteEnter, placeholderTextColor, textAlign, selectionColor, tagsToEliminate, rootTagsToEliminate, disableEditingMenu, fontSize, fontFamily, fontWeight, fontStyle, minWidth, maxWidth, onBlur, setRef, ...props }, forwardedRef ) { const instanceId = useInstanceId( RichTextWrapper ); identifier = identifier || instanceId; const fallbackRef = useRef(); const { clientId, isSelected: blockIsSelected } = useBlockEditContext(); const nativeProps = useNativeProps(); const embedHandlerPickerRef = useRef(); const selector = ( select ) => { const { isCaretWithinFormattedText, getSelectionStart, getSelectionEnd, getSettings, didAutomaticChange, getBlock, isMultiSelecting, hasMultiSelection, } = select( blockEditorStore ); const selectionStart = getSelectionStart(); const selectionEnd = getSelectionEnd(); const { __experimentalUndo: undo } = getSettings(); let isSelected; if ( originalIsSelected === undefined ) { isSelected = selectionStart.clientId === clientId && selectionStart.attributeKey === identifier; } else if ( originalIsSelected ) { isSelected = selectionStart.clientId === clientId; } let extraProps = {}; if ( Platform.OS === 'native' ) { // If the block of this RichText is unmodified then it's a candidate for replacing when adding a new block. // In order to fix https://github.com/gechiui-mobile/gutenberg-mobile/issues/1126, let's blur on unmount in that case. // This apparently assumes functionality the BlockHlder actually const block = clientId && getBlock( clientId ); const shouldBlurOnUnmount = block && isSelected && isUnmodifiedDefaultBlock( block ); extraProps = { shouldBlurOnUnmount, }; } return { isCaretWithinFormattedText: isCaretWithinFormattedText(), selectionStart: isSelected ? selectionStart.offset : undefined, selectionEnd: isSelected ? selectionEnd.offset : undefined, isSelected, didAutomaticChange: didAutomaticChange(), disabled: isMultiSelecting() || hasMultiSelection(), undo, ...extraProps, }; }; // This selector must run on every render so the right selection state is // retreived from the store on merge. // To do: fix this somehow. const { isCaretWithinFormattedText, selectionStart, selectionEnd, isSelected, didAutomaticChange, disabled, undo, shouldBlurOnUnmount, } = useSelect( selector ); const { __unstableMarkLastChangeAsPersistent, enterFormattedText, exitFormattedText, selectionChange, __unstableMarkAutomaticChange, } = useDispatch( blockEditorStore ); const multilineTag = getMultilineTag( multiline ); const adjustedAllowedFormats = getAllowedFormats( { allowedFormats, formattingControls, disableFormats, } ); const hasFormats = ! adjustedAllowedFormats || adjustedAllowedFormats.length > 0; let adjustedValue = originalValue; let adjustedOnChange = originalOnChange; // Handle deprecated format. if ( Array.isArray( originalValue ) ) { adjustedValue = childrenSource.toHTML( originalValue ); adjustedOnChange = ( newValue ) => originalOnChange( childrenSource.fromDOM( __unstableCreateElement( document, newValue ).childNodes ) ); } const onSelectionChange = useCallback( ( start, end ) => { selectionChange( clientId, identifier, start, end ); }, [ clientId, identifier ] ); const onDelete = useCallback( ( { value, isReverse } ) => { if ( onMerge ) { onMerge( ! isReverse ); } // Only handle remove on Backspace. This serves dual-purpose of being // an intentional user interaction distinguishing between Backspace and // Delete to remove the empty field, but also to avoid merge & remove // causing destruction of two fields (merge, then removed merged). if ( onRemove && isEmpty( value ) && isReverse ) { onRemove( ! isReverse ); } }, [ onMerge, onRemove ] ); /** * Signals to the RichText owner that the block can be replaced with two * blocks as a result of splitting the block by pressing enter, or with * blocks as a result of splitting the block by pasting block content in the * instance. * * @param {Object} record The rich text value to split. * @param {Array} pastedBlocks The pasted blocks to insert, if any. */ const splitValue = useCallback( ( record, pastedBlocks = [] ) => { if ( ! onReplace || ! onSplit ) { return; } const blocks = []; const [ before, after ] = split( record ); const hasPastedBlocks = pastedBlocks.length > 0; let lastPastedBlockIndex = -1; // Consider the after value to be the original it is not empty and // the before value *is* empty. const isAfterOriginal = isEmpty( before ) && ! isEmpty( after ); // Create a block with the content before the caret if there's no pasted // blocks, or if there are pasted blocks and the value is not empty. // We do not want a leading empty block on paste, but we do if split // with e.g. the enter key. if ( ! hasPastedBlocks || ! isEmpty( before ) ) { blocks.push( onSplit( toHTMLString( { value: before, multilineTag, } ), ! isAfterOriginal ) ); lastPastedBlockIndex += 1; } if ( hasPastedBlocks ) { blocks.push( ...pastedBlocks ); lastPastedBlockIndex += pastedBlocks.length; } else if ( onSplitMiddle ) { blocks.push( onSplitMiddle() ); } // If there's pasted blocks, append a block with non empty content /// after the caret. Otherwise, do append an empty block if there // is no `onSplitMiddle` prop, but if there is and the content is // empty, the middle block is enough to set focus in. if ( hasPastedBlocks ? ! isEmpty( after ) : ! onSplitMiddle || ! isEmpty( after ) ) { blocks.push( onSplit( toHTMLString( { value: after, multilineTag, } ), isAfterOriginal ) ); } // If there are pasted blocks, set the selection to the last one. // Otherwise, set the selection to the second block. const indexToSelect = hasPastedBlocks ? lastPastedBlockIndex : 1; // If there are pasted blocks, move the caret to the end of the selected block // Otherwise, retain the default value. const initialPosition = hasPastedBlocks ? -1 : 0; onReplace( blocks, indexToSelect, initialPosition ); }, [ onReplace, onSplit, multilineTag, onSplitMiddle ] ); const onEnter = useCallback( ( { value, onChange, shiftKey } ) => { const canSplit = onReplace && onSplit; if ( onReplace ) { const transforms = getBlockTransforms( 'from' ).filter( ( { type } ) => type === 'enter' ); const transformation = findTransform( transforms, ( item ) => { return item.regExp.test( value.text ); } ); if ( transformation ) { onReplace( [ transformation.transform( { content: value.text } ), ] ); __unstableMarkAutomaticChange(); } } if ( multiline ) { if ( shiftKey ) { if ( ! disableLineBreaks ) { onChange( insert( value, '\n' ) ); } } else if ( canSplit && isEmptyLine( value ) ) { splitValue( value ); } else { onChange( insertLineSeparator( value ) ); } } else { const { text, start, end } = value; const canSplitAtEnd = onSplitAtEnd && start === end && end === text.length; if ( shiftKey || ( ! canSplit && ! canSplitAtEnd ) ) { if ( ! disableLineBreaks ) { onChange( insert( value, '\n' ) ); } } else if ( ! canSplit && canSplitAtEnd ) { onSplitAtEnd(); } else if ( canSplit ) { splitValue( value ); } } }, [ onReplace, onSplit, __unstableMarkAutomaticChange, multiline, splitValue, onSplitAtEnd, ] ); const onPaste = useCallback( ( { value, onChange, html, plainText, isInternal, files, activeFormats, } ) => { // If the data comes from a rich text instance, we can directly use it // without filtering the data. The filters are only meant for externally // pasted content and remove inline styles. if ( isInternal ) { const pastedValue = create( { html, multilineTag, multilineWrapperTags: multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, preserveWhiteSpace, } ); addActiveFormats( pastedValue, activeFormats ); onChange( insert( value, pastedValue ) ); return; } if ( pastePlainText ) { onChange( insert( value, create( { text: plainText } ) ) ); return; } // Only process file if no HTML is present. // Note: a pasted file may have the URL as plain text. if ( files && files.length && ! html ) { const content = pasteHandler( { HTML: filePasteHandler( files ), mode: 'BLOCKS', tagName, preserveWhiteSpace, } ); // Allows us to ask for this information when we get a report. // eslint-disable-next-line no-console window.console.log( 'Received items:\n\n', files ); if ( onReplace && isEmpty( value ) ) { onReplace( content ); } else { splitValue( value, content ); } return; } let mode = onReplace && onSplit ? 'AUTO' : 'INLINE'; // Force the blocks mode when the user is pasting // on a new line & the content resembles a shortcode. // Otherwise it's going to be detected as inline // and the shortcode won't be replaced. if ( mode === 'AUTO' && isEmpty( value ) && isShortcode( plainText ) ) { mode = 'BLOCKS'; } const isPastedURL = isURL( plainText.trim() ); const presentEmbedHandlerPicker = () => embedHandlerPickerRef.current?.presentPicker( { createEmbed: () => onReplace( content, content.length - 1, -1 ), createLink: () => createLinkInParagraph( plainText.trim(), onReplace ), } ); if ( __unstableEmbedURLOnPaste && isEmpty( value ) && isPastedURL ) { mode = 'BLOCKS'; } const content = pasteHandler( { HTML: html, plainText, mode, tagName, preserveWhiteSpace, } ); if ( typeof content === 'string' ) { let valueToInsert = create( { html: content } ); addActiveFormats( valueToInsert, activeFormats ); // If the content should be multiline, we should process text // separated by a line break as separate lines. if ( multilineTag ) { valueToInsert = replace( valueToInsert, /\n+/g, LINE_SEPARATOR ); } onChange( insert( value, valueToInsert ) ); } else if ( content.length > 0 ) { // When an URL is pasted in an empty paragraph then the EmbedHandlerPicker should showcase options allowing the transformation of that URL // into either an Embed block or a link within the target paragraph. If the paragraph is non-empty, the URL is pasted as text. const canPasteEmbed = isPastedURL && content.length === 1 && content[ 0 ].name === 'core/embed'; if ( onReplace && isEmpty( value ) ) { if ( canPasteEmbed ) { onChange( insert( value, create( { text: plainText } ) ) ); if ( __unstableEmbedURLOnPaste ) { presentEmbedHandlerPicker(); } return; } onReplace( content, content.length - 1, -1 ); } else { if ( canPasteEmbed ) { onChange( insert( value, create( { text: plainText } ) ) ); return; } splitValue( value, content ); } } }, [ tagName, onReplace, onSplit, splitValue, __unstableEmbedURLOnPaste, multilineTag, preserveWhiteSpace, pastePlainText, ] ); const inputRule = useCallback( ( value, valueToFormat ) => { if ( ! onReplace ) { return; } const { start, text } = value; const characterBefore = text.slice( start - 1, start ); // The character right before the caret must be a plain space. if ( characterBefore !== ' ' ) { return; } const trimmedTextBefore = text.slice( 0, start ).trim(); const prefixTransforms = getBlockTransforms( 'from' ).filter( ( { type } ) => type === 'prefix' ); const transformation = findTransform( prefixTransforms, ( { prefix } ) => { return trimmedTextBefore === prefix; } ); if ( ! transformation ) { return; } const content = valueToFormat( slice( value, start, text.length ) ); const block = transformation.transform( content ); onReplace( [ block ] ); __unstableMarkAutomaticChange(); }, [ onReplace, __unstableMarkAutomaticChange ] ); const mergedRef = useMergeRefs( [ forwardedRef, fallbackRef ] ); const content = ( <RichText clientId={ clientId } identifier={ identifier } ref={ mergedRef } value={ adjustedValue } onChange={ adjustedOnChange } selectionStart={ selectionStart } selectionEnd={ selectionEnd } onSelectionChange={ onSelectionChange } tagName={ tagName } placeholder={ placeholder } allowedFormats={ adjustedAllowedFormats } withoutInteractiveFormatting={ withoutInteractiveFormatting } onEnter={ onEnter } onDelete={ onDelete } onPaste={ onPaste } __unstableIsSelected={ isSelected } __unstableInputRule={ inputRule } __unstableMultilineTag={ multilineTag } __unstableIsCaretWithinFormattedText={ isCaretWithinFormattedText } __unstableOnEnterFormattedText={ enterFormattedText } __unstableOnExitFormattedText={ exitFormattedText } __unstableOnCreateUndoLevel={ __unstableMarkLastChangeAsPersistent } __unstableMarkAutomaticChange={ __unstableMarkAutomaticChange } __unstableDidAutomaticChange={ didAutomaticChange } __unstableUndo={ undo } __unstableDisableFormats={ disableFormats } preserveWhiteSpace={ preserveWhiteSpace } disabled={ disabled } unstableOnFocus={ unstableOnFocus } __unstableAllowPrefixTransformations={ __unstableAllowPrefixTransformations } __unstableMultilineRootTag={ __unstableMultilineRootTag } // Native props. { ...nativeProps } blockIsSelected={ originalIsSelected !== undefined ? originalIsSelected : blockIsSelected } shouldBlurOnUnmount={ shouldBlurOnUnmount } __unstableMobileNoFocusOnMount={ __unstableMobileNoFocusOnMount } deleteEnter={ deleteEnter } placeholderTextColor={ placeholderTextColor } textAlign={ textAlign } selectionColor={ selectionColor } tagsToEliminate={ tagsToEliminate } rootTagsToEliminate={ rootTagsToEliminate } disableEditingMenu={ disableEditingMenu } fontSize={ fontSize } fontFamily={ fontFamily } fontWeight={ fontWeight } fontStyle={ fontStyle } minWidth={ minWidth } maxWidth={ maxWidth } onBlur={ onBlur } setRef={ setRef } // Props to be set on the editable container are destructured on the // element itself for web (see below), but passed through rich text // for native. id={ props.id } style={ props.style } > { ( { isSelected: nestedIsSelected, value, onChange, onFocus, editableProps, editableTagName: TagName, } ) => ( <> { children && children( { value, onChange, onFocus } ) } { nestedIsSelected && hasFormats && ( <FormatToolbarContainer inline={ inlineToolbar } anchorRef={ fallbackRef.current } /> ) } { nestedIsSelected && <RemoveBrowserShortcuts /> } <Autocomplete onReplace={ onReplace } completers={ autocompleters } record={ value } onChange={ onChange } isSelected={ nestedIsSelected } contentRef={ fallbackRef } > { ( { listBoxId, activeId, onKeyDown } ) => ( <TagName { ...editableProps } { ...props } style={ props.style ? { ...props.style, ...editableProps.style, } : editableProps.style } className={ classnames( classes, props.className, editableProps.className ) } aria-autocomplete={ listBoxId ? 'list' : undefined } aria-owns={ listBoxId } aria-activedescendant={ activeId } onKeyDown={ ( event ) => { onKeyDown( event ); editableProps.onKeyDown( event ); } } /> ) } </Autocomplete> <EmbedHandlerPicker ref={ embedHandlerPickerRef } /> </> ) } </RichText> ); if ( ! wrapperClassName ) { return content; } deprecated( 'gc.blockEditor.RichText wrapperClassName prop', { since: '5.4', alternative: 'className prop or create your own wrapper div', } ); return ( <div className={ classnames( wrapperClasses, wrapperClassName ) }> { content } </div> ); } const ForwardedRichTextContainer = forwardRef( RichTextWrapper ); ForwardedRichTextContainer.Content = ( { value, tagName: Tag, multiline, ...props } ) => { // Handle deprecated `children` and `node` sources. if ( Array.isArray( value ) ) { value = childrenSource.toHTML( value ); } const MultilineTag = getMultilineTag( multiline ); if ( ! value && MultilineTag ) { value = `<${ MultilineTag }></${ MultilineTag }>`; } const content = <RawHTML>{ value }</RawHTML>; if ( Tag ) { return <Tag { ...omit( props, [ 'format' ] ) }>{ content }</Tag>; } return content; }; ForwardedRichTextContainer.isEmpty = ( value ) => { return ! value || value.length === 0; }; ForwardedRichTextContainer.Content.defaultProps = { format: 'string', value: '', }; /** * @see https://github.com/GeChiUI/gutenberg/blob/HEAD/packages/block-editor/src/components/rich-text/README.md */ export default ForwardedRichTextContainer; export { RichTextShortcut } from './shortcut'; export { RichTextToolbarButton } from './toolbar-button'; export { __unstableRichTextInputEvent } from './input-event';