UNPKG

@wordpress/block-editor

Version:
1,176 lines (1,123 loc) 42.7 kB
/* eslint no-console: ["error", { allow: ["warn"] }] */ /** * External dependencies */ import { View, Platform, Dimensions } from 'react-native'; import memize from 'memize'; import { colord } from 'colord'; /** * WordPress dependencies */ import RCTAztecView from '@wordpress/react-native-aztec'; import { showUserSuggestions, showXpostSuggestions } from '@wordpress/react-native-bridge'; import { BlockFormatControls } from '@wordpress/block-editor'; import { getPxFromCssUnit } from '@wordpress/components'; import { Component } from '@wordpress/element'; import { compose, debounce, withPreferredColorScheme } from '@wordpress/compose'; import { withSelect } from '@wordpress/data'; import { childrenBlock } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/html-entities'; import { BACKSPACE, DELETE, ENTER } from '@wordpress/keycodes'; import { isURL } from '@wordpress/url'; import { atSymbol, plus } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { applyFormat, getActiveFormat, getActiveFormats, insert, getTextContent, isEmpty, create, toHTMLString, isCollapsed, remove } from '@wordpress/rich-text'; /** * Internal dependencies */ import { useFormatTypes } from './use-format-types'; import FormatEdit from './format-edit'; import { getFormatColors } from './get-format-colors'; import styles from './style.scss'; import ToolbarButtonWithOptions from './toolbar-button-with-options'; // The flattened color palettes array is memoized to ensure that the same array instance is // returned for the colors palettes. This value might be used as a prop, so having the same // instance will prevent unnecessary re-renders of the RichText component. import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const flatColorPalettes = memize(colorsPalettes => [...(colorsPalettes?.theme || []), ...(colorsPalettes?.custom || []), ...(colorsPalettes?.default || [])]); const getSelectionColor = memize((currentSelectionColor, defaultSelectionColor, baseGlobalStyles, isBlockBasedTheme) => { let selectionColor = defaultSelectionColor; if (currentSelectionColor) { selectionColor = currentSelectionColor; } if (isBlockBasedTheme) { const colordTextColor = colord(selectionColor); const colordBackgroundColor = colord(baseGlobalStyles?.color?.background); const isColordTextReadable = colordTextColor.isReadable(colordBackgroundColor); if (!isColordTextReadable) { selectionColor = baseGlobalStyles?.color?.text; } } return selectionColor; }); const gutenbergFormatNamesToAztec = { 'core/bold': 'bold', 'core/italic': 'italic', 'core/strikethrough': 'strikethrough', 'core/text-color': 'mark' }; const EMPTY_PARAGRAPH_TAGS = '<p></p>'; const DEFAULT_FONT_SIZE = 16; const MIN_LINE_HEIGHT = 1; export class RichText extends Component { constructor({ value, selectionStart, selectionEnd }) { super(...arguments); this.isIOS = Platform.OS === 'ios'; this.createRecord = this.createRecord.bind(this); this.onChangeFromAztec = this.onChangeFromAztec.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.handleEnter = this.handleEnter.bind(this); this.handleDelete = this.handleDelete.bind(this); this.onPaste = this.onPaste.bind(this); this.onFocus = this.onFocus.bind(this); this.onBlur = this.onBlur.bind(this); this.onTextUpdate = this.onTextUpdate.bind(this); this.onContentSizeChange = this.onContentSizeChange.bind(this); this.onFormatChange = this.onFormatChange.bind(this); this.formatToValue = memize(this.formatToValue.bind(this), { maxSize: 1 }); this.debounceCreateUndoLevel = debounce(this.onCreateUndoLevel, 1000); // This prevents a bug in Aztec which triggers onSelectionChange twice on format change. this.onSelectionChange = this.onSelectionChange.bind(this); this.onSelectionChangeFromAztec = this.onSelectionChangeFromAztec.bind(this); this.valueToFormat = this.valueToFormat.bind(this); this.getHtmlToRender = this.getHtmlToRender.bind(this); this.handleSuggestionFunc = this.handleSuggestionFunc.bind(this); this.handleUserSuggestion = this.handleSuggestionFunc(showUserSuggestions, '@').bind(this); this.handleXpostSuggestion = this.handleSuggestionFunc(showXpostSuggestions, '+').bind(this); this.suggestionOptions = this.suggestionOptions.bind(this); this.insertString = this.insertString.bind(this); this.manipulateEventCounterToForceNativeToRefresh = this.manipulateEventCounterToForceNativeToRefresh.bind(this); this.shouldDropEventFromAztec = this.shouldDropEventFromAztec.bind(this); this.state = { activeFormats: [], selectedFormat: null, height: 0, currentFontSize: this.getFontSize(arguments[0]) }; this.needsSelectionUpdate = false; this.savedContent = ''; this.isTouched = false; this.lastAztecEventType = null; this.lastHistoryValue = value; // Internal values that are update synchronously, unlike props. this.value = value; this.selectionStart = selectionStart; this.selectionEnd = selectionEnd; } /** * Get the current record (value and selection) from props and state. * * @return {Object} The current record (value and selection). */ getRecord() { const { selectionStart: start, selectionEnd: end, colorPalette } = this.props; const { value } = this.props; const currentValue = this.formatToValue(value); const { formats, replacements, text } = currentValue; const { activeFormats } = this.state; const newFormats = getFormatColors(formats, colorPalette); return { formats: newFormats, replacements, text, start, end, activeFormats }; } /** * Creates a RichText value "record" from the current content and selection * information * * * @return {Object} A RichText value with formats and selection. */ createRecord() { const { preserveWhiteSpace } = this.props; const value = { start: this.selectionStart, end: this.selectionEnd, ...create({ html: this.value, range: null, preserveWhiteSpace }) }; const start = Math.min(this.selectionStart, value.text.length); const end = Math.min(this.selectionEnd, value.text.length); return { ...value, start, end }; } valueToFormat(value) { // Remove the outer root tags. return this.removeRootTagsProducedByAztec(toHTMLString({ value })); } getActiveFormatNames(record) { const { formatTypes } = this.props; return formatTypes.map(({ name }) => name).filter(name => { return getActiveFormat(record, name) !== undefined; }).map(name => gutenbergFormatNamesToAztec[name]).filter(Boolean); } onFormatChange(record) { const { start = 0, end = 0, activeFormats = [] } = record; const changeHandlers = Object.fromEntries(Object.entries(this.props).filter(([key]) => key.startsWith('format_on_change_functions_'))); Object.values(changeHandlers).forEach(changeHandler => { changeHandler(record.formats, record.text); }); this.value = this.valueToFormat(record); this.props.onChange(this.value); this.setState({ activeFormats }); this.props.onSelectionChange(start, end); this.selectionStart = start; this.selectionEnd = end; this.onCreateUndoLevel(); this.lastAztecEventType = 'format change'; } insertString(record, string) { if (record && string) { this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side const toInsert = insert(record, string); this.onFormatChange(toInsert); } } onCreateUndoLevel() { const { __unstableOnCreateUndoLevel: onCreateUndoLevel } = this.props; // If the content is the same, no level needs to be created. if (this.lastHistoryValue?.toString() === this.value?.toString()) { return; } onCreateUndoLevel(); this.lastHistoryValue = this.value; } /* * Cleans up any root tags produced by aztec. * TODO: This should be removed on a later version when aztec doesn't return the top tag of the text being edited */ removeRootTagsProducedByAztec(html) { let result = this.removeRootTag(this.props.tagName, html); if (this.props.tagsToEliminate) { this.props.tagsToEliminate.forEach(element => { result = this.removeTag(element, result); }); } return result; } removeRootTag(tag, html) { const openingTagRegexp = RegExp('^<' + tag + '[^>]*>', 'gim'); const closingTagRegexp = RegExp('</' + tag + '>$', 'gim'); return html.replace(openingTagRegexp, '').replace(closingTagRegexp, ''); } removeTag(tag, html) { const openingTagRegexp = RegExp('<' + tag + '>', 'gim'); const closingTagRegexp = RegExp('</' + tag + '>', 'gim'); return html.replace(openingTagRegexp, '').replace(closingTagRegexp, ''); } /* * Handles any case where the content of the AztecRN instance has changed */ onChangeFromAztec(event) { if (this.shouldDropEventFromAztec(event, 'onChange')) { return; } const contentWithoutRootTag = this.removeRootTagsProducedByAztec(event.nativeEvent.text); const { __unstableInputRule } = this.props; const currentValuePosition = { end: this.isIOS ? this.selectionEnd : this.selectionEnd + 1, start: this.isIOS ? this.selectionStart : this.selectionStart + 1 }; if (__unstableInputRule && __unstableInputRule({ ...currentValuePosition, ...this.formatToValue(contentWithoutRootTag) })) { return; } // On iOS, onChange can be triggered after selection changes, even though there are no content changes. if (contentWithoutRootTag === this.value?.toString()) { return; } this.lastEventCount = event.nativeEvent.eventCount; this.comesFromAztec = true; this.firedAfterTextChanged = true; // The onChange event always fires after the fact. this.onTextUpdate(event); this.lastAztecEventType = 'input'; } onTextUpdate(event) { const contentWithoutRootTag = this.removeRootTagsProducedByAztec(event.nativeEvent.text); this.debounceCreateUndoLevel(); const refresh = this.value?.toString() !== contentWithoutRootTag; this.value = contentWithoutRootTag; // We don't want to refresh if our goal is just to create a record. if (refresh) { this.props.onChange(contentWithoutRootTag); } } /* * Handles any case where the content of the AztecRN instance has changed in size */ onContentSizeChange(contentSize) { this.setState(contentSize); this.lastAztecEventType = 'content size change'; } onKeyDown(event) { if (event.defaultPrevented) { return; } // Add stubs for conformance in downstream autocompleters logic. this.customEditableOnKeyDown?.({ preventDefault: () => undefined, ...event, key: RCTAztecView.KeyCodes[event?.keyCode] }); this.handleDelete(event); this.handleEnter(event); this.handleTriggerKeyCodes(event); } handleEnter(event) { if (event.keyCode !== ENTER) { return; } const { onEnter } = this.props; if (!onEnter) { return; } onEnter({ value: this.createRecord(), onChange: this.onFormatChange, shiftKey: event.shiftKey }); this.lastAztecEventType = 'input'; } handleDelete(event) { if (this.shouldDropEventFromAztec(event, 'handleDelete')) { return; } const { keyCode } = event; if (keyCode !== DELETE && keyCode !== BACKSPACE) { return; } const isReverse = keyCode === BACKSPACE; const { onDelete } = this.props; this.lastEventCount = event.nativeEvent.eventCount; this.comesFromAztec = true; this.firedAfterTextChanged = event.nativeEvent.firedAfterTextChanged; const value = this.createRecord(); const { start, end, text, activeFormats } = value; const hasActiveFormats = activeFormats && !!activeFormats.length; let newValue; // Always handle full content deletion ourselves. if (start === 0 && end !== 0 && end >= text.length) { newValue = remove(value); this.onFormatChange(newValue); event.preventDefault(); return; } // Only process delete if the key press occurs at an uncollapsed edge. if (!isCollapsed(value) || hasActiveFormats || isReverse && start !== 0 || !isReverse && end !== text.length) { return; } if (onDelete) { onDelete({ isReverse, value }); } event.preventDefault(); this.lastAztecEventType = 'input'; } handleTriggerKeyCodes(event) { const { keyCode } = event; const triggeredOption = this.suggestionOptions().find(option => { const triggeredKeyCode = option.triggerChar.charCodeAt(0); return triggeredKeyCode === keyCode; }); if (triggeredOption) { const record = this.getRecord(); const text = getTextContent(record); // Only respond to the trigger if the selection is on the start of text or line // or if the character before is a space. const useTrigger = text.length === 0 || record.start === 0 || text.charAt(record.start - 1) === '\n' || text.charAt(record.start - 1) === ' '; if (useTrigger && triggeredOption.onClick) { triggeredOption.onClick(); } else { this.insertString(record, triggeredOption.triggerChar); } } } suggestionOptions() { const { areMentionsSupported, areXPostsSupported } = this.props; const allOptions = [{ supported: areMentionsSupported, title: __('Insert mention'), onClick: this.handleUserSuggestion, triggerChar: '@', value: 'mention', label: __('Mention'), icon: atSymbol }, { supported: areXPostsSupported, title: __('Insert crosspost'), onClick: this.handleXpostSuggestion, triggerChar: '+', value: 'crosspost', label: __('Crosspost'), icon: plus }]; return allOptions.filter(op => op.supported); } handleSuggestionFunc(suggestionFunction, prefix) { return () => { const record = this.getRecord(); suggestionFunction().then(suggestion => { this.insertString(record, `${prefix}${suggestion} `); }).catch(() => {}); }; } /** * Handles a paste event from the native Aztec Wrapper. * * @param {Object} event The paste event which wraps `nativeEvent`. */ onPaste(event) { const { onPaste, onChange } = this.props; const { activeFormats = [] } = this.state; const { pastedText, pastedHtml, files } = event.nativeEvent; const currentRecord = this.createRecord(); event.preventDefault(); // There is a selection, check if a URL is pasted. if (!isCollapsed(currentRecord)) { const trimmedText = (pastedHtml || pastedText).replace(/<[^>]+>/g, '').trim(); // A URL was pasted, turn the selection into a link. if (isURL(trimmedText)) { const linkedRecord = applyFormat(currentRecord, { type: 'a', attributes: { href: decodeEntities(trimmedText) } }); this.value = this.valueToFormat(linkedRecord); onChange(this.value); // Allows us to ask for this information when we get a report. window.console.log('Created link:\n\n', trimmedText); return; } } if (onPaste) { onPaste({ value: currentRecord, onChange: this.onFormatChange, html: pastedHtml, plainText: pastedText, files, activeFormats }); } } onFocus() { this.isTouched = true; const { unstableOnFocus, onSelectionChange } = this.props; if (unstableOnFocus) { unstableOnFocus(); } // We know for certain that on focus, the old selection is invalid. It // will be recalculated on `selectionchange`. onSelectionChange(this.selectionStart, this.selectionEnd); this.lastAztecEventType = 'focus'; } onBlur(event) { this.isTouched = false; // Check if value is up to date with latest state of native AztecView. if (event.nativeEvent.text && event.nativeEvent.text !== this.props.value?.toString()) { this.onTextUpdate(event); } if (this.props.onBlur) { this.props.onBlur(event); } this.lastAztecEventType = 'blur'; } onSelectionChange(start, end) { const hasChanged = this.selectionStart !== start || this.selectionEnd !== end; this.selectionStart = start; this.selectionEnd = end; // This is a manual selection change event if onChange was not triggered just before // and we did not just trigger a text update // `onChange` could be the last event and could have been triggered a long time ago so // this approach is not perfectly reliable. const isManual = this.lastAztecEventType !== 'input' && this.props.value?.toString() === this.value?.toString(); if (hasChanged && isManual) { const value = this.createRecord(); const activeFormats = getActiveFormats(value); this.setState({ activeFormats }); } this.props.onSelectionChange(start, end); } shouldDropEventFromAztec(event, logText) { const shouldDrop = !this.isIOS && event.nativeEvent.eventCount <= this.lastEventCount; if (shouldDrop) { window.console.log(`Dropping ${logText} from Aztec as its event counter is older than latest sent to the native side. Got ${event.nativeEvent.eventCount} but lastEventCount is ${this.lastEventCount}.`); } return shouldDrop; } /** * Determines whether the text input should receive focus after an update. * For cases where a RichText with a value is merged with an empty one. * * @param {Object} prevProps - The previous props of the component. * @return {boolean} True if the text input should receive focus, false otherwise. */ shouldFocusTextInputAfterMerge(prevProps) { const { __unstableIsSelected: isSelected, blockIsSelected, selectionStart, selectionEnd, __unstableMobileNoFocusOnMount } = this.props; const { __unstableIsSelected: prevIsSelected, blockIsSelected: prevBlockIsSelected } = prevProps; const noSelectionValues = selectionStart === undefined && selectionEnd === undefined; const textInputWasNotFocused = !prevIsSelected && !isSelected; return !__unstableMobileNoFocusOnMount && noSelectionValues && textInputWasNotFocused && !prevBlockIsSelected && blockIsSelected; } onSelectionChangeFromAztec(start, end, text, event) { if (this.shouldDropEventFromAztec(event, 'onSelectionChange')) { return; } // `end` can be less than `start` on iOS // Let's fix that here so `rich-text/slice` can work properly. const realStart = Math.min(start, end); const realEnd = Math.max(start, end); // Check and dicsard stray event, where the text and selection is equal to the ones already cached. const contentWithoutRootTag = this.removeRootTagsProducedByAztec(event.nativeEvent.text); if (contentWithoutRootTag === this.value?.toString() && realStart === this.selectionStart && realEnd === this.selectionEnd) { return; } this.comesFromAztec = true; this.firedAfterTextChanged = true; // Selection change event always fires after the fact. // Update text before updating selection // Make sure there are changes made to the content before upgrading it upward. this.onTextUpdate(event); // Aztec can send us selection change events after it has lost focus. // For instance the autocorrect feature will complete a partially written // word when resigning focus, causing a selection change event. // Forwarding this selection change could cause this RichText to regain // focus and start a focus loop. // // See https://github.com/wordpress-mobile/gutenberg-mobile/issues/1696 if (this.props.__unstableIsSelected) { this.onSelectionChange(realStart, realEnd); } // Update lastEventCount to prevent Aztec from re-rendering the content it just sent. this.lastEventCount = event.nativeEvent.eventCount; this.lastAztecEventType = 'selection change'; } isEmpty() { return isEmpty(this.formatToValue(this.props.value)); } formatToValue(value) { const { preserveWhiteSpace } = this.props; // Handle deprecated `children` and `node` sources. if (Array.isArray(value)) { return create({ html: childrenBlock.toHTML(value), preserveWhiteSpace }); } if (this.props.format === 'string') { return create({ html: value, preserveWhiteSpace }); } // Guard for blocks passing `null` in onSplit callbacks. May be removed // if onSplit is revised to not pass a `null` value. if (value === null) { return create(); } return value; } manipulateEventCounterToForceNativeToRefresh() { if (this.isIOS) { this.lastEventCount = undefined; return; } if (typeof this.lastEventCount !== 'undefined') { this.lastEventCount += 100; // bump by a hundred, hopefully native hasn't bombarded the JS side in the meantime. } // no need to bump when 'undefined' as native side won't receive the key when the value is undefined, and that will cause force updating anyway, // see https://github.com/WordPress/gutenberg/blob/82e578dcc75e67891c750a41a04c1e31994192fc/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java#L213-L215 } shouldComponentUpdate(nextProps, nextState) { if (nextProps.tagName !== this.props.tagName || nextProps.reversed !== this.props.reversed || nextProps.start !== this.props.start) { this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side this.value = undefined; return true; } // TODO: Please re-introduce the check to avoid updating the content right after an `onChange` call. // It was removed in https://github.com/WordPress/gutenberg/pull/12417 to fix undo/redo problem. // If the component is changed React side (undo/redo/merging/splitting/custom text actions) // we need to make sure the native is updated as well. // Also, don't trust the "this.lastContent" as on Android, incomplete text events arrive // with only some of the text, while the virtual keyboard's suggestion system does its magic. // ** compare with this.lastContent for optimizing performance by not forcing Aztec with text it already has // , but compare with props.value to not lose "half word" text because of Android virtual keyb autosuggestion behavior if (typeof nextProps.value !== 'undefined' && typeof this.props.value !== 'undefined' && (!this.comesFromAztec || !this.firedAfterTextChanged) && nextProps.value?.toString() !== this.props.value?.toString()) { // Gutenberg seems to try to mirror the caret state even on events that only change the content so, // let's force caret update if state has selection set. if (typeof nextProps.selectionStart !== 'undefined' && typeof nextProps.selectionEnd !== 'undefined') { this.needsSelectionUpdate = true; } this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side } if (!this.comesFromAztec) { if (typeof nextProps.selectionStart !== 'undefined' && typeof nextProps.selectionEnd !== 'undefined' && nextProps.selectionStart !== this.props.selectionStart && nextProps.selectionStart !== this.selectionStart && nextProps.__unstableIsSelected) { this.needsSelectionUpdate = true; this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side } // For font size changes from a prop value a force refresh // is needed without the selection update. if (nextProps?.fontSize !== this.props?.fontSize) { this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side } if (nextProps?.style?.fontSize !== this.props?.style?.fontSize && nextState.currentFontSize !== this.state.currentFontSize || nextState.currentFontSize !== this.state.currentFontSize || nextProps?.style?.lineHeight !== this.props?.style?.lineHeight) { this.needsSelectionUpdate = true; this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side } } return true; } componentDidMount() { // Request focus if wrapping block is selected and parent hasn't inhibited the focus request. This method of focusing // is trying to implement the web-side counterpart of BlockList's `focusTabbable` where the BlockList is focusing an // inputbox by searching the DOM. We don't have the DOM in RN so, using the combination of blockIsSelected and __unstableMobileNoFocusOnMount // to determine if we should focus the RichText. if (this.props.blockIsSelected && !this.props.__unstableMobileNoFocusOnMount) { this._editor.focus(); this.onSelectionChange(this.props.selectionStart || 0, this.props.selectionEnd || 0); } } componentDidUpdate(prevProps) { const { style, tagName } = this.props; const { currentFontSize } = this.state; if (this.props.value?.toString() !== this.value?.toString()) { this.value = this.props.value; } const { __unstableIsSelected: prevIsSelected } = prevProps; const { __unstableIsSelected: isSelected } = this.props; if (isSelected && !prevIsSelected) { this._editor.focus(); // Update selection props explicitly when component is selected as Aztec won't call onSelectionChange // if its internal value hasn't change. When created, default value is 0, 0. this.onSelectionChange(this.props.selectionStart || 0, this.props.selectionEnd || 0); } else if (this.shouldFocusTextInputAfterMerge(prevProps)) { var _this$value$toString$; // Since this is happening when merging blocks, the selection should be at the last character position. // As a fallback the internal selectionEnd value is used. const lastCharacterPosition = (_this$value$toString$ = this.value?.toString().length) !== null && _this$value$toString$ !== void 0 ? _this$value$toString$ : this.selectionEnd; this._editor.focus(); this.props.onSelectionChange(lastCharacterPosition, lastCharacterPosition); } else if (!isSelected && prevIsSelected) { this._editor.blur(); } // For font size values changes from the font size picker // we compare previous values to refresh the selected font size, // this is also used when the tag name changes // e.g Heading block and a level change like h1->h2. const currentFontSizeStyle = this.getParsedFontSize(style?.fontSize); const prevFontSizeStyle = this.getParsedFontSize(prevProps?.style?.fontSize); const isDifferentTag = prevProps.tagName !== tagName; if (currentFontSize && (currentFontSizeStyle || prevFontSizeStyle) && currentFontSizeStyle !== currentFontSize || isDifferentTag) { this.setState({ currentFontSize: this.getFontSize(this.props) }); } } componentWillUnmount() { const { clearCurrentSelectionOnUnmount } = this.props; // There are cases when the component is unmounted e.g. scrolling in a // long post due to virtualization, so the block selection needs to be cleared // so it doesn't auto-focus when it's added back. if (this._editor?.isFocused()) { clearCurrentSelectionOnUnmount?.(); } } getHtmlToRender(record, tagName) { // Save back to HTML from React tree. let value = this.valueToFormat(record); if (value === undefined) { this.manipulateEventCounterToForceNativeToRefresh(); // force a refresh on the native side value = ''; } // On android if content is empty we need to send no content or else the placeholder will not show. if (!this.isIOS && (value?.toString() === '' || value?.toString() === EMPTY_PARAGRAPH_TAGS)) { return ''; } if (tagName) { let extraAttributes = ``; if (tagName === `ol`) { if (this.props.reversed) { extraAttributes += ` reversed`; } if (this.props.start) { extraAttributes += ` start=${this.props.start}`; } } value = `<${tagName}${extraAttributes}>${value}</${tagName}>`; } return value; } getEditableProps() { return { // Overridable props. style: {}, className: 'rich-text', onKeyDown: () => null }; } getParsedFontSize(fontSize) { var _getPxFromCssUnit; const { height, width } = Dimensions.get('window'); const cssUnitOptions = { height, width, fontSize: DEFAULT_FONT_SIZE }; if (!fontSize) { return fontSize; } const selectedPxValue = (_getPxFromCssUnit = getPxFromCssUnit(fontSize, cssUnitOptions)) !== null && _getPxFromCssUnit !== void 0 ? _getPxFromCssUnit : DEFAULT_FONT_SIZE; return parseFloat(selectedPxValue); } getFontSize(props) { const { baseGlobalStyles, tagName, fontSize, style } = props; const tagNameFontSize = baseGlobalStyles?.elements?.[tagName]?.typography?.fontSize; let newFontSize = DEFAULT_FONT_SIZE; // Disables line-height rendering for pre elements until we fix some issues with AztecAndroid. if (tagName === 'pre' && !this.isIOS) { return undefined; } // For block-based themes, get the default editor font size. if (baseGlobalStyles?.typography?.fontSize && tagName === 'p') { newFontSize = baseGlobalStyles?.typography?.fontSize; } // For block-based themes, get the default element font size // e.g h1, h2. if (tagNameFontSize) { newFontSize = tagNameFontSize; } // For font size values provided from the styles, // usually from values set from the font size picker. if (style?.fontSize) { newFontSize = style.fontSize; } // Fall-back to a font size provided from its props (if there's any) // and there are no other default values to use. if (fontSize && !tagNameFontSize && !style?.fontSize) { newFontSize = fontSize; } // We need to always convert to px units because the selected value // could be coming from the web where it could be stored as a different unit. const selectedPxValue = this.getParsedFontSize(newFontSize); return selectedPxValue; } getLineHeight() { const { baseGlobalStyles, tagName, lineHeight, style } = this.props; const tagNameLineHeight = baseGlobalStyles?.elements?.[tagName]?.typography?.lineHeight; let newLineHeight; // Disables line-height rendering for pre elements until we fix some issues with AztecAndroid. if (tagName === 'pre' && !this.isIOS) { return undefined; } if (!this.getIsBlockBasedTheme()) { return; } // For block-based themes, get the default editor line height. if (baseGlobalStyles?.typography?.lineHeight && tagName === 'p') { newLineHeight = parseFloat(baseGlobalStyles?.typography?.lineHeight); } // For block-based themes, get the default element line height // e.g h1, h2. if (tagNameLineHeight) { newLineHeight = parseFloat(tagNameLineHeight); } // For line height values provided from the styles, // usually from values set from the line height picker. if (style?.lineHeight) { newLineHeight = parseFloat(style.lineHeight); } // Fall-back to a line height provided from its props (if there's any) // and there are no other default values to use. if (lineHeight && !tagNameLineHeight && !style?.lineHeight) { newLineHeight = lineHeight; } // Check the final value is not over the minimum supported value. if (newLineHeight && newLineHeight < MIN_LINE_HEIGHT) { newLineHeight = MIN_LINE_HEIGHT; } // Until we parse CSS values correctly, avoid passing NaN values to Aztec if (isNaN(newLineHeight)) { return undefined; } return newLineHeight; } getIsBlockBasedTheme() { const { baseGlobalStyles } = this.props; return baseGlobalStyles && Object.entries(baseGlobalStyles).length !== 0; } getBlockUseDefaultFont() { // For block-based themes it enables using the defaultFont // in Aztec for iOS so it allows customizing the font size // for the Preformatted/Code and Heading blocks. if (!this.isIOS) { return; } const { tagName } = this.props; const isBlockBasedTheme = this.getIsBlockBasedTheme(); const tagsToMatch = /pre|h([1-6])$/gm; return isBlockBasedTheme && tagsToMatch.test(tagName); } getLinkTextColor(defaultColor) { const { style } = this.props; const customColor = style?.linkColor && colord(style.linkColor); return customColor && customColor.isValid() ? customColor.toHex() : defaultColor; } getPlaceholderTextColor() { var _ref, _ref2; const { baseGlobalStyles, getStylesFromColorScheme, placeholderTextColor, style } = this.props; // Default placeholder text color. const placeholderStyle = getStylesFromColorScheme(styles.richTextPlaceholder, styles.richTextPlaceholderDark); const { color: defaultPlaceholderTextColor } = placeholderStyle; // Custom 63% opacity for theme and inherited colors. const placeholderOpacity = 'A1'; // Determine inherited placeholder color if available. const inheritPlaceholderColor = style?.placeholderColor ? `${style.placeholderColor}${placeholderOpacity}` : undefined; // If using block-based themes, derive the placeholder color from global styles. const globalStylesPlaceholderColor = baseGlobalStyles?.color?.text ? `${baseGlobalStyles.color.text}${placeholderOpacity}` : undefined; return (_ref = (_ref2 = inheritPlaceholderColor !== null && inheritPlaceholderColor !== void 0 ? inheritPlaceholderColor : placeholderTextColor) !== null && _ref2 !== void 0 ? _ref2 : globalStylesPlaceholderColor) !== null && _ref !== void 0 ? _ref : defaultPlaceholderTextColor; } render() { const { tagName, style, __unstableIsSelected: isSelected, children, getStylesFromColorScheme, minWidth, maxWidth, formatTypes, parentBlockStyles, accessibilityLabel, disableEditingMenu = false, baseGlobalStyles, selectionStart, selectionEnd, disableSuggestions, containerWidth } = this.props; const { currentFontSize } = this.state; const record = this.getRecord(); const html = this.getHtmlToRender(record, tagName); const editableProps = this.getEditableProps(); const blockUseDefaultFont = this.getBlockUseDefaultFont(); const fontSize = currentFontSize; const lineHeight = this.getLineHeight(); const { color: defaultColor, textDecorationColor: defaultTextDecorationColor, fontFamily: defaultFontFamily } = getStylesFromColorScheme(styles.richText, styles.richTextDark); const linkTextColor = this.getLinkTextColor(defaultTextDecorationColor); const currentSelectionStart = selectionStart !== null && selectionStart !== void 0 ? selectionStart : 0; const currentSelectionEnd = selectionEnd !== null && selectionEnd !== void 0 ? selectionEnd : 0; let selection = null; if (this.needsSelectionUpdate) { this.needsSelectionUpdate = false; selection = { start: currentSelectionStart, end: currentSelectionEnd }; // On AztecAndroid, setting the caret to an out-of-bounds position will crash the editor so, let's check for some cases. if (!this.isIOS) { // The following regular expression is used in Aztec here: // https://github.com/wordpress-mobile/AztecEditor-Android/blob/b1fad439d56fa6d4aa0b78526fef355c59d00dd3/aztec/src/main/kotlin/org/wordpress/aztec/AztecParser.kt#L656 const brBeforeParaMatches = html.match(/(<br>)+<\/p>$/g); if (brBeforeParaMatches) { console.warn('Oops, BR tag(s) at the end of content. Aztec will remove them, adapting the selection...'); const count = (brBeforeParaMatches[0].match(/br/g) || []).length; if (count > 0) { let newSelectionStart = currentSelectionStart - count; if (newSelectionStart < 0) { newSelectionStart = 0; } let newSelectionEnd = currentSelectionEnd - count; if (newSelectionEnd < 0) { newSelectionEnd = 0; } selection = { start: newSelectionStart, end: newSelectionEnd }; } } } } if (this.comesFromAztec) { this.comesFromAztec = false; this.firedAfterTextChanged = false; } // Logic below assures that `RichText` width will always have equal value when container is almost fully filled. const width = maxWidth && this.state.width && maxWidth - this.state.width < 10 ? maxWidth : this.state.width; const containerStyles = [style?.padding && style?.backgroundColor && { padding: style.padding, backgroundColor: style.backgroundColor }, containerWidth && { width: containerWidth }]; const defaultSelectionColor = getStylesFromColorScheme(styles['rich-text-selection'], styles['rich-text-selection--dark']).color; const selectionColor = getSelectionColor(this.props.selectionColor, defaultSelectionColor, baseGlobalStyles, this.getIsBlockBasedTheme()); const EditableView = props => { this.customEditableOnKeyDown = props?.onKeyDown; return /*#__PURE__*/_jsx(_Fragment, {}); }; return /*#__PURE__*/_jsxs(View, { style: containerStyles, children: [children && children({ isSelected, value: record, onChange: this.onFormatChange, onFocus: () => {}, editableProps, editableTagName: EditableView }), /*#__PURE__*/_jsx(RCTAztecView, { accessibilityLabel: accessibilityLabel, ref: ref => { this._editor = ref; if (this.props.nativeEditorRef) { this.props.nativeEditorRef(ref); } }, style: { backgroundColor: styles.richText.backgroundColor, ...style, ...(this.isIOS && minWidth && maxWidth ? { width } : { maxWidth }), minHeight: this.state.height }, blockUseDefaultFont: blockUseDefaultFont, text: { text: html, eventCount: this.lastEventCount, selection, linkTextColor, tag: tagName }, placeholder: this.props.placeholder, placeholderTextColor: this.getPlaceholderTextColor(), deleteEnter: this.props.deleteEnter, onChange: this.onChangeFromAztec, onFocus: this.onFocus, onBlur: this.onBlur, onKeyDown: this.onKeyDown, triggerKeyCodes: disableEditingMenu ? [] : this.suggestionOptions().map(op => op.triggerChar), onPaste: this.onPaste, activeFormats: this.getActiveFormatNames(record), onContentSizeChange: this.onContentSizeChange, onSelectionChange: this.onSelectionChangeFromAztec, blockType: { tag: tagName }, color: style && style.color || parentBlockStyles && parentBlockStyles.color || baseGlobalStyles && baseGlobalStyles?.color?.text || defaultColor, maxImagesWidth: 200, fontFamily: this.props.fontFamily || defaultFontFamily, fontSize: fontSize, lineHeight: lineHeight, fontWeight: this.props.fontWeight, fontStyle: this.props.fontStyle, disableEditingMenu: disableEditingMenu, isMultiline: false, textAlign: this.props.textAlign, ...(this.isIOS ? { maxWidth } : {}), minWidth: minWidth, id: this.props.id, selectionColor: selectionColor, disableAutocorrection: this.props.disableAutocorrection }), isSelected && /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(FormatEdit, { forwardedRef: this._editor, formatTypes: formatTypes, value: record, onChange: this.onFormatChange, onFocus: () => {} }), !disableSuggestions && /*#__PURE__*/_jsx(BlockFormatControls, { children: /*#__PURE__*/_jsx(ToolbarButtonWithOptions, { options: this.suggestionOptions() }) })] })] }); } } RichText.defaultProps = { format: 'string', value: '', tagName: 'div' }; const withFormatTypes = WrappedComponent => props => { const { clientId, identifier, withoutInteractiveFormatting, allowedFormats } = props; const { formatTypes } = useFormatTypes({ clientId, identifier, withoutInteractiveFormatting, allowedFormats }); return /*#__PURE__*/_jsx(WrappedComponent, { ...props, formatTypes: formatTypes }); }; export default compose([withSelect((select, { clientId }) => { const { getBlockParents, getBlock, getSettings } = select('core/block-editor'); const parents = getBlockParents(clientId, true); const parentBlock = parents ? getBlock(parents[0]) : undefined; const parentBlockStyles = parentBlock?.attributes?.childrenStyles; const settings = getSettings(); const baseGlobalStyles = settings?.__experimentalGlobalStylesBaseStyles; const colorPalettes = settings?.__experimentalFeatures?.color?.palette; const colorPalette = colorPalettes ? flatColorPalettes(colorPalettes) : settings?.colors; return { areMentionsSupported: settings?.capabilities?.mentions === true, areXPostsSupported: settings?.capabilities?.xposts === true, parentBlockStyles, baseGlobalStyles, colorPalette }; }), withPreferredColorScheme, withFormatTypes])(RichText); //# sourceMappingURL=index.native.js.map