@wordpress/block-library
Version:
Block library for the WordPress editor.
573 lines (557 loc) • 20.6 kB
JavaScript
/**
* 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';
import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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;
const dropdownMenuProps = useToolsPanelDropdownMenuProps();
return /*#__PURE__*/_jsxs(ToolsPanel, {
label: __('Settings'),
resetAll: () => {
setAttributes({
label: '',
url: '',
description: '',
rel: ''
});
},
dropdownMenuProps: dropdownMenuProps,
children: [/*#__PURE__*/_jsx(ToolsPanelItem, {
hasValue: () => !!label,
label: __('Text'),
onDeselect: () => setAttributes({
label: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/_jsx(TextControl, {
__nextHasNoMarginBottom: true,
__next40pxDefaultSize: true,
label: __('Text'),
value: label ? stripHTML(label) : '',
onChange: labelValue => {
setAttributes({
label: labelValue
});
},
autoComplete: "off",
onFocus: () => setIsLabelFieldFocused(true),
onBlur: () => setIsLabelFieldFocused(false)
})
}), /*#__PURE__*/_jsx(ToolsPanelItem, {
hasValue: () => !!url,
label: __('Link'),
onDeselect: () => setAttributes({
url: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/_jsx(TextControl, {
__nextHasNoMarginBottom: true,
__next40pxDefaultSize: true,
label: __('Link'),
value: url ? safeDecodeURI(url) : '',
onChange: urlValue => {
updateAttributes({
url: urlValue
}, setAttributes, attributes);
},
autoComplete: "off",
type: "url"
})
}), /*#__PURE__*/_jsx(ToolsPanelItem, {
hasValue: () => !!description,
label: __('Description'),
onDeselect: () => setAttributes({
description: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/_jsx(TextareaControl, {
__nextHasNoMarginBottom: true,
label: __('Description'),
value: description || '',
onChange: descriptionValue => {
setAttributes({
description: descriptionValue
});
},
help: __('The description will be displayed in the menu if the current theme supports it.')
})
}), /*#__PURE__*/_jsx(ToolsPanelItem, {
hasValue: () => !!rel,
label: __('Rel attribute'),
onDeselect: () => setAttributes({
rel: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/_jsx(TextControl, {
__nextHasNoMarginBottom: true,
__next40pxDefaultSize: true,
label: __('Rel attribute'),
value: rel || '',
onChange: relValue => {
setAttributes({
rel: relValue
});
},
autoComplete: "off",
help: __('The relationship of the linked URL as space-separated link types.')
})
})]
});
}
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 /*#__PURE__*/_jsxs(_Fragment, {
children: [/*#__PURE__*/_jsx(BlockControls, {
children: /*#__PURE__*/_jsxs(ToolbarGroup, {
children: [/*#__PURE__*/_jsx(ToolbarButton, {
name: "link",
icon: linkIcon,
title: __('Link'),
shortcut: displayShortcut.primary('k'),
onClick: event => {
setIsLinkOpen(true);
setOpenedBy(event.currentTarget);
}
}), !isAtMaxNesting && /*#__PURE__*/_jsx(ToolbarButton, {
name: "submenu",
icon: addSubmenu,
title: __('Add submenu'),
onClick: transformToSubmenu
})]
})
}), /*#__PURE__*/_jsx(InspectorControls, {
children: /*#__PURE__*/_jsx(Controls, {
attributes: attributes,
setAttributes: setAttributes,
setIsLabelFieldFocused: setIsLabelFieldFocused
})
}), /*#__PURE__*/_jsxs("div", {
...blockProps,
children: [/*#__PURE__*/_jsxs("a", {
className: classes,
children: [!url ? /*#__PURE__*/_jsx("div", {
className: "wp-block-navigation-link__placeholder-text",
children: /*#__PURE__*/_jsx("span", {
children: missingText
})
}) : /*#__PURE__*/_jsxs(_Fragment, {
children: [!isInvalid && !isDraft && !isLabelFieldFocused && /*#__PURE__*/_jsxs(_Fragment, {
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,
__unstableOnSplitAtEnd: () => insertBlocksAfter(createBlock('core/navigation-link')),
"aria-label": __('Navigation link text'),
placeholder: itemLabelPlaceholder,
withoutInteractiveFormatting: true
}), description && /*#__PURE__*/_jsx("span", {
className: "wp-block-navigation-item__description",
children: description
})]
}), (isInvalid || isDraft || isLabelFieldFocused) && /*#__PURE__*/_jsx("div", {
className: clsx('wp-block-navigation-link__placeholder-text', 'wp-block-navigation-link__label', {
'is-invalid': isInvalid,
'is-draft': isDraft
}),
children: /*#__PURE__*/_jsx("span", {
children:
// 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()
})
})]
}), isLinkOpen && /*#__PURE__*/_jsx(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);
}
})]
}), /*#__PURE__*/_jsx("div", {
...innerBlocksProps
})]
})]
});
}
//# sourceMappingURL=edit.js.map