UNPKG

@wordpress/block-library

Version:
694 lines (635 loc) 20.3 kB
/** * 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> ); }