UNPKG

@wordpress/block-editor

Version:
573 lines (558 loc) 19.6 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { Platform, useRef, useCallback, forwardRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { pasteHandler, children as childrenSource, getBlockTransforms, findTransform, isUnmodifiedDefaultBlock } from '@wordpress/blocks'; import { useInstanceId, useMergeRefs } from '@wordpress/compose'; import { __unstableCreateElement, isEmpty, insert, remove, create, split, toHTMLString } from '@wordpress/rich-text'; import { isURL } from '@wordpress/url'; /** * Internal dependencies */ import Autocomplete from '../autocomplete'; import { useBlockEditContext } from '../block-edit'; import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; import { filePasteHandler } from './file-paste-handler'; import FormatToolbarContainer from './format-toolbar-container'; import { store as blockEditorStore } from '../../store'; import { addActiveFormats, getAllowedFormats, createLinkInParagraph } from './utils'; import EmbedHandlerPicker from './embed-handler-picker'; import { Content } from './content'; import RichText from './native'; import { withDeprecations } from './with-deprecations'; import { findSelection } from './event-listeners/input-rules'; import { START_OF_SELECTED_AREA } from '../../utils/selection'; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; const classes = 'block-editor-rich-text__editable'; export function RichTextWrapper({ children, tagName, start, reversed, value: originalValue, onChange: originalOnChange, isSelected: originalIsSelected, inlineToolbar, wrapperClassName, autocompleters, onReplace, placeholder, allowedFormats, withoutInteractiveFormatting, onRemove, onMerge, onSplit, __unstableOnSplitAtEnd: onSplitAtEnd, __unstableOnSplitMiddle: onSplitMiddle, __unstableOnSplitAtDoubleLineEnd: onSplitAtDoubleLineEnd, identifier, preserveWhiteSpace, __unstablePastePlainText: pastePlainText, __unstableEmbedURLOnPaste, __unstableDisableFormats: disableFormats, disableLineBreaks, unstableOnFocus, __unstableAllowPrefixTransformations, // Native props. __unstableUseSplitSelection, __unstableMobileNoFocusOnMount, deleteEnter, placeholderTextColor, textAlign, selectionColor, tagsToEliminate, disableEditingMenu, fontSize, fontFamily, fontWeight, fontStyle, minWidth, maxWidth, onBlur, disableSuggestions, disableAutocorrection, containerWidth, onEnter: onCustomEnter, ...props }, providedRef) { const instanceId = useInstanceId(RichTextWrapper); identifier = identifier || instanceId; const fallbackRef = useRef(); const { clientId, isSelected: blockIsSelected } = useBlockEditContext(); const embedHandlerPickerRef = useRef(); const selector = select => { const { getSelectionStart, getSelectionEnd, getSettings, didAutomaticChange, getBlock, isMultiSelecting, hasMultiSelection, getSelectedBlockClientId } = select(blockEditorStore); const selectionStart = getSelectionStart(); const selectionEnd = getSelectionEnd(); const { __experimentalUndo: undo } = getSettings(); let isSelected; if (originalIsSelected === undefined) { isSelected = selectionStart.clientId === clientId && selectionStart.attributeKey === identifier; } else if (originalIsSelected) { isSelected = selectionStart.clientId === clientId; } let extraProps = {}; if (Platform.OS === 'native') { // If the block of this RichText is unmodified then it's a candidate for replacing when adding a new block. // In order to fix https://github.com/wordpress-mobile/gutenberg-mobile/issues/1126, let's blur on unmount in that case. // This apparently assumes functionality the BlockHlder actually. const block = clientId && getBlock(clientId); const shouldBlurOnUnmount = block && isSelected && isUnmodifiedDefaultBlock(block); extraProps = { shouldBlurOnUnmount }; } return { selectionStart: isSelected ? selectionStart.offset : undefined, selectionEnd: isSelected ? selectionEnd.offset : undefined, isSelected, didAutomaticChange: didAutomaticChange(), disabled: isMultiSelecting() || hasMultiSelection(), undo, getSelectedBlockClientId, ...extraProps }; }; // This selector must run on every render so the right selection state is // retrieved from the store on merge. // To do: fix this somehow. const { selectionStart, selectionEnd, isSelected, getSelectedBlockClientId, didAutomaticChange, disabled, undo, shouldBlurOnUnmount } = useSelect(selector); const { __unstableMarkLastChangeAsPersistent, enterFormattedText, exitFormattedText, selectionChange, __unstableMarkAutomaticChange, __unstableSplitSelection, clearSelectedBlock } = useDispatch(blockEditorStore); const adjustedAllowedFormats = getAllowedFormats({ allowedFormats, disableFormats }); const hasFormats = !adjustedAllowedFormats || adjustedAllowedFormats.length > 0; let adjustedValue = originalValue; let adjustedOnChange = originalOnChange; // Handle deprecated format. if (Array.isArray(originalValue)) { adjustedValue = childrenSource.toHTML(originalValue); adjustedOnChange = newValue => originalOnChange(childrenSource.fromDOM(__unstableCreateElement(document, newValue).childNodes)); } const onSelectionChange = useCallback((selectionChangeStart, selectionChangeEnd) => { selectionChange(clientId, identifier, selectionChangeStart, selectionChangeEnd); }, [clientId, identifier]); const clearCurrentSelectionOnUnmount = useCallback(() => { if (getSelectedBlockClientId() === clientId) { clearSelectedBlock(); } }, [clearSelectedBlock, clientId, getSelectedBlockClientId]); const onDelete = useCallback(({ value, isReverse }) => { if (onMerge) { onMerge(!isReverse); } // Only handle remove on Backspace. This serves dual-purpose of being // an intentional user interaction distinguishing between Backspace and // Delete to remove the empty field, but also to avoid merge & remove // causing destruction of two fields (merge, then removed merged). else if (onRemove && isEmpty(value) && isReverse) { onRemove(!isReverse); } }, [onMerge, onRemove]); /** * Signals to the RichText owner that the block can be replaced with two * blocks as a result of splitting the block by pressing enter, or with * blocks as a result of splitting the block by pasting block content in the * instance. * * @param {Object} record The rich text value to split. * @param {Array} pastedBlocks The pasted blocks to insert, if any. */ const splitValue = useCallback((record, pastedBlocks = []) => { if (!onReplace || !onSplit) { return; } const blocks = []; const [before, after] = split(record); const hasPastedBlocks = pastedBlocks.length > 0; let lastPastedBlockIndex = -1; // Consider the after value to be the original it is not empty and // the before value *is* empty. const isAfterOriginal = isEmpty(before) && !isEmpty(after); // Create a block with the content before the caret if there's no pasted // blocks, or if there are pasted blocks and the value is not empty. // We do not want a leading empty block on paste, but we do if split // with e.g. the enter key. if (!hasPastedBlocks || !isEmpty(before)) { blocks.push(onSplit(toHTMLString({ value: before }), !isAfterOriginal)); lastPastedBlockIndex += 1; } if (hasPastedBlocks) { blocks.push(...pastedBlocks); lastPastedBlockIndex += pastedBlocks.length; } else if (onSplitMiddle) { blocks.push(onSplitMiddle()); } // If there's pasted blocks, append a block with non empty content // after the caret. Otherwise, do append an empty block if there // is no `onSplitMiddle` prop, but if there is and the content is // empty, the middle block is enough to set focus in. if (hasPastedBlocks ? !isEmpty(after) : !onSplitMiddle || !isEmpty(after)) { blocks.push(onSplit(toHTMLString({ value: after }), isAfterOriginal)); } // If there are pasted blocks, set the selection to the last one. // Otherwise, set the selection to the second block. const indexToSelect = hasPastedBlocks ? lastPastedBlockIndex : 1; // If there are pasted blocks, move the caret to the end of the selected block // Otherwise, retain the default value. const initialPosition = hasPastedBlocks ? -1 : 0; onReplace(blocks, indexToSelect, initialPosition); }, [onReplace, onSplit, onSplitMiddle]); const onEnter = useCallback(({ value, onChange, shiftKey }) => { const canSplit = onReplace && onSplit; if (onReplace) { const transforms = getBlockTransforms('from').filter(({ type }) => type === 'enter'); const transformation = findTransform(transforms, item => { return item.regExp.test(value.text); }); if (transformation) { onReplace([transformation.transform({ content: value.text })]); __unstableMarkAutomaticChange(); return; } } if (onCustomEnter) { onCustomEnter(); } const { text, start: splitStart, end: splitEnd } = value; const canSplitAtEnd = onSplitAtEnd && splitStart === splitEnd && splitEnd === text.length; if (shiftKey) { if (!disableLineBreaks) { onChange(insert(value, '\n')); } } else if (canSplit) { splitValue(value); } else if (__unstableUseSplitSelection) { __unstableSplitSelection(); } else if (canSplitAtEnd) { onSplitAtEnd(); } else if ( // For some blocks it's desirable to split at the end of the // block when there are two line breaks at the end of the // block, so triple Enter exits the block. onSplitAtDoubleLineEnd && splitStart === splitEnd && splitEnd === text.length && text.slice(-2) === '\n\n') { value.start = value.end - 2; onChange(remove(value)); onSplitAtDoubleLineEnd(); } else if (!disableLineBreaks) { onChange(insert(value, '\n')); } }, [onReplace, onSplit, __unstableMarkAutomaticChange, splitValue, onSplitAtEnd]); const onPaste = useCallback(({ value, onChange, html, plainText, isInternal, files, activeFormats }) => { // If the data comes from a rich text instance, we can directly use it // without filtering the data. The filters are only meant for externally // pasted content and remove inline styles. if (isInternal) { const pastedValue = create({ html, preserveWhiteSpace }); addActiveFormats(pastedValue, activeFormats); onChange(insert(value, pastedValue)); return; } if (pastePlainText) { onChange(insert(value, create({ text: plainText }))); return; } // Only process file if no HTML is present. // Note: a pasted file may have the URL as plain text. if (files && files.length && !html) { const content = pasteHandler({ HTML: filePasteHandler(files), mode: 'BLOCKS', tagName, preserveWhiteSpace }); // Allows us to ask for this information when we get a report. // eslint-disable-next-line no-console window.console.log('Received items:\n\n', files); if (onReplace && isEmpty(value)) { onReplace(content); } else { splitValue(value, content); } return; } let mode = onReplace && onSplit ? 'AUTO' : 'INLINE'; const isPastedURL = isURL(plainText.trim()); const presentEmbedHandlerPicker = () => embedHandlerPickerRef.current?.presentPicker({ createEmbed: () => onReplace(content, content.length - 1, -1), createLink: () => createLinkInParagraph(plainText.trim(), onReplace) }); if (__unstableEmbedURLOnPaste && isEmpty(value) && isPastedURL) { mode = 'BLOCKS'; } const content = pasteHandler({ HTML: html, plainText, mode, tagName, preserveWhiteSpace }); if (typeof content === 'string') { const valueToInsert = create({ html: content }); addActiveFormats(valueToInsert, activeFormats); onChange(insert(value, valueToInsert)); } else if (content.length > 0) { // When an URL is pasted in an empty paragraph then the EmbedHandlerPicker should showcase options allowing the transformation of that URL // into either an Embed block or a link within the target paragraph. If the paragraph is non-empty, the URL is pasted as text. const canPasteEmbed = isPastedURL && content.length === 1 && content[0].name === 'core/embed'; if (onReplace && isEmpty(value)) { if (canPasteEmbed) { onChange(insert(value, create({ text: plainText }))); if (__unstableEmbedURLOnPaste) { presentEmbedHandlerPicker(); } return; } onReplace(content, content.length - 1, -1, { source: 'clipboard' }); } else { if (canPasteEmbed) { onChange(insert(value, create({ text: plainText }))); return; } splitValue(value, content); } } }, [tagName, onReplace, onSplit, splitValue, __unstableEmbedURLOnPaste, preserveWhiteSpace, pastePlainText]); const inputRule = useCallback(value => { if (!onReplace) { return; } const { start: startPosition, text } = value; const characterBefore = text.slice(startPosition - 1, startPosition); // The character right before the caret must be a plain space. if (characterBefore !== ' ') { return; } const trimmedTextBefore = text.slice(0, start).trim(); const prefixTransforms = getBlockTransforms('from').filter(({ type }) => type === 'prefix'); const transformation = findTransform(prefixTransforms, ({ prefix }) => { return trimmedTextBefore === prefix; }); if (!transformation) { return; } const content = toHTMLString({ value: insert(value, START_OF_SELECTED_AREA, 0, start) }); const block = transformation.transform(content); const currentSelection = findSelection([block]); onReplace([block]); selectionChange(...currentSelection); __unstableMarkAutomaticChange(); }, [onReplace, start, selectionChange, __unstableMarkAutomaticChange]); const mergedRef = useMergeRefs([providedRef, fallbackRef]); return /*#__PURE__*/_jsx(RichText, { clientId: clientId, identifier: identifier, nativeEditorRef: mergedRef, value: adjustedValue, onChange: adjustedOnChange, selectionStart: selectionStart, selectionEnd: selectionEnd, onSelectionChange: onSelectionChange, tagName: tagName, start: start, reversed: reversed, placeholder: placeholder, allowedFormats: adjustedAllowedFormats, withoutInteractiveFormatting: withoutInteractiveFormatting, onEnter: onEnter, onDelete: onDelete, onPaste: onPaste, __unstableIsSelected: isSelected, __unstableInputRule: inputRule, __unstableOnEnterFormattedText: enterFormattedText, __unstableOnExitFormattedText: exitFormattedText, __unstableOnCreateUndoLevel: __unstableMarkLastChangeAsPersistent, __unstableMarkAutomaticChange: __unstableMarkAutomaticChange, __unstableDidAutomaticChange: didAutomaticChange, __unstableUndo: undo, __unstableDisableFormats: disableFormats, preserveWhiteSpace: preserveWhiteSpace, disabled: disabled, unstableOnFocus: unstableOnFocus, __unstableAllowPrefixTransformations: __unstableAllowPrefixTransformations // Native props. , blockIsSelected: originalIsSelected !== undefined ? originalIsSelected : blockIsSelected, shouldBlurOnUnmount: shouldBlurOnUnmount, __unstableMobileNoFocusOnMount: __unstableMobileNoFocusOnMount, deleteEnter: deleteEnter, placeholderTextColor: placeholderTextColor, textAlign: textAlign, selectionColor: selectionColor, tagsToEliminate: tagsToEliminate, disableEditingMenu: disableEditingMenu, fontSize: fontSize, fontFamily: fontFamily, fontWeight: fontWeight, fontStyle: fontStyle, minWidth: minWidth, maxWidth: maxWidth, onBlur: onBlur, disableSuggestions: disableSuggestions, disableAutocorrection: disableAutocorrection, containerWidth: containerWidth, clearCurrentSelectionOnUnmount: clearCurrentSelectionOnUnmount // Props to be set on the editable container are destructured on the // element itself for web (see below), but passed through rich text // for native. , id: props.id, style: props.style, children: ({ isSelected: nestedIsSelected, value, onChange, onFocus, editableProps, editableTagName: TagName }) => /*#__PURE__*/_jsxs(_Fragment, { children: [children && children({ value, onChange, onFocus }), nestedIsSelected && hasFormats && /*#__PURE__*/_jsx(FormatToolbarContainer, { inline: inlineToolbar, anchorRef: fallbackRef.current }), nestedIsSelected && /*#__PURE__*/_jsx(RemoveBrowserShortcuts, {}), /*#__PURE__*/_jsx(Autocomplete, { onReplace: onReplace, completers: autocompleters, record: value, onChange: onChange, isSelected: nestedIsSelected, contentRef: fallbackRef, children: ({ listBoxId, activeId, onKeyDown }) => /*#__PURE__*/_jsx(TagName, { ...editableProps, ...props, style: props.style ? { ...props.style, ...editableProps.style } : editableProps.style, className: clsx(classes, props.className, editableProps.className), "aria-autocomplete": listBoxId ? 'list' : undefined, "aria-owns": listBoxId, "aria-activedescendant": activeId, onKeyDown: event => { onKeyDown(event); editableProps.onKeyDown(event); } }) }), /*#__PURE__*/_jsx(EmbedHandlerPicker, { ref: embedHandlerPickerRef })] }) }); } // This export does not actually implement a private API, but was exported // under this name for interoperability with the web version of the RichText // component. export const PrivateRichText = withDeprecations(forwardRef(RichTextWrapper)); PrivateRichText.Content = Content; PrivateRichText.isEmpty = value => { return !value || value.length === 0; }; PrivateRichText.Content.defaultProps = { format: 'string', value: '' }; PrivateRichText.Raw = forwardRef((props, ref) => /*#__PURE__*/_jsx(RichText, { ...props, nativeEditorRef: ref })); /** * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/rich-text/README.md */ export default PrivateRichText; export { RichTextShortcut } from './shortcut'; export { RichTextToolbarButton } from './toolbar-button'; export { __unstableRichTextInputEvent } from './input-event'; //# sourceMappingURL=index.native.js.map