diff --git a/packages/block-editor/src/components/list-view/block-contents.js b/packages/block-editor/src/components/list-view/block-contents.js index a1f5f3562cfd4..796240b0a143c 100644 --- a/packages/block-editor/src/components/list-view/block-contents.js +++ b/packages/block-editor/src/components/list-view/block-contents.js @@ -7,16 +7,25 @@ import classnames from 'classnames'; * WordPress dependencies */ import { useSelect } from '@wordpress/data'; -import { forwardRef } from '@wordpress/element'; +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( ( { @@ -34,19 +43,54 @@ const ListViewBlockContents = forwardRef( ref ) => { const { clientId } = block; - - const { blockMovingClientId, selectedBlockInBlockEditor } = useSelect( + const [ isLinkUIOpen, setIsLinkUIOpen ] = useState(); + const { + blockMovingClientId, + selectedBlockInBlockEditor, + lastInsertedBlockClientId, + } = useSelect( ( select ) => { - const { hasBlockMovingClientId, getSelectedBlockClientId } = - select( blockEditorStore ); + 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 = @@ -67,6 +111,23 @@ const ListViewBlockContents = forwardRef( return ( <> { renderAdditionalBlockUI && renderAdditionalBlockUI( block ) } + { isLinkUIOpen && ( + setIsLinkUIOpen( false ) } + hasCreateSuggestion={ false } + onChange={ ( updatedValue ) => { + updateAttributes( + updatedValue, + setInsertedBlockAttributes, + insertedBlockAttributes + ); + setIsLinkUIOpen( false ); + } } + onCancel={ () => setIsLinkUIOpen( false ) } + /> + ) } { ( { draggable, onDragStart, onDragEnd } ) => ( { + 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/list-view/update-attributes.js b/packages/block-editor/src/components/list-view/update-attributes.js new file mode 100644 index 0000000000000..5133cae387833 --- /dev/null +++ b/packages/block-editor/src/components/list-view/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/list-view/use-inserted-block.js b/packages/block-editor/src/components/list-view/use-inserted-block.js new file mode 100644 index 0000000000000..0e5a25c980a1c --- /dev/null +++ b/packages/block-editor/src/components/list-view/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, + }; +};