@wordpress/block-library
Version:
Block library for the WordPress editor.
694 lines (635 loc) • 20.3 kB
JavaScript
/**
* External dependencies
*/
import classnames from 'classnames';
import { escape, without } from 'lodash';
/**
* WordPress dependencies
*/
import { useSelect, useDispatch } from '@wordpress/data';
import {
PanelBody,
Popover,
TextControl,
TextareaControl,
ToolbarButton,
ToolbarGroup,
} from '@wordpress/components';
import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes';
import { __, sprintf } from '@wordpress/i18n';
import {
BlockControls,
InnerBlocks,
useInnerBlocksProps,
InspectorControls,
RichText,
__experimentalLinkControl as LinkControl,
useBlockProps,
store as blockEditorStore,
getColorClassName,
} from '@wordpress/block-editor';
import { isURL, prependHTTP, safeDecodeURI } from '@wordpress/url';
import {
Fragment,
useState,
useEffect,
useRef,
createInterpolateElement,
} from '@wordpress/element';
import { placeCaretAtHorizontalEdge } from '@wordpress/dom';
import { link as linkIcon, removeSubmenu } from '@wordpress/icons';
import {
useResourcePermissions,
store as coreStore,
} from '@wordpress/core-data';
import { speak } from '@wordpress/a11y';
import { createBlock } from '@wordpress/blocks';
import { useMergeRefs } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { ItemSubmenuIcon } from './icons';
import { name } from './block.json';
const ALLOWED_BLOCKS = [ 'core/navigation-link', 'core/navigation-submenu' ];
const DEFAULT_BLOCK = {
name: 'core/navigation-link',
};
/**
* A React hook to determine if it's dragging within the target element.
*
* @typedef {import('@wordpress/element').RefObject} RefObject
*
* @param {RefObject<HTMLElement>} elementRef The target elementRef object.
*
* @return {boolean} Is dragging within the target element.
*/
const useIsDraggingWithin = ( elementRef ) => {
const [ isDraggingWithin, setIsDraggingWithin ] = useState( false );
useEffect( () => {
const { ownerDocument } = elementRef.current;
function handleDragStart( event ) {
// Check the first time when the dragging starts.
handleDragEnter( event );
}
// Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape.
function handleDragEnd() {
setIsDraggingWithin( false );
}
function handleDragEnter( event ) {
// Check if the current target is inside the item element.
if ( elementRef.current.contains( event.target ) ) {
setIsDraggingWithin( true );
} else {
setIsDraggingWithin( false );
}
}
// Bind these events to the document to catch all drag events.
// Ideally, we can also use `event.relatedTarget`, but sadly that
// doesn't work in Safari.
ownerDocument.addEventListener( 'dragstart', handleDragStart );
ownerDocument.addEventListener( 'dragend', handleDragEnd );
ownerDocument.addEventListener( 'dragenter', handleDragEnter );
return () => {
ownerDocument.removeEventListener( 'dragstart', handleDragStart );
ownerDocument.removeEventListener( 'dragend', handleDragEnd );
ownerDocument.removeEventListener( 'dragenter', handleDragEnter );
};
}, [] );
return isDraggingWithin;
};
/**
* 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.
*/
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 {};
}
}
/**
* Determine the colors for a menu.
*
* Order of priority is:
* 1: Overlay custom colors (if submenu)
* 2: Overlay theme colors (if submenu)
* 3: Custom colors
* 4: Theme colors
* 5: Global styles
*
* @param {Object} context
* @param {boolean} isSubMenu
*/
function getColors( context, isSubMenu ) {
const {
textColor,
customTextColor,
backgroundColor,
customBackgroundColor,
overlayTextColor,
customOverlayTextColor,
overlayBackgroundColor,
customOverlayBackgroundColor,
style,
} = context;
const colors = {};
if ( isSubMenu && !! customOverlayTextColor ) {
colors.customTextColor = customOverlayTextColor;
} else if ( isSubMenu && !! overlayTextColor ) {
colors.textColor = overlayTextColor;
} else if ( !! customTextColor ) {
colors.customTextColor = customTextColor;
} else if ( !! textColor ) {
colors.textColor = textColor;
} else if ( !! style?.color?.text ) {
colors.customTextColor = style.color.text;
}
if ( isSubMenu && !! customOverlayBackgroundColor ) {
colors.customBackgroundColor = customOverlayBackgroundColor;
} else if ( isSubMenu && !! overlayBackgroundColor ) {
colors.backgroundColor = overlayBackgroundColor;
} else if ( !! customBackgroundColor ) {
colors.customBackgroundColor = customBackgroundColor;
} else if ( !! backgroundColor ) {
colors.backgroundColor = backgroundColor;
} else if ( !! style?.color?.background ) {
colors.customTextColor = style.color.background;
}
return colors;
}
/**
* @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 updateNavigationLinkBlockAttributes = (
updatedValue = {},
setAttributes,
blockAttributes = {}
) => {
const {
label: originalLabel = '',
kind: originalKind = '',
type: originalType = '',
} = blockAttributes;
const {
title = '',
url = '',
opensInNewTab,
id,
kind: newKind = originalKind,
type: newType = originalType,
} = updatedValue;
const normalizedTitle = title.replace( /http(s?):\/\//gi, '' );
const normalizedURL = url.replace( /http(s?):\/\//gi, '' );
const escapeTitle =
title !== '' &&
normalizedTitle !== normalizedURL &&
originalLabel !== title;
const label = escapeTitle
? escape( title )
: originalLabel || escape( normalizedURL );
// 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.
...( url && { url: encodeURI( safeDecodeURI( url ) ) } ),
...( label && { label } ),
...( undefined !== opensInNewTab && { opensInNewTab } ),
...( id && Number.isInteger( id ) && { id } ),
...( kind && { kind } ),
...( type && type !== 'URL' && { type } ),
} );
};
export default function NavigationSubmenuEdit( {
attributes,
isSelected,
setAttributes,
mergeBlocks,
onReplace,
context,
clientId,
} ) {
const { label, type, opensInNewTab, url, description, rel, title, kind } =
attributes;
const link = {
url,
opensInNewTab,
};
const { showSubmenuIcon, maxNestingLevel, openSubmenusOnClick } = context;
const { saveEntityRecord } = useDispatch( coreStore );
const { __unstableMarkNextChangeAsNotPersistent, replaceBlock } =
useDispatch( blockEditorStore );
const [ isLinkOpen, setIsLinkOpen ] = useState( false );
// Use internal state instead of a ref to make sure that the component
// re-renders when the popover's anchor updates.
const [ popoverAnchor, setPopoverAnchor ] = useState( null );
const listItemRef = useRef( null );
const isDraggingWithin = useIsDraggingWithin( listItemRef );
const itemLabelPlaceholder = __( 'Add text…' );
const ref = useRef();
const pagesPermissions = useResourcePermissions( 'pages' );
const postsPermissions = useResourcePermissions( 'posts' );
const {
isAtMaxNesting,
isTopLevelItem,
isParentOfSelectedBlock,
isImmediateParentOfSelectedBlock,
hasChildren,
selectedBlockHasChildren,
onlyDescendantIsEmptyLink,
} = useSelect(
( select ) => {
const {
hasSelectedInnerBlock,
getSelectedBlockClientId,
getBlockParentsByBlockName,
getBlock,
getBlockCount,
getBlockOrder,
} = select( blockEditorStore );
let _onlyDescendantIsEmptyLink;
const selectedBlockId = getSelectedBlockClientId();
const selectedBlockChildren = getBlockOrder( selectedBlockId );
// Check for a single descendant in the submenu. If that block
// is a link block in a "placeholder" state with no label then
// we can consider as an "empty" link.
if ( selectedBlockChildren?.length === 1 ) {
const singleBlock = getBlock( selectedBlockChildren[ 0 ] );
_onlyDescendantIsEmptyLink =
singleBlock?.name === 'core/navigation-link' &&
! singleBlock?.attributes?.label;
}
return {
isAtMaxNesting:
getBlockParentsByBlockName( clientId, name ).length >=
maxNestingLevel,
isTopLevelItem:
getBlockParentsByBlockName( clientId, name ).length === 0,
isParentOfSelectedBlock: hasSelectedInnerBlock(
clientId,
true
),
isImmediateParentOfSelectedBlock: hasSelectedInnerBlock(
clientId,
false
),
hasChildren: !! getBlockCount( clientId ),
selectedBlockHasChildren: !! selectedBlockChildren?.length,
onlyDescendantIsEmptyLink: _onlyDescendantIsEmptyLink,
};
},
[ clientId ]
);
// Show the LinkControl on mount if the URL is empty
// ( When adding a new menu item)
// This can't be done in the useState call because it conflicts
// with the autofocus behavior of the BlockListBlock component.
useEffect( () => {
if ( ! openSubmenusOnClick && ! url ) {
setIsLinkOpen( true );
}
}, [] );
// Store the colors from context as attributes for rendering.
useEffect( () => {
// This side-effect should not create an undo level as those should
// only be created via user interactions. Mark this change as
// not persistent to avoid undo level creation.
// See https://github.com/WordPress/gutenberg/issues/34564.
__unstableMarkNextChangeAsNotPersistent();
setAttributes( { isTopLevelItem } );
}, [ isTopLevelItem ] );
/**
* The hook shouldn't be necessary but due to a focus loss happening
* when selecting a suggestion in the link popover, we force close on block unselection.
*/
useEffect( () => {
if ( ! isSelected ) {
setIsLinkOpen( false );
}
}, [ isSelected ] );
// If the LinkControl popover is open and the URL has changed, close the LinkControl and focus the label text.
useEffect( () => {
if ( isLinkOpen && url ) {
// Does this look like a URL and have something TLD-ish?
if (
isURL( prependHTTP( label ) ) &&
/^.+\.[a-z]+/.test( label )
) {
// Focus and select the label text.
selectLabelText();
} else {
// Focus it (but do not select).
placeCaretAtHorizontalEdge( ref.current, true );
}
}
}, [ url ] );
/**
* Focus the Link label text and select it.
*/
function selectLabelText() {
ref.current.focus();
const { ownerDocument } = ref.current;
const { defaultView } = ownerDocument;
const selection = defaultView.getSelection();
const range = ownerDocument.createRange();
// Get the range of the current ref contents so we can add this range to the selection.
range.selectNodeContents( ref.current );
selection.removeAllRanges();
selection.addRange( range );
}
let userCanCreate = false;
if ( ! type || type === 'page' ) {
userCanCreate = pagesPermissions.canCreate;
} else if ( type === 'post' ) {
userCanCreate = postsPermissions.canCreate;
}
async function handleCreate( pageTitle ) {
const postType = type || 'page';
const page = await saveEntityRecord( 'postType', postType, {
title: pageTitle,
status: 'draft',
} );
return {
id: page.id,
type: postType,
title: page.title.rendered,
url: page.link,
kind: 'post-type',
};
}
const {
textColor,
customTextColor,
backgroundColor,
customBackgroundColor,
} = getColors( context, ! isTopLevelItem );
function onKeyDown( event ) {
if ( isKeyboardEvent.primary( event, 'k' ) ) {
setIsLinkOpen( true );
}
}
const blockProps = useBlockProps( {
ref: useMergeRefs( [ setPopoverAnchor, listItemRef ] ),
className: classnames( 'wp-block-navigation-item', {
'is-editing': isSelected || isParentOfSelectedBlock,
'is-dragging-within': isDraggingWithin,
'has-link': !! url,
'has-child': hasChildren,
'has-text-color': !! textColor || !! customTextColor,
[ getColorClassName( 'color', textColor ) ]: !! textColor,
'has-background': !! backgroundColor || customBackgroundColor,
[ getColorClassName( 'background-color', backgroundColor ) ]:
!! backgroundColor,
'open-on-click': openSubmenusOnClick,
} ),
style: {
color: ! textColor && customTextColor,
backgroundColor: ! backgroundColor && customBackgroundColor,
},
onKeyDown,
} );
// Always use overlay colors for submenus.
const innerBlocksColors = getColors( context, true );
const allowedBlocks = isAtMaxNesting
? without( ALLOWED_BLOCKS, 'core/navigation-submenu' )
: ALLOWED_BLOCKS;
const innerBlocksProps = useInnerBlocksProps(
{
className: classnames( 'wp-block-navigation__submenu-container', {
'is-parent-of-selected-block': isParentOfSelectedBlock,
'has-text-color': !! (
innerBlocksColors.textColor ||
innerBlocksColors.customTextColor
),
[ `has-${ innerBlocksColors.textColor }-color` ]:
!! innerBlocksColors.textColor,
'has-background': !! (
innerBlocksColors.backgroundColor ||
innerBlocksColors.customBackgroundColor
),
[ `has-${ innerBlocksColors.backgroundColor }-background-color` ]:
!! innerBlocksColors.backgroundColor,
} ),
style: {
color: innerBlocksColors.customTextColor,
backgroundColor: innerBlocksColors.customBackgroundColor,
},
},
{
allowedBlocks,
__experimentalDefaultBlock: DEFAULT_BLOCK,
__experimentalDirectInsert: true,
// Ensure block toolbar is not too far removed from item
// being edited.
// see: https://github.com/WordPress/gutenberg/pull/34615.
__experimentalCaptureToolbars: true,
renderAppender:
isSelected ||
( isImmediateParentOfSelectedBlock &&
! selectedBlockHasChildren ) ||
// Show the appender while dragging to allow inserting element between item and the appender.
hasChildren
? InnerBlocks.ButtonBlockAppender
: false,
}
);
const ParentElement = openSubmenusOnClick ? 'button' : 'a';
function transformToLink() {
const newLinkBlock = createBlock( 'core/navigation-link', attributes );
replaceBlock( clientId, newLinkBlock );
}
const canConvertToLink =
! selectedBlockHasChildren || onlyDescendantIsEmptyLink;
return (
<Fragment>
<BlockControls>
<ToolbarGroup>
{ ! openSubmenusOnClick && (
<ToolbarButton
name="link"
icon={ linkIcon }
title={ __( 'Link' ) }
shortcut={ displayShortcut.primary( 'k' ) }
onClick={ () => setIsLinkOpen( true ) }
/>
) }
<ToolbarButton
name="revert"
icon={ removeSubmenu }
title={ __( 'Convert to Link' ) }
onClick={ transformToLink }
className="wp-block-navigation__submenu__revert"
isDisabled={ ! canConvertToLink }
/>
</ToolbarGroup>
</BlockControls>
<InspectorControls>
<PanelBody title={ __( 'Link settings' ) }>
<TextareaControl
value={ description || '' }
onChange={ ( descriptionValue ) => {
setAttributes( {
description: descriptionValue,
} );
} }
label={ __( 'Description' ) }
help={ __(
'The description will be displayed in the menu if the current theme supports it.'
) }
/>
<TextControl
value={ title || '' }
onChange={ ( titleValue ) => {
setAttributes( { title: titleValue } );
} }
label={ __( 'Link title' ) }
autoComplete="off"
/>
<TextControl
value={ rel || '' }
onChange={ ( relValue ) => {
setAttributes( { rel: relValue } );
} }
label={ __( 'Link rel' ) }
autoComplete="off"
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
{ /* eslint-disable jsx-a11y/anchor-is-valid */ }
<ParentElement className="wp-block-navigation-item__content">
{ /* eslint-enable */ }
{
<RichText
ref={ ref }
identifier="label"
className="wp-block-navigation-item__label"
value={ label }
onChange={ ( labelValue ) =>
setAttributes( { label: labelValue } )
}
onMerge={ mergeBlocks }
onReplace={ onReplace }
aria-label={ __( 'Navigation link text' ) }
placeholder={ itemLabelPlaceholder }
withoutInteractiveFormatting
allowedFormats={ [
'core/bold',
'core/italic',
'core/image',
'core/strikethrough',
] }
onClick={ () => {
if ( ! openSubmenusOnClick && ! url ) {
setIsLinkOpen( true );
}
} }
/>
}
{ ! openSubmenusOnClick && isLinkOpen && (
<Popover
position="bottom center"
onClose={ () => setIsLinkOpen( false ) }
anchor={ popoverAnchor }
shift
>
<LinkControl
className="wp-block-navigation-link__inline-link-input"
value={ link }
showInitialSuggestions={ true }
withCreateSuggestion={ userCanCreate }
createSuggestion={ handleCreate }
createSuggestionButtonText={ ( searchTerm ) => {
let format;
if ( type === 'post' ) {
/* translators: %s: search term. */
format = __(
'Create draft post: <mark>%s</mark>'
);
} else {
/* translators: %s: search term. */
format = __(
'Create draft page: <mark>%s</mark>'
);
}
return createInterpolateElement(
sprintf( format, searchTerm ),
{ mark: <mark /> }
);
} }
noDirectEntry={ !! type }
noURLSuggestion={ !! type }
suggestionsQuery={ getSuggestionsQuery(
type,
kind
) }
onChange={ ( updatedValue ) =>
updateNavigationLinkBlockAttributes(
updatedValue,
setAttributes,
attributes
)
}
onRemove={ () => {
setAttributes( { url: '' } );
speak( __( 'Link removed.' ), 'assertive' );
} }
/>
</Popover>
) }
</ParentElement>
{ ( showSubmenuIcon || openSubmenusOnClick ) && (
<span className="wp-block-navigation__submenu-icon">
<ItemSubmenuIcon />
</span>
) }
<div { ...innerBlocksProps } />
</div>
</Fragment>
);
}