import React, { forwardRef, useImperativeHandle, useRef, useCallback, useState } from 'react'; import { StyleSheet, UIManager, findNodeHandle } from 'react-native'; import type { Block, TextAlignment, ContentChangeEvent, SelectionChangeEvent, RichTextEditorProps, RichTextEditorRef, } from './types'; import RichTextEditorViewNative from './RichTextEditorViewNativeComponent'; // Command constants const COMMANDS = { focus: 'focus', blur: 'blur', clear: 'clear', toggleBold: 'toggleBold', toggleItalic: 'toggleItalic', toggleUnderline: 'toggleUnderline', toggleStrikethrough: 'toggleStrikethrough', toggleCode: 'toggleCode', toggleCodeBlock: 'toggleCodeBlock', toggleHighlight: 'toggleHighlight', setHeading: 'setHeading', setBulletList: 'setBulletList', setNumberedList: 'setNumberedList', setQuote: 'setQuote', setChecklist: 'setChecklist', setParagraph: 'setParagraph', insertLink: 'insertLink', undo: 'undo', redo: 'redo', clearFormatting: 'clearFormatting', indent: 'indent', outdent: 'outdent', alignLeft: 'alignLeft', alignCenter: 'alignCenter', alignRight: 'alignRight', toggleChecklistItem: 'toggleChecklistItem', setText: 'setText', setSelection: 'setSelection', setContent: 'setContent', setMentionRanges: 'setMentionRanges', removeLink: 'removeLink', updateLink: 'updateLink', } as const; // Helper to dispatch commands to native view const dispatchCommand = ( ref: React.RefObject, command: string, args: any[] = [] ) => { const viewTag = findNodeHandle(ref.current); if (viewTag != null) { UIManager.dispatchViewManagerCommand(viewTag, command, args); } }; interface SizeChangeEvent { nativeEvent: { height: number; }; } export interface ActiveStylesState { bold: boolean; italic: boolean; underline: boolean; strikethrough: boolean; code: boolean; codeBlock: boolean; highlight: boolean; blockType: string; alignment: string; } interface ActiveStylesChangeEvent { nativeEvent: { bold: boolean; italic: boolean; underline: boolean; strikethrough: boolean; code: boolean; codeBlock: boolean; highlight: boolean; blockType: string; alignment: string; }; } export interface LinkTapEventData { url: string; text: string; location: number; length: number; } interface LinkTapEvent { nativeEvent: LinkTapEventData; } export interface RichTextEditorPropsExtended extends RichTextEditorProps { /** Test ID for testing frameworks (e.g. Detox, React Native Testing Library) */ testID?: string; /** Accessibility label for screen readers */ accessibilityLabel?: string; onActiveStylesChange?: (styles: ActiveStylesState) => void; onSizeChange?: (height: number) => void; onLinkTap?: (data: LinkTapEventData) => void; /** Callback when Enter is pressed in "sendMessage" mode (Android only) */ onSendRequest?: () => void; /** Enter key behavior: "newLine" (default) or "sendMessage" (Android only) */ enterKeyBehavior?: string; /** Show Bold/Italic/Underline/Strikethrough in text selection context menu */ showTextSelectionMenuItems?: boolean; /** Code block background color */ codeBackgroundColor?: string; /** Code block border color */ codeBorderColor?: string; /** Code block text color */ codeTextColor?: string; /** Code block font size */ codeFontSize?: number; } const RichTextEditor = forwardRef((props, ref) => { const nativeRef = useRef>(null); const [height, setHeight] = useState(undefined); const handleSizeChange = useCallback((event: SizeChangeEvent) => { const newHeight = event.nativeEvent?.height; if (newHeight && newHeight > 0) { setHeight(newHeight); props.onSizeChange?.(newHeight); } }, [props.onSizeChange]); useImperativeHandle(ref, () => ({ setContent: (blocks: Block[]) => { dispatchCommand(nativeRef, COMMANDS.setContent, [blocks]); }, getText: async (): Promise => '', getBlocks: async (): Promise => [], clear: () => { dispatchCommand(nativeRef, COMMANDS.clear); }, focus: () => { dispatchCommand(nativeRef, COMMANDS.focus); }, blur: () => { dispatchCommand(nativeRef, COMMANDS.blur); }, toggleBold: () => { dispatchCommand(nativeRef, COMMANDS.toggleBold); }, toggleItalic: () => { dispatchCommand(nativeRef, COMMANDS.toggleItalic); }, toggleUnderline: () => { dispatchCommand(nativeRef, COMMANDS.toggleUnderline); }, toggleStrikethrough: () => { dispatchCommand(nativeRef, COMMANDS.toggleStrikethrough); }, toggleCode: () => { dispatchCommand(nativeRef, COMMANDS.toggleCode); }, toggleCodeBlock: () => { dispatchCommand(nativeRef, COMMANDS.toggleCodeBlock); }, toggleHighlight: (_color?: string) => { dispatchCommand(nativeRef, COMMANDS.toggleHighlight); }, setHeading: () => { dispatchCommand(nativeRef, COMMANDS.setHeading); }, setBulletList: () => { dispatchCommand(nativeRef, COMMANDS.setBulletList); }, setNumberedList: () => { dispatchCommand(nativeRef, COMMANDS.setNumberedList); }, setQuote: () => { dispatchCommand(nativeRef, COMMANDS.setQuote); }, setChecklist: () => { dispatchCommand(nativeRef, COMMANDS.setChecklist); }, setParagraph: () => { dispatchCommand(nativeRef, COMMANDS.setParagraph); }, insertLink: (url: string, text: string) => { dispatchCommand(nativeRef, COMMANDS.insertLink, [url, text]); }, undo: () => { dispatchCommand(nativeRef, COMMANDS.undo); }, redo: () => { dispatchCommand(nativeRef, COMMANDS.redo); }, clearFormatting: () => { dispatchCommand(nativeRef, COMMANDS.clearFormatting); }, indent: () => { dispatchCommand(nativeRef, COMMANDS.indent); }, outdent: () => { dispatchCommand(nativeRef, COMMANDS.outdent); }, setAlignment: (alignment: TextAlignment) => { switch (alignment) { case 'left': dispatchCommand(nativeRef, COMMANDS.alignLeft); break; case 'center': dispatchCommand(nativeRef, COMMANDS.alignCenter); break; case 'right': dispatchCommand(nativeRef, COMMANDS.alignRight); break; } }, toggleChecklistItem: () => { dispatchCommand(nativeRef, COMMANDS.toggleChecklistItem); }, setText: (text: string) => { dispatchCommand(nativeRef, COMMANDS.setText, [text]); }, setSelection: (start: number, end?: number) => { dispatchCommand(nativeRef, COMMANDS.setSelection, [start, end ?? start]); }, setMentionRanges: (ranges: Array<{ start: number; end: number }>) => { dispatchCommand(nativeRef, COMMANDS.setMentionRanges, [ranges]); }, removeLink: (location: number, length: number) => { dispatchCommand(nativeRef, COMMANDS.removeLink, [location, length]); }, updateLink: (location: number, length: number, newUrl: string, newText: string) => { dispatchCommand(nativeRef, COMMANDS.updateLink, [location, length, newUrl, newText]); }, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleContentChange = useCallback( (event: any) => { // Parse blocksJson string (codegen doesn't support nested ReadonlyArray) let blocks: Block[] = []; try { if (event.nativeEvent.blocksJson) { blocks = JSON.parse(event.nativeEvent.blocksJson); } else if (event.nativeEvent.blocks) { // Fallback for backward compatibility blocks = [...event.nativeEvent.blocks]; } } catch { blocks = []; } // Convert native event to our API format const contentEvent: ContentChangeEvent = { nativeEvent: { text: event.nativeEvent.text, blocks, delta: event.nativeEvent.delta, }, }; props.onContentChange?.(contentEvent); }, [props.onContentChange], ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleSelectionChange = useCallback( (event: any) => { const selectionEvent: SelectionChangeEvent = { nativeEvent: { start: event.nativeEvent.start, end: event.nativeEvent.end, }, }; props.onSelectionChange?.(selectionEvent); }, [props.onSelectionChange], ); const handleFocus = useCallback(() => { props.onFocus?.(); }, [props.onFocus]); const handleBlur = useCallback(() => { props.onBlur?.(); }, [props.onBlur]); const handleActiveStylesChange = useCallback( (event: ActiveStylesChangeEvent) => { const native = event.nativeEvent; const styles: ActiveStylesState = { bold: native.bold, italic: native.italic, underline: native.underline, strikethrough: native.strikethrough, code: native.codeBlock ? false : native.code, codeBlock: native.codeBlock, highlight: native.highlight, blockType: native.blockType, alignment: native.alignment, }; props.onActiveStylesChange?.(styles); }, [props.onActiveStylesChange], ); const handleLinkTap = useCallback( (event: LinkTapEvent) => { props.onLinkTap?.(event.nativeEvent); }, [props.onLinkTap], ); const handleSendRequest = useCallback(() => { props.onSendRequest?.(); }, [props.onSendRequest]); const combinedStyle = StyleSheet.flatten([props.style, height != null ? { height } : undefined]); return ( ); }); RichTextEditor.displayName = 'RichTextEditor'; export default RichTextEditor; export { DEFAULT_TOOLBAR_OPTIONS } from './types'; export type { Block, BlockType, StyleRange, TextAlignment, EditorVariant, ContentChangeEvent, SelectionChangeEvent, RichTextEditorRef, RichTextEditorProps, ToolbarOption, ContentDelta, DeltaType, Selection, TextStyle, } from './types'; export { dispatchCommand };