From 00374e02f40c6a70f561609393023f3a7695662c Mon Sep 17 00:00:00 2001 From: antpb Date: Fri, 19 May 2023 18:56:55 -0500 Subject: [PATCH] Process shortcodes before processing blocks so that dynamic blocks, by default, do not have shortcodes expanded --- .../components/off-canvas-editor/README.md | 5 + .../components/off-canvas-editor/appender.js | 100 +++++ .../off-canvas-editor/block-contents.js | 156 ++++++++ .../off-canvas-editor/block-select-button.js | 128 +++++++ .../src/components/off-canvas-editor/block.js | 349 ++++++++++++++++++ .../components/off-canvas-editor/branch.js | 238 ++++++++++++ .../components/off-canvas-editor/context.js | 8 + .../off-canvas-editor/drop-indicator.js | 126 +++++++ .../components/off-canvas-editor/expander.js | 26 ++ .../src/components/off-canvas-editor/index.js | 271 ++++++++++++++ .../src/components/off-canvas-editor/leaf.js | 52 +++ .../components/off-canvas-editor/link-ui.js | 167 +++++++++ .../components/off-canvas-editor/style.scss | 34 ++ .../test/use-inserted-block.js | 108 ++++++ .../off-canvas-editor/test/utils.js | 50 +++ .../off-canvas-editor/update-attributes.js | 99 +++++ .../off-canvas-editor/use-block-selection.js | 169 +++++++++ .../off-canvas-editor/use-inserted-block.js | 47 +++ .../use-list-view-client-ids.js | 29 ++ .../use-list-view-drop-zone.js | 260 +++++++++++++ .../use-list-view-expand-selected-item.js | 58 +++ .../src/components/off-canvas-editor/utils.js | 58 +++ .../block-library/src/template-part/index.php | 4 +- 23 files changed, 2540 insertions(+), 2 deletions(-) create mode 100644 packages/block-editor/src/components/off-canvas-editor/README.md create mode 100644 packages/block-editor/src/components/off-canvas-editor/appender.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/block-contents.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/block-select-button.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/block.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/branch.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/context.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/drop-indicator.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/expander.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/index.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/leaf.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/link-ui.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/style.scss create mode 100644 packages/block-editor/src/components/off-canvas-editor/test/use-inserted-block.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/test/utils.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/update-attributes.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/use-block-selection.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/use-inserted-block.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/use-list-view-client-ids.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/use-list-view-drop-zone.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/use-list-view-expand-selected-item.js create mode 100644 packages/block-editor/src/components/off-canvas-editor/utils.js 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 00000000000000..c2f5293edf422d --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/README.md @@ -0,0 +1,5 @@ +# Off Canvas Editor + +The OffCanvasEditor 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/appender.js b/packages/block-editor/src/components/off-canvas-editor/appender.js new file mode 100644 index 00000000000000..1b91f5bdd76845 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/appender.js @@ -0,0 +1,100 @@ +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; +import { speak } from '@wordpress/a11y'; +import { useSelect } from '@wordpress/data'; +import { forwardRef, useState, useEffect } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import useBlockDisplayTitle from '../block-title/use-block-display-title'; +import { ComposedPrivateInserter as PrivateInserter } from '../inserter'; + +export const Appender = forwardRef( + ( { nestingLevel, blockCount, clientId, ...props }, ref ) => { + const [ insertedBlock, setInsertedBlock ] = useState( null ); + + const instanceId = useInstanceId( Appender ); + const { hideInserter } = useSelect( + ( select ) => { + const { getTemplateLock, __unstableGetEditorMode } = + select( blockEditorStore ); + + return { + hideInserter: + !! getTemplateLock( clientId ) || + __unstableGetEditorMode() === 'zoom-out', + }; + }, + [ clientId ] + ); + + const blockTitle = useBlockDisplayTitle( { + clientId, + context: 'list-view', + } ); + + const insertedBlockTitle = useBlockDisplayTitle( { + clientId: insertedBlock?.clientId, + context: 'list-view', + } ); + + useEffect( () => { + if ( ! insertedBlockTitle?.length ) { + return; + } + + speak( + sprintf( + // translators: %s: name of block being inserted (i.e. Paragraph, Image, Group etc) + __( '%s block inserted' ), + insertedBlockTitle + ), + 'assertive' + ); + }, [ insertedBlockTitle ] ); + + if ( hideInserter ) { + return null; + } + const descriptionId = `off-canvas-editor-appender__${ instanceId }`; + const description = sprintf( + /* translators: 1: The name of the block. 2: The numerical position of the block. 3: The level of nesting for the block. */ + __( 'Append to %1$s block at position %2$d, Level %3$d' ), + blockTitle, + blockCount + 1, + nestingLevel + ); + + return ( +
+ { + if ( maybeInsertedBlock?.clientId ) { + setInsertedBlock( maybeInsertedBlock ); + } + } } + /> +
+ { description } +
+
+ ); + } +); 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 00000000000000..796240b0a143cc --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/block-contents.js @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { forwardRef, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import ListViewBlockSelectButton from './block-select-button'; +import BlockDraggable from '../block-draggable'; +import { store as blockEditorStore } from '../../store'; +import { updateAttributes } from './update-attributes'; +import { LinkUI } from './link-ui'; +import { useInsertedBlock } from './use-inserted-block'; +import { useListViewContext } from './context'; + +const BLOCKS_WITH_LINK_UI_SUPPORT = [ + 'core/navigation-link', + 'core/navigation-submenu', +]; + +const ListViewBlockContents = forwardRef( + ( + { + onClick, + onToggleExpanded, + block, + isSelected, + position, + siblingBlockCount, + level, + isExpanded, + selectedClientIds, + ...props + }, + ref + ) => { + const { clientId } = block; + const [ isLinkUIOpen, setIsLinkUIOpen ] = useState(); + const { + blockMovingClientId, + selectedBlockInBlockEditor, + lastInsertedBlockClientId, + } = useSelect( + ( select ) => { + const { + hasBlockMovingClientId, + getSelectedBlockClientId, + getLastInsertedBlocksClientIds, + } = unlock( select( blockEditorStore ) ); + const lastInsertedBlocksClientIds = + getLastInsertedBlocksClientIds(); + return { + blockMovingClientId: hasBlockMovingClientId(), + selectedBlockInBlockEditor: getSelectedBlockClientId(), + lastInsertedBlockClientId: + lastInsertedBlocksClientIds && + lastInsertedBlocksClientIds[ 0 ], + }; + }, + [ clientId ] + ); + + const { + insertedBlockAttributes, + insertedBlockName, + setInsertedBlockAttributes, + } = useInsertedBlock( lastInsertedBlockClientId ); + + const hasExistingLinkValue = insertedBlockAttributes?.url; + + useEffect( () => { + if ( + clientId === lastInsertedBlockClientId && + BLOCKS_WITH_LINK_UI_SUPPORT?.includes( insertedBlockName ) && + ! hasExistingLinkValue // don't re-show the Link UI if the block already has a link value. + ) { + setIsLinkUIOpen( true ); + } + }, [ + lastInsertedBlockClientId, + clientId, + insertedBlockName, + hasExistingLinkValue, + ] ); + + const { renderAdditionalBlockUI } = useListViewContext(); + + 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 ( + <> + { renderAdditionalBlockUI && renderAdditionalBlockUI( block ) } + { isLinkUIOpen && ( + setIsLinkUIOpen( false ) } + hasCreateSuggestion={ false } + onChange={ ( updatedValue ) => { + updateAttributes( + updatedValue, + setInsertedBlockAttributes, + insertedBlockAttributes + ); + setIsLinkUIOpen( false ); + } } + onCancel={ () => setIsLinkUIOpen( false ) } + /> + ) } + + { ( { draggable, onDragStart, onDragEnd } ) => ( + + ) } + + + ); + } +); + +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 00000000000000..51404f6a39dbc5 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/block-select-button.js @@ -0,0 +1,128 @@ +/** + * 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, lockSmall as lock } from '@wordpress/icons'; +import { SPACE, ENTER } from '@wordpress/keycodes'; +import { sprintf, __ } from '@wordpress/i18n'; + +/** + * 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, + onClick, + onToggleExpanded, + tabIndex, + onFocus, + onDragStart, + onDragEnd, + draggable, + }, + ref +) { + const { clientId } = block; + 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 ); + } + } + + const editAriaLabel = blockInformation + ? sprintf( + // translators: %s: The title of the block. + __( 'Edit %s block' ), + blockInformation.title + ) + : __( 'Edit' ); + + return ( + <> + + + ); +} + +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 00000000000000..d5bc4e46c6ce97 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/block.js @@ -0,0 +1,349 @@ +/** + * 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: { clientId }, + isDragged, + isSelected, + isBranchSelected, + selectBlock, + position, + level, + rowCount, + siblingBlockCount, + showBlockMovers, + path, + isExpanded, + selectedClientIds, + preventAnnouncement, +} ) { + const cellRef = useRef( null ); + const [ isHovered, setIsHovered ] = useState( false ); + + 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 block = useSelect( + ( select ) => select( blockEditorStore ).getBlock( clientId ), + [ clientId ] + ); + + // 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 { isTreeGridMounted, expand, expandedState, collapse, LeafMoreMenu } = + useListViewContext(); + + 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 ] + ); + + const instanceId = useInstanceId( ListViewBlock ); + + if ( ! block ) { + return null; + } + + // 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 = + !! block && + hasBlockSupport( block.name, '__experimentalToolbar', true ); + + 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 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 } + ); + + let colSpan; + if ( hasRenderedMovers ) { + colSpan = 1; + } else if ( ! showBlockActions ) { + colSpan = 2; + } + + 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 ]; + + const MoreMenuComponent = LeafMoreMenu + ? LeafMoreMenu + : BlockSettingsDropdown; + + return ( + + + { ( { ref, tabIndex, onFocus } ) => ( +
+ +
+ { blockPositionDescription } +
+
+ ) } +
+ { hasRenderedMovers && ( + <> + + + { ( { ref, tabIndex, onFocus } ) => ( + + ) } + + + { ( { ref, tabIndex, onFocus } ) => ( + + ) } + + + + ) } + + { showBlockActions && ( + <> + + { ( { ref, tabIndex, onFocus } ) => ( + <> + + + ) } + + + ) } +
+ ); +} + +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 00000000000000..3749ef65add494 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/branch.js @@ -0,0 +1,238 @@ +/** + * WordPress dependencies + */ +import { + __experimentalTreeGridRow as TreeGridRow, + __experimentalTreeGridCell as TreeGridCell, +} from '@wordpress/components'; +import { AsyncModeProvider, useSelect } from '@wordpress/data'; +import { memo } from '@wordpress/element'; + +/** + * Internal dependencies + */ + +import { Appender } from './appender'; +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; + }; + +const noop = () => {}; + +function ListViewBranch( props ) { + const { + blocks, + selectBlock = noop, + showBlockMovers, + selectedClientIds, + level = 1, + path = '', + isBranchSelected = false, + listPosition = 0, + fixedListWindow, + isExpanded, + parentId, + shouldShowInnerBlocks = true, + showAppender: showAppenderProp = true, + } = props; + + const isContentLocked = useSelect( + ( select ) => { + return !! ( + parentId && + select( blockEditorStore ).getTemplateLock( parentId ) === + 'contentOnly' + ); + }, + [ parentId ] + ); + + const { expandedState, draggedClientIds } = useListViewContext(); + + if ( isContentLocked ) { + return null; + } + + // Only show the appender at the first level. + const showAppender = showAppenderProp && level === 1; + + const filteredBlocks = blocks.filter( Boolean ); + const blockCount = filteredBlocks.length; + + // The appender means an extra row in List View, so add 1 to the row count. + const rowCount = showAppender ? blockCount + 1 : blockCount; + 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 ( + + { showBlock && ( + + ) } + { ! showBlock && ( + + + + ) } + { hasNestedBlocks && shouldExpand && ! isDragged && ( + + ) } + + ); + } ) } + { showAppender && ( + + + { ( treeGridCellProps ) => ( + + ) } + + + ) } + + ); +} + +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 00000000000000..c837dce9ca23fd --- /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 00000000000000..1e8d51a73919ab --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/drop-indicator.js @@ -0,0 +1,126 @@ +/** + * 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 ( + +
+ + ); +} 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 00000000000000..3b93f8ad01185c --- /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 + onClick( event, { forceToggle: true } ) } + aria-hidden="true" + > + + + ); +} 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 00000000000000..97b8a4d4a71c51 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/index.js @@ -0,0 +1,271 @@ +/** + * WordPress dependencies + */ +import { + useMergeRefs, + __experimentalUseFixedWindowList as useFixedWindowList, +} from '@wordpress/compose'; +import { + __experimentalTreeGrid as TreeGrid, + __experimentalTreeGridRow as TreeGridRow, + __experimentalTreeGridCell as TreeGridCell, +} 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 {string} props.parentClientId The client id of the parent block. + * @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 {Object} props.LeafMoreMenu Optional more menu substitution. + * @param {string} props.description Optional accessible description for the tree grid component. + * @param {string} props.onSelect Optional callback to be invoked when a block is selected. + * @param {string} props.showAppender Flag to show or hide the block appender. + * @param {Function} props.renderAdditionalBlockUI Function that renders additional block content UI. + * @param {Object} ref Forwarded ref. + */ +function OffCanvasEditor( + { + id, + parentClientId, + blocks, + showBlockMovers = false, + isExpanded = false, + showAppender = true, + LeafMoreMenu, + description = __( 'Block navigation structure' ), + onSelect, + renderAdditionalBlockUI, + }, + ref +) { + const { getBlock } = useSelect( blockEditorStore ); + 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, blocks ] + ); + + 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, blockClientId ) => { + updateBlockSelection( event, blockClientId ); + setSelectedTreeId( blockClientId ); + if ( onSelect ) { + onSelect( getBlock( blockClientId ) ); + } + }, + [ setSelectedTreeId, updateBlockSelection, onSelect, getBlock ] + ); + 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( + ( blockClientId ) => { + if ( ! blockClientId ) { + return; + } + setExpandedState( { + type: 'expand', + clientIds: [ blockClientId ], + } ); + }, + [ setExpandedState ] + ); + const collapse = useCallback( + ( blockClientId ) => { + if ( ! blockClientId ) { + return; + } + setExpandedState( { + type: 'collapse', + clientIds: [ blockClientId ], + } ); + }, + [ 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, + LeafMoreMenu, + renderAdditionalBlockUI, + } ), + [ + isMounted.current, + draggedClientIds, + expandedState, + expand, + collapse, + LeafMoreMenu, + renderAdditionalBlockUI, + ] + ); + + return ( + + +
+ + + + + { ! clientIdsTree.length && ( + +
+ { __( + 'Your menu is currently empty. Add your first menu item to get started.' + ) } +
+
+ ) } +
+
+
+
+
+ ); +} + +export default forwardRef( OffCanvasEditor ); 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 00000000000000..7d74c85ffeb367 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/leaf.js @@ -0,0 +1,52 @@ +/** + * 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 ( + + { children } + + ); +} diff --git a/packages/block-editor/src/components/off-canvas-editor/link-ui.js b/packages/block-editor/src/components/off-canvas-editor/link-ui.js new file mode 100644 index 00000000000000..f6b5e2538d9e7c --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/link-ui.js @@ -0,0 +1,167 @@ +// Note: this file is copied directly from packages/block-library/src/navigation-link/link-ui.js + +/** + * WordPress dependencies + */ +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { Popover, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { switchToBlockType } from '@wordpress/blocks'; +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import LinkControl from '../link-control'; +import BlockIcon from '../block-icon'; + +/** + * Given the Link block's type attribute, return the query params to give to + * /wp/v2/search. + * + * @param {string} type Link block's type attribute. + * @param {string} kind Link block's entity of kind (post-type|taxonomy) + * @return {{ type?: string, subtype?: string }} Search query params. + */ +export function getSuggestionsQuery( type, kind ) { + switch ( type ) { + case 'post': + case 'page': + return { type: 'post', subtype: type }; + case 'category': + return { type: 'term', subtype: 'category' }; + case 'tag': + return { type: 'term', subtype: 'post_tag' }; + case 'post_format': + return { type: 'post-format' }; + default: + if ( kind === 'taxonomy' ) { + return { type: 'term', subtype: type }; + } + if ( kind === 'post-type' ) { + return { type: 'post', subtype: type }; + } + return {}; + } +} + +/** + * Add transforms to Link Control + * + * @param {Object} props Component props. + * @param {string} props.clientId Block client ID. + */ +function LinkControlTransforms( { clientId } ) { + const { getBlock, blockTransforms } = useSelect( + ( select ) => { + const { + getBlock: _getBlock, + getBlockRootClientId, + getBlockTransformItems, + } = select( blockEditorStore ); + + return { + getBlock: _getBlock, + blockTransforms: getBlockTransformItems( + _getBlock( clientId ), + getBlockRootClientId( clientId ) + ), + }; + }, + [ clientId ] + ); + + const { replaceBlock } = useDispatch( blockEditorStore ); + + const featuredBlocks = [ + 'core/page-list', + 'core/site-logo', + 'core/social-links', + 'core/search', + ]; + + const transforms = blockTransforms.filter( ( item ) => { + return featuredBlocks.includes( item.name ); + } ); + + if ( ! transforms?.length ) { + return null; + } + + if ( ! clientId ) { + return null; + } + + return ( +
+

+ { __( 'Transform' ) } +

+
+ { transforms.map( ( item ) => { + return ( + + ); + } ) } +
+
+ ); +} + +export function LinkUI( props ) { + const { label, url, opensInNewTab, type, kind } = props.link; + const link = { + url, + opensInNewTab, + title: label && stripHTML( label ), + }; + + return ( + + ( + + ) + : null + } + /> + + ); +} 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 00000000000000..6cf9f312265e30 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/style.scss @@ -0,0 +1,34 @@ +.offcanvas-editor-appender .block-editor-inserter__toggle { + background-color: #1e1e1e; + color: #fff; + margin: $grid-unit-10 0 0 24px; + border-radius: 2px; + height: 24px; + min-width: 24px; + padding: 0; + + &:hover, + &:focus { + background: var(--wp-admin-theme-color); + color: #fff; + } +} + +.offcanvas-editor-appender__description { + display: none; +} + +.offcanvas-editor-list-view-tree-wrapper { + max-width: 100%; + overflow-x: auto; +} + +.offcanvas-editor-list-view-leaf { + display: block; + // sidebar width - tab panel padding + max-width: $sidebar-width - (2 * $grid-unit-20); +} + +.offcanvas-editor-list-view-is-empty { + margin-left: $grid-unit-20; +} diff --git a/packages/block-editor/src/components/off-canvas-editor/test/use-inserted-block.js b/packages/block-editor/src/components/off-canvas-editor/test/use-inserted-block.js new file mode 100644 index 00000000000000..f4e6746581e008 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/test/use-inserted-block.js @@ -0,0 +1,108 @@ +/** + * Internal dependencies + */ +import { useInsertedBlock } from '../use-inserted-block'; + +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react'; + +jest.mock( '@wordpress/data/src/components/use-select', () => { + // This allows us to tweak the returned value on each test. + const mock = jest.fn(); + return mock; +} ); + +jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { + useDispatch: jest.fn(), +} ) ); + +describe( 'useInsertedBlock', () => { + const mockUpdateBlockAttributes = jest.fn(); + + it( 'returns undefined values when called without a block clientId', () => { + useSelect.mockImplementation( () => ( { + insertedBlockAttributes: { + 'some-attribute': 'some-value', + }, + insertedBlockName: 'core/navigation-link', + } ) ); + + useDispatch.mockImplementation( () => ( { + updateBlockAttributes: mockUpdateBlockAttributes, + } ) ); + + const { result } = renderHook( () => useInsertedBlock() ); + + const { + insertedBlockName, + insertedBlockAttributes, + setInsertedBlockAttributes, + } = result.current; + + expect( insertedBlockName ).toBeUndefined(); + expect( insertedBlockAttributes ).toBeUndefined(); + expect( + setInsertedBlockAttributes( { 'some-attribute': 'new-value' } ) + ).toBeUndefined(); + } ); + + it( 'returns name and attributes when called with a block clientId', () => { + useSelect.mockImplementation( () => ( { + insertedBlockAttributes: { + 'some-attribute': 'some-value', + }, + insertedBlockName: 'core/navigation-link', + } ) ); + + useDispatch.mockImplementation( () => ( { + updateBlockAttributes: mockUpdateBlockAttributes, + } ) ); + + const { result } = renderHook( () => + useInsertedBlock( 'some-client-id-here' ) + ); + + const { insertedBlockName, insertedBlockAttributes } = result.current; + + expect( insertedBlockName ).toBe( 'core/navigation-link' ); + expect( insertedBlockAttributes ).toEqual( { + 'some-attribute': 'some-value', + } ); + } ); + + it( 'dispatches updateBlockAttributes on provided client ID with new attributes when setInsertedBlockAttributes is called', () => { + useSelect.mockImplementation( () => ( { + insertedBlockAttributes: { + 'some-attribute': 'some-value', + }, + insertedBlockName: 'core/navigation-link', + } ) ); + + useDispatch.mockImplementation( () => ( { + updateBlockAttributes: mockUpdateBlockAttributes, + } ) ); + + const clientId = '123456789'; + + const { result } = renderHook( () => useInsertedBlock( clientId ) ); + + const { setInsertedBlockAttributes } = result.current; + + act( () => { + setInsertedBlockAttributes( { + 'some-attribute': 'new-value', + } ); + } ); + + expect( mockUpdateBlockAttributes ).toHaveBeenCalledWith( clientId, { + 'some-attribute': 'new-value', + } ); + } ); +} ); 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 00000000000000..78d78a9d90069c --- /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/update-attributes.js b/packages/block-editor/src/components/off-canvas-editor/update-attributes.js new file mode 100644 index 00000000000000..5133cae3878338 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/update-attributes.js @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ +import { escapeHTML } from '@wordpress/escape-html'; +import { safeDecodeURI } from '@wordpress/url'; + +/** + * @typedef {'post-type'|'custom'|'taxonomy'|'post-type-archive'} WPNavigationLinkKind + */ +/** + * Navigation Link Block Attributes + * + * @typedef {Object} WPNavigationLinkBlockAttributes + * + * @property {string} [label] Link text. + * @property {WPNavigationLinkKind} [kind] Kind is used to differentiate between term and post ids to check post draft status. + * @property {string} [type] The type such as post, page, tag, category and other custom types. + * @property {string} [rel] The relationship of the linked URL. + * @property {number} [id] A post or term id. + * @property {boolean} [opensInNewTab] Sets link target to _blank when true. + * @property {string} [url] Link href. + * @property {string} [title] Link title attribute. + */ +/** + * Link Control onChange handler that updates block attributes when a setting is changed. + * + * @param {Object} updatedValue New block attributes to update. + * @param {Function} setAttributes Block attribute update function. + * @param {WPNavigationLinkBlockAttributes} blockAttributes Current block attributes. + * + */ + +export const updateAttributes = ( + updatedValue = {}, + setAttributes, + blockAttributes = {} +) => { + const { + label: originalLabel = '', + kind: originalKind = '', + type: originalType = '', + } = blockAttributes; + + const { + title: newLabel = '', // the title of any provided Post. + url: newUrl = '', + opensInNewTab, + id, + kind: newKind = originalKind, + type: newType = originalType, + } = updatedValue; + + const newLabelWithoutHttp = newLabel.replace( /http(s?):\/\//gi, '' ); + const newUrlWithoutHttp = newUrl.replace( /http(s?):\/\//gi, '' ); + + const useNewLabel = + newLabel && + newLabel !== originalLabel && + // LinkControl without the title field relies + // on the check below. Specifically, it assumes that + // the URL is the same as a title. + // This logic a) looks suspicious and b) should really + // live in the LinkControl and not here. It's a great + // candidate for future refactoring. + newLabelWithoutHttp !== newUrlWithoutHttp; + + // Unfortunately this causes the escaping model to be inverted. + // The escaped content is stored in the block attributes (and ultimately in the database), + // and then the raw data is "recovered" when outputting into the DOM. + // It would be preferable to store the **raw** data in the block attributes and escape it in JS. + // Why? Because there isn't one way to escape data. Depending on the context, you need to do + // different transforms. It doesn't make sense to me to choose one of them for the purposes of storage. + // See also: + // - https://github.com/WordPress/gutenberg/pull/41063 + // - https://github.com/WordPress/gutenberg/pull/18617. + const label = useNewLabel + ? escapeHTML( newLabel ) + : originalLabel || escapeHTML( newUrlWithoutHttp ); + + // In https://github.com/WordPress/gutenberg/pull/24670 we decided to use "tag" in favor of "post_tag" + const type = newType === 'post_tag' ? 'tag' : newType.replace( '-', '_' ); + + const isBuiltInType = + [ 'post', 'page', 'tag', 'category' ].indexOf( type ) > -1; + + const isCustomLink = + ( ! newKind && ! isBuiltInType ) || newKind === 'custom'; + const kind = isCustomLink ? 'custom' : newKind; + + setAttributes( { + // Passed `url` may already be encoded. To prevent double encoding, decodeURI is executed to revert to the original string. + ...( newUrl && { url: encodeURI( safeDecodeURI( newUrl ) ) } ), + ...( label && { label } ), + ...( undefined !== opensInNewTab && { opensInNewTab } ), + ...( id && Number.isInteger( id ) && { id } ), + ...( kind && { kind } ), + ...( type && type !== 'URL' && { type } ), + } ); +}; 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 00000000000000..59aaaeacb01d40 --- /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-inserted-block.js b/packages/block-editor/src/components/off-canvas-editor/use-inserted-block.js new file mode 100644 index 00000000000000..0e5a25c980a1c3 --- /dev/null +++ b/packages/block-editor/src/components/off-canvas-editor/use-inserted-block.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +export const useInsertedBlock = ( insertedBlockClientId ) => { + const { insertedBlockAttributes, insertedBlockName } = useSelect( + ( select ) => { + const { getBlockName, getBlockAttributes } = + select( blockEditorStore ); + + return { + insertedBlockAttributes: getBlockAttributes( + insertedBlockClientId + ), + insertedBlockName: getBlockName( insertedBlockClientId ), + }; + }, + [ insertedBlockClientId ] + ); + + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + + const setInsertedBlockAttributes = ( _updatedAttributes ) => { + if ( ! insertedBlockClientId ) return; + updateBlockAttributes( insertedBlockClientId, _updatedAttributes ); + }; + + if ( ! insertedBlockClientId ) { + return { + insertedBlockAttributes: undefined, + insertedBlockName: undefined, + setInsertedBlockAttributes, + }; + } + + return { + insertedBlockAttributes, + insertedBlockName, + setInsertedBlockAttributes, + }; +}; 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 00000000000000..5dafa765f16ea5 --- /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 00000000000000..680beafd3c07cd --- /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 | undefined} 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 00000000000000..09b5e09e4713a3 --- /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 00000000000000..f53f5a4cd4884a --- /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/template-part/index.php b/packages/block-library/src/template-part/index.php index e12f67566a0fdb..d3de7d0b3afbd5 100644 --- a/packages/block-library/src/template-part/index.php +++ b/packages/block-library/src/template-part/index.php @@ -143,14 +143,14 @@ function render_block_core_template_part( $attributes ) { } // Run through the actions that are typically taken on the_content. + $content = shortcode_unautop( $content ); + $content = do_shortcode( $content ); $seen_ids[ $template_part_id ] = true; $content = do_blocks( $content ); unset( $seen_ids[ $template_part_id ] ); $content = wptexturize( $content ); $content = convert_smilies( $content ); - $content = shortcode_unautop( $content ); $content = wp_filter_content_tags( $content, "template_part_{$area}" ); - $content = do_shortcode( $content ); // Handle embeds for block template parts. global $wp_embed;