UNPKG

@wordpress/block-library

Version:
669 lines (614 loc) 22.1 kB
import { createElement } from "@wordpress/element"; /** * 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'; const { name: name } = { $schema: "https://schemas.wp.org/trunk/block.json", apiVersion: 2, name: "core/navigation-submenu", title: "Submenu", category: "design", parent: ["core/navigation"], description: "Add a submenu to your navigation.", textdomain: "default", attributes: { label: { type: "string" }, type: { type: "string" }, description: { type: "string" }, rel: { type: "string" }, id: { type: "number" }, opensInNewTab: { type: "boolean", "default": false }, url: { type: "string" }, title: { type: "string" }, kind: { type: "string" }, isTopLevelItem: { type: "boolean" } }, usesContext: ["textColor", "customTextColor", "backgroundColor", "customBackgroundColor", "overlayTextColor", "customOverlayTextColor", "overlayBackgroundColor", "customOverlayBackgroundColor", "fontSize", "customFontSize", "showSubmenuIcon", "maxNestingLevel", "openSubmenusOnClick", "style"], supports: { reusable: false, html: false }, editorStyle: "wp-block-navigation-submenu-editor", style: "wp-block-navigation-submenu" }; 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) { var _style$color, _style$color2; 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 !== null && style !== void 0 && (_style$color = style.color) !== null && _style$color !== void 0 && _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 !== null && style !== void 0 && (_style$color2 = style.color) !== null && _style$color2 !== void 0 && _style$color2.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 = function () { let updatedValue = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; let setAttributes = arguments.length > 1 ? arguments[1] : undefined; let blockAttributes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; 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(_ref) { let { attributes, isSelected, setAttributes, mergeBlocks, onReplace, context, clientId } = _ref; 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 === null || selectedBlockChildren === void 0 ? void 0 : selectedBlockChildren.length) === 1) { var _singleBlock$attribut; const singleBlock = getBlock(selectedBlockChildren[0]); _onlyDescendantIsEmptyLink = (singleBlock === null || singleBlock === void 0 ? void 0 : singleBlock.name) === 'core/navigation-link' && !(singleBlock !== null && singleBlock !== void 0 && (_singleBlock$attribut = singleBlock.attributes) !== null && _singleBlock$attribut !== void 0 && _singleBlock$attribut.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 !== null && selectedBlockChildren !== void 0 && 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 createElement(Fragment, null, createElement(BlockControls, null, createElement(ToolbarGroup, null, !openSubmenusOnClick && createElement(ToolbarButton, { name: "link", icon: linkIcon, title: __('Link'), shortcut: displayShortcut.primary('k'), onClick: () => setIsLinkOpen(true) }), createElement(ToolbarButton, { name: "revert", icon: removeSubmenu, title: __('Convert to Link'), onClick: transformToLink, className: "wp-block-navigation__submenu__revert", isDisabled: !canConvertToLink }))), createElement(InspectorControls, null, createElement(PanelBody, { title: __('Link settings') }, createElement(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.') }), createElement(TextControl, { value: title || '', onChange: titleValue => { setAttributes({ title: titleValue }); }, label: __('Link title'), autoComplete: "off" }), createElement(TextControl, { value: rel || '', onChange: relValue => { setAttributes({ rel: relValue }); }, label: __('Link rel'), autoComplete: "off" }))), createElement("div", blockProps, createElement(ParentElement, { className: "wp-block-navigation-item__content" }, createElement(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, allowedFormats: ['core/bold', 'core/italic', 'core/image', 'core/strikethrough'], onClick: () => { if (!openSubmenusOnClick && !url) { setIsLinkOpen(true); } } }), !openSubmenusOnClick && isLinkOpen && createElement(Popover, { position: "bottom center", onClose: () => setIsLinkOpen(false), anchor: popoverAnchor, shift: true }, createElement(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: createElement("mark", null) }); }, noDirectEntry: !!type, noURLSuggestion: !!type, suggestionsQuery: getSuggestionsQuery(type, kind), onChange: updatedValue => updateNavigationLinkBlockAttributes(updatedValue, setAttributes, attributes), onRemove: () => { setAttributes({ url: '' }); speak(__('Link removed.'), 'assertive'); } }))), (showSubmenuIcon || openSubmenusOnClick) && createElement("span", { className: "wp-block-navigation__submenu-icon" }, createElement(ItemSubmenuIcon, null)), createElement("div", innerBlocksProps))); } //# sourceMappingURL=edit.js.map