@wordpress/block-library
Version:
Block library for the WordPress editor.
581 lines (563 loc) • 21.9 kB
JavaScript
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = NavigationLinkEdit;
var _clsx = _interopRequireDefault(require("clsx"));
var _blocks = require("@wordpress/blocks");
var _data = require("@wordpress/data");
var _components = require("@wordpress/components");
var _keycodes = require("@wordpress/keycodes");
var _i18n = require("@wordpress/i18n");
var _blockEditor = require("@wordpress/block-editor");
var _url = require("@wordpress/url");
var _element = require("@wordpress/element");
var _dom = require("@wordpress/dom");
var _htmlEntities = require("@wordpress/html-entities");
var _icons = require("@wordpress/icons");
var _coreData = require("@wordpress/core-data");
var _compose = require("@wordpress/compose");
var _linkUi = require("./link-ui");
var _updateAttributes = require("./update-attributes");
var _utils = require("../navigation/edit/utils");
var _hooks = require("../utils/hooks");
var _jsxRuntime = require("react/jsx-runtime");
/**
* External dependencies
*/
/**
* WordPress dependencies
*/
/**
* Internal dependencies
*/
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] = (0, _element.useState)(false);
(0, _element.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 = (0, _blockEditor.useBlockEditingMode)();
const postStatus = (0, _data.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(_coreData.store);
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 = (0, _i18n.__)('Select post');
break;
case 'page':
/* translators: label for missing page in navigation link block */
missingText = (0, _i18n.__)('Select page');
break;
case 'category':
/* translators: label for missing category in navigation link block */
missingText = (0, _i18n.__)('Select category');
break;
case 'tag':
/* translators: label for missing tag in navigation link block */
missingText = (0, _i18n.__)('Select tag');
break;
default:
/* translators: label for missing values in navigation link block */
missingText = (0, _i18n.__)('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 = (0, _hooks.useToolsPanelDropdownMenuProps)();
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_components.__experimentalToolsPanel, {
label: (0, _i18n.__)('Settings'),
resetAll: () => {
setAttributes({
label: '',
url: '',
description: '',
rel: ''
});
},
dropdownMenuProps: dropdownMenuProps,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_components.__experimentalToolsPanelItem, {
hasValue: () => !!label,
label: (0, _i18n.__)('Text'),
onDeselect: () => setAttributes({
label: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.TextControl, {
__nextHasNoMarginBottom: true,
__next40pxDefaultSize: true,
label: (0, _i18n.__)('Text'),
value: label ? (0, _dom.__unstableStripHTML)(label) : '',
onChange: labelValue => {
setAttributes({
label: labelValue
});
},
autoComplete: "off",
onFocus: () => setIsLabelFieldFocused(true),
onBlur: () => setIsLabelFieldFocused(false)
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.__experimentalToolsPanelItem, {
hasValue: () => !!url,
label: (0, _i18n.__)('Link'),
onDeselect: () => setAttributes({
url: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.TextControl, {
__nextHasNoMarginBottom: true,
__next40pxDefaultSize: true,
label: (0, _i18n.__)('Link'),
value: url ? (0, _url.safeDecodeURI)(url) : '',
onChange: urlValue => {
(0, _updateAttributes.updateAttributes)({
url: urlValue
}, setAttributes, attributes);
},
autoComplete: "off",
type: "url"
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.__experimentalToolsPanelItem, {
hasValue: () => !!description,
label: (0, _i18n.__)('Description'),
onDeselect: () => setAttributes({
description: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.TextareaControl, {
__nextHasNoMarginBottom: true,
label: (0, _i18n.__)('Description'),
value: description || '',
onChange: descriptionValue => {
setAttributes({
description: descriptionValue
});
},
help: (0, _i18n.__)('The description will be displayed in the menu if the current theme supports it.')
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.__experimentalToolsPanelItem, {
hasValue: () => !!rel,
label: (0, _i18n.__)('Rel attribute'),
onDeselect: () => setAttributes({
rel: ''
}),
isShownByDefault: true,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.TextControl, {
__nextHasNoMarginBottom: true,
__next40pxDefaultSize: true,
label: (0, _i18n.__)('Rel attribute'),
value: rel || '',
onChange: relValue => {
setAttributes({
rel: relValue
});
},
autoComplete: "off",
help: (0, _i18n.__)('The relationship of the linked URL as space-separated link types.')
})
})]
});
}
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
} = (0, _data.useDispatch)(_blockEditor.store);
// Have the link editing ui open on mount when lacking a url and selected.
const [isLinkOpen, setIsLinkOpen] = (0, _element.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] = (0, _element.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] = (0, _element.useState)(null);
const listItemRef = (0, _element.useRef)(null);
const isDraggingWithin = useIsDraggingWithin(listItemRef);
const itemLabelPlaceholder = (0, _i18n.__)('Add label…');
const ref = (0, _element.useRef)();
const linkUIref = (0, _element.useRef)();
const prevUrl = (0, _compose.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] = (0, _element.useState)(false);
const {
isAtMaxNesting,
isTopLevelLink,
isParentOfSelectedBlock,
hasChildren,
validateLinkStatus
} = (0, _data.useSelect)(select => {
const {
getBlockCount,
getBlockName,
getBlockRootClientId,
hasSelectedInnerBlock,
getBlockParentsByBlockName,
getSelectedBlockClientId
} = select(_blockEditor.store);
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
} = (0, _data.useSelect)(_blockEditor.store);
const [isInvalid, isDraft] = useIsInvalidLink(kind, type, id, validateLinkStatus);
/**
* Transform to submenu block.
*/
const transformToSubmenu = () => {
let innerBlocks = getBlocks(clientId);
if (innerBlocks.length === 0) {
innerBlocks = [(0, _blocks.createBlock)('core/navigation-link')];
selectBlock(innerBlocks[0].clientId);
}
const newSubmenu = (0, _blocks.createBlock)('core/navigation-submenu', attributes, innerBlocks);
replaceBlock(clientId, newSubmenu);
};
(0, _element.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.
(0, _element.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 && (0, _url.isURL)((0, _url.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
} = (0, _utils.getColors)(context, !isTopLevelLink);
function onKeyDown(event) {
if (_keycodes.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 = (0, _blockEditor.useBlockProps)({
ref: (0, _compose.useMergeRefs)([setPopoverAnchor, listItemRef]),
className: (0, _clsx.default)('wp-block-navigation-item', {
'is-editing': isSelected || isParentOfSelectedBlock,
'is-dragging-within': isDraggingWithin,
'has-link': !!url,
'has-child': hasChildren,
'has-text-color': !!textColor || !!customTextColor,
[(0, _blockEditor.getColorClassName)('color', textColor)]: !!textColor,
'has-background': !!backgroundColor || customBackgroundColor,
[(0, _blockEditor.getColorClassName)('background-color', backgroundColor)]: !!backgroundColor
}),
style: {
color: !textColor && customTextColor,
backgroundColor: !backgroundColor && customBackgroundColor
},
onKeyDown
});
const innerBlocksProps = (0, _blockEditor.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 = (0, _clsx.default)('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 ? (0, _i18n.__)('Invalid') : (0, _i18n.__)('Draft')})`;
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_blockEditor.BlockControls, {
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_components.ToolbarGroup, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_components.ToolbarButton, {
name: "link",
icon: _icons.link,
title: (0, _i18n.__)('Link'),
shortcut: _keycodes.displayShortcut.primary('k'),
onClick: event => {
setIsLinkOpen(true);
setOpenedBy(event.currentTarget);
}
}), !isAtMaxNesting && /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.ToolbarButton, {
name: "submenu",
icon: _icons.addSubmenu,
title: (0, _i18n.__)('Add submenu'),
onClick: transformToSubmenu
})]
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_blockEditor.InspectorControls, {
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(Controls, {
attributes: attributes,
setAttributes: setAttributes,
setIsLabelFieldFocused: setIsLabelFieldFocused
})
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
...blockProps,
children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("a", {
className: classes,
children: [!url ? /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
className: "wp-block-navigation-link__placeholder-text",
children: /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
children: missingText
})
}) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
children: [!isInvalid && !isDraft && !isLabelFieldFocused && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_blockEditor.RichText, {
ref: ref,
identifier: "label",
className: "wp-block-navigation-item__label",
value: label,
onChange: labelValue => setAttributes({
label: labelValue
}),
onMerge: mergeBlocks,
onReplace: onReplace,
__unstableOnSplitAtEnd: () => insertBlocksAfter((0, _blocks.createBlock)('core/navigation-link')),
"aria-label": (0, _i18n.__)('Navigation link text'),
placeholder: itemLabelPlaceholder,
withoutInteractiveFormatting: true
}), description && /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
className: "wp-block-navigation-item__description",
children: description
})]
}), (isInvalid || isDraft || isLabelFieldFocused) && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
className: (0, _clsx.default)('wp-block-navigation-link__placeholder-text', 'wp-block-navigation-link__label', {
'is-invalid': isInvalid,
'is-draft': isDraft
}),
children: /*#__PURE__*/(0, _jsxRuntime.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.
`${(0, _htmlEntities.decodeEntities)(label)} ${isInvalid || isDraft ? placeholderText : ''}`.trim()
})
})]
}), isLinkOpen && /*#__PURE__*/(0, _jsxRuntime.jsx)(_linkUi.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 => {
(0, _updateAttributes.updateAttributes)(updatedValue, setAttributes, attributes);
}
})]
}), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
...innerBlocksProps
})]
})]
});
}
//# sourceMappingURL=edit.js.map
;