@wordpress/block-editor
Version:
422 lines (391 loc) • 12.9 kB
JavaScript
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import {
useInstanceId,
useMergeRefs,
__experimentalUseFixedWindowList as useFixedWindowList,
} from '@wordpress/compose';
import {
__experimentalTreeGrid as TreeGrid,
VisuallyHidden,
} from '@wordpress/components';
import { AsyncModeProvider, useSelect } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';
import {
useCallback,
useEffect,
useMemo,
useRef,
useReducer,
forwardRef,
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import ListViewBranch from './branch';
import { ListViewContext } from './context';
import ListViewDropIndicatorPreview from './drop-indicator';
import useBlockSelection from './use-block-selection';
import useListViewBlockIndexes from './use-list-view-block-indexes';
import useListViewClientIds from './use-list-view-client-ids';
import useListViewCollapseItems from './use-list-view-collapse-items';
import useListViewDropZone from './use-list-view-drop-zone';
import useListViewExpandSelectedItem from './use-list-view-expand-selected-item';
import { store as blockEditorStore } from '../../store';
import { BlockSettingsDropdown } from '../block-settings-menu/block-settings-dropdown';
import { focusListItem } from './utils';
import useClipboardHandler from './use-clipboard-handler';
const expanded = ( state, action ) => {
if ( action.type === 'clear' ) {
return {};
}
if ( Array.isArray( action.clientIds ) ) {
return {
...state,
...action.clientIds.reduce(
( newState, id ) => ( {
...newState,
[ id ]: action.type === 'expand',
} ),
{}
),
};
}
return state;
};
export const BLOCK_LIST_ITEM_HEIGHT = 32;
/** @typedef {import('react').ComponentType} ComponentType */
/** @typedef {import('react').Ref<HTMLElement>} Ref */
/**
* Show a hierarchical list of blocks.
*
* @param {Object} props Components props.
* @param {string} props.id An HTML element id for the root element of ListView.
* @param {Array} props.blocks _deprecated_ Custom subset of block client IDs to be used instead of the default hierarchy.
* @param {?HTMLElement} props.dropZoneElement Optional element to be used as the drop zone.
* @param {?boolean} props.showBlockMovers Flag to enable block movers. Defaults to `false`.
* @param {?boolean} props.isExpanded Flag to determine whether nested levels are expanded by default. Defaults to `false`.
* @param {?boolean} props.showAppender Flag to show or hide the block appender. Defaults to `false`.
* @param {?ComponentType} props.blockSettingsMenu Optional more menu substitution. Defaults to the standard `BlockSettingsDropdown` component.
* @param {string} props.rootClientId The client id of the root block from which we determine the blocks to show in the list.
* @param {string} props.description Optional accessible description for the tree grid component.
* @param {?Function} props.onSelect Optional callback to be invoked when a block is selected. Receives the block object that was selected.
* @param {?ComponentType} props.additionalBlockContent Component that renders additional block content UI.
* @param {Ref} ref Forwarded ref
*/
function ListViewComponent(
{
id,
blocks,
dropZoneElement,
showBlockMovers = false,
isExpanded = false,
showAppender = false,
blockSettingsMenu: BlockSettingsMenu = BlockSettingsDropdown,
rootClientId,
description,
onSelect,
additionalBlockContent: AdditionalBlockContent,
},
ref
) {
// This can be removed once we no longer need to support the blocks prop.
if ( blocks ) {
deprecated(
'`blocks` property in `wp.blockEditor.__experimentalListView`',
{
since: '6.3',
alternative: '`rootClientId` property',
}
);
}
const instanceId = useInstanceId( ListViewComponent );
const { clientIdsTree, draggedClientIds, selectedClientIds } =
useListViewClientIds( { blocks, rootClientId } );
const blockIndexes = useListViewBlockIndexes( clientIdsTree );
const { getBlock } = useSelect( blockEditorStore );
const { visibleBlockCount } = useSelect(
( select ) => {
const { getGlobalBlockCount, getClientIdsOfDescendants } =
select( blockEditorStore );
const draggedBlockCount =
draggedClientIds?.length > 0
? getClientIdsOfDescendants( draggedClientIds ).length + 1
: 0;
return {
visibleBlockCount: getGlobalBlockCount() - draggedBlockCount,
};
},
[ draggedClientIds ]
);
const { updateBlockSelection } = useBlockSelection();
const [ expandedState, setExpandedState ] = useReducer( expanded, {} );
const [ insertedBlock, setInsertedBlock ] = useState( null );
const { setSelectedTreeId } = useListViewExpandSelectedItem( {
firstSelectedBlockClientId: selectedClientIds[ 0 ],
setExpandedState,
} );
const selectEditorBlock = useCallback(
/**
* @param {MouseEvent | KeyboardEvent | undefined} event
* @param {string} blockClientId
* @param {null | undefined | -1 | 1} focusPosition
*/
( event, blockClientId, focusPosition ) => {
updateBlockSelection( event, blockClientId, null, focusPosition );
setSelectedTreeId( blockClientId );
if ( onSelect ) {
onSelect( getBlock( blockClientId ) );
}
},
[ setSelectedTreeId, updateBlockSelection, onSelect, getBlock ]
);
const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( {
dropZoneElement,
expandedState,
setExpandedState,
} );
const elementRef = useRef();
// Allow handling of copy, cut, and paste events.
const clipBoardRef = useClipboardHandler( {
selectBlock: selectEditorBlock,
} );
const treeGridRef = useMergeRefs( [
clipBoardRef,
elementRef,
dropZoneRef,
ref,
] );
useEffect( () => {
// If a blocks are already selected when the list view is initially
// mounted, shift focus to the first selected block.
if ( selectedClientIds?.length ) {
focusListItem( selectedClientIds[ 0 ], elementRef?.current );
}
// Only focus on the selected item when the list view is mounted.
}, [] );
const expand = useCallback(
( clientId ) => {
if ( ! clientId ) {
return;
}
const clientIds = Array.isArray( clientId )
? clientId
: [ clientId ];
setExpandedState( { type: 'expand', clientIds } );
},
[ setExpandedState ]
);
const collapse = useCallback(
( clientId ) => {
if ( ! clientId ) {
return;
}
setExpandedState( { type: 'collapse', clientIds: [ clientId ] } );
},
[ setExpandedState ]
);
const collapseAll = useCallback( () => {
setExpandedState( { type: 'clear' } );
}, [ setExpandedState ] );
const expandRow = useCallback(
( row ) => {
expand( row?.dataset?.block );
},
[ expand ]
);
const collapseRow = useCallback(
( row ) => {
collapse( row?.dataset?.block );
},
[ collapse ]
);
const focusRow = useCallback(
( event, startRow, endRow ) => {
if ( event.shiftKey ) {
updateBlockSelection(
event,
startRow?.dataset?.block,
endRow?.dataset?.block
);
}
},
[ updateBlockSelection ]
);
useListViewCollapseItems( {
collapseAll,
expand,
} );
const firstDraggedBlockClientId = draggedClientIds?.[ 0 ];
// Convert a blockDropTarget into indexes relative to the blocks in the list view.
// These values are used to determine which blocks should be displaced to make room
// for the drop indicator. See `ListViewBranch` and `getDragDisplacementValues`.
const { blockDropTargetIndex, blockDropPosition, firstDraggedBlockIndex } =
useMemo( () => {
let _blockDropTargetIndex, _firstDraggedBlockIndex;
if ( blockDropTarget?.clientId ) {
const foundBlockIndex =
blockIndexes[ blockDropTarget.clientId ];
// If dragging below or inside the block, treat the drop target as the next block.
_blockDropTargetIndex =
foundBlockIndex === undefined ||
blockDropTarget?.dropPosition === 'top'
? foundBlockIndex
: foundBlockIndex + 1;
} else if ( blockDropTarget === null ) {
// A `null` value is used to indicate that the user is dragging outside of the list view.
_blockDropTargetIndex = null;
}
if ( firstDraggedBlockClientId ) {
const foundBlockIndex =
blockIndexes[ firstDraggedBlockClientId ];
_firstDraggedBlockIndex =
foundBlockIndex === undefined ||
blockDropTarget?.dropPosition === 'top'
? foundBlockIndex
: foundBlockIndex + 1;
}
return {
blockDropTargetIndex: _blockDropTargetIndex,
blockDropPosition: blockDropTarget?.dropPosition,
firstDraggedBlockIndex: _firstDraggedBlockIndex,
};
}, [ blockDropTarget, blockIndexes, firstDraggedBlockClientId ] );
const contextValue = useMemo(
() => ( {
blockDropPosition,
blockDropTargetIndex,
blockIndexes,
draggedClientIds,
expandedState,
expand,
firstDraggedBlockIndex,
collapse,
collapseAll,
BlockSettingsMenu,
listViewInstanceId: instanceId,
AdditionalBlockContent,
insertedBlock,
setInsertedBlock,
treeGridElementRef: elementRef,
rootClientId,
} ),
[
blockDropPosition,
blockDropTargetIndex,
blockIndexes,
draggedClientIds,
expandedState,
expand,
firstDraggedBlockIndex,
collapse,
collapseAll,
BlockSettingsMenu,
instanceId,
AdditionalBlockContent,
insertedBlock,
setInsertedBlock,
rootClientId,
]
);
// List View renders a fixed number of items and relies on each having a fixed item height of 36px.
// If this value changes, we should also change the itemHeight value set in useFixedWindowList.
// See: https://github.com/WordPress/gutenberg/pull/35230 for additional context.
const [ fixedListWindow ] = useFixedWindowList(
elementRef,
BLOCK_LIST_ITEM_HEIGHT,
visibleBlockCount,
{
// Ensure that the windowing logic is recalculated when the expanded state changes.
// This is necessary because expanding a collapsed block in a short list view can
// switch the list view to a tall list view with a scrollbar, and vice versa.
// When this happens, the windowing logic needs to be recalculated to ensure that
// the correct number of blocks are rendered, by rechecking for a scroll container.
expandedState,
useWindowing: true,
windowOverscan: 40,
}
);
// If there are no blocks to show and we're not showing the appender, do not render the list view.
if ( ! clientIdsTree.length && ! showAppender ) {
return null;
}
const describedById =
description && `block-editor-list-view-description-${ instanceId }`;
return (
<AsyncModeProvider value>
<ListViewDropIndicatorPreview
draggedBlockClientId={ firstDraggedBlockClientId }
listViewRef={ elementRef }
blockDropTarget={ blockDropTarget }
/>
{ description && (
<VisuallyHidden id={ describedById }>
{ description }
</VisuallyHidden>
) }
<TreeGrid
id={ id }
className={ clsx( 'block-editor-list-view-tree', {
'is-dragging':
draggedClientIds?.length > 0 &&
blockDropTargetIndex !== undefined,
} ) }
aria-label={ __( 'Block navigation structure' ) }
ref={ treeGridRef }
onCollapseRow={ collapseRow }
onExpandRow={ expandRow }
onFocusRow={ focusRow }
applicationAriaLabel={ __( 'Block navigation structure' ) }
aria-describedby={ describedById }
style={ {
'--wp-admin--list-view-dragged-items-height':
draggedClientIds?.length
? `${
BLOCK_LIST_ITEM_HEIGHT *
( draggedClientIds.length - 1 )
}px`
: null,
} }
>
<ListViewContext.Provider value={ contextValue }>
<ListViewBranch
blocks={ clientIdsTree }
parentId={ rootClientId }
selectBlock={ selectEditorBlock }
showBlockMovers={ showBlockMovers }
fixedListWindow={ fixedListWindow }
selectedClientIds={ selectedClientIds }
isExpanded={ isExpanded }
showAppender={ showAppender }
/>
</ListViewContext.Provider>
</TreeGrid>
</AsyncModeProvider>
);
}
// This is the private API for the ListView component.
// It allows access to all props, not just the public ones.
export const PrivateListView = forwardRef( ListViewComponent );
// This is the public API for the ListView component.
// We wrap the PrivateListView component to hide some props from the public API.
export default forwardRef( ( props, ref ) => {
return (
<PrivateListView
ref={ ref }
{ ...props }
showAppender={ false }
rootClientId={ null }
onSelect={ null }
additionalBlockContent={ null }
blockSettingsMenu={ undefined }
/>
);
} );