UNPKG

@wordpress/block-editor

Version:
574 lines (527 loc) 15.2 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { useRef, useCallback, forwardRef, createContext, useContext, } from '@wordpress/element'; import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { useMergeRefs, useInstanceId } from '@wordpress/compose'; import { __unstableUseRichText as useRichText, removeFormat, } from '@wordpress/rich-text'; import { Popover } from '@wordpress/components'; import { getBlockBindingsSource } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import { useBlockEditorAutocompleteProps } from '../autocomplete'; import { useBlockEditContext } from '../block-edit'; import { blockBindingsKey, isPreviewModeKey } from '../block-edit/context'; import FormatToolbarContainer from './format-toolbar-container'; import { store as blockEditorStore } from '../../store'; import { useMarkPersistent } from './use-mark-persistent'; import { useFormatTypes } from './use-format-types'; import { useEventListeners } from './event-listeners'; import FormatEdit from './format-edit'; import { getAllowedFormats } from './utils'; import { Content, valueToHTMLString } from './content'; import { withDeprecations } from './with-deprecations'; import { canBindBlock } from '../../utils/block-bindings'; import BlockContext from '../block-context'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); const instanceIdKey = Symbol( 'instanceId' ); /** * Removes props used for the native version of RichText so that they are not * passed to the DOM element and log warnings. * * @param {Object} props Props to filter. * * @return {Object} Filtered props. */ function removeNativeProps( props ) { const { __unstableMobileNoFocusOnMount, deleteEnter, placeholderTextColor, textAlign, selectionColor, tagsToEliminate, disableEditingMenu, fontSize, fontFamily, fontWeight, fontStyle, minWidth, maxWidth, disableSuggestions, disableAutocorrection, ...restProps } = props; return restProps; } export function RichTextWrapper( { children, tagName = 'div', value: adjustedValue = '', onChange: adjustedOnChange, isSelected: originalIsSelected, multiline, inlineToolbar, wrapperClassName, autocompleters, onReplace, placeholder, allowedFormats, withoutInteractiveFormatting, onRemove, onMerge, onSplit, __unstableOnSplitAtEnd: onSplitAtEnd, __unstableOnSplitAtDoubleLineEnd: onSplitAtDoubleLineEnd, identifier, preserveWhiteSpace, __unstablePastePlainText: pastePlainText, __unstableEmbedURLOnPaste, __unstableDisableFormats: disableFormats, disableLineBreaks, __unstableAllowPrefixTransformations, readOnly, ...props }, forwardedRef ) { props = removeNativeProps( props ); if ( onSplit ) { deprecated( 'wp.blockEditor.RichText onSplit prop', { since: '6.4', alternative: 'block.json support key: "splitting"', } ); } const instanceId = useInstanceId( RichTextWrapper ); const anchorRef = useRef(); const context = useBlockEditContext(); const { clientId, isSelected: isBlockSelected, name: blockName } = context; const blockBindings = context[ blockBindingsKey ]; const blockContext = useContext( BlockContext ); const registry = useRegistry(); const selector = ( select ) => { // Avoid subscribing to the block editor store if the block is not // selected. if ( ! isBlockSelected ) { return { isSelected: false }; } const { getSelectionStart, getSelectionEnd } = select( blockEditorStore ); const selectionStart = getSelectionStart(); const selectionEnd = getSelectionEnd(); let isSelected; if ( originalIsSelected === undefined ) { isSelected = selectionStart.clientId === clientId && selectionEnd.clientId === clientId && ( identifier ? selectionStart.attributeKey === identifier : selectionStart[ instanceIdKey ] === instanceId ); } else if ( originalIsSelected ) { isSelected = selectionStart.clientId === clientId; } return { selectionStart: isSelected ? selectionStart.offset : undefined, selectionEnd: isSelected ? selectionEnd.offset : undefined, isSelected, }; }; const { selectionStart, selectionEnd, isSelected } = useSelect( selector, [ clientId, identifier, instanceId, originalIsSelected, isBlockSelected, ] ); const { disableBoundBlock, bindingsPlaceholder, bindingsLabel } = useSelect( ( select ) => { if ( ! blockBindings?.[ identifier ] || ! canBindBlock( blockName ) ) { return {}; } const relatedBinding = blockBindings[ identifier ]; const blockBindingsSource = getBlockBindingsSource( relatedBinding.source ); const blockBindingsContext = {}; if ( blockBindingsSource?.usesContext?.length ) { for ( const key of blockBindingsSource.usesContext ) { blockBindingsContext[ key ] = blockContext[ key ]; } } const _disableBoundBlock = ! blockBindingsSource?.canUserEditValue?.( { select, context: blockBindingsContext, args: relatedBinding.args, } ); // Don't modify placeholders if value is not empty. if ( adjustedValue.length > 0 ) { return { disableBoundBlock: _disableBoundBlock, // Null values will make them fall back to the default behavior. bindingsPlaceholder: null, bindingsLabel: null, }; } const { getBlockAttributes } = select( blockEditorStore ); const blockAttributes = getBlockAttributes( clientId ); const fieldsList = blockBindingsSource?.getFieldsList?.( { select, context: blockBindingsContext, } ); const bindingKey = fieldsList?.[ relatedBinding?.args?.key ]?.label ?? blockBindingsSource?.label; const _bindingsPlaceholder = _disableBoundBlock ? bindingKey : sprintf( /* translators: %s: connected field label or source label */ __( 'Add %s' ), bindingKey ); const _bindingsLabel = _disableBoundBlock ? relatedBinding?.args?.key || blockBindingsSource?.label : sprintf( /* translators: %s: source label or key */ __( 'Empty %s; start writing to edit its value' ), relatedBinding?.args?.key || blockBindingsSource?.label ); return { disableBoundBlock: _disableBoundBlock, bindingsPlaceholder: blockAttributes?.placeholder || _bindingsPlaceholder, bindingsLabel: _bindingsLabel, }; }, [ blockBindings, identifier, blockName, adjustedValue, clientId, blockContext, ] ); const shouldDisableEditing = readOnly || disableBoundBlock; const { getSelectionStart, getSelectionEnd, getBlockRootClientId } = useSelect( blockEditorStore ); const { selectionChange } = useDispatch( blockEditorStore ); const adjustedAllowedFormats = getAllowedFormats( { allowedFormats, disableFormats, } ); const hasFormats = ! adjustedAllowedFormats || adjustedAllowedFormats.length > 0; const onSelectionChange = useCallback( ( start, end ) => { const selection = {}; const unset = start === undefined && end === undefined; const baseSelection = { clientId, [ identifier ? 'attributeKey' : instanceIdKey ]: identifier ? identifier : instanceId, }; if ( typeof start === 'number' || unset ) { // If we are only setting the start (or the end below), which // means a partial selection, and we're not updating a selection // with the same client ID, abort. This means the selected block // is a parent block. if ( end === undefined && getBlockRootClientId( clientId ) !== getBlockRootClientId( getSelectionEnd().clientId ) ) { return; } selection.start = { ...baseSelection, offset: start, }; } if ( typeof end === 'number' || unset ) { if ( start === undefined && getBlockRootClientId( clientId ) !== getBlockRootClientId( getSelectionStart().clientId ) ) { return; } selection.end = { ...baseSelection, offset: end, }; } selectionChange( selection ); }, [ clientId, getBlockRootClientId, getSelectionEnd, getSelectionStart, identifier, instanceId, selectionChange, ] ); const { formatTypes, prepareHandlers, valueHandlers, changeHandlers, dependencies, } = useFormatTypes( { clientId, identifier, withoutInteractiveFormatting, allowedFormats: adjustedAllowedFormats, } ); function addEditorOnlyFormats( value ) { return valueHandlers.reduce( ( accumulator, fn ) => fn( accumulator, value.text ), value.formats ); } function removeEditorOnlyFormats( value ) { 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 ); } } ); return value.formats; } function addInvisibleFormats( value ) { return prepareHandlers.reduce( ( accumulator, fn ) => fn( accumulator, value.text ), value.formats ); } const { value, getValue, onChange, ref: richTextRef, } = useRichText( { value: adjustedValue, onChange( html, { __unstableFormats, __unstableText } ) { adjustedOnChange( html ); Object.values( changeHandlers ).forEach( ( changeHandler ) => { changeHandler( __unstableFormats, __unstableText ); } ); }, selectionStart, selectionEnd, onSelectionChange, placeholder: bindingsPlaceholder || placeholder, __unstableIsSelected: isSelected, __unstableDisableFormats: disableFormats, preserveWhiteSpace, __unstableDependencies: [ ...dependencies, tagName ], __unstableAfterParse: addEditorOnlyFormats, __unstableBeforeSerialize: removeEditorOnlyFormats, __unstableAddInvisibleFormats: addInvisibleFormats, } ); const autocompleteProps = useBlockEditorAutocompleteProps( { onReplace, completers: autocompleters, record: value, onChange, } ); useMarkPersistent( { html: adjustedValue, value } ); const keyboardShortcuts = useRef( new Set() ); const inputEvents = useRef( new Set() ); function onFocus() { anchorRef.current?.focus(); } const TagName = tagName; return ( <> { isSelected && ( <keyboardShortcutContext.Provider value={ keyboardShortcuts }> <inputEventContext.Provider value={ inputEvents }> <Popover.__unstableSlotNameProvider value="__unstable-block-tools-after"> { children && children( { value, onChange, onFocus } ) } <FormatEdit value={ value } onChange={ onChange } onFocus={ onFocus } formatTypes={ formatTypes } forwardedRef={ anchorRef } /> </Popover.__unstableSlotNameProvider> </inputEventContext.Provider> </keyboardShortcutContext.Provider> ) } { isSelected && hasFormats && ( <FormatToolbarContainer inline={ inlineToolbar } editableContentElement={ anchorRef.current } /> ) } <TagName // Overridable props. role="textbox" aria-multiline={ ! disableLineBreaks } aria-readonly={ shouldDisableEditing } { ...props } // Unset draggable (coming from block props) for contentEditable // elements because it will interfere with multi block selection // when the contentEditable and draggable elements are the same // element. draggable={ undefined } aria-label={ bindingsLabel || props[ 'aria-label' ] || placeholder } { ...autocompleteProps } ref={ useMergeRefs( [ // Rich text ref must be first because its focus listener // must be set up before any other ref calls .focus() on // mount. richTextRef, forwardedRef, autocompleteProps.ref, props.ref, useEventListeners( { registry, getValue, onChange, __unstableAllowPrefixTransformations, formatTypes, onReplace, selectionChange, isSelected, disableFormats, value, tagName, onSplit, __unstableEmbedURLOnPaste, pastePlainText, onMerge, onRemove, removeEditorOnlyFormats, disableLineBreaks, onSplitAtEnd, onSplitAtDoubleLineEnd, keyboardShortcuts, inputEvents, } ), anchorRef, ] ) } contentEditable={ ! shouldDisableEditing } suppressContentEditableWarning className={ clsx( 'block-editor-rich-text__editable', props.className, 'rich-text' ) } // Setting tabIndex to 0 is unnecessary, the element is already // focusable because it's contentEditable. This also fixes a // Safari bug where it's not possible to Shift+Click multi // select blocks when Shift Clicking into an element with // tabIndex because Safari will focus the element. However, // Safari will correctly ignore nested contentEditable elements. tabIndex={ props.tabIndex === 0 && ! shouldDisableEditing ? null : props.tabIndex } data-wp-block-attribute-key={ identifier } /> </> ); } // This is the private API for the RichText component. // It allows access to all props, not just the public ones. export const PrivateRichText = withDeprecations( forwardRef( RichTextWrapper ) ); PrivateRichText.Content = Content; PrivateRichText.isEmpty = ( value ) => { return ! value || value.length === 0; }; // This is the public API for the RichText component. // We wrap the PrivateRichText component to hide some props from the public API. /** * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/rich-text/README.md */ const PublicForwardedRichTextContainer = forwardRef( ( props, ref ) => { const context = useBlockEditContext(); const isPreviewMode = context[ isPreviewModeKey ]; if ( isPreviewMode ) { // Remove all non-content props. const { children, tagName: Tag = 'div', value, onChange, isSelected, multiline, inlineToolbar, wrapperClassName, autocompleters, onReplace, placeholder, allowedFormats, withoutInteractiveFormatting, onRemove, onMerge, onSplit, __unstableOnSplitAtEnd, __unstableOnSplitAtDoubleLineEnd, identifier, preserveWhiteSpace, __unstablePastePlainText, __unstableEmbedURLOnPaste, __unstableDisableFormats, disableLineBreaks, __unstableAllowPrefixTransformations, readOnly, ...contentProps } = removeNativeProps( props ); return ( <Tag { ...contentProps } dangerouslySetInnerHTML={ { __html: valueToHTMLString( value, multiline ), } } /> ); } return <PrivateRichText ref={ ref } { ...props } readOnly={ false } />; } ); PublicForwardedRichTextContainer.Content = Content; PublicForwardedRichTextContainer.isEmpty = ( value ) => { return ! value || value.length === 0; }; export default PublicForwardedRichTextContainer; export { RichTextShortcut } from './shortcut'; export { RichTextToolbarButton } from './toolbar-button'; export { __unstableRichTextInputEvent } from './input-event';