UNPKG

@wordpress/block-editor

Version:
834 lines (775 loc) 23.4 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { memo, RawHTML, useContext, useMemo } from '@wordpress/element'; import { getBlockType, getSaveContent, isUnmodifiedDefaultBlock, serializeRawBlock, switchToBlockType, getDefaultBlockName, isUnmodifiedBlock, isReusableBlock, getBlockDefaultClassName, hasBlockSupport, createBlock, store as blocksStore, } from '@wordpress/blocks'; import { withFilters } from '@wordpress/components'; import { withDispatch, useSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; import { safeHTML } from '@wordpress/dom'; /** * Internal dependencies */ import BlockEdit from '../block-edit'; import BlockInvalidWarning from './block-invalid-warning'; import BlockCrashWarning from './block-crash-warning'; import BlockCrashBoundary from './block-crash-boundary'; import BlockHtml from './block-html'; import { useBlockProps } from './use-block-props'; import { store as blockEditorStore } from '../../store'; import { useLayout } from './layout'; import { PrivateBlockContext } from './private-block-context'; import { unlock } from '../../lock-unlock'; /** * Merges wrapper props with special handling for classNames and styles. * * @param {Object} propsA * @param {Object} propsB * * @return {Object} Merged props. */ function mergeWrapperProps( propsA, propsB ) { const newProps = { ...propsA, ...propsB, }; // May be set to undefined, so check if the property is set! if ( propsA?.hasOwnProperty( 'className' ) && propsB?.hasOwnProperty( 'className' ) ) { newProps.className = clsx( propsA.className, propsB.className ); } if ( propsA?.hasOwnProperty( 'style' ) && propsB?.hasOwnProperty( 'style' ) ) { newProps.style = { ...propsA.style, ...propsB.style }; } return newProps; } function Block( { children, isHtml, ...props } ) { return ( <div { ...useBlockProps( props, { __unstableIsHtml: isHtml } ) }> { children } </div> ); } function BlockListBlock( { block: { __unstableBlockSource }, mode, isLocked, canRemove, clientId, isSelected, isSelectionEnabled, className, __unstableLayoutClassNames: layoutClassNames, name, isValid, attributes, wrapperProps, setAttributes, onReplace, onRemove, onInsertBlocksAfter, onMerge, toggleSelection, } ) { const { mayDisplayControls, mayDisplayParentControls, themeSupportsLayout, ...context } = useContext( PrivateBlockContext ); const parentLayout = useLayout() || {}; // We wrap the BlockEdit component in a div that hides it when editing in // HTML mode. This allows us to render all of the ancillary pieces // (InspectorControls, etc.) which are inside `BlockEdit` but not // `BlockHTML`, even in HTML mode. let blockEdit = ( <BlockEdit name={ name } isSelected={ isSelected } attributes={ attributes } setAttributes={ setAttributes } insertBlocksAfter={ isLocked ? undefined : onInsertBlocksAfter } onReplace={ canRemove ? onReplace : undefined } onRemove={ canRemove ? onRemove : undefined } mergeBlocks={ canRemove ? onMerge : undefined } clientId={ clientId } isSelectionEnabled={ isSelectionEnabled } toggleSelection={ toggleSelection } __unstableLayoutClassNames={ layoutClassNames } __unstableParentLayout={ Object.keys( parentLayout ).length ? parentLayout : undefined } mayDisplayControls={ mayDisplayControls } mayDisplayParentControls={ mayDisplayParentControls } blockEditingMode={ context.blockEditingMode } isPreviewMode={ context.isPreviewMode } /> ); const blockType = getBlockType( name ); // Determine whether the block has props to apply to the wrapper. if ( blockType?.getEditWrapperProps ) { wrapperProps = mergeWrapperProps( wrapperProps, blockType.getEditWrapperProps( attributes ) ); } const isAligned = wrapperProps && !! wrapperProps[ 'data-align' ] && ! themeSupportsLayout; // Support for sticky position in classic themes with alignment wrappers. const isSticky = className?.includes( 'is-position-sticky' ); // For aligned blocks, provide a wrapper element so the block can be // positioned relative to the block column. // This is only kept for classic themes that don't support layout // Historically we used to rely on extra divs and data-align to // provide the alignments styles in the editor. // Due to the differences between frontend and backend, we migrated // to the layout feature, and we're now aligning the markup of frontend // and backend. if ( isAligned ) { blockEdit = ( <div className={ clsx( 'wp-block', isSticky && className ) } data-align={ wrapperProps[ 'data-align' ] } > { blockEdit } </div> ); } let block; if ( ! isValid ) { const saveContent = __unstableBlockSource ? serializeRawBlock( __unstableBlockSource ) : getSaveContent( blockType, attributes ); block = ( <Block className="has-warning"> <BlockInvalidWarning clientId={ clientId } /> <RawHTML>{ safeHTML( saveContent ) }</RawHTML> </Block> ); } else if ( mode === 'html' ) { // Render blockEdit so the inspector controls don't disappear. // See #8969. block = ( <> <div style={ { display: 'none' } }>{ blockEdit }</div> <Block isHtml> <BlockHtml clientId={ clientId } /> </Block> </> ); } else if ( blockType?.apiVersion > 1 ) { block = blockEdit; } else { block = <Block>{ blockEdit }</Block>; } const { 'data-align': dataAlign, ...restWrapperProps } = wrapperProps ?? {}; const updatedWrapperProps = { ...restWrapperProps, className: clsx( restWrapperProps.className, dataAlign && themeSupportsLayout && `align${ dataAlign }`, ! ( dataAlign && isSticky ) && className ), }; // We set a new context with the adjusted and filtered wrapperProps (through // `editor.BlockListBlock`), which the `BlockListBlockProvider` did not have // access to. // Note that the context value doesn't have to be memoized in this case // because when it changes, this component will be re-rendered anyway, and // none of the consumers (BlockListBlock and useBlockProps) are memoized or // "pure". This is different from the public BlockEditContext, where // consumers might be memoized or "pure". return ( <PrivateBlockContext.Provider value={ { wrapperProps: updatedWrapperProps, isAligned, ...context, } } > <BlockCrashBoundary fallback={ <Block className="has-warning"> <BlockCrashWarning /> </Block> } > { block } </BlockCrashBoundary> </PrivateBlockContext.Provider> ); } const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { const { updateBlockAttributes, insertBlocks, mergeBlocks, replaceBlocks, toggleSelection, __unstableMarkLastChangeAsPersistent, moveBlocksToPosition, removeBlock, selectBlock, } = dispatch( blockEditorStore ); // Do not add new properties here, use `useDispatch` instead to avoid // leaking new props to the public API (editor.BlockListBlock filter). return { setAttributes( newAttributes ) { const { getMultiSelectedBlockClientIds } = registry.select( blockEditorStore ); const multiSelectedBlockClientIds = getMultiSelectedBlockClientIds(); const { clientId } = ownProps; const clientIds = multiSelectedBlockClientIds.length ? multiSelectedBlockClientIds : [ clientId ]; updateBlockAttributes( clientIds, newAttributes ); }, onInsertBlocks( blocks, index ) { const { rootClientId } = ownProps; insertBlocks( blocks, index, rootClientId ); }, onInsertBlocksAfter( blocks ) { const { clientId, rootClientId } = ownProps; const { getBlockIndex } = registry.select( blockEditorStore ); const index = getBlockIndex( clientId ); insertBlocks( blocks, index + 1, rootClientId ); }, onMerge( forward ) { const { clientId, rootClientId } = ownProps; const { getPreviousBlockClientId, getNextBlockClientId, getBlock, getBlockAttributes, getBlockName, getBlockOrder, getBlockIndex, getBlockRootClientId, canInsertBlockType, } = registry.select( blockEditorStore ); function switchToDefaultOrRemove() { const block = getBlock( clientId ); const defaultBlockName = getDefaultBlockName(); const defaultBlockType = getBlockType( defaultBlockName ); if ( getBlockName( clientId ) !== defaultBlockName ) { const replacement = switchToBlockType( block, defaultBlockName ); if ( replacement && replacement.length ) { replaceBlocks( clientId, replacement ); } } else if ( isUnmodifiedDefaultBlock( block ) ) { const nextBlockClientId = getNextBlockClientId( clientId ); if ( nextBlockClientId ) { registry.batch( () => { removeBlock( clientId ); selectBlock( nextBlockClientId ); } ); } } else if ( defaultBlockType.merge ) { const attributes = defaultBlockType.merge( {}, block.attributes ); replaceBlocks( [ clientId ], [ createBlock( defaultBlockName, attributes ) ] ); } } /** * Moves the block with clientId up one level. If the block type * cannot be inserted at the new location, it will be attempted to * convert to the default block type. * * @param {string} _clientId The block to move. * @param {boolean} changeSelection Whether to change the selection * to the moved block. */ function moveFirstItemUp( _clientId, changeSelection = true ) { const wrapperBlockName = getBlockName( _clientId ); const wrapperBlockType = getBlockType( wrapperBlockName ); const isTextualWrapper = wrapperBlockType.category === 'text'; const targetRootClientId = getBlockRootClientId( _clientId ); const blockOrder = getBlockOrder( _clientId ); const [ firstClientId ] = blockOrder; if ( blockOrder.length === 1 && isUnmodifiedBlock( getBlock( firstClientId ) ) ) { removeBlock( _clientId ); } else if ( isTextualWrapper ) { registry.batch( () => { if ( canInsertBlockType( getBlockName( firstClientId ), targetRootClientId ) ) { moveBlocksToPosition( [ firstClientId ], _clientId, targetRootClientId, getBlockIndex( _clientId ) ); } else { const replacement = switchToBlockType( getBlock( firstClientId ), getDefaultBlockName() ); if ( replacement && replacement.length && replacement.every( ( block ) => canInsertBlockType( block.name, targetRootClientId ) ) ) { insertBlocks( replacement, getBlockIndex( _clientId ), targetRootClientId, changeSelection ); removeBlock( firstClientId, false ); } else { switchToDefaultOrRemove(); } } if ( ! getBlockOrder( _clientId ).length && isUnmodifiedBlock( getBlock( _clientId ) ) ) { removeBlock( _clientId, false ); } } ); } else { switchToDefaultOrRemove(); } } // For `Delete` or forward merge, we should do the exact same thing // as `Backspace`, but from the other block. if ( forward ) { if ( rootClientId ) { const nextRootClientId = getNextBlockClientId( rootClientId ); if ( nextRootClientId ) { // If there is a block that follows with the same parent // block name and the same attributes, merge the inner // blocks. if ( getBlockName( rootClientId ) === getBlockName( nextRootClientId ) ) { const rootAttributes = getBlockAttributes( rootClientId ); const previousRootAttributes = getBlockAttributes( nextRootClientId ); if ( Object.keys( rootAttributes ).every( ( key ) => rootAttributes[ key ] === previousRootAttributes[ key ] ) ) { registry.batch( () => { moveBlocksToPosition( getBlockOrder( nextRootClientId ), nextRootClientId, rootClientId ); removeBlock( nextRootClientId, false ); } ); return; } } else { mergeBlocks( rootClientId, nextRootClientId ); return; } } } const nextBlockClientId = getNextBlockClientId( clientId ); if ( ! nextBlockClientId ) { return; } if ( getBlockOrder( nextBlockClientId ).length ) { moveFirstItemUp( nextBlockClientId, false ); } else { mergeBlocks( clientId, nextBlockClientId ); } } else { const previousBlockClientId = getPreviousBlockClientId( clientId ); if ( previousBlockClientId ) { mergeBlocks( previousBlockClientId, clientId ); } else if ( rootClientId ) { const previousRootClientId = getPreviousBlockClientId( rootClientId ); // If there is a preceding block with the same parent block // name and the same attributes, merge the inner blocks. if ( previousRootClientId && getBlockName( rootClientId ) === getBlockName( previousRootClientId ) ) { const rootAttributes = getBlockAttributes( rootClientId ); const previousRootAttributes = getBlockAttributes( previousRootClientId ); if ( Object.keys( rootAttributes ).every( ( key ) => rootAttributes[ key ] === previousRootAttributes[ key ] ) ) { registry.batch( () => { moveBlocksToPosition( getBlockOrder( rootClientId ), rootClientId, previousRootClientId ); removeBlock( rootClientId, false ); } ); return; } } moveFirstItemUp( rootClientId ); } else { switchToDefaultOrRemove(); } } }, onReplace( blocks, indexToSelect, initialPosition ) { if ( blocks.length && ! isUnmodifiedDefaultBlock( blocks[ blocks.length - 1 ] ) ) { __unstableMarkLastChangeAsPersistent(); } //Unsynced patterns are nested in an array so we need to flatten them. const replacementBlocks = blocks?.length === 1 && Array.isArray( blocks[ 0 ] ) ? blocks[ 0 ] : blocks; replaceBlocks( [ ownProps.clientId ], replacementBlocks, indexToSelect, initialPosition ); }, onRemove() { removeBlock( ownProps.clientId ); }, toggleSelection( selectionEnabled ) { toggleSelection( selectionEnabled ); }, }; } ); // This component is used by the BlockListBlockProvider component below. It will // add the props necessary for the `editor.BlockListBlock` filters. BlockListBlock = compose( applyWithDispatch, withFilters( 'editor.BlockListBlock' ) )( BlockListBlock ); // This component provides all the information we need through a single store // subscription (useSelect mapping). Only the necessary props are passed down // to the BlockListBlock component, which is a filtered component, so these // props are public API. To avoid adding to the public API, we use a private // context to pass the rest of the information to the filtered BlockListBlock // component, and useBlockProps. function BlockListBlockProvider( props ) { const { clientId, rootClientId } = props; const selectedProps = useSelect( ( select ) => { const { isBlockSelected, getBlockMode, isSelectionEnabled, getTemplateLock, isSectionBlock: _isSectionBlock, getBlockWithoutAttributes, getBlockAttributes, canRemoveBlock, canMoveBlock, getSettings, getTemporarilyEditingAsBlocks, getBlockEditingMode, getBlockName, isFirstMultiSelectedBlock, getMultiSelectedBlockClientIds, hasSelectedInnerBlock, getBlocksByName, getBlockIndex, isBlockMultiSelected, isBlockSubtreeDisabled, isBlockHighlighted, __unstableIsFullySelected, __unstableSelectionHasUnmergeableBlock, isBlockBeingDragged, isDragging, __unstableHasActiveBlockOverlayActive, getSelectedBlocksInitialCaretPosition, } = unlock( select( blockEditorStore ) ); const blockWithoutAttributes = getBlockWithoutAttributes( clientId ); // This is a temporary fix. // This function should never be called when a block is not // present in the state. It happens now because the order in // withSelect rendering is not correct. if ( ! blockWithoutAttributes ) { return; } const { hasBlockSupport: _hasBlockSupport, getActiveBlockVariation, } = select( blocksStore ); const attributes = getBlockAttributes( clientId ); const { name: blockName, isValid } = blockWithoutAttributes; const blockType = getBlockType( blockName ); const { supportsLayout, isPreviewMode } = getSettings(); const hasLightBlockWrapper = blockType?.apiVersion > 1; const previewContext = { isPreviewMode, blockWithoutAttributes, name: blockName, attributes, isValid, themeSupportsLayout: supportsLayout, index: getBlockIndex( clientId ), isReusable: isReusableBlock( blockType ), className: hasLightBlockWrapper ? attributes.className : undefined, defaultClassName: hasLightBlockWrapper ? getBlockDefaultClassName( blockName ) : undefined, blockTitle: blockType?.title, }; // When in preview mode, we can avoid a lot of selection and // editing related selectors. if ( isPreviewMode ) { return previewContext; } const _isSelected = isBlockSelected( clientId ); const canRemove = canRemoveBlock( clientId ); const canMove = canMoveBlock( clientId ); const match = getActiveBlockVariation( blockName, attributes ); const isMultiSelected = isBlockMultiSelected( clientId ); const checkDeep = true; const isAncestorOfSelectedBlock = hasSelectedInnerBlock( clientId, checkDeep ); const blockEditingMode = getBlockEditingMode( clientId ); const multiple = hasBlockSupport( blockName, 'multiple', true ); // For block types with `multiple` support, there is no "original // block" to be found in the content, as the block itself is valid. const blocksWithSameName = multiple ? [] : getBlocksByName( blockName ); const isInvalid = blocksWithSameName.length && blocksWithSameName[ 0 ] !== clientId; return { ...previewContext, mode: getBlockMode( clientId ), isSelectionEnabled: isSelectionEnabled(), isLocked: !! getTemplateLock( rootClientId ), isSectionBlock: _isSectionBlock( clientId ), canRemove, canMove, isSelected: _isSelected, isTemporarilyEditingAsBlocks: getTemporarilyEditingAsBlocks() === clientId, blockEditingMode, mayDisplayControls: _isSelected || ( isFirstMultiSelectedBlock( clientId ) && getMultiSelectedBlockClientIds().every( ( id ) => getBlockName( id ) === blockName ) ), mayDisplayParentControls: _hasBlockSupport( getBlockName( clientId ), '__experimentalExposeControlsToChildren', false ) && hasSelectedInnerBlock( clientId ), blockApiVersion: blockType?.apiVersion || 1, blockTitle: match?.title || blockType?.title, isSubtreeDisabled: blockEditingMode === 'disabled' && isBlockSubtreeDisabled( clientId ), hasOverlay: __unstableHasActiveBlockOverlayActive( clientId ) && ! isDragging(), initialPosition: _isSelected ? getSelectedBlocksInitialCaretPosition() : undefined, isHighlighted: isBlockHighlighted( clientId ), isMultiSelected, isPartiallySelected: isMultiSelected && ! __unstableIsFullySelected() && ! __unstableSelectionHasUnmergeableBlock(), isDragging: isBlockBeingDragged( clientId ), hasChildSelected: isAncestorOfSelectedBlock, isEditingDisabled: blockEditingMode === 'disabled', hasEditableOutline: blockEditingMode !== 'disabled' && getBlockEditingMode( rootClientId ) === 'disabled', originalBlockClientId: isInvalid ? blocksWithSameName[ 0 ] : false, }; }, [ clientId, rootClientId ] ); const { isPreviewMode, // Fill values that end up as a public API and may not be defined in // preview mode. mode = 'visual', isSelectionEnabled = false, isLocked = false, canRemove = false, canMove = false, blockWithoutAttributes, name, attributes, isValid, isSelected = false, themeSupportsLayout, isTemporarilyEditingAsBlocks, blockEditingMode, mayDisplayControls, mayDisplayParentControls, index, blockApiVersion, blockTitle, isSubtreeDisabled, hasOverlay, initialPosition, isHighlighted, isMultiSelected, isPartiallySelected, isReusable, isDragging, hasChildSelected, isSectionBlock, isEditingDisabled, hasEditableOutline, className, defaultClassName, originalBlockClientId, } = selectedProps; // Users of the editor.BlockListBlock filter used to be able to // access the block prop. // Ideally these blocks would rely on the clientId prop only. // This is kept for backward compatibility reasons. const block = useMemo( () => ( { ...blockWithoutAttributes, attributes } ), [ blockWithoutAttributes, attributes ] ); // Block is sometimes not mounted at the right time, causing it be // undefined see issue for more info // https://github.com/WordPress/gutenberg/issues/17013 if ( ! selectedProps ) { return null; } const privateContext = { isPreviewMode, clientId, className, index, mode, name, blockApiVersion, blockTitle, isSelected, isSubtreeDisabled, hasOverlay, initialPosition, blockEditingMode, isHighlighted, isMultiSelected, isPartiallySelected, isReusable, isDragging, hasChildSelected, isSectionBlock, isEditingDisabled, hasEditableOutline, isTemporarilyEditingAsBlocks, defaultClassName, mayDisplayControls, mayDisplayParentControls, originalBlockClientId, themeSupportsLayout, canMove, }; // Here we separate between the props passed to BlockListBlock and any other // information we selected for internal use. BlockListBlock is a filtered // component and thus ALL the props are PUBLIC API. // Note that the context value doesn't have to be memoized in this case // because when it changes, this component will be re-rendered anyway, and // none of the consumers (BlockListBlock and useBlockProps) are memoized or // "pure". This is different from the public BlockEditContext, where // consumers might be memoized or "pure". return ( <PrivateBlockContext.Provider value={ privateContext }> <BlockListBlock { ...props } // WARNING: all the following props are public API (through the // editor.BlockListBlock filter) and normally nothing new should // be added to it. { ...{ mode, isSelectionEnabled, isLocked, canRemove, canMove, // Users of the editor.BlockListBlock filter used to be able // to access the block prop. Ideally these blocks would rely // on the clientId prop only. This is kept for backward // compatibility reasons. block, name, attributes, isValid, isSelected, } } /> </PrivateBlockContext.Provider> ); } export default memo( BlockListBlockProvider );