diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 2b46a401d4b0e..408c5f7aecfa3 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -83,3 +83,15 @@ function gutenberg_enable_zoomed_out_view() { } add_action( 'admin_init', 'gutenberg_enable_zoomed_out_view' ); + +/** + * Sets a global JS variable used to trigger the availability of the Navigation List View experiment. + */ +function gutenberg_enable_off_canvas_navigation_editor() { + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-off-canvas-navigation-editor', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableOffCanvasNavigationEditor = true', 'before' ); + } +} + +add_action( 'admin_init', 'gutenberg_enable_off_canvas_navigation_editor' ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 3d09f05bd655c..d925b77925cfd 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -52,6 +52,17 @@ function gutenberg_initialize_experiments_settings() { 'id' => 'gutenberg-zoomed-out-view', ) ); + add_settings_field( + 'gutenberg-off-canvas-navigation-editor', + __( 'Off canvas navigation editor ', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test a new off canvas editor for navigation block', 'gutenberg' ), + 'id' => 'gutenberg-off-canvas-navigation-editor', + ) + ); register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 6b7a24887b342..7743a98066c43 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -73,6 +73,7 @@ export { default as __experimentalLinkControlSearchResults } from './link-contro export { default as __experimentalLinkControlSearchItem } from './link-control/search-item'; export { default as LineHeightControl } from './line-height-control'; export { default as __experimentalListView } from './list-view'; +export { default as __experimentalOffCanvasEditor } from './off-canvas-editor'; export { default as MediaReplaceFlow } from './media-replace-flow'; export { default as MediaPlaceholder } from './media-placeholder'; export { default as MediaUpload } from './media-upload'; diff --git a/packages/block-editor/src/components/off-canvas-editor/README.md b/packages/block-editor/src/components/off-canvas-editor/README.md new file mode 100644 index 0000000000000..0c5f2a36156a5 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/README.md @@ -0,0 +1,5 @@ +# Experimental Off Canvas Editor + +The __ExperimentalOffCanvasEditor component is a modified ListView compoent. It provides an overview of the hierarchical structure of all blocks in the editor. The blocks are presented vertically one below the other. It enables editing of hierarchy and addition of elements in the block tree without selecting the block instance on the canvas. + +It is an experimental component which may end up completely merged into the ListView component via configuration props. diff --git a/packages/block-editor/src/components/off-canvas-editor/block-contents.js b/packages/block-editor/src/components/off-canvas-editor/block-contents.js new file mode 100644 index 0000000000000..507e7575344ab --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/block-contents.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ListViewBlockSelectButton from './block-select-button'; +import BlockDraggable from '../block-draggable'; +import { store as blockEditorStore } from '../../store'; + +const ListViewBlockContents = forwardRef( + ( + { + onClick, + onToggleExpanded, + block, + isSelected, + position, + siblingBlockCount, + level, + isExpanded, + selectedClientIds, + ...props + }, + ref + ) => { + const { clientId } = block; + + const { blockMovingClientId, selectedBlockInBlockEditor } = useSelect( + ( select ) => { + const { hasBlockMovingClientId, getSelectedBlockClientId } = + select( blockEditorStore ); + return { + blockMovingClientId: hasBlockMovingClientId(), + selectedBlockInBlockEditor: getSelectedBlockClientId(), + }; + }, + [ clientId ] + ); + + const isBlockMoveTarget = + blockMovingClientId && selectedBlockInBlockEditor === clientId; + + const className = classnames( 'block-editor-list-view-block-contents', { + 'is-dropping-before': isBlockMoveTarget, + } ); + + // Only include all selected blocks if the currently clicked on block + // is one of the selected blocks. This ensures that if a user attempts + // to drag a block that isn't part of the selection, they're still able + // to drag it and rearrange its position. + const draggableClientIds = selectedClientIds.includes( clientId ) + ? selectedClientIds + : [ clientId ]; + + return ( + <BlockDraggable clientIds={ draggableClientIds }> + { ( { draggable, onDragStart, onDragEnd } ) => ( + <ListViewBlockSelectButton + ref={ ref } + className={ className } + block={ block } + onClick={ onClick } + onToggleExpanded={ onToggleExpanded } + isSelected={ isSelected } + position={ position } + siblingBlockCount={ siblingBlockCount } + level={ level } + draggable={ draggable } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + isExpanded={ isExpanded } + { ...props } + /> + ) } + </BlockDraggable> + ); + } +); + +export default ListViewBlockContents; diff --git a/packages/block-editor/src/components/off-canvas-editor/block-select-button.js b/packages/block-editor/src/components/off-canvas-editor/block-select-button.js new file mode 100644 index 0000000000000..9477eb2cda40c --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/block-select-button.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + Button, + __experimentalHStack as HStack, + __experimentalTruncate as Truncate, +} from '@wordpress/components'; +import { forwardRef } from '@wordpress/element'; +import { Icon, lock } from '@wordpress/icons'; +import { SPACE, ENTER } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; +import useBlockDisplayInformation from '../use-block-display-information'; +import useBlockDisplayTitle from '../block-title/use-block-display-title'; +import ListViewExpander from './expander'; +import { useBlockLock } from '../block-lock'; + +function ListViewBlockSelectButton( + { + className, + block: { clientId }, + onClick, + onToggleExpanded, + tabIndex, + onFocus, + onDragStart, + onDragEnd, + draggable, + }, + ref +) { + const blockInformation = useBlockDisplayInformation( clientId ); + const blockTitle = useBlockDisplayTitle( { + clientId, + context: 'list-view', + } ); + const { isLocked } = useBlockLock( clientId ); + + // The `href` attribute triggers the browser's native HTML drag operations. + // When the link is dragged, the element's outerHTML is set in DataTransfer object as text/html. + // We need to clear any HTML drag data to prevent `pasteHandler` from firing + // inside the `useOnBlockDrop` hook. + const onDragStartHandler = ( event ) => { + event.dataTransfer.clearData(); + onDragStart?.( event ); + }; + + function onKeyDownHandler( event ) { + if ( event.keyCode === ENTER || event.keyCode === SPACE ) { + onClick( event ); + } + } + + return ( + <> + <Button + className={ classnames( + 'block-editor-list-view-block-select-button', + className + ) } + onClick={ onClick } + onKeyDown={ onKeyDownHandler } + ref={ ref } + tabIndex={ tabIndex } + onFocus={ onFocus } + onDragStart={ onDragStartHandler } + onDragEnd={ onDragEnd } + draggable={ draggable } + href={ `#block-${ clientId }` } + aria-hidden={ true } + > + <ListViewExpander onClick={ onToggleExpanded } /> + <BlockIcon icon={ blockInformation?.icon } showColors /> + <HStack + alignment="center" + className="block-editor-list-view-block-select-button__label-wrapper" + justify="flex-start" + spacing={ 1 } + > + <span className="block-editor-list-view-block-select-button__title"> + <Truncate ellipsizeMode="auto">{ blockTitle }</Truncate> + </span> + { blockInformation?.anchor && ( + <span className="block-editor-list-view-block-select-button__anchor-wrapper"> + <Truncate + className="block-editor-list-view-block-select-button__anchor" + ellipsizeMode="auto" + > + { blockInformation.anchor } + </Truncate> + </span> + ) } + { isLocked && ( + <span className="block-editor-list-view-block-select-button__lock"> + <Icon icon={ lock } /> + </span> + ) } + </HStack> + </Button> + </> + ); +} + +export default forwardRef( ListViewBlockSelectButton ); diff --git a/packages/block-editor/src/components/off-canvas-editor/block.js b/packages/block-editor/src/components/off-canvas-editor/block.js new file mode 100644 index 0000000000000..0444630004d87 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/block.js @@ -0,0 +1,333 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { hasBlockSupport } from '@wordpress/blocks'; +import { + __experimentalTreeGridCell as TreeGridCell, + __experimentalTreeGridItem as TreeGridItem, +} from '@wordpress/components'; +import { useInstanceId } from '@wordpress/compose'; +import { moreVertical } from '@wordpress/icons'; +import { + useState, + useRef, + useEffect, + useCallback, + memo, +} from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { sprintf, __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ListViewLeaf from './leaf'; +import { + BlockMoverUpButton, + BlockMoverDownButton, +} from '../block-mover/button'; +import ListViewBlockContents from './block-contents'; +import BlockSettingsDropdown from '../block-settings-menu/block-settings-dropdown'; +import { useListViewContext } from './context'; +import { getBlockPositionDescription } from './utils'; +import { store as blockEditorStore } from '../../store'; +import useBlockDisplayInformation from '../use-block-display-information'; +import { useBlockLock } from '../block-lock'; + +function ListViewBlock( { + block, + isDragged, + isSelected, + isBranchSelected, + selectBlock, + position, + level, + rowCount, + siblingBlockCount, + showBlockMovers, + path, + isExpanded, + selectedClientIds, + preventAnnouncement, + selectBlockInCanvas, +} ) { + const cellRef = useRef( null ); + const [ isHovered, setIsHovered ] = useState( false ); + const { clientId } = block; + + const { isLocked, isContentLocked } = useBlockLock( clientId ); + const forceSelectionContentLock = useSelect( + ( select ) => { + if ( isSelected ) { + return false; + } + if ( ! isContentLocked ) { + return false; + } + return select( blockEditorStore ).hasSelectedInnerBlock( + clientId, + true + ); + }, + [ isContentLocked, clientId, isSelected ] + ); + + const isFirstSelectedBlock = + forceSelectionContentLock || + ( isSelected && selectedClientIds[ 0 ] === clientId ); + const isLastSelectedBlock = + forceSelectionContentLock || + ( isSelected && + selectedClientIds[ selectedClientIds.length - 1 ] === clientId ); + + const { toggleBlockHighlight } = useDispatch( blockEditorStore ); + + const blockInformation = useBlockDisplayInformation( clientId ); + const blockName = useSelect( + ( select ) => select( blockEditorStore ).getBlockName( clientId ), + [ clientId ] + ); + + // When a block hides its toolbar it also hides the block settings menu, + // since that menu is part of the toolbar in the editor canvas. + // List View respects this by also hiding the block settings menu. + const showBlockActions = hasBlockSupport( + blockName, + '__experimentalToolbar', + true + ); + const instanceId = useInstanceId( ListViewBlock ); + const descriptionId = `list-view-block-select-button__${ instanceId }`; + const blockPositionDescription = getBlockPositionDescription( + position, + siblingBlockCount, + level + ); + + let blockAriaLabel = __( 'Link' ); + if ( blockInformation ) { + blockAriaLabel = isLocked + ? sprintf( + // translators: %s: The title of the block. This string indicates a link to select the locked block. + __( '%s link (locked)' ), + blockInformation.title + ) + : sprintf( + // translators: %s: The title of the block. This string indicates a link to select the block. + __( '%s link' ), + blockInformation.title + ); + } + + const settingsAriaLabel = blockInformation + ? sprintf( + // translators: %s: The title of the block. + __( 'Options for %s block' ), + blockInformation.title + ) + : __( 'Options' ); + + const { isTreeGridMounted, expand, collapse } = useListViewContext(); + + const hasSiblings = siblingBlockCount > 0; + const hasRenderedMovers = showBlockMovers && hasSiblings; + const moverCellClassName = classnames( + 'block-editor-list-view-block__mover-cell', + { 'is-visible': isHovered || isSelected } + ); + + const listViewBlockSettingsClassName = classnames( + 'block-editor-list-view-block__menu-cell', + { 'is-visible': isHovered || isFirstSelectedBlock } + ); + + // If ListView has experimental features related to the Persistent List View, + // only focus the selected list item on mount; otherwise the list would always + // try to steal the focus from the editor canvas. + useEffect( () => { + if ( ! isTreeGridMounted && isSelected ) { + cellRef.current.focus(); + } + }, [] ); + + const onMouseEnter = useCallback( () => { + setIsHovered( true ); + toggleBlockHighlight( clientId, true ); + }, [ clientId, setIsHovered, toggleBlockHighlight ] ); + const onMouseLeave = useCallback( () => { + setIsHovered( false ); + toggleBlockHighlight( clientId, false ); + }, [ clientId, setIsHovered, toggleBlockHighlight ] ); + + const selectEditorBlock = useCallback( + ( event ) => { + selectBlock( event, clientId ); + event.preventDefault(); + }, + [ clientId, selectBlock ] + ); + + const updateSelection = useCallback( + ( newClientId ) => { + selectBlock( undefined, newClientId ); + }, + [ selectBlock ] + ); + + const toggleExpanded = useCallback( + ( event ) => { + // Prevent shift+click from opening link in a new window when toggling. + event.preventDefault(); + event.stopPropagation(); + if ( isExpanded === true ) { + collapse( clientId ); + } else if ( isExpanded === false ) { + expand( clientId ); + } + }, + [ clientId, expand, collapse, isExpanded ] + ); + + let colSpan; + if ( hasRenderedMovers ) { + colSpan = 2; + } else if ( ! showBlockActions ) { + colSpan = 3; + } + + const classes = classnames( { + 'is-selected': isSelected || forceSelectionContentLock, + 'is-first-selected': isFirstSelectedBlock, + 'is-last-selected': isLastSelectedBlock, + 'is-branch-selected': isBranchSelected, + 'is-dragging': isDragged, + 'has-single-cell': ! showBlockActions, + } ); + + // Only include all selected blocks if the currently clicked on block + // is one of the selected blocks. This ensures that if a user attempts + // to alter a block that isn't part of the selection, they're still able + // to do so. + const dropdownClientIds = selectedClientIds.includes( clientId ) + ? selectedClientIds + : [ clientId ]; + + return ( + <ListViewLeaf + className={ classes } + onMouseEnter={ onMouseEnter } + onMouseLeave={ onMouseLeave } + onFocus={ onMouseEnter } + onBlur={ onMouseLeave } + level={ level } + position={ position } + rowCount={ rowCount } + path={ path } + id={ `list-view-block-${ clientId }` } + data-block={ clientId } + isExpanded={ isContentLocked ? undefined : isExpanded } + aria-selected={ !! isSelected || forceSelectionContentLock } + > + <TreeGridCell + className="block-editor-list-view-block__contents-cell" + colSpan={ colSpan } + ref={ cellRef } + aria-label={ blockAriaLabel } + aria-selected={ !! isSelected || forceSelectionContentLock } + aria-expanded={ isContentLocked ? undefined : isExpanded } + aria-describedby={ descriptionId } + > + { ( { ref, tabIndex, onFocus } ) => ( + <div className="block-editor-list-view-block__contents-container"> + <ListViewBlockContents + block={ block } + onClick={ + selectBlockInCanvas + ? selectEditorBlock + : () => {} + } + onToggleExpanded={ toggleExpanded } + isSelected={ isSelected } + position={ position } + siblingBlockCount={ siblingBlockCount } + level={ level } + ref={ ref } + tabIndex={ tabIndex } + onFocus={ onFocus } + isExpanded={ isExpanded } + selectedClientIds={ selectedClientIds } + preventAnnouncement={ preventAnnouncement } + /> + <div + className="block-editor-list-view-block-select-button__description" + id={ descriptionId } + > + { blockPositionDescription } + </div> + </div> + ) } + </TreeGridCell> + { hasRenderedMovers && ( + <> + <TreeGridCell + className={ moverCellClassName } + withoutGridItem + > + <TreeGridItem> + { ( { ref, tabIndex, onFocus } ) => ( + <BlockMoverUpButton + orientation="vertical" + clientIds={ [ clientId ] } + ref={ ref } + tabIndex={ tabIndex } + onFocus={ onFocus } + /> + ) } + </TreeGridItem> + <TreeGridItem> + { ( { ref, tabIndex, onFocus } ) => ( + <BlockMoverDownButton + orientation="vertical" + clientIds={ [ clientId ] } + ref={ ref } + tabIndex={ tabIndex } + onFocus={ onFocus } + /> + ) } + </TreeGridItem> + </TreeGridCell> + </> + ) } + + { showBlockActions && ( + <TreeGridCell + className={ listViewBlockSettingsClassName } + aria-selected={ !! isSelected || forceSelectionContentLock } + > + { ( { ref, tabIndex, onFocus } ) => ( + <BlockSettingsDropdown + clientIds={ dropdownClientIds } + icon={ moreVertical } + label={ settingsAriaLabel } + toggleProps={ { + ref, + className: 'block-editor-list-view-block__menu', + tabIndex, + onFocus, + } } + disableOpenOnArrowDown + __experimentalSelectBlock={ updateSelection } + /> + ) } + </TreeGridCell> + ) } + </ListViewLeaf> + ); +} + +export default memo( ListViewBlock ); diff --git a/packages/block-editor/src/components/off-canvas-editor/branch.js b/packages/block-editor/src/components/off-canvas-editor/branch.js new file mode 100644 index 0000000000000..c43a0e8737f89 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/branch.js @@ -0,0 +1,212 @@ +/** + * WordPress dependencies + */ +import { memo } from '@wordpress/element'; +import { AsyncModeProvider, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +/** + * Internal dependencies + */ +import ListViewBlock from './block'; +import { useListViewContext } from './context'; +import { isClientIdSelected } from './utils'; +import { store as blockEditorStore } from '../../store'; + +/** + * Given a block, returns the total number of blocks in that subtree. This is used to help determine + * the list position of a block. + * + * When a block is collapsed, we do not count their children as part of that total. In the current drag + * implementation dragged blocks and their children are not counted. + * + * @param {Object} block block tree + * @param {Object} expandedState state that notes which branches are collapsed + * @param {Array} draggedClientIds a list of dragged client ids + * @param {boolean} isExpandedByDefault flag to determine the default fallback expanded state. + * @return {number} block count + */ +function countBlocks( + block, + expandedState, + draggedClientIds, + isExpandedByDefault +) { + const isDragged = draggedClientIds?.includes( block.clientId ); + if ( isDragged ) { + return 0; + } + const isExpanded = expandedState[ block.clientId ] ?? isExpandedByDefault; + + if ( isExpanded ) { + return ( + 1 + + block.innerBlocks.reduce( + countReducer( + expandedState, + draggedClientIds, + isExpandedByDefault + ), + 0 + ) + ); + } + return 1; +} +const countReducer = + ( expandedState, draggedClientIds, isExpandedByDefault ) => + ( count, block ) => { + const isDragged = draggedClientIds?.includes( block.clientId ); + if ( isDragged ) { + return count; + } + const isExpanded = + expandedState[ block.clientId ] ?? isExpandedByDefault; + if ( isExpanded && block.innerBlocks.length > 0 ) { + return ( + count + + countBlocks( + block, + expandedState, + draggedClientIds, + isExpandedByDefault + ) + ); + } + return count + 1; + }; + +function ListViewBranch( props ) { + const { + blocks, + selectBlock, + showBlockMovers, + selectedClientIds, + level = 1, + path = '', + isBranchSelected = false, + listPosition = 0, + fixedListWindow, + isExpanded, + parentId, + shouldShowInnerBlocks = true, + selectBlockInCanvas, + } = props; + + const isContentLocked = useSelect( + ( select ) => { + return !! ( + parentId && + select( blockEditorStore ).getTemplateLock( parentId ) === + 'contentOnly' + ); + }, + [ parentId ] + ); + + const { expandedState, draggedClientIds } = useListViewContext(); + + if ( isContentLocked ) { + return null; + } + + const filteredBlocks = blocks.filter( Boolean ); + const blockCount = filteredBlocks.length; + let nextPosition = listPosition; + + return ( + <> + { filteredBlocks.map( ( block, index ) => { + const { clientId, innerBlocks } = block; + + if ( index > 0 ) { + nextPosition += countBlocks( + filteredBlocks[ index - 1 ], + expandedState, + draggedClientIds, + isExpanded + ); + } + + const { itemInView } = fixedListWindow; + const blockInView = itemInView( nextPosition ); + + const position = index + 1; + const updatedPath = + path.length > 0 + ? `${ path }_${ position }` + : `${ position }`; + const hasNestedBlocks = !! innerBlocks?.length; + + const shouldExpand = + hasNestedBlocks && shouldShowInnerBlocks + ? expandedState[ clientId ] ?? isExpanded + : undefined; + + const isDragged = !! draggedClientIds?.includes( clientId ); + + const showBlock = isDragged || blockInView; + + // Make updates to the selected or dragged blocks synchronous, + // but asynchronous for any other block. + const isSelected = isClientIdSelected( + clientId, + selectedClientIds + ); + const isSelectedBranch = + isBranchSelected || ( isSelected && hasNestedBlocks ); + return ( + <AsyncModeProvider key={ clientId } value={ ! isSelected }> + { showBlock && ( + <ListViewBlock + block={ block } + selectBlock={ selectBlock } + isSelected={ isSelected } + isBranchSelected={ isSelectedBranch } + isDragged={ isDragged } + level={ level } + position={ position } + rowCount={ blockCount } + siblingBlockCount={ blockCount } + showBlockMovers={ showBlockMovers } + path={ updatedPath } + isExpanded={ shouldExpand } + listPosition={ nextPosition } + selectedClientIds={ selectedClientIds } + selectBlockInCanvas={ selectBlockInCanvas } + /> + ) } + { ! showBlock && ( + <tr> + <td className="block-editor-list-view-placeholder" /> + </tr> + ) } + { hasNestedBlocks && shouldExpand && ! isDragged && ( + <ListViewBranch + parentId={ clientId } + blocks={ innerBlocks } + selectBlock={ selectBlock } + showBlockMovers={ showBlockMovers } + level={ level + 1 } + path={ updatedPath } + listPosition={ nextPosition + 1 } + fixedListWindow={ fixedListWindow } + isBranchSelected={ isSelectedBranch } + selectedClientIds={ selectedClientIds } + isExpanded={ isExpanded } + /> + ) } + </AsyncModeProvider> + ); + } ) } + </> + ); +} + +ListViewBranch.defaultProps = { + selectBlock: () => {}, +}; + +export default memo( ListViewBranch ); diff --git a/packages/block-editor/src/components/off-canvas-editor/context.js b/packages/block-editor/src/components/off-canvas-editor/context.js new file mode 100644 index 0000000000000..c837dce9ca23f --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/context.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +export const ListViewContext = createContext( {} ); + +export const useListViewContext = () => useContext( ListViewContext ); diff --git a/packages/block-editor/src/components/off-canvas-editor/drop-indicator.js b/packages/block-editor/src/components/off-canvas-editor/drop-indicator.js new file mode 100644 index 0000000000000..1500e2f887fad --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/drop-indicator.js @@ -0,0 +1,125 @@ +/** + * WordPress dependencies + */ +import { Popover } from '@wordpress/components'; +import { useCallback, useMemo } from '@wordpress/element'; + +export default function ListViewDropIndicator( { + listViewRef, + blockDropTarget, +} ) { + const { rootClientId, clientId, dropPosition } = blockDropTarget || {}; + + const [ rootBlockElement, blockElement ] = useMemo( () => { + if ( ! listViewRef.current ) { + return []; + } + + // The rootClientId will be defined whenever dropping into inner + // block lists, but is undefined when dropping at the root level. + const _rootBlockElement = rootClientId + ? listViewRef.current.querySelector( + `[data-block="${ rootClientId }"]` + ) + : undefined; + + // The clientId represents the sibling block, the dragged block will + // usually be inserted adjacent to it. It will be undefined when + // dropping a block into an empty block list. + const _blockElement = clientId + ? listViewRef.current.querySelector( + `[data-block="${ clientId }"]` + ) + : undefined; + + return [ _rootBlockElement, _blockElement ]; + }, [ rootClientId, clientId ] ); + + // The targetElement is the element that the drop indicator will appear + // before or after. When dropping into an empty block list, blockElement + // is undefined, so the indicator will appear after the rootBlockElement. + const targetElement = blockElement || rootBlockElement; + + const getDropIndicatorIndent = useCallback( () => { + if ( ! rootBlockElement ) { + return 0; + } + + // Calculate the indent using the block icon of the root block. + // Using a classname selector here might be flaky and could be + // improved. + const targetElementRect = targetElement.getBoundingClientRect(); + const rootBlockIconElement = rootBlockElement.querySelector( + '.block-editor-block-icon' + ); + const rootBlockIconRect = rootBlockIconElement.getBoundingClientRect(); + return rootBlockIconRect.right - targetElementRect.left; + }, [ rootBlockElement, targetElement ] ); + + const style = useMemo( () => { + if ( ! targetElement ) { + return {}; + } + + const indent = getDropIndicatorIndent(); + + return { + width: targetElement.offsetWidth - indent, + }; + }, [ getDropIndicatorIndent, targetElement ] ); + + const popoverAnchor = useMemo( () => { + const isValidDropPosition = + dropPosition === 'top' || + dropPosition === 'bottom' || + dropPosition === 'inside'; + if ( ! targetElement || ! isValidDropPosition ) { + return undefined; + } + + return { + ownerDocument: targetElement.ownerDocument, + getBoundingClientRect() { + const rect = targetElement.getBoundingClientRect(); + const indent = getDropIndicatorIndent(); + + const left = rect.left + indent; + const right = rect.right; + let top = 0; + let bottom = 0; + + if ( dropPosition === 'top' ) { + top = rect.top; + bottom = rect.top; + } else { + // `dropPosition` is either `bottom` or `inside` + top = rect.bottom; + bottom = rect.bottom; + } + + const width = right - left; + const height = bottom - top; + + return new window.DOMRect( left, top, width, height ); + }, + }; + }, [ targetElement, dropPosition, getDropIndicatorIndent ] ); + + if ( ! targetElement ) { + return null; + } + + return ( + <Popover + animate={ false } + anchor={ popoverAnchor } + focusOnMount={ false } + className="block-editor-list-view-drop-indicator" + > + <div + style={ style } + className="block-editor-list-view-drop-indicator__line" + /> + </Popover> + ); +} diff --git a/packages/block-editor/src/components/off-canvas-editor/expander.js b/packages/block-editor/src/components/off-canvas-editor/expander.js new file mode 100644 index 0000000000000..3b93f8ad01185 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/expander.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { chevronRightSmall, chevronLeftSmall, Icon } from '@wordpress/icons'; +import { isRTL } from '@wordpress/i18n'; + +export default function ListViewExpander( { onClick } ) { + return ( + // Keyboard events are handled by TreeGrid see: components/src/tree-grid/index.js + // + // The expander component is implemented as a pseudo element in the w3 example + // https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html + // + // We've mimicked this by adding an icon with aria-hidden set to true to hide this from the accessibility tree. + // For the current tree grid implementation, please do not try to make this a button. + // + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + <span + className="block-editor-list-view__expander" + onClick={ ( event ) => onClick( event, { forceToggle: true } ) } + aria-hidden="true" + > + <Icon icon={ isRTL() ? chevronLeftSmall : chevronRightSmall } /> + </span> + ); +} diff --git a/packages/block-editor/src/components/off-canvas-editor/index.js b/packages/block-editor/src/components/off-canvas-editor/index.js new file mode 100644 index 0000000000000..dd88b9ec43cc9 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/index.js @@ -0,0 +1,216 @@ +/** + * WordPress dependencies + */ +import { + useMergeRefs, + __experimentalUseFixedWindowList as useFixedWindowList, +} from '@wordpress/compose'; +import { __experimentalTreeGrid as TreeGrid } from '@wordpress/components'; +import { AsyncModeProvider, useSelect } from '@wordpress/data'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useReducer, + forwardRef, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ListViewBranch from './branch'; +import { ListViewContext } from './context'; +import ListViewDropIndicator from './drop-indicator'; +import useBlockSelection from './use-block-selection'; +import useListViewClientIds from './use-list-view-client-ids'; +import useListViewDropZone from './use-list-view-drop-zone'; +import useListViewExpandSelectedItem from './use-list-view-expand-selected-item'; +import { store as blockEditorStore } from '../../store'; + +const expanded = ( state, action ) => { + 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 = 36; + +/** + * 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 Custom subset of block client IDs to be used instead of the default hierarchy. + * @param {boolean} props.showBlockMovers Flag to enable block movers + * @param {boolean} props.isExpanded Flag to determine whether nested levels are expanded by default. + * @param {boolean} props.selectBlockInCanvas Flag to determine whether the list view should be a block selection mechanism,. + * @param {Object} ref Forwarded ref + */ +function __ExperimentalOffCanvasEditor( + { + id, + blocks, + showBlockMovers = false, + isExpanded = false, + selectBlockInCanvas = true, + }, + ref +) { + const { clientIdsTree, draggedClientIds, selectedClientIds } = + useListViewClientIds( blocks ); + + const { visibleBlockCount, shouldShowInnerBlocks } = useSelect( + ( select ) => { + const { + getGlobalBlockCount, + getClientIdsOfDescendants, + __unstableGetEditorMode, + } = select( blockEditorStore ); + const draggedBlockCount = + draggedClientIds?.length > 0 + ? getClientIdsOfDescendants( draggedClientIds ).length + 1 + : 0; + return { + visibleBlockCount: getGlobalBlockCount() - draggedBlockCount, + shouldShowInnerBlocks: __unstableGetEditorMode() !== 'zoom-out', + }; + }, + [ draggedClientIds ] + ); + + const { updateBlockSelection } = useBlockSelection(); + + const [ expandedState, setExpandedState ] = useReducer( expanded, {} ); + + const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone(); + const elementRef = useRef(); + const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] ); + + const isMounted = useRef( false ); + const { setSelectedTreeId } = useListViewExpandSelectedItem( { + firstSelectedBlockClientId: selectedClientIds[ 0 ], + setExpandedState, + } ); + const selectEditorBlock = useCallback( + ( event, clientId ) => { + updateBlockSelection( event, clientId ); + setSelectedTreeId( clientId ); + }, + [ setSelectedTreeId, updateBlockSelection ] + ); + useEffect( () => { + isMounted.current = true; + }, [] ); + + // 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, + { + useWindowing: true, + windowOverscan: 40, + } + ); + + const expand = useCallback( + ( clientId ) => { + if ( ! clientId ) { + return; + } + setExpandedState( { type: 'expand', clientIds: [ clientId ] } ); + }, + [ setExpandedState ] + ); + const collapse = useCallback( + ( clientId ) => { + if ( ! clientId ) { + return; + } + setExpandedState( { type: 'collapse', clientIds: [ clientId ] } ); + }, + [ 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 ] + ); + + const contextValue = useMemo( + () => ( { + isTreeGridMounted: isMounted.current, + draggedClientIds, + expandedState, + expand, + collapse, + } ), + [ isMounted.current, draggedClientIds, expandedState, expand, collapse ] + ); + + return ( + <AsyncModeProvider value={ true }> + <ListViewDropIndicator + listViewRef={ elementRef } + blockDropTarget={ blockDropTarget } + /> + <TreeGrid + id={ id } + className="block-editor-list-view-tree" + aria-label={ __( 'Block navigation structure' ) } + ref={ treeGridRef } + onCollapseRow={ collapseRow } + onExpandRow={ expandRow } + onFocusRow={ focusRow } + applicationAriaLabel={ __( 'Block navigation structure' ) } + > + <ListViewContext.Provider value={ contextValue }> + <ListViewBranch + blocks={ clientIdsTree } + selectBlock={ selectEditorBlock } + showBlockMovers={ showBlockMovers } + fixedListWindow={ fixedListWindow } + selectedClientIds={ selectedClientIds } + isExpanded={ isExpanded } + shouldShowInnerBlocks={ shouldShowInnerBlocks } + selectBlockInCanvas={ selectBlockInCanvas } + /> + </ListViewContext.Provider> + </TreeGrid> + </AsyncModeProvider> + ); +} +export default forwardRef( __ExperimentalOffCanvasEditor ); diff --git a/packages/block-editor/src/components/off-canvas-editor/leaf.js b/packages/block-editor/src/components/off-canvas-editor/leaf.js new file mode 100644 index 0000000000000..41bf4bc34cc66 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/leaf.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { animated } from '@react-spring/web'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __experimentalTreeGridRow as TreeGridRow } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import useMovingAnimation from '../use-moving-animation'; + +const AnimatedTreeGridRow = animated( TreeGridRow ); + +export default function ListViewLeaf( { + isSelected, + position, + level, + rowCount, + children, + className, + path, + ...props +} ) { + const ref = useMovingAnimation( { + isSelected, + adjustScrolling: false, + enableAnimation: true, + triggerAnimationOnChange: path, + } ); + + return ( + <AnimatedTreeGridRow + ref={ ref } + className={ classnames( 'block-editor-list-view-leaf', className ) } + level={ level } + positionInSet={ position } + setSize={ rowCount } + { ...props } + > + { children } + </AnimatedTreeGridRow> + ); +} diff --git a/packages/block-editor/src/components/off-canvas-editor/style.scss b/packages/block-editor/src/components/off-canvas-editor/style.scss new file mode 100644 index 0000000000000..ce5539dbe3aa7 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/style.scss @@ -0,0 +1,432 @@ +.block-editor-list-view-tree { + width: 100%; + border-collapse: collapse; + padding: 0; + margin: 0; + + // Move upwards when in modal. + .components-modal__content & { + margin: (-$grid-unit-15) (-$grid-unit-15 * 0.5) 0; + width: calc(100% + #{ $grid-unit-15 }); + } +} + +.block-editor-list-view-leaf { + // Use position relative for row animation. + position: relative; + + // The background has to be applied to the td, not tr, or border-radius won't work. + &.is-selected td { + background: var(--wp-admin-theme-color); + } + &.is-selected .block-editor-list-view-block-contents, + &.is-selected .components-button.has-icon { + color: $white; + } + &.is-selected .block-editor-list-view-block-contents { + // Hide selection styles while a user is dragging blocks/files etc. + .is-dragging-components-draggable & { + background: none; + color: $gray-900; + } + } + &.is-selected .block-editor-list-view-block-contents:focus { + &::after { + box-shadow: + inset 0 0 0 1px $white, + 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + } + } + &.is-selected .block-editor-list-view-block__menu:focus { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $white; + } + + &.is-dragging { + display: none; + } + + // Border radius for corners of the selected item. + &.is-first-selected td:first-child { + border-top-left-radius: $radius-block-ui; + } + &.is-first-selected td:last-child { + border-top-right-radius: $radius-block-ui; + } + &.is-last-selected td:first-child { + border-bottom-left-radius: $radius-block-ui; + } + &.is-last-selected td:last-child { + border-bottom-right-radius: $radius-block-ui; + } + &.is-branch-selected:not(.is-selected) { + // Lighten a CSS variable without introducing a new SASS variable + background: + linear-gradient(transparentize($white, 0.1), transparentize($white, 0.1)), + linear-gradient(var(--wp-admin-theme-color), var(--wp-admin-theme-color)); + } + &.is-branch-selected.is-first-selected td:first-child { + border-top-left-radius: $radius-block-ui; + } + &.is-branch-selected.is-first-selected td:last-child { + border-top-right-radius: $radius-block-ui; + } + &[aria-expanded="false"] { + &.is-branch-selected.is-first-selected td:first-child { + border-top-left-radius: $radius-block-ui; + } + &.is-branch-selected.is-first-selected td:last-child { + border-top-right-radius: $radius-block-ui; + } + &.is-branch-selected.is-last-selected td:first-child { + border-bottom-left-radius: $radius-block-ui; + } + &.is-branch-selected.is-last-selected td:last-child { + border-bottom-right-radius: $radius-block-ui; + } + } + &.is-branch-selected:not(.is-selected) td { + border-radius: 0; + } + + + // List View renders a fixed number of items and relies on each item having a fixed 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. + .block-editor-list-view-block-contents { + display: flex; + align-items: center; + width: 100%; + height: auto; + padding: ($grid-unit-15 * 0.5) $grid-unit-05 ($grid-unit-15 * 0.5) 0; + text-align: left; + color: $gray-900; + border-radius: $radius-block-ui; + position: relative; + white-space: nowrap; + + &.is-dropping-before::before { + content: ""; + position: absolute; + pointer-events: none; + transition: border-color 0.1s linear, border-style 0.1s linear, box-shadow 0.1s linear; + top: -2px; + right: 0; + left: 0; + border-top: 4px solid var(--wp-admin-theme-color); + } + + .components-modal__content & { + padding-left: 0; + padding-right: 0; + } + } + + .block-editor-list-view-block-contents:focus { + box-shadow: none; + + &::after { + content: ""; + position: absolute; + top: 0; + right: -(24px + 5px); // Icon size + padding. + bottom: 0; + left: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + z-index: 2; + pointer-events: none; + + // Hide focus styles while a user is dragging blocks/files etc. + .is-dragging-components-draggable & { + box-shadow: none; + } + } + } + // Fix focus styling width when one row has fewer cells. + &.has-single-cell .block-editor-list-view-block-contents:focus::after { + right: 0; + } + + .block-editor-list-view-block__menu:focus { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + z-index: 1; + + // Hide focus styles while a user is dragging blocks/files etc. + .is-dragging-components-draggable & { + box-shadow: none; + } + } + + &.is-visible .block-editor-list-view-block-contents { + opacity: 1; + @include edit-post__fade-in-animation; + } + + .block-editor-block-icon { + align-self: flex-start; + margin-right: $grid-unit-10; + width: $icon-size; + } + + .block-editor-list-view-block__menu-cell, + .block-editor-list-view-block__mover-cell, + .block-editor-list-view-block__contents-cell { + padding-top: 0; + padding-bottom: 0; + } + + .block-editor-list-view-block__menu-cell, + .block-editor-list-view-block__mover-cell { + line-height: 0; + width: $button-size; + vertical-align: middle; + @include reduce-motion("transition"); + + > * { + opacity: 0; + } + + // Show on hover, visible, and show above to keep the hit area size. + &:hover, + &.is-visible { + position: relative; + z-index: 1; + + > * { + opacity: 1; + @include edit-post__fade-in-animation; + } + } + + &, + .components-button.has-icon { + width: 24px; + min-width: 24px; + padding: 0; + } + } + + .block-editor-list-view-block__menu-cell { + padding-right: $grid-unit-05; + + .components-button.has-icon { + height: 24px; + } + } + + .block-editor-list-view-block__mover-cell-alignment-wrapper { + display: flex; + height: 100%; + flex-direction: column; + align-items: center; + } + + // Keep the tap target large but the focus target small. + .block-editor-block-mover-button { + position: relative; + width: $button-size; + height: $button-size-small; + + // Position the icon. + svg { + position: relative; + height: $button-size-small; + } + + &.is-up-button { + margin-top: -$grid-unit-15 * 0.5; + align-items: flex-end; + svg { + bottom: -$grid-unit-05; + } + } + + &.is-down-button { + margin-bottom: -$grid-unit-15 * 0.5; + align-items: flex-start; + svg { + top: -$grid-unit-05; + } + } + + // Don't show the focus inherited by the Button component. + &:focus:enabled { + box-shadow: none; + outline: none; + } + + // Focus style. + &:focus { + box-shadow: none; + outline: none; + } + + &:focus::before { + @include block-toolbar-button-style__focus(); + } + + // Focus and toggle pseudo elements. + &::before { + content: ""; + position: absolute; + display: block; + border-radius: $radius-block-ui; + height: 16px; + min-width: 100%; + + // Position the focus rectangle. + left: 0; + right: 0; + + // Animate in. + animation: components-button__appear-animation 0.1s ease; + animation-fill-mode: forwards; + @include reduce-motion("animation"); + } + } + + .block-editor-inserter__toggle { + background: $gray-900; + color: $white; + height: $grid-unit-30; + margin: 6px 6px 6px 1px; + min-width: $grid-unit-30; + + &:active { + color: $white; + } + } + + .block-editor-list-view-block-select-button__label-wrapper { + min-width: 120px; + } + + .block-editor-list-view-block-select-button__title { + flex: 1; + position: relative; + + .components-truncate { + position: absolute; + width: 100%; + transform: translateY(-50%); + } + } + + .block-editor-list-view-block-select-button__anchor-wrapper { + position: relative; + max-width: min(110px, 40%); + width: 100%; + } + + .block-editor-list-view-block-select-button__anchor { + position: absolute; + right: 0; + transform: translateY(-50%); + background: rgba($black, 0.1); + border-radius: $radius-block-ui; + padding: 2px 6px; + max-width: 100%; + box-sizing: border-box; + } + + &.is-selected .block-editor-list-view-block-select-button__anchor { + background: rgba($black, 0.3); + } + + .block-editor-list-view-block-select-button__lock { + line-height: 0; + width: 24px; + min-width: 24px; + margin-left: auto; + padding: 0; + vertical-align: middle; + } +} + +.block-editor-list-view-block-select-button__description, +.block-editor-list-view-appender__description { + display: none; +} + +.block-editor-list-view-block__contents-cell, +.block-editor-list-view-appender__cell { + .block-editor-list-view-block__contents-container, + .block-editor-list-view-appender__container { + display: flex; + } +} + +// Chevron container metrics. +.block-editor-list-view__expander { + height: $icon-size; + margin-left: $grid-unit-05; + width: $icon-size; +} + +// First level of indentation is aria-level 2, max indent is 8. +// Indent is a full icon size, plus 4px which optically aligns child icons to the text label above. +$block-navigation-max-indent: 8; +.block-editor-list-view-leaf[aria-level] .block-editor-list-view__expander { + margin-left: ( $icon-size ) * $block-navigation-max-indent + 4 * ( $block-navigation-max-indent - 1 ); +} + +.block-editor-list-view-leaf:not([aria-level="1"]) { + .block-editor-list-view__expander { + margin-right: 4px; + } +} + +@for $i from 0 to $block-navigation-max-indent { + .block-editor-list-view-leaf[aria-level="#{ $i + 1 }"] .block-editor-list-view__expander { + @if $i - 1 >= 0 { + margin-left: ( $icon-size * $i ) + 4 * ($i - 1); + } + @else { + margin-left: ( $icon-size * $i ); + } + } +} + +.block-editor-list-view-leaf .block-editor-list-view__expander { + visibility: hidden; +} + +// Point downwards when open. +.block-editor-list-view-leaf[aria-expanded="true"] .block-editor-list-view__expander svg { + visibility: visible; + transition: transform 0.2s ease; + transform: rotate(90deg); + @include reduce-motion("transition"); +} + +// Point rightwards when closed +.block-editor-list-view-leaf[aria-expanded="false"] .block-editor-list-view__expander svg { + visibility: visible; + transform: rotate(0deg); + transition: transform 0.2s ease; + @include reduce-motion("transition"); +} + +.block-editor-list-view-drop-indicator { + pointer-events: none; + + .block-editor-list-view-drop-indicator__line { + background: var(--wp-admin-theme-color); + height: $border-width; + } +} + +// Reset some popover defaults for the drop indicator. +.block-editor-list-view-drop-indicator > .components-popover__content { + margin-left: 0; + border: none; + box-shadow: none; + outline: none; +} + +.block-editor-list-view-placeholder { + padding: 0; + margin: 0; + height: 36px; +} + diff --git a/packages/block-editor/src/components/off-canvas-editor/test/utils.js b/packages/block-editor/src/components/off-canvas-editor/test/utils.js new file mode 100644 index 0000000000000..78d78a9d90069 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/test/utils.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import { getCommonDepthClientIds } from '../utils'; + +describe( 'getCommonDepthClientIds', () => { + it( 'should return start and end when no depth is provided', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [], + [] + ); + + expect( result ).toEqual( { start: 'start-id', end: 'clicked-id' } ); + } ); + + it( 'should return deepest start and end when depths match', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [ 'start-1', 'start-2', 'start-3' ], + [ 'end-1', 'end-2', 'end-3' ] + ); + + expect( result ).toEqual( { start: 'start-id', end: 'clicked-id' } ); + } ); + + it( 'should return shallower ids when start is shallower', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [ 'start-1' ], + [ 'end-1', 'end-2', 'end-3' ] + ); + + expect( result ).toEqual( { start: 'start-id', end: 'end-2' } ); + } ); + + it( 'should return shallower ids when end is shallower', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [ 'start-1', 'start-2', 'start-3' ], + [ 'end-1', 'end-2' ] + ); + + expect( result ).toEqual( { start: 'start-3', end: 'clicked-id' } ); + } ); +} ); diff --git a/packages/block-editor/src/components/off-canvas-editor/use-block-selection.js b/packages/block-editor/src/components/off-canvas-editor/use-block-selection.js new file mode 100644 index 0000000000000..59aaaeacb01d4 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/use-block-selection.js @@ -0,0 +1,169 @@ +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; +import { UP, DOWN, HOME, END } from '@wordpress/keycodes'; +import { store as blocksStore } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { getCommonDepthClientIds } from './utils'; + +export default function useBlockSelection() { + const { clearSelectedBlock, multiSelect, selectBlock } = + useDispatch( blockEditorStore ); + const { + getBlockName, + getBlockParents, + getBlockSelectionStart, + getBlockSelectionEnd, + getSelectedBlockClientIds, + hasMultiSelection, + hasSelectedBlock, + } = useSelect( blockEditorStore ); + + const { getBlockType } = useSelect( blocksStore ); + + const updateBlockSelection = useCallback( + async ( event, clientId, destinationClientId ) => { + if ( ! event?.shiftKey ) { + selectBlock( clientId ); + return; + } + + // To handle multiple block selection via the `SHIFT` key, prevent + // the browser default behavior of opening the link in a new window. + event.preventDefault(); + + const isKeyPress = + event.type === 'keydown' && + ( event.keyCode === UP || + event.keyCode === DOWN || + event.keyCode === HOME || + event.keyCode === END ); + + // Handle clicking on a block when no blocks are selected, and return early. + if ( + ! isKeyPress && + ! hasSelectedBlock() && + ! hasMultiSelection() + ) { + selectBlock( clientId, null ); + return; + } + + const selectedBlocks = getSelectedBlockClientIds(); + const clientIdWithParents = [ + ...getBlockParents( clientId ), + clientId, + ]; + + if ( + isKeyPress && + ! selectedBlocks.some( ( blockId ) => + clientIdWithParents.includes( blockId ) + ) + ) { + // Ensure that shift-selecting blocks via the keyboard only + // expands the current selection if focusing over already + // selected blocks. Otherwise, clear the selection so that + // a user can create a new selection entirely by keyboard. + await clearSelectedBlock(); + } + + let startTarget = getBlockSelectionStart(); + let endTarget = clientId; + + // Handle keyboard behavior for selecting multiple blocks. + if ( isKeyPress ) { + if ( ! hasSelectedBlock() && ! hasMultiSelection() ) { + // Set the starting point of the selection to the currently + // focused block, if there are no blocks currently selected. + // This ensures that as the selection is expanded or contracted, + // the starting point of the selection is anchored to that block. + startTarget = clientId; + } + if ( destinationClientId ) { + // If the user presses UP or DOWN, we want to ensure that the block they're + // moving to is the target for selection, and not the currently focused one. + endTarget = destinationClientId; + } + } + + const startParents = getBlockParents( startTarget ); + const endParents = getBlockParents( endTarget ); + + const { start, end } = getCommonDepthClientIds( + startTarget, + endTarget, + startParents, + endParents + ); + await multiSelect( start, end, null ); + + // Announce deselected block, or number of deselected blocks if + // the total number of blocks deselected is greater than one. + const updatedSelectedBlocks = getSelectedBlockClientIds(); + + // If the selection is greater than 1 and the Home or End keys + // were used to generate the selection, then skip announcing the + // deselected blocks. + if ( + ( event.keyCode === HOME || event.keyCode === END ) && + updatedSelectedBlocks.length > 1 + ) { + return; + } + + const selectionDiff = selectedBlocks.filter( + ( blockId ) => ! updatedSelectedBlocks.includes( blockId ) + ); + + let label; + if ( selectionDiff.length === 1 ) { + const title = getBlockType( + getBlockName( selectionDiff[ 0 ] ) + )?.title; + if ( title ) { + label = sprintf( + /* translators: %s: block name */ + __( '%s deselected.' ), + title + ); + } + } else if ( selectionDiff.length > 1 ) { + label = sprintf( + /* translators: %s: number of deselected blocks */ + __( '%s blocks deselected.' ), + selectionDiff.length + ); + } + + if ( label ) { + speak( label ); + } + }, + [ + clearSelectedBlock, + getBlockName, + getBlockType, + getBlockParents, + getBlockSelectionStart, + getBlockSelectionEnd, + getSelectedBlockClientIds, + hasMultiSelection, + hasSelectedBlock, + multiSelect, + selectBlock, + ] + ); + + return { + updateBlockSelection, + }; +} diff --git a/packages/block-editor/src/components/off-canvas-editor/use-list-view-client-ids.js b/packages/block-editor/src/components/off-canvas-editor/use-list-view-client-ids.js new file mode 100644 index 0000000000000..5dafa765f16ea --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/use-list-view-client-ids.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ + +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +export default function useListViewClientIds( blocks ) { + return useSelect( + ( select ) => { + const { + getDraggedBlockClientIds, + getSelectedBlockClientIds, + __unstableGetClientIdsTree, + } = select( blockEditorStore ); + + return { + selectedClientIds: getSelectedBlockClientIds(), + draggedClientIds: getDraggedBlockClientIds(), + clientIdsTree: blocks ? blocks : __unstableGetClientIdsTree(), + }; + }, + [ blocks ] + ); +} diff --git a/packages/block-editor/src/components/off-canvas-editor/use-list-view-drop-zone.js b/packages/block-editor/src/components/off-canvas-editor/use-list-view-drop-zone.js new file mode 100644 index 0000000000000..346631667c254 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/use-list-view-drop-zone.js @@ -0,0 +1,260 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useState, useCallback } from '@wordpress/element'; +import { + useThrottle, + __experimentalUseDropZone as useDropZone, +} from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { + getDistanceToNearestEdge, + isPointContainedByRect, +} from '../../utils/math'; +import useOnBlockDrop from '../use-on-block-drop'; +import { store as blockEditorStore } from '../../store'; + +/** @typedef {import('../../utils/math').WPPoint} WPPoint */ + +/** + * The type of a drag event. + * + * @typedef {'default'|'file'|'html'} WPDragEventType + */ + +/** + * An array representing data for blocks in the DOM used by drag and drop. + * + * @typedef {Object} WPListViewDropZoneBlocks + * @property {string} clientId The client id for the block. + * @property {string} rootClientId The root client id for the block. + * @property {number} blockIndex The block's index. + * @property {Element} element The DOM element representing the block. + * @property {number} innerBlockCount The number of inner blocks the block has. + * @property {boolean} isDraggedBlock Whether the block is currently being dragged. + * @property {boolean} canInsertDraggedBlocksAsSibling Whether the dragged block can be a sibling of this block. + * @property {boolean} canInsertDraggedBlocksAsChild Whether the dragged block can be a child of this block. + */ + +/** + * An object containing details of a drop target. + * + * @typedef {Object} WPListViewDropZoneTarget + * @property {string} blockIndex The insertion index. + * @property {string} rootClientId The root client id for the block. + * @property {string|undefined} clientId The client id for the block. + * @property {'top'|'bottom'|'inside'} dropPosition The position relative to the block that the user is dropping to. + * 'inside' refers to nesting as an inner block. + */ + +/** + * Determines whether the user positioning the dragged block to nest as an + * inner block. + * + * Presently this is determined by whether the cursor is on the right hand side + * of the block. + * + * @param {WPPoint} point The point representing the cursor position when dragging. + * @param {DOMRect} rect The rectangle. + */ +function isNestingGesture( point, rect ) { + const blockCenterX = rect.left + rect.width / 2; + return point.x > blockCenterX; +} + +// Block navigation is always a vertical list, so only allow dropping +// to the above or below a block. +const ALLOWED_DROP_EDGES = [ 'top', 'bottom' ]; + +/** + * Given blocks data and the cursor position, compute the drop target. + * + * @param {WPListViewDropZoneBlocks} blocksData Data about the blocks in list view. + * @param {WPPoint} position The point representing the cursor position when dragging. + * + * @return {WPListViewDropZoneTarget} An object containing data about the drop target. + */ +function getListViewDropTarget( blocksData, position ) { + let candidateEdge; + let candidateBlockData; + let candidateDistance; + let candidateRect; + + for ( const blockData of blocksData ) { + if ( blockData.isDraggedBlock ) { + continue; + } + + const rect = blockData.element.getBoundingClientRect(); + const [ distance, edge ] = getDistanceToNearestEdge( + position, + rect, + ALLOWED_DROP_EDGES + ); + + const isCursorWithinBlock = isPointContainedByRect( position, rect ); + if ( + candidateDistance === undefined || + distance < candidateDistance || + isCursorWithinBlock + ) { + candidateDistance = distance; + + const index = blocksData.indexOf( blockData ); + const previousBlockData = blocksData[ index - 1 ]; + + // If dragging near the top of a block and the preceding block + // is at the same level, use the preceding block as the candidate + // instead, as later it makes determining a nesting drop easier. + if ( + edge === 'top' && + previousBlockData && + previousBlockData.rootClientId === blockData.rootClientId && + ! previousBlockData.isDraggedBlock + ) { + candidateBlockData = previousBlockData; + candidateEdge = 'bottom'; + candidateRect = + previousBlockData.element.getBoundingClientRect(); + } else { + candidateBlockData = blockData; + candidateEdge = edge; + candidateRect = rect; + } + + // If the mouse position is within the block, break early + // as the user would intend to drop either before or after + // this block. + // + // This solves an issue where some rows in the list view + // tree overlap slightly due to sub-pixel rendering. + if ( isCursorWithinBlock ) { + break; + } + } + } + + if ( ! candidateBlockData ) { + return; + } + + const isDraggingBelow = candidateEdge === 'bottom'; + + // If the user is dragging towards the bottom of the block check whether + // they might be trying to nest the block as a child. + // If the block already has inner blocks, this should always be treated + // as nesting since the next block in the tree will be the first child. + if ( + isDraggingBelow && + candidateBlockData.canInsertDraggedBlocksAsChild && + ( candidateBlockData.innerBlockCount > 0 || + isNestingGesture( position, candidateRect ) ) + ) { + return { + rootClientId: candidateBlockData.clientId, + blockIndex: 0, + dropPosition: 'inside', + }; + } + + // If dropping as a sibling, but block cannot be inserted in + // this context, return early. + if ( ! candidateBlockData.canInsertDraggedBlocksAsSibling ) { + return; + } + + const offset = isDraggingBelow ? 1 : 0; + return { + rootClientId: candidateBlockData.rootClientId, + clientId: candidateBlockData.clientId, + blockIndex: candidateBlockData.blockIndex + offset, + dropPosition: candidateEdge, + }; +} + +/** + * A react hook for implementing a drop zone in list view. + * + * @return {WPListViewDropZoneTarget} The drop target. + */ +export default function useListViewDropZone() { + const { + getBlockRootClientId, + getBlockIndex, + getBlockCount, + getDraggedBlockClientIds, + canInsertBlocks, + } = useSelect( blockEditorStore ); + const [ target, setTarget ] = useState(); + const { rootClientId: targetRootClientId, blockIndex: targetBlockIndex } = + target || {}; + + const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex ); + + const draggedBlockClientIds = getDraggedBlockClientIds(); + const throttled = useThrottle( + useCallback( + ( event, currentTarget ) => { + const position = { x: event.clientX, y: event.clientY }; + const isBlockDrag = !! draggedBlockClientIds?.length; + + const blockElements = Array.from( + currentTarget.querySelectorAll( '[data-block]' ) + ); + + const blocksData = blockElements.map( ( blockElement ) => { + const clientId = blockElement.dataset.block; + const rootClientId = getBlockRootClientId( clientId ); + + return { + clientId, + rootClientId, + blockIndex: getBlockIndex( clientId ), + element: blockElement, + isDraggedBlock: isBlockDrag + ? draggedBlockClientIds.includes( clientId ) + : false, + innerBlockCount: getBlockCount( clientId ), + canInsertDraggedBlocksAsSibling: isBlockDrag + ? canInsertBlocks( + draggedBlockClientIds, + rootClientId + ) + : true, + canInsertDraggedBlocksAsChild: isBlockDrag + ? canInsertBlocks( draggedBlockClientIds, clientId ) + : true, + }; + } ); + + const newTarget = getListViewDropTarget( blocksData, position ); + + if ( newTarget ) { + setTarget( newTarget ); + } + }, + [ draggedBlockClientIds ] + ), + 200 + ); + + const ref = useDropZone( { + onDrop: onBlockDrop, + onDragOver( event ) { + // `currentTarget` is only available while the event is being + // handled, so get it now and pass it to the thottled function. + // https://developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget + throttled( event, event.currentTarget ); + }, + onDragEnd() { + throttled.cancel(); + setTarget( null ); + }, + } ); + + return { ref, target }; +} diff --git a/packages/block-editor/src/components/off-canvas-editor/use-list-view-expand-selected-item.js b/packages/block-editor/src/components/off-canvas-editor/use-list-view-expand-selected-item.js new file mode 100644 index 0000000000000..09b5e09e4713a --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/use-list-view-expand-selected-item.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +export default function useListViewExpandSelectedItem( { + firstSelectedBlockClientId, + setExpandedState, +} ) { + const [ selectedTreeId, setSelectedTreeId ] = useState( null ); + const { selectedBlockParentClientIds } = useSelect( + ( select ) => { + const { getBlockParents } = select( blockEditorStore ); + return { + selectedBlockParentClientIds: getBlockParents( + firstSelectedBlockClientId, + false + ), + }; + }, + [ firstSelectedBlockClientId ] + ); + + const parentClientIds = + Array.isArray( selectedBlockParentClientIds ) && + selectedBlockParentClientIds.length + ? selectedBlockParentClientIds + : null; + + // Expand tree when a block is selected. + useEffect( () => { + // If the selectedTreeId is the same as the selected block, + // it means that the block was selected using the block list tree. + if ( selectedTreeId === firstSelectedBlockClientId ) { + return; + } + + // If the selected block has parents, get the top-level parent. + if ( parentClientIds ) { + // If the selected block has parents, + // expand the tree branch. + setExpandedState( { + type: 'expand', + clientIds: selectedBlockParentClientIds, + } ); + } + }, [ firstSelectedBlockClientId ] ); + + return { + setSelectedTreeId, + }; +} diff --git a/packages/block-editor/src/components/off-canvas-editor/utils.js b/packages/block-editor/src/components/off-canvas-editor/utils.js new file mode 100644 index 0000000000000..f53f5a4cd4884 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/utils.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +export const getBlockPositionDescription = ( position, siblingCount, level ) => + sprintf( + /* translators: 1: The numerical position of the block. 2: The total number of blocks. 3. The level of nesting for the block. */ + __( 'Block %1$d of %2$d, Level %3$d' ), + position, + siblingCount, + level + ); + +/** + * Returns true if the client ID occurs within the block selection or multi-selection, + * or false otherwise. + * + * @param {string} clientId Block client ID. + * @param {string|string[]} selectedBlockClientIds Selected block client ID, or an array of multi-selected blocks client IDs. + * + * @return {boolean} Whether the block is in multi-selection set. + */ +export const isClientIdSelected = ( clientId, selectedBlockClientIds ) => + Array.isArray( selectedBlockClientIds ) && selectedBlockClientIds.length + ? selectedBlockClientIds.indexOf( clientId ) !== -1 + : selectedBlockClientIds === clientId; + +/** + * From a start and end clientId of potentially different nesting levels, + * return the nearest-depth ids that have a common level of depth in the + * nesting hierarchy. For multiple block selection, this ensure that the + * selection is always at the same nesting level, and not split across + * separate levels. + * + * @param {string} startId The first id of a selection. + * @param {string} endId The end id of a selection, usually one that has been clicked on. + * @param {string[]} startParents An array of ancestor ids for the start id, in descending order. + * @param {string[]} endParents An array of ancestor ids for the end id, in descending order. + * @return {Object} An object containing the start and end ids. + */ +export function getCommonDepthClientIds( + startId, + endId, + startParents, + endParents +) { + const startPath = [ ...startParents, startId ]; + const endPath = [ ...endParents, endId ]; + const depth = Math.min( startPath.length, endPath.length ) - 1; + const start = startPath[ depth ]; + const end = endPath[ depth ]; + + return { + start, + end, + }; +} diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 918b609f92a0e..12a20d8970308 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -9,6 +9,7 @@ import classnames from 'classnames'; import { useState, useEffect, useRef, Platform } from '@wordpress/element'; import { addQueryArgs } from '@wordpress/url'; import { + __experimentalOffCanvasEditor as OffCanvasEditor, InspectorControls, useBlockProps, __experimentalRecursionProvider as RecursionProvider, @@ -31,6 +32,8 @@ import { __experimentalToggleGroupControlOption as ToggleGroupControlOption, Button, Spinner, + __experimentalHStack as HStack, + __experimentalHeading as Heading, } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; @@ -82,6 +85,9 @@ function Navigation( { hasColorSettings = true, customPlaceholder: CustomPlaceholder = null, } ) { + const isOffCanvasNavigationEditorEnabled = + window?.__experimentalEnableOffCanvasNavigationEditor === true; + const { openSubmenusOnClick, overlayMenu, @@ -650,45 +656,92 @@ function Navigation( { return ( <TagName { ...blockProps }> <InspectorControls> - <PanelBody title={ __( 'Menu' ) }> - <NavigationMenuSelector - currentMenuId={ ref } - clientId={ clientId } - onSelectNavigationMenu={ ( menuId ) => { - handleUpdateMenu( menuId ); - } } - onSelectClassicMenu={ async ( classicMenu ) => { - const navMenu = await convertClassicMenu( - classicMenu.id, - classicMenu.name, - 'draft' - ); - if ( navMenu ) { - handleUpdateMenu( navMenu.id, { - focusNavigationBlock: true, - } ); + { isOffCanvasNavigationEditorEnabled && ( + <PanelBody> + <HStack className="wp-block-navigation-off-canvas-editor__header"> + <Heading + className="wp-block-navigation-off-canvas-editor__title" + level={ 2 } + > + { __( 'Menu' ) } + </Heading> + <NavigationMenuSelector + currentMenuId={ ref } + clientId={ clientId } + onSelectNavigationMenu={ ( menuId ) => { + handleUpdateMenu( menuId ); + } } + onSelectClassicMenu={ async ( + classicMenu + ) => { + const navMenu = + await convertClassicMenu( + classicMenu.id, + classicMenu.name, + 'draft' + ); + if ( navMenu ) { + handleUpdateMenu( navMenu.id, { + focusNavigationBlock: true, + } ); + } + } } + onCreateNew={ + createUntitledEmptyNavigationMenu + } + createNavigationMenuIsSuccess={ + createNavigationMenuIsSuccess + } + /* translators: %s: The name of a menu. */ + actionLabel={ __( "Switch to '%s'" ) } + /> + </HStack> + + <OffCanvasEditor + blocks={ innerBlocks } + isExpanded={ true } + selectBlockInCanvas={ false } + /> + </PanelBody> + ) } + { ! isOffCanvasNavigationEditorEnabled && ( + <PanelBody title={ __( 'Menu' ) }> + <NavigationMenuSelector + currentMenuId={ ref } + clientId={ clientId } + onSelectNavigationMenu={ ( menuId ) => { + handleUpdateMenu( menuId ); + } } + onSelectClassicMenu={ async ( classicMenu ) => { + const navMenu = await convertClassicMenu( + classicMenu.id, + classicMenu.name, + 'draft' + ); + if ( navMenu ) { + handleUpdateMenu( navMenu.id, { + focusNavigationBlock: true, + } ); + } + } } + onCreateNew={ + createUntitledEmptyNavigationMenu } - } } - onCreateNew={ createUntitledEmptyNavigationMenu } - createNavigationMenuIsSuccess={ - createNavigationMenuIsSuccess - } - /* translators: %s: The name of a menu. */ - actionLabel={ __( "Switch to '%s'" ) } - /> - <Button - variant="link" - disabled={ - ! hasManagePermissions || - ! hasResolvedNavigationMenus - } - href={ addQueryArgs( 'edit.php', { - post_type: 'wp_navigation', - } ) } - > - { __( 'Manage menus' ) } - </Button> - </PanelBody> + createNavigationMenuIsSuccess={ + createNavigationMenuIsSuccess + } + /* translators: %s: The name of a menu. */ + actionLabel={ __( "Switch to '%s'" ) } + /> + { isOffCanvasNavigationEditorEnabled && ( + <OffCanvasEditor + blocks={ innerBlocks } + isExpanded={ true } + selectBlockInCanvas={ false } + /> + ) } + </PanelBody> + ) } </InspectorControls> { stylingInspectorControls } <ResponsiveWrapper @@ -733,45 +786,85 @@ function Navigation( { return ( <TagName { ...blockProps }> <InspectorControls> - <PanelBody title={ __( 'Menu' ) }> - <NavigationMenuSelector - currentMenuId={ null } - clientId={ clientId } - onSelectNavigationMenu={ ( menuId ) => { - handleUpdateMenu( menuId ); - } } - onSelectClassicMenu={ async ( classicMenu ) => { - const navMenu = await convertClassicMenu( - classicMenu.id, - classicMenu.name, - 'draft' - ); - if ( navMenu ) { - handleUpdateMenu( navMenu.id, { - focusNavigationBlock: true, - } ); + { isOffCanvasNavigationEditorEnabled && ( + <PanelBody> + <HStack className="wp-block-navigation-off-canvas-editor__header"> + <Heading + className="wp-block-navigation-off-canvas-editor__title" + level={ 2 } + > + { __( 'Menu' ) } + </Heading> + <NavigationMenuSelector + currentMenuId={ null } + clientId={ clientId } + onSelectNavigationMenu={ ( menuId ) => { + handleUpdateMenu( menuId ); + } } + onSelectClassicMenu={ async ( + classicMenu + ) => { + const navMenu = + await convertClassicMenu( + classicMenu.id, + classicMenu.name, + 'draft' + ); + if ( navMenu ) { + handleUpdateMenu( navMenu.id, { + focusNavigationBlock: true, + } ); + } + } } + onCreateNew={ + createUntitledEmptyNavigationMenu + } + createNavigationMenuIsSuccess={ + createNavigationMenuIsSuccess + } + /* translators: %s: The name of a menu. */ + actionLabel={ __( "Switch to '%s'" ) } + /> + </HStack> + + <OffCanvasEditor + blocks={ innerBlocks } + isExpanded={ true } + selectBlockInCanvas={ false } + /> + </PanelBody> + ) } + { ! isOffCanvasNavigationEditorEnabled && ( + <PanelBody title={ __( 'Menu' ) }> + <NavigationMenuSelector + currentMenuId={ null } + clientId={ clientId } + onSelectNavigationMenu={ ( menuId ) => { + handleUpdateMenu( menuId ); + } } + onSelectClassicMenu={ async ( classicMenu ) => { + const navMenu = await convertClassicMenu( + classicMenu.id, + classicMenu.name, + 'draft' + ); + if ( navMenu ) { + handleUpdateMenu( navMenu.id, { + focusNavigationBlock: true, + } ); + } + } } + onCreateNew={ + createUntitledEmptyNavigationMenu } - } } - onCreateNew={ createUntitledEmptyNavigationMenu } - createNavigationMenuIsSuccess={ - createNavigationMenuIsSuccess - } - /* translators: %s: The name of a menu. */ - actionLabel={ __( "Switch to '%s'" ) } - /> - <Button - variant="link" - disabled={ - ! hasManagePermissions || - ! hasResolvedNavigationMenus - } - href={ addQueryArgs( 'edit.php', { - post_type: 'wp_navigation', - } ) } - > - { __( 'Manage menus' ) } - </Button> - </PanelBody> + createNavigationMenuIsSuccess={ + createNavigationMenuIsSuccess + } + /* translators: %s: The name of a menu. */ + actionLabel={ __( "Switch to '%s'" ) } + /> + </PanelBody> + ) } </InspectorControls> <Warning> { __( @@ -844,52 +937,73 @@ function Navigation( { ); } + const navigationMenuSelectorInstance = ( + <NavigationMenuSelector + currentMenuId={ ref } + clientId={ clientId } + onSelectNavigationMenu={ ( menuId ) => { + handleUpdateMenu( menuId ); + } } + onSelectClassicMenu={ async ( classicMenu ) => { + const navMenu = await convertClassicMenu( + classicMenu.id, + classicMenu.name, + 'draft' + ); + if ( navMenu ) { + handleUpdateMenu( navMenu.id, { + focusNavigationBlock: true, + } ); + } + } } + onCreateNew={ createUntitledEmptyNavigationMenu } + createNavigationMenuIsSuccess={ createNavigationMenuIsSuccess } + createNavigationMenuIsError={ createNavigationMenuIsError } + /* translators: %s: The name of a menu. */ + actionLabel={ __( "Switch to '%s'" ) } + /> + ); + return ( <EntityProvider kind="postType" type="wp_navigation" id={ ref }> <RecursionProvider uniqueId={ recursionId }> <InspectorControls> - <PanelBody title={ __( 'Menu' ) }> - <NavigationMenuSelector - currentMenuId={ ref } - clientId={ clientId } - onSelectNavigationMenu={ ( menuId ) => { - handleUpdateMenu( menuId ); - } } - onSelectClassicMenu={ async ( classicMenu ) => { - const navMenu = await convertClassicMenu( - classicMenu.id, - classicMenu.name, - 'draft' - ); - if ( navMenu ) { - handleUpdateMenu( navMenu.id, { - focusNavigationBlock: true, - } ); + { isOffCanvasNavigationEditorEnabled && ( + <PanelBody> + <HStack className="wp-block-navigation-off-canvas-editor__header"> + <Heading + className="wp-block-navigation-off-canvas-editor__title" + level={ 2 } + > + { __( 'Menu' ) } + </Heading> + { navigationMenuSelectorInstance } + </HStack> + + <OffCanvasEditor + blocks={ innerBlocks } + isExpanded={ true } + selectBlockInCanvas={ false } + /> + </PanelBody> + ) } + { ! isOffCanvasNavigationEditorEnabled && ( + <PanelBody title={ __( 'Menu' ) }> + { navigationMenuSelectorInstance } + <Button + variant="link" + disabled={ + ! hasManagePermissions || + ! hasResolvedNavigationMenus } - } } - onCreateNew={ createUntitledEmptyNavigationMenu } - createNavigationMenuIsSuccess={ - createNavigationMenuIsSuccess - } - createNavigationMenuIsError={ - createNavigationMenuIsError - } - /* translators: %s: The name of a menu. */ - actionLabel={ __( "Switch to '%s'" ) } - /> - <Button - variant="link" - disabled={ - ! hasManagePermissions || - ! hasResolvedNavigationMenus - } - href={ addQueryArgs( 'edit.php', { - post_type: 'wp_navigation', - } ) } - > - { __( 'Manage menus' ) } - </Button> - </PanelBody> + href={ addQueryArgs( 'edit.php', { + post_type: 'wp_navigation', + } ) } + > + { __( 'Manage menus' ) } + </Button> + </PanelBody> + ) } </InspectorControls> { stylingInspectorControls } { isEntityAvailable && ( @@ -915,6 +1029,21 @@ function Navigation( { } } /> ) } + { isOffCanvasNavigationEditorEnabled && ( + <Button + variant="link" + className="wp-block-navigation-manage-menus-button" + disabled={ + ! hasManagePermissions || + ! hasResolvedNavigationMenus + } + href={ addQueryArgs( 'edit.php', { + post_type: 'wp_navigation', + } ) } + > + { __( 'Manage menus' ) } + </Button> + ) } </InspectorControls> ) } diff --git a/packages/block-library/src/navigation/edit/navigation-menu-selector.js b/packages/block-library/src/navigation/edit/navigation-menu-selector.js index 036d86f06f215..8103dfeef3d1b 100644 --- a/packages/block-library/src/navigation/edit/navigation-menu-selector.js +++ b/packages/block-library/src/navigation/edit/navigation-menu-selector.js @@ -10,7 +10,7 @@ import { VisuallyHidden, } from '@wordpress/components'; import { useEntityProp } from '@wordpress/core-data'; -import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; +import { Icon, chevronUp, chevronDown, moreVertical } from '@wordpress/icons'; import { __, sprintf } from '@wordpress/i18n'; import { decodeEntities } from '@wordpress/html-entities'; import { useEffect, useMemo, useState } from '@wordpress/element'; @@ -31,6 +31,9 @@ function NavigationMenuSelector( { createNavigationMenuIsError, toggleProps = {}, } ) { + const isOffCanvasNavigationEditorEnabled = + window?.__experimentalEnableOffCanvasNavigationEditor === true; + /* translators: %s: The name of a menu. */ const createActionLabel = __( "Create from '%s'" ); @@ -161,11 +164,19 @@ function NavigationMenuSelector( { return ( <DropdownMenu - className="wp-block-navigation__navigation-selector" + className={ + isOffCanvasNavigationEditorEnabled + ? '' + : 'wp-block-navigation__navigation-selector' + } label={ selectorLabel } - text={ selectorLabel } - icon={ null } - toggleProps={ toggleProps } + text={ isOffCanvasNavigationEditorEnabled ? '' : selectorLabel } + icon={ isOffCanvasNavigationEditorEnabled ? moreVertical : null } + toggleProps={ + isOffCanvasNavigationEditorEnabled + ? { isSmall: true } + : toggleProps + } > { ( { onClose } ) => ( <> diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index 1658c90fa38f1..5c7e9a231fc1b 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -544,6 +544,13 @@ body.editor-styles-wrapper color: inherit; } +.components-heading.wp-block-navigation-off-canvas-editor__title { + margin: 0; +} +.wp-block-navigation-off-canvas-editor__header { + margin-bottom: $grid-unit-10; +} + // Customize the mobile editing. // This can be revisited in the future, but for now, inherit design from the parent. .is-menu-open .wp-block-navigation__responsive-container-content * { @@ -582,6 +589,13 @@ body.editor-styles-wrapper margin-bottom: $grid-unit-20; } +// increased specificity to override button variant +// for the manage menus button in the advanced area +// of the navigation block +.components-button.is-link.wp-block-navigation-manage-menus-button { + margin-bottom: $grid-unit-20; +} + .wp-block-navigation__overlay-menu-preview { display: flex; align-items: center;