UNPKG

@wordpress/block-library

Version:
463 lines (449 loc) 16.9 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; import { TextControl, TextareaControl, ToolbarButton, ToolbarGroup, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem } from '@wordpress/components'; import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes'; import { __ } from '@wordpress/i18n'; import { BlockControls, InnerBlocks, useInnerBlocksProps, InspectorControls, RichText, useBlockProps, store as blockEditorStore, getColorClassName } from '@wordpress/block-editor'; import { isURL, prependHTTP } from '@wordpress/url'; import { useState, useEffect, useRef } from '@wordpress/element'; import { link as linkIcon, removeSubmenu } from '@wordpress/icons'; import { speak } from '@wordpress/a11y'; import { createBlock } from '@wordpress/blocks'; import { useMergeRefs, usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ import { ItemSubmenuIcon } from './icons'; import { LinkUI } from '../navigation-link/link-ui'; import { updateAttributes } from '../navigation-link/update-attributes'; import { getColors, getNavigationChildBlockProps } from '../navigation/edit/utils'; import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; const ALLOWED_BLOCKS = ['core/navigation-link', 'core/navigation-submenu', 'core/page-list']; 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; }; /** * @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. */ export default function NavigationSubmenuEdit({ attributes, isSelected, setAttributes, mergeBlocks, onReplace, context, clientId }) { const { label, url, description, rel } = attributes; const { showSubmenuIcon, maxNestingLevel, openSubmenusOnClick } = context; const { __unstableMarkNextChangeAsNotPersistent, replaceBlock, selectBlock } = useDispatch(blockEditorStore); const [isLinkOpen, setIsLinkOpen] = useState(false); // 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 text…'); const ref = useRef(); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); const { parentCount, 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 { parentCount: getBlockParentsByBlockName(clientId, 'core/navigation-submenu').length, isParentOfSelectedBlock: hasSelectedInnerBlock(clientId, true), isImmediateParentOfSelectedBlock: hasSelectedInnerBlock(clientId, false), hasChildren: !!getBlockCount(clientId), selectedBlockHasChildren: !!selectedBlockChildren?.length, onlyDescendantIsEmptyLink: _onlyDescendantIsEmptyLink }; }, [clientId]); const prevHasChildren = usePrevious(hasChildren); // 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); } }, []); /** * 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(); } } }, [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); } const { textColor, customTextColor, backgroundColor, customBackgroundColor } = getColors(context, parentCount > 0); 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 we don't stop propagation, this event bubbles up to the parent submenu item 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, '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 = parentCount >= maxNestingLevel ? ALLOWED_BLOCKS.filter(blockName => blockName !== 'core/navigation-submenu') : ALLOWED_BLOCKS; const navigationChildBlockProps = getNavigationChildBlockProps(innerBlocksColors); const innerBlocksProps = useInnerBlocksProps(navigationChildBlockProps, { allowedBlocks, defaultBlock: DEFAULT_BLOCK, directInsert: 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); } useEffect(() => { // If block becomes empty, transform to Navigation Link. if (!hasChildren && prevHasChildren) { // This side-effect should not create an undo level as those should // only be created via user interactions. __unstableMarkNextChangeAsNotPersistent(); transformToLink(); } }, [hasChildren, prevHasChildren]); const canConvertToLink = !selectedBlockHasChildren || onlyDescendantIsEmptyLink; return /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(BlockControls, { children: /*#__PURE__*/_jsxs(ToolbarGroup, { children: [!openSubmenusOnClick && /*#__PURE__*/_jsx(ToolbarButton, { name: "link", icon: linkIcon, title: __('Link'), shortcut: displayShortcut.primary('k'), onClick: event => { setIsLinkOpen(true); setOpenedBy(event.currentTarget); } }), /*#__PURE__*/_jsx(ToolbarButton, { name: "revert", icon: removeSubmenu, title: __('Convert to Link'), onClick: transformToLink, className: "wp-block-navigation__submenu__revert", disabled: !canConvertToLink })] }) }), /*#__PURE__*/_jsx(InspectorControls, { children: /*#__PURE__*/_jsxs(ToolsPanel, { label: __('Settings'), resetAll: () => { setAttributes({ label: '', url: '', description: '', rel: '' }); }, dropdownMenuProps: dropdownMenuProps, children: [/*#__PURE__*/_jsx(ToolsPanelItem, { label: __('Text'), isShownByDefault: true, hasValue: () => !!label, onDeselect: () => setAttributes({ label: '' }), children: /*#__PURE__*/_jsx(TextControl, { __nextHasNoMarginBottom: true, __next40pxDefaultSize: true, value: label || '', onChange: labelValue => { setAttributes({ label: labelValue }); }, label: __('Text'), autoComplete: "off" }) }), /*#__PURE__*/_jsx(ToolsPanelItem, { label: __('Link'), isShownByDefault: true, hasValue: () => !!url, onDeselect: () => setAttributes({ url: '' }), children: /*#__PURE__*/_jsx(TextControl, { __nextHasNoMarginBottom: true, __next40pxDefaultSize: true, value: url || '', onChange: urlValue => { setAttributes({ url: urlValue }); }, label: __('Link'), autoComplete: "off", type: "url" }) }), /*#__PURE__*/_jsx(ToolsPanelItem, { label: __('Description'), isShownByDefault: true, hasValue: () => !!description, onDeselect: () => setAttributes({ description: '' }), children: /*#__PURE__*/_jsx(TextareaControl, { __nextHasNoMarginBottom: true, value: description || '', onChange: descriptionValue => { setAttributes({ description: descriptionValue }); }, label: __('Description'), help: __('The description will be displayed in the menu if the current theme supports it.') }) }), /*#__PURE__*/_jsx(ToolsPanelItem, { label: __('Rel attribute'), isShownByDefault: true, hasValue: () => !!rel, onDeselect: () => setAttributes({ rel: '' }), children: /*#__PURE__*/_jsx(TextControl, { __nextHasNoMarginBottom: true, __next40pxDefaultSize: true, value: rel || '', onChange: relValue => { setAttributes({ rel: relValue }); }, label: __('Rel attribute'), autoComplete: "off", help: __('The relationship of the linked URL as space-separated link types.') }) })] }) }), /*#__PURE__*/_jsxs("div", { ...blockProps, children: [/*#__PURE__*/_jsxs(ParentElement, { className: "wp-block-navigation-item__content", children: [/*#__PURE__*/_jsx(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: true, onClick: () => { if (!openSubmenusOnClick && !url) { setIsLinkOpen(true); setOpenedBy(ref.current); } } }), description && /*#__PURE__*/_jsx("span", { className: "wp-block-navigation-item__description", children: description }), !openSubmenusOnClick && isLinkOpen && /*#__PURE__*/_jsx(LinkUI, { clientId: clientId, link: attributes, onClose: () => { setIsLinkOpen(false); if (openedBy) { openedBy.focus(); setOpenedBy(null); } else { selectBlock(clientId); } }, anchor: popoverAnchor, onRemove: () => { setAttributes({ url: '' }); speak(__('Link removed.'), 'assertive'); }, onChange: updatedValue => { updateAttributes(updatedValue, setAttributes, attributes); } })] }), (showSubmenuIcon || openSubmenusOnClick) && /*#__PURE__*/_jsx("span", { className: "wp-block-navigation__submenu-icon", children: /*#__PURE__*/_jsx(ItemSubmenuIcon, {}) }), /*#__PURE__*/_jsx("div", { ...innerBlocksProps })] })] }); } //# sourceMappingURL=edit.js.map