@wordpress/block-library
Version:
Block library for the WordPress editor.
669 lines (614 loc) • 22.1 kB
JavaScript
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