@wordpress/block-editor
Version:
1,176 lines (1,123 loc) • 42.7 kB
JavaScript
/* 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