If we set a default value or change a value via the setValue method, then if the user focuses on the text field it doesn't fire the onChangeState callback and doesn't update the Toolbar properly
import {
View,
StyleSheet,
Text,
type NativeSyntheticEvent,
ScrollView,
} from 'react-native';
import {
EnrichedTextInput,
type OnChangeTextEvent,
type EnrichedTextInputInstance,
type OnLinkDetected,
type OnChangeMentionEvent,
type OnChangeHtmlEvent,
type OnChangeStateEvent,
type OnChangeSelectionEvent,
type HtmlStyle,
} from 'react-native-enriched';
import { useRef, useState } from 'react';
import { Button } from './components/Button';
import { Toolbar } from './components/Toolbar';
import { LinkModal } from './components/LinkModal';
import { ValueModal } from './components/ValueModal';
import { launchImageLibrary } from 'react-native-image-picker';
import { type MentionItem, MentionPopup } from './components/MentionPopup';
import { useUserMention } from './useUserMention';
import { useChannelMention } from './useChannelMention';
import { HtmlSection } from './components/HtmlSection';
type StylesState = OnChangeStateEvent;
type CurrentLinkState = OnLinkDetected;
interface Selection {
start: number;
end: number;
text: string;
}
const DEFAULT_STYLE: StylesState = {
isBold: false,
isItalic: false,
isUnderline: false,
isStrikeThrough: false,
isInlineCode: false,
isH1: false,
isH2: false,
isH3: false,
isBlockQuote: false,
isCodeBlock: false,
isOrderedList: false,
isUnorderedList: false,
isLink: false,
isImage: false,
isMention: false,
};
const DEFAULT_LINK_STATE = {
text: '',
url: '',
start: 0,
end: 0,
};
const DEBUG_SCROLLABLE = false;
// Enabling this prop fixes input flickering while auto growing.
// However, it's still experimental and not tested well.
const ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS = true;
export default function App() {
const [isChannelPopupOpen, setIsChannelPopupOpen] = useState(false);
const [isUserPopupOpen, setIsUserPopupOpen] = useState(false);
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [isValueModalOpen, setIsValueModalOpen] = useState(false);
const [currentHtml, setCurrentHtml] = useState('');
const currentHeadingRef = useRef<number>(1);
const [selection, setSelection] = useState<Selection>();
const [stylesState, setStylesState] = useState<StylesState>(DEFAULT_STYLE);
const [currentLink, setCurrentLink] =
useState<CurrentLinkState>(DEFAULT_LINK_STATE);
const [key, setKey] = useState(0);
const incrementKey = () => setKey((v) => v + 1);
const collectGarbage = () => global.gc?.();
const ref = useRef<EnrichedTextInputInstance>(null);
const userMention = useUserMention();
const channelMention = useChannelMention();
const insideCurrentLink =
stylesState.isLink &&
currentLink.url.length > 0 &&
(currentLink.start || currentLink.end) &&
selection &&
selection.start >= currentLink.start &&
selection.end <= currentLink.end;
const handleChangeText = (e: NativeSyntheticEvent<OnChangeTextEvent>) => {
console.log('Text changed:', e?.nativeEvent.value);
};
const handleChangeHtml = (e: NativeSyntheticEvent<OnChangeHtmlEvent>) => {
console.log('HTML changed:', e?.nativeEvent.value);
setCurrentHtml(e?.nativeEvent.value);
};
const handleChangeState = (e: NativeSyntheticEvent<OnChangeStateEvent>) => {
setStylesState(e.nativeEvent);
console.log('Style state changed:', e.nativeEvent);
};
const handleFocus = () => {
ref.current?.focus();
};
const handleBlur = () => {
ref.current?.blur();
};
const openLinkModal = () => {
setIsLinkModalOpen(true);
};
const closeLinkModal = () => {
setIsLinkModalOpen(false);
};
const openUserMentionPopup = () => {
setIsUserPopupOpen(true);
};
const closeUserMentionPopup = () => {
setIsUserPopupOpen(false);
userMention.onMentionChange('');
};
const openChannelMentionPopup = () => {
setIsChannelPopupOpen(true);
};
const closeChannelMentionPopup = () => {
setIsChannelPopupOpen(false);
channelMention.onMentionChange('');
};
const openValueModal = () => {
setIsValueModalOpen(true);
};
const closeValueModal = () => {
setIsValueModalOpen(false);
};
const handleStartMention = (indicator: string) => {
if (indicator === '@') {
userMention.onMentionChange('');
openUserMentionPopup();
return;
}
channelMention.onMentionChange('');
openChannelMentionPopup();
};
const handleEndMention = (indicator: string) => {
const isUserMention = indicator === '@';
if (isUserMention) {
closeUserMentionPopup();
userMention.onMentionChange('');
return;
}
closeChannelMentionPopup();
channelMention.onMentionChange('');
};
const submitLink = (text: string, url: string) => {
if (!selection || url.length === 0) {
closeLinkModal();
return;
}
const newText = text.length > 0 ? text : url;
if (insideCurrentLink) {
ref.current?.setLink(currentLink.start, currentLink.end, newText, url);
} else {
ref.current?.setLink(selection.start, selection.end, newText, url);
}
closeLinkModal();
};
const submitSetValue = (value: string) => {
ref.current?.setValue(value);
closeValueModal();
};
const selectImage = async () => {
const response = await launchImageLibrary({
mediaType: 'photo',
selectionLimit: 1,
});
const imageUri = response.assets?.[0]?.originalPath;
if (!imageUri) return;
ref.current?.setImage(imageUri);
};
const handleChangeMention = ({ indicator, text }: OnChangeMentionEvent) => {
indicator === '@'
? userMention.onMentionChange(text)
: channelMention.onMentionChange(text);
indicator === '@'
? !isUserPopupOpen && setIsUserPopupOpen(true)
: !isChannelPopupOpen && setIsChannelPopupOpen(true);
};
const handleUserMentionSelected = (item: MentionItem) => {
ref.current?.setMention('@', `@${item.name}`, {
id: item.id,
type: 'user',
});
closeUserMentionPopup();
};
const handleChannelMentionSelected = (item: MentionItem) => {
ref.current?.setMention('#', `#${item.name}`, {
id: item.id,
type: 'channel',
});
closeChannelMentionPopup();
};
const handleFocusEvent = () => {
console.log('Input focused');
};
const handleBlurEvent = () => {
console.log('Input blurred');
};
const handleLinkDetected = (state: CurrentLinkState) => {
console.log(state);
setCurrentLink(state);
};
const handleSelectionChangeEvent = (
e: NativeSyntheticEvent<OnChangeSelectionEvent>
) => {
setSelection(e.nativeEvent);
};
const handleChangeTextProgrammatically = () => {
const nextHeading =
currentHeadingRef.current === 3 ? 1 : currentHeadingRef.current! + 1;
currentHeadingRef.current = nextHeading;
ref.current?.setValue(
`<html><h${nextHeading}>Random</h${nextHeading}><p>This value was set programmatically.</p></html>`
);
};
return (
<>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
>
<Text style={styles.label}>Enriched Text Input</Text>
<View style={styles.editor}>
<EnrichedTextInput
ref={ref}
key={key}
mentionIndicators={['@', '#']}
style={styles.editorInput}
htmlStyle={htmlStyle}
placeholder="Type something here..."
placeholderTextColor="rgb(0, 26, 114)"
selectionColor="deepskyblue"
cursorColor="dodgerblue"
autoCapitalize="sentences"
defaultValue="<html><h1>Test</h1></html>"
onChangeText={handleChangeText}
onChangeHtml={handleChangeHtml}
onChangeState={handleChangeState}
onLinkDetected={handleLinkDetected}
onMentionDetected={console.log}
onStartMention={handleStartMention}
onChangeMention={handleChangeMention}
onEndMention={handleEndMention}
onFocus={handleFocusEvent}
onBlur={handleBlurEvent}
onChangeSelection={handleSelectionChangeEvent}
androidExperimentalSynchronousEvents={
ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS
}
/>
<Toolbar
stylesState={stylesState}
editorRef={ref}
onOpenLinkModal={openLinkModal}
onSelectImage={selectImage}
/>
</View>
<Button
title="Change current value programmatically"
onPress={handleChangeTextProgrammatically}
/>
<View style={styles.buttonStack}>
<Button title="Focus" onPress={handleFocus} style={styles.button} />
<Button title="Blur" onPress={handleBlur} style={styles.button} />
</View>
<View style={styles.buttonStack}>
<Button
title="Bump key"
onPress={incrementKey}
style={styles.button}
/>
<Button title="GC" onPress={collectGarbage} style={styles.button} />
</View>
<Button
title="Set input's value"
onPress={openValueModal}
style={styles.valueButton}
/>
<HtmlSection currentHtml={currentHtml} />
{DEBUG_SCROLLABLE && <View style={styles.scrollPlaceholder} />}
</ScrollView>
<LinkModal
isOpen={isLinkModalOpen}
editedText={
insideCurrentLink ? currentLink.text : (selection?.text ?? '')
}
editedUrl={insideCurrentLink ? currentLink.url : ''}
onSubmit={submitLink}
onClose={closeLinkModal}
/>
<ValueModal
isOpen={isValueModalOpen}
onSubmit={submitSetValue}
onClose={closeValueModal}
/>
<MentionPopup
variant="user"
data={userMention.data}
isOpen={isUserPopupOpen}
onItemPress={handleUserMentionSelected}
/>
<MentionPopup
variant="channel"
data={channelMention.data}
isOpen={isChannelPopupOpen}
onItemPress={handleChannelMentionSelected}
/>
</>
);
}
const htmlStyle: HtmlStyle = {
h1: {
fontSize: 40,
bold: true,
},
h2: {
fontSize: 32,
bold: true,
},
h3: {
fontSize: 24,
bold: true,
},
blockquote: {
borderColor: 'navy',
borderWidth: 4,
gapWidth: 16,
color: 'navy',
},
codeblock: {
color: 'green',
borderRadius: 8,
backgroundColor: 'aquamarine',
},
code: {
color: 'purple',
backgroundColor: 'yellow',
},
a: {
color: 'green',
textDecorationLine: 'underline',
},
mention: {
'#': {
color: 'blue',
backgroundColor: 'lightblue',
textDecorationLine: 'underline',
},
'@': {
color: 'green',
backgroundColor: 'lightgreen',
textDecorationLine: 'none',
},
},
img: {
width: 50,
height: 50,
},
ol: {
gapWidth: 16,
marginLeft: 24,
markerColor: 'navy',
markerFontWeight: 'bold',
},
ul: {
bulletColor: 'aquamarine',
bulletSize: 8,
marginLeft: 24,
gapWidth: 16,
},
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
},
content: {
flexGrow: 1,
padding: 16,
paddingTop: 100,
alignItems: 'center',
},
editor: {
width: '100%',
},
label: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
color: 'rgb(0, 26, 114)',
},
buttonStack: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
button: {
width: '45%',
},
valueButton: {
width: '100%',
},
editorInput: {
marginTop: 24,
width: '100%',
maxHeight: 180,
backgroundColor: 'gainsboro',
fontSize: 18,
fontFamily: 'Nunito-Regular',
paddingVertical: 12,
paddingHorizontal: 14,
},
scrollPlaceholder: {
marginTop: 24,
width: '100%',
height: 1000,
backgroundColor: 'rgb(0, 26, 114)',
},
});
The toolbar state should correspond to the focused content.
It would be nice to hear your point of view on it, but I think there is no sense in firing the onChangeStyle event if the user is not focused on the text input, and clearing it if the text field loses focus.
Describe the bug
If we set a default value or change a value via the setValue method, then if the user focuses on the text field it doesn't fire the
onChangeStatecallback and doesn't update theToolbarproperlyTo Reproduce
Steps to reproduce the behavior:
defaultValue="<html><h1>Test</h1></html>"The second scenario:
Expected behavior
The toolbar state should correspond to the focused content.
Screenshots
Screen.Recording.2025-11-11.at.21.58.01.mov
Screen.Recording.2025-11-11.at.22.29.56.mov
The Second issue:
Screen.Recording.2025-11-14.at.16.54.14.mov
Screen.Recording.2025-11-14.at.16.54.43.mov
My thoughts
It would be nice to hear your point of view on it, but I think there is no sense in firing the onChangeStyle event if the user is not focused on the text input, and clearing it if the text field loses focus.
Device (please complete the following information):
Additional context
Add any other context about the problem here.