UNPKG

@wordpress/block-library

Version:
673 lines (625 loc) 19.6 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { createBlock } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; import { __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, TextControl, TextareaControl, ToolbarButton, ToolbarGroup, } from '@wordpress/components'; import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes'; import { __ } from '@wordpress/i18n'; import { BlockControls, InspectorControls, RichText, useBlockProps, store as blockEditorStore, getColorClassName, useInnerBlocksProps, useBlockEditingMode, } from '@wordpress/block-editor'; import { isURL, prependHTTP, safeDecodeURI } from '@wordpress/url'; import { useState, useEffect, useRef } from '@wordpress/element'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { decodeEntities } from '@wordpress/html-entities'; import { link as linkIcon, addSubmenu } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { useMergeRefs, usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ import { LinkUI } from './link-ui'; import { updateAttributes } from './update-attributes'; import { getColors } from '../navigation/edit/utils'; const DEFAULT_BLOCK = { name: 'core/navigation-link' }; const NESTING_BLOCK_NAMES = [ 'core/navigation-link', 'core/navigation-submenu', ]; /** * 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 ); }; }, [ elementRef ] ); return isDraggingWithin; }; const useIsInvalidLink = ( kind, type, id, enabled ) => { const isPostType = kind === 'post-type' || type === 'post' || type === 'page'; const hasId = Number.isInteger( id ); const blockEditingMode = useBlockEditingMode(); const postStatus = useSelect( ( select ) => { if ( ! isPostType ) { return null; } // Fetching the posts status is an "expensive" operation. Especially for sites with large navigations. // When the block is rendered in a template or other disabled contexts we can skip this check in order // to avoid all these additional requests that don't really add any value in that mode. if ( blockEditingMode === 'disabled' || ! enabled ) { return null; } const { getEntityRecord } = select( coreStore ); return getEntityRecord( 'postType', type, id )?.status; }, [ isPostType, blockEditingMode, enabled, type, id ] ); // Check Navigation Link validity if: // 1. Link is 'post-type'. // 2. It has an id. // 3. It's neither null, nor undefined, as valid items might be either of those while loading. // If those conditions are met, check if // 1. The post status is published. // 2. The Navigation Link item has no label. // If either of those is true, invalidate. const isInvalid = isPostType && hasId && postStatus && 'trash' === postStatus; const isDraft = 'draft' === postStatus; return [ isInvalid, isDraft ]; }; function getMissingText( type ) { let missingText = ''; switch ( type ) { case 'post': /* translators: label for missing post in navigation link block */ missingText = __( 'Select post' ); break; case 'page': /* translators: label for missing page in navigation link block */ missingText = __( 'Select page' ); break; case 'category': /* translators: label for missing category in navigation link block */ missingText = __( 'Select category' ); break; case 'tag': /* translators: label for missing tag in navigation link block */ missingText = __( 'Select tag' ); break; default: /* translators: label for missing values in navigation link block */ missingText = __( 'Add link' ); } return missingText; } /* * Warning, this duplicated in * packages/block-library/src/navigation-submenu/edit.js * Consider reusing this components for both blocks. */ function Controls( { attributes, setAttributes, setIsLabelFieldFocused } ) { const { label, url, description, rel } = attributes; return ( <ToolsPanel label={ __( 'Settings' ) }> <ToolsPanelItem hasValue={ () => !! label } label={ __( 'Text' ) } onDeselect={ () => setAttributes( { label: '' } ) } isShownByDefault > <TextControl __nextHasNoMarginBottom __next40pxDefaultSize label={ __( 'Text' ) } value={ label ? stripHTML( label ) : '' } onChange={ ( labelValue ) => { setAttributes( { label: labelValue } ); } } autoComplete="off" onFocus={ () => setIsLabelFieldFocused( true ) } onBlur={ () => setIsLabelFieldFocused( false ) } /> </ToolsPanelItem> <ToolsPanelItem hasValue={ () => !! url } label={ __( 'Link' ) } onDeselect={ () => setAttributes( { url: '' } ) } isShownByDefault > <TextControl __nextHasNoMarginBottom __next40pxDefaultSize label={ __( 'Link' ) } value={ url ? safeDecodeURI( url ) : '' } onChange={ ( urlValue ) => { updateAttributes( { url: urlValue }, setAttributes, attributes ); } } autoComplete="off" type="url" /> </ToolsPanelItem> <ToolsPanelItem hasValue={ () => !! description } label={ __( 'Description' ) } onDeselect={ () => setAttributes( { description: '' } ) } isShownByDefault > <TextareaControl __nextHasNoMarginBottom label={ __( 'Description' ) } value={ description || '' } onChange={ ( descriptionValue ) => { setAttributes( { description: descriptionValue } ); } } help={ __( 'The description will be displayed in the menu if the current theme supports it.' ) } /> </ToolsPanelItem> <ToolsPanelItem hasValue={ () => !! rel } label={ __( 'Rel attribute' ) } onDeselect={ () => setAttributes( { rel: '' } ) } isShownByDefault > <TextControl __nextHasNoMarginBottom __next40pxDefaultSize label={ __( 'Rel attribute' ) } value={ rel || '' } onChange={ ( relValue ) => { setAttributes( { rel: relValue } ); } } autoComplete="off" help={ __( 'The relationship of the linked URL as space-separated link types.' ) } /> </ToolsPanelItem> </ToolsPanel> ); } export default function NavigationLinkEdit( { attributes, isSelected, setAttributes, insertBlocksAfter, mergeBlocks, onReplace, context, clientId, } ) { const { id, label, type, url, description, kind } = attributes; const { maxNestingLevel } = context; const { replaceBlock, __unstableMarkNextChangeAsNotPersistent, selectBlock, selectPreviousBlock, } = useDispatch( blockEditorStore ); // Have the link editing ui open on mount when lacking a url and selected. const [ isLinkOpen, setIsLinkOpen ] = useState( isSelected && ! url ); // Store what element opened the popover, so we know where to return focus to (toolbar button vs navigation link text) const [ openedBy, setOpenedBy ] = useState( null ); // 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 label…' ); const ref = useRef(); const linkUIref = useRef(); const prevUrl = usePrevious( url ); // Change the label using inspector causes rich text to change focus on firefox. // This is a workaround to keep the focus on the label field when label filed is focused we don't render the rich text. const [ isLabelFieldFocused, setIsLabelFieldFocused ] = useState( false ); const { isAtMaxNesting, isTopLevelLink, isParentOfSelectedBlock, hasChildren, validateLinkStatus, } = useSelect( ( select ) => { const { getBlockCount, getBlockName, getBlockRootClientId, hasSelectedInnerBlock, getBlockParentsByBlockName, getSelectedBlockClientId, } = select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); const isTopLevel = getBlockName( rootClientId ) === 'core/navigation'; const selectedBlockClientId = getSelectedBlockClientId(); const rootNavigationClientId = isTopLevel ? rootClientId : getBlockParentsByBlockName( clientId, 'core/navigation' )[ 0 ]; // Enable when the root Navigation block is selected or any of its inner blocks. const enableLinkStatusValidation = selectedBlockClientId === rootNavigationClientId || hasSelectedInnerBlock( rootNavigationClientId, true ); return { isAtMaxNesting: getBlockParentsByBlockName( clientId, NESTING_BLOCK_NAMES ) .length >= maxNestingLevel, isTopLevelLink: isTopLevel, isParentOfSelectedBlock: hasSelectedInnerBlock( clientId, true ), hasChildren: !! getBlockCount( clientId ), validateLinkStatus: enableLinkStatusValidation, }; }, [ clientId, maxNestingLevel ] ); const { getBlocks } = useSelect( blockEditorStore ); const [ isInvalid, isDraft ] = useIsInvalidLink( kind, type, id, validateLinkStatus ); /** * Transform to submenu block. */ const transformToSubmenu = () => { let innerBlocks = getBlocks( clientId ); if ( innerBlocks.length === 0 ) { innerBlocks = [ createBlock( 'core/navigation-link' ) ]; selectBlock( innerBlocks[ 0 ].clientId ); } const newSubmenu = createBlock( 'core/navigation-submenu', attributes, innerBlocks ); replaceBlock( clientId, newSubmenu ); }; useEffect( () => { // If block has inner blocks, transform to Submenu. if ( hasChildren ) { // This side-effect should not create an undo level as those should // only be created via user interactions. __unstableMarkNextChangeAsNotPersistent(); transformToSubmenu(); } }, [ hasChildren ] ); // If the LinkControl popover is open and the URL has changed, close the LinkControl and focus the label text. useEffect( () => { // We only want to do this when the URL has gone from nothing to a new URL AND the label looks like a URL if ( ! prevUrl && url && isLinkOpen && isURL( prependHTTP( label ) ) && /^.+\.[a-z]+/.test( label ) ) { // Focus and select the label text. selectLabelText(); } }, [ prevUrl, url, isLinkOpen, label ] ); /** * 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 ); } /** * Removes the current link if set. */ function removeLink() { // Reset all attributes that comprise the link. // It is critical that all attributes are reset // to their default values otherwise this may // in advertently trigger side effects because // the values will have "changed". setAttributes( { url: undefined, label: undefined, id: undefined, kind: undefined, type: undefined, opensInNewTab: false, } ); // Close the link editing UI. setIsLinkOpen( false ); } const { textColor, customTextColor, backgroundColor, customBackgroundColor, } = getColors( context, ! isTopLevelLink ); function onKeyDown( event ) { if ( isKeyboardEvent.primary( event, 'k' ) ) { // Required to prevent the command center from opening, // as it shares the CMD+K shortcut. // See https://github.com/WordPress/gutenberg/pull/59845. event.preventDefault(); // If this link is a child of a parent submenu item, the parent submenu item event will also open, closing this popover event.stopPropagation(); setIsLinkOpen( true ); setOpenedBy( ref.current ); } } const blockProps = useBlockProps( { ref: useMergeRefs( [ setPopoverAnchor, listItemRef ] ), className: clsx( '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, } ), style: { color: ! textColor && customTextColor, backgroundColor: ! backgroundColor && customBackgroundColor, }, onKeyDown, } ); const innerBlocksProps = useInnerBlocksProps( { ...blockProps, className: 'remove-outline', // Remove the outline from the inner blocks container. }, { defaultBlock: DEFAULT_BLOCK, directInsert: true, renderAppender: false, } ); if ( ! url || isInvalid || isDraft ) { blockProps.onClick = () => { setIsLinkOpen( true ); setOpenedBy( ref.current ); }; } const classes = clsx( 'wp-block-navigation-item__content', { 'wp-block-navigation-link__placeholder': ! url || isInvalid || isDraft, } ); const missingText = getMissingText( type ); /* translators: Whether the navigation link is Invalid or a Draft. */ const placeholderText = `(${ isInvalid ? __( 'Invalid' ) : __( 'Draft' ) })`; return ( <> <BlockControls> <ToolbarGroup> <ToolbarButton name="link" icon={ linkIcon } title={ __( 'Link' ) } shortcut={ displayShortcut.primary( 'k' ) } onClick={ ( event ) => { setIsLinkOpen( true ); setOpenedBy( event.currentTarget ); } } /> { ! isAtMaxNesting && ( <ToolbarButton name="submenu" icon={ addSubmenu } title={ __( 'Add submenu' ) } onClick={ transformToSubmenu } /> ) } </ToolbarGroup> </BlockControls> { /* Warning, this duplicated in packages/block-library/src/navigation-submenu/edit.js */ } <InspectorControls> <Controls attributes={ attributes } setAttributes={ setAttributes } setIsLabelFieldFocused={ setIsLabelFieldFocused } /> </InspectorControls> <div { ...blockProps }> { /* eslint-disable jsx-a11y/anchor-is-valid */ } <a className={ classes }> { /* eslint-enable */ } { ! url ? ( <div className="wp-block-navigation-link__placeholder-text"> <span>{ missingText }</span> </div> ) : ( <> { ! isInvalid && ! isDraft && ! isLabelFieldFocused && ( <> <RichText ref={ ref } identifier="label" className="wp-block-navigation-item__label" value={ label } onChange={ ( labelValue ) => setAttributes( { label: labelValue, } ) } onMerge={ mergeBlocks } onReplace={ onReplace } __unstableOnSplitAtEnd={ () => insertBlocksAfter( createBlock( 'core/navigation-link' ) ) } aria-label={ __( 'Navigation link text' ) } placeholder={ itemLabelPlaceholder } withoutInteractiveFormatting /> { description && ( <span className="wp-block-navigation-item__description"> { description } </span> ) } </> ) } { ( isInvalid || isDraft || isLabelFieldFocused ) && ( <div className={ clsx( 'wp-block-navigation-link__placeholder-text', 'wp-block-navigation-link__label', { 'is-invalid': isInvalid, 'is-draft': isDraft, } ) } > <span> { // Some attributes are stored in an escaped form. It's a legacy issue. // Ideally they would be stored in a raw, unescaped form. // Unescape is used here to "recover" the escaped characters // so they display without encoding. // See `updateAttributes` for more details. `${ decodeEntities( label ) } ${ isInvalid || isDraft ? placeholderText : '' }`.trim() } </span> </div> ) } </> ) } { isLinkOpen && ( <LinkUI ref={ linkUIref } clientId={ clientId } link={ attributes } onClose={ () => { // If there is no link then remove the auto-inserted block. // This avoids empty blocks which can provided a poor UX. if ( ! url ) { // Fixes https://github.com/WordPress/gutenberg/issues/61361 // There's a chance we're closing due to the user selecting the browse all button. // Only move focus if the focus is still within the popover ui. If it's not within // the popover, it's because something has taken the focus from the popover, and // we don't want to steal it back. if ( linkUIref.current.contains( window.document.activeElement ) ) { // Select the previous block to keep focus nearby selectPreviousBlock( clientId, true ); } // Remove the link. onReplace( [] ); return; } setIsLinkOpen( false ); if ( openedBy ) { openedBy.focus(); setOpenedBy( null ); } else if ( ref.current ) { // select the ref when adding a new link ref.current.focus(); } else { // Fallback selectPreviousBlock( clientId, true ); } } } anchor={ popoverAnchor } onRemove={ removeLink } onChange={ ( updatedValue ) => { updateAttributes( updatedValue, setAttributes, attributes ); } } /> ) } </a> <div { ...innerBlocksProps } /> </div> </> ); }