@wordpress/block-editor
Version:
463 lines (457 loc) • 15.6 kB
JavaScript
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import { useRef, useCallback, forwardRef, createContext, useContext } from '@wordpress/element';
import { useDispatch, useRegistry, useSelect } from '@wordpress/data';
import { useMergeRefs, useInstanceId } from '@wordpress/compose';
import { __unstableUseRichText as useRichText, removeFormat } from '@wordpress/rich-text';
import { Popover } from '@wordpress/components';
import { getBlockBindingsSource } from '@wordpress/blocks';
import deprecated from '@wordpress/deprecated';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { useBlockEditorAutocompleteProps } from '../autocomplete';
import { useBlockEditContext } from '../block-edit';
import { blockBindingsKey, isPreviewModeKey } from '../block-edit/context';
import FormatToolbarContainer from './format-toolbar-container';
import { store as blockEditorStore } from '../../store';
import { useMarkPersistent } from './use-mark-persistent';
import { useFormatTypes } from './use-format-types';
import { useEventListeners } from './event-listeners';
import FormatEdit from './format-edit';
import { getAllowedFormats } from './utils';
import { Content, valueToHTMLString } from './content';
import { withDeprecations } from './with-deprecations';
import { canBindBlock } from '../../utils/block-bindings';
import BlockContext from '../block-context';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
export const keyboardShortcutContext = createContext();
export const inputEventContext = createContext();
const instanceIdKey = Symbol('instanceId');
/**
* Removes props used for the native version of RichText so that they are not
* passed to the DOM element and log warnings.
*
* @param {Object} props Props to filter.
*
* @return {Object} Filtered props.
*/
function removeNativeProps(props) {
const {
__unstableMobileNoFocusOnMount,
deleteEnter,
placeholderTextColor,
textAlign,
selectionColor,
tagsToEliminate,
disableEditingMenu,
fontSize,
fontFamily,
fontWeight,
fontStyle,
minWidth,
maxWidth,
disableSuggestions,
disableAutocorrection,
...restProps
} = props;
return restProps;
}
export function RichTextWrapper({
children,
tagName = 'div',
value: adjustedValue = '',
onChange: adjustedOnChange,
isSelected: originalIsSelected,
multiline,
inlineToolbar,
wrapperClassName,
autocompleters,
onReplace,
placeholder,
allowedFormats,
withoutInteractiveFormatting,
onRemove,
onMerge,
onSplit,
__unstableOnSplitAtEnd: onSplitAtEnd,
__unstableOnSplitAtDoubleLineEnd: onSplitAtDoubleLineEnd,
identifier,
preserveWhiteSpace,
__unstablePastePlainText: pastePlainText,
__unstableEmbedURLOnPaste,
__unstableDisableFormats: disableFormats,
disableLineBreaks,
__unstableAllowPrefixTransformations,
readOnly,
...props
}, forwardedRef) {
props = removeNativeProps(props);
if (onSplit) {
deprecated('wp.blockEditor.RichText onSplit prop', {
since: '6.4',
alternative: 'block.json support key: "splitting"'
});
}
const instanceId = useInstanceId(RichTextWrapper);
const anchorRef = useRef();
const context = useBlockEditContext();
const {
clientId,
isSelected: isBlockSelected,
name: blockName
} = context;
const blockBindings = context[blockBindingsKey];
const blockContext = useContext(BlockContext);
const registry = useRegistry();
const selector = select => {
// Avoid subscribing to the block editor store if the block is not
// selected.
if (!isBlockSelected) {
return {
isSelected: false
};
}
const {
getSelectionStart,
getSelectionEnd
} = select(blockEditorStore);
const selectionStart = getSelectionStart();
const selectionEnd = getSelectionEnd();
let isSelected;
if (originalIsSelected === undefined) {
isSelected = selectionStart.clientId === clientId && selectionEnd.clientId === clientId && (identifier ? selectionStart.attributeKey === identifier : selectionStart[instanceIdKey] === instanceId);
} else if (originalIsSelected) {
isSelected = selectionStart.clientId === clientId;
}
return {
selectionStart: isSelected ? selectionStart.offset : undefined,
selectionEnd: isSelected ? selectionEnd.offset : undefined,
isSelected
};
};
const {
selectionStart,
selectionEnd,
isSelected
} = useSelect(selector, [clientId, identifier, instanceId, originalIsSelected, isBlockSelected]);
const {
disableBoundBlock,
bindingsPlaceholder,
bindingsLabel
} = useSelect(select => {
var _fieldsList$relatedBi;
if (!blockBindings?.[identifier] || !canBindBlock(blockName)) {
return {};
}
const relatedBinding = blockBindings[identifier];
const blockBindingsSource = getBlockBindingsSource(relatedBinding.source);
const blockBindingsContext = {};
if (blockBindingsSource?.usesContext?.length) {
for (const key of blockBindingsSource.usesContext) {
blockBindingsContext[key] = blockContext[key];
}
}
const _disableBoundBlock = !blockBindingsSource?.canUserEditValue?.({
select,
context: blockBindingsContext,
args: relatedBinding.args
});
// Don't modify placeholders if value is not empty.
if (adjustedValue.length > 0) {
return {
disableBoundBlock: _disableBoundBlock,
// Null values will make them fall back to the default behavior.
bindingsPlaceholder: null,
bindingsLabel: null
};
}
const {
getBlockAttributes
} = select(blockEditorStore);
const blockAttributes = getBlockAttributes(clientId);
const fieldsList = blockBindingsSource?.getFieldsList?.({
select,
context: blockBindingsContext
});
const bindingKey = (_fieldsList$relatedBi = fieldsList?.[relatedBinding?.args?.key]?.label) !== null && _fieldsList$relatedBi !== void 0 ? _fieldsList$relatedBi : blockBindingsSource?.label;
const _bindingsPlaceholder = _disableBoundBlock ? bindingKey : sprintf(/* translators: %s: connected field label or source label */
__('Add %s'), bindingKey);
const _bindingsLabel = _disableBoundBlock ? relatedBinding?.args?.key || blockBindingsSource?.label : sprintf(/* translators: %s: source label or key */
__('Empty %s; start writing to edit its value'), relatedBinding?.args?.key || blockBindingsSource?.label);
return {
disableBoundBlock: _disableBoundBlock,
bindingsPlaceholder: blockAttributes?.placeholder || _bindingsPlaceholder,
bindingsLabel: _bindingsLabel
};
}, [blockBindings, identifier, blockName, adjustedValue, clientId, blockContext]);
const shouldDisableEditing = readOnly || disableBoundBlock;
const {
getSelectionStart,
getSelectionEnd,
getBlockRootClientId
} = useSelect(blockEditorStore);
const {
selectionChange
} = useDispatch(blockEditorStore);
const adjustedAllowedFormats = getAllowedFormats({
allowedFormats,
disableFormats
});
const hasFormats = !adjustedAllowedFormats || adjustedAllowedFormats.length > 0;
const onSelectionChange = useCallback((start, end) => {
const selection = {};
const unset = start === undefined && end === undefined;
const baseSelection = {
clientId,
[identifier ? 'attributeKey' : instanceIdKey]: identifier ? identifier : instanceId
};
if (typeof start === 'number' || unset) {
// If we are only setting the start (or the end below), which
// means a partial selection, and we're not updating a selection
// with the same client ID, abort. This means the selected block
// is a parent block.
if (end === undefined && getBlockRootClientId(clientId) !== getBlockRootClientId(getSelectionEnd().clientId)) {
return;
}
selection.start = {
...baseSelection,
offset: start
};
}
if (typeof end === 'number' || unset) {
if (start === undefined && getBlockRootClientId(clientId) !== getBlockRootClientId(getSelectionStart().clientId)) {
return;
}
selection.end = {
...baseSelection,
offset: end
};
}
selectionChange(selection);
}, [clientId, getBlockRootClientId, getSelectionEnd, getSelectionStart, identifier, instanceId, selectionChange]);
const {
formatTypes,
prepareHandlers,
valueHandlers,
changeHandlers,
dependencies
} = useFormatTypes({
clientId,
identifier,
withoutInteractiveFormatting,
allowedFormats: adjustedAllowedFormats
});
function addEditorOnlyFormats(value) {
return valueHandlers.reduce((accumulator, fn) => fn(accumulator, value.text), value.formats);
}
function removeEditorOnlyFormats(value) {
formatTypes.forEach(formatType => {
// Remove formats created by prepareEditableTree, because they are editor only.
if (formatType.__experimentalCreatePrepareEditableTree) {
value = removeFormat(value, formatType.name, 0, value.text.length);
}
});
return value.formats;
}
function addInvisibleFormats(value) {
return prepareHandlers.reduce((accumulator, fn) => fn(accumulator, value.text), value.formats);
}
const {
value,
getValue,
onChange,
ref: richTextRef
} = useRichText({
value: adjustedValue,
onChange(html, {
__unstableFormats,
__unstableText
}) {
adjustedOnChange(html);
Object.values(changeHandlers).forEach(changeHandler => {
changeHandler(__unstableFormats, __unstableText);
});
},
selectionStart,
selectionEnd,
onSelectionChange,
placeholder: bindingsPlaceholder || placeholder,
__unstableIsSelected: isSelected,
__unstableDisableFormats: disableFormats,
preserveWhiteSpace,
__unstableDependencies: [...dependencies, tagName],
__unstableAfterParse: addEditorOnlyFormats,
__unstableBeforeSerialize: removeEditorOnlyFormats,
__unstableAddInvisibleFormats: addInvisibleFormats
});
const autocompleteProps = useBlockEditorAutocompleteProps({
onReplace,
completers: autocompleters,
record: value,
onChange
});
useMarkPersistent({
html: adjustedValue,
value
});
const keyboardShortcuts = useRef(new Set());
const inputEvents = useRef(new Set());
function onFocus() {
anchorRef.current?.focus();
}
const TagName = tagName;
return /*#__PURE__*/_jsxs(_Fragment, {
children: [isSelected && /*#__PURE__*/_jsx(keyboardShortcutContext.Provider, {
value: keyboardShortcuts,
children: /*#__PURE__*/_jsx(inputEventContext.Provider, {
value: inputEvents,
children: /*#__PURE__*/_jsxs(Popover.__unstableSlotNameProvider, {
value: "__unstable-block-tools-after",
children: [children && children({
value,
onChange,
onFocus
}), /*#__PURE__*/_jsx(FormatEdit, {
value: value,
onChange: onChange,
onFocus: onFocus,
formatTypes: formatTypes,
forwardedRef: anchorRef
})]
})
})
}), isSelected && hasFormats && /*#__PURE__*/_jsx(FormatToolbarContainer, {
inline: inlineToolbar,
editableContentElement: anchorRef.current
}), /*#__PURE__*/_jsx(TagName
// Overridable props.
, {
role: "textbox",
"aria-multiline": !disableLineBreaks,
"aria-readonly": shouldDisableEditing,
...props,
// Unset draggable (coming from block props) for contentEditable
// elements because it will interfere with multi block selection
// when the contentEditable and draggable elements are the same
// element.
draggable: undefined,
"aria-label": bindingsLabel || props['aria-label'] || placeholder,
...autocompleteProps,
ref: useMergeRefs([
// Rich text ref must be first because its focus listener
// must be set up before any other ref calls .focus() on
// mount.
richTextRef, forwardedRef, autocompleteProps.ref, props.ref, useEventListeners({
registry,
getValue,
onChange,
__unstableAllowPrefixTransformations,
formatTypes,
onReplace,
selectionChange,
isSelected,
disableFormats,
value,
tagName,
onSplit,
__unstableEmbedURLOnPaste,
pastePlainText,
onMerge,
onRemove,
removeEditorOnlyFormats,
disableLineBreaks,
onSplitAtEnd,
onSplitAtDoubleLineEnd,
keyboardShortcuts,
inputEvents
}), anchorRef]),
contentEditable: !shouldDisableEditing,
suppressContentEditableWarning: true,
className: clsx('block-editor-rich-text__editable', props.className, 'rich-text')
// Setting tabIndex to 0 is unnecessary, the element is already
// focusable because it's contentEditable. This also fixes a
// Safari bug where it's not possible to Shift+Click multi
// select blocks when Shift Clicking into an element with
// tabIndex because Safari will focus the element. However,
// Safari will correctly ignore nested contentEditable elements.
,
tabIndex: props.tabIndex === 0 && !shouldDisableEditing ? null : props.tabIndex,
"data-wp-block-attribute-key": identifier
})]
});
}
// This is the private API for the RichText component.
// It allows access to all props, not just the public ones.
export const PrivateRichText = withDeprecations(forwardRef(RichTextWrapper));
PrivateRichText.Content = Content;
PrivateRichText.isEmpty = value => {
return !value || value.length === 0;
};
// This is the public API for the RichText component.
// We wrap the PrivateRichText component to hide some props from the public API.
/**
* @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/rich-text/README.md
*/
const PublicForwardedRichTextContainer = forwardRef((props, ref) => {
const context = useBlockEditContext();
const isPreviewMode = context[isPreviewModeKey];
if (isPreviewMode) {
// Remove all non-content props.
const {
children,
tagName: Tag = 'div',
value,
onChange,
isSelected,
multiline,
inlineToolbar,
wrapperClassName,
autocompleters,
onReplace,
placeholder,
allowedFormats,
withoutInteractiveFormatting,
onRemove,
onMerge,
onSplit,
__unstableOnSplitAtEnd,
__unstableOnSplitAtDoubleLineEnd,
identifier,
preserveWhiteSpace,
__unstablePastePlainText,
__unstableEmbedURLOnPaste,
__unstableDisableFormats,
disableLineBreaks,
__unstableAllowPrefixTransformations,
readOnly,
...contentProps
} = removeNativeProps(props);
return /*#__PURE__*/_jsx(Tag, {
...contentProps,
dangerouslySetInnerHTML: {
__html: valueToHTMLString(value, multiline)
}
});
}
return /*#__PURE__*/_jsx(PrivateRichText, {
ref: ref,
...props,
readOnly: false
});
});
PublicForwardedRichTextContainer.Content = Content;
PublicForwardedRichTextContainer.isEmpty = value => {
return !value || value.length === 0;
};
export default PublicForwardedRichTextContainer;
export { RichTextShortcut } from './shortcut';
export { RichTextToolbarButton } from './toolbar-button';
export { __unstableRichTextInputEvent } from './input-event';
//# sourceMappingURL=index.js.map