diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index f6ed05a7f..de66e337b 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -29,6 +29,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.events.MentionHandler import com.swmansion.enriched.events.OnInputBlurEvent import com.swmansion.enriched.events.OnInputFocusEvent +import com.swmansion.enriched.events.OnRequestHtmlResultEvent import com.swmansion.enriched.spans.EnrichedImageSpan import com.swmansion.enriched.spans.EnrichedSpans import com.swmansion.enriched.styles.InlineStyles @@ -566,6 +567,19 @@ class EnrichedTextInputView : AppCompatEditText { parametrizedStyles?.setMentionSpan(text, indicator, attributes) } + fun requestHTML(requestId: Int) { + val html = try { + EnrichedParser.toHtmlWithDefault(text) + } catch (e: Exception) { + null + } + + val reactContext = context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + dispatcher?.dispatchEvent(OnRequestHtmlResultEvent(surfaceId, id, requestId, html, experimentalSynchronousEvents)) + } + // Sometimes setting up style triggers many changes in sequence // Eg. removing conflicting styles -> changing text -> applying spans // In such scenario we want to prevent from handling side effects (eg. onTextChanged) diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index b31b8d4ac..8c629cf5b 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -24,6 +24,7 @@ import com.swmansion.enriched.events.OnInputFocusEvent import com.swmansion.enriched.events.OnLinkDetectedEvent import com.swmansion.enriched.events.OnMentionDetectedEvent import com.swmansion.enriched.events.OnMentionEvent +import com.swmansion.enriched.events.OnRequestHtmlResultEvent import com.swmansion.enriched.spans.EnrichedSpans import com.swmansion.enriched.styles.HtmlStyle import com.swmansion.enriched.utils.jsonStringToStringMap @@ -71,6 +72,7 @@ class EnrichedTextInputViewManager : SimpleViewManager(), map.put(OnMentionDetectedEvent.EVENT_NAME, mapOf("registrationName" to OnMentionDetectedEvent.EVENT_NAME)) map.put(OnMentionEvent.EVENT_NAME, mapOf("registrationName" to OnMentionEvent.EVENT_NAME)) map.put(OnChangeSelectionEvent.EVENT_NAME, mapOf("registrationName" to OnChangeSelectionEvent.EVENT_NAME)) + map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME)) return map } @@ -268,6 +270,10 @@ class EnrichedTextInputViewManager : SimpleViewManager(), view?.addMention(text, indicator, attributes) } + override fun requestHTML(view: EnrichedTextInputView?, requestId: Int) { + view?.requestHTML(requestId) + } + override fun measure( context: Context, localData: ReadableMap?, diff --git a/android/src/main/java/com/swmansion/enriched/events/OnRequestHtmlResultEvent.kt b/android/src/main/java/com/swmansion/enriched/events/OnRequestHtmlResultEvent.kt new file mode 100644 index 000000000..bda88e76b --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/events/OnRequestHtmlResultEvent.kt @@ -0,0 +1,33 @@ +package com.swmansion.enriched.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnRequestHtmlResultEvent( + surfaceId: Int, + viewId: Int, + private val requestId: Int, + private val html: String?, + private val experimentalSynchronousEvents: Boolean +) : Event(surfaceId, viewId) { + + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + eventData.putInt("requestId", requestId) + if (html != null) { + eventData.putString("html", html) + } else { + eventData.putNull("html") + } + return eventData + } + + override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents + + companion object { + const val EVENT_NAME: String = "onRequestHtmlResult" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java index d89885d5c..1b0758be7 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java @@ -105,6 +105,14 @@ public static String toHtml(Spanned text) { String normalizedBlockQuote = normalizedCodeBlock.replaceAll("\\n
", ""); return "\n" + normalizedBlockQuote + ""; } + + public static String toHtmlWithDefault(CharSequence text) { + if (text instanceof Spanned) { + return toHtml((Spanned) text); + } + return "\n

\n"; + } + /** * Returns an HTML escaped representation of the given plain text. */ diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 8e1f0fee3..67a16da94 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -333,6 +333,14 @@ focus: () => void; Focuses the input. +### `.getHTML()` + +```ts +getHTML: () => Promise; +``` + +Returns a Promise that resolves with the current HTML content of the input. This is useful when you need to get the HTML on-demand (e.g., when saving) without the performance overhead of continuous HTML parsing via `onChangeHtml`. + ### `.setImage()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c84dd532f..e1aaf9c01 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -974,6 +974,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { CGFloat imgHeight = [(NSNumber *)args[2] floatValue]; [self addImage:uri width:imgWidth height:imgHeight]; + } else if ([commandName isEqualToString:@"requestHTML"]) { + NSInteger requestId = [((NSNumber *)args[0]) integerValue]; + [self requestHTML:requestId]; } } @@ -1072,6 +1075,22 @@ - (void)tryEmittingOnChangeHtmlEvent { } } +- (void)requestHTML:(NSInteger)requestId { + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + @try { + NSString *htmlOutput = [parser + parseToHtmlFromRange:NSMakeRange(0, + textView.textStorage.string.length)]; + emitter->onRequestHtmlResult({.requestId = static_cast(requestId), + .html = [htmlOutput toCppString]}); + } @catch (NSException *exception) { + emitter->onRequestHtmlResult({.requestId = static_cast(requestId), + .html = folly::dynamic(nullptr)}); + } + } +} + // MARK: - Styles manipulation - (void)toggleRegularStyle:(StyleType)type { diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index c2202c679..22cc1f373 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -1,6 +1,7 @@ import { type Component, type RefObject, + useEffect, useImperativeHandle, useMemo, useRef, @@ -16,6 +17,7 @@ import EnrichedTextInputNativeComponent, { type OnMentionEvent, type OnMentionDetected, type OnMentionDetectedInternal, + type OnRequestHtmlResultEvent, type MentionStyleProperties, } from './EnrichedTextInputNativeComponent'; import type { @@ -37,6 +39,7 @@ export interface EnrichedTextInputInstance extends NativeMethods { focus: () => void; blur: () => void; setValue: (value: string) => void; + getHTML: () => Promise; // Text formatting commands toggleBold: () => void; @@ -164,6 +167,11 @@ const warnAboutMissconfiguredMentions = (indicator: string) => { type ComponentType = (Component & NativeMethods) | null; +type HtmlRequest = { + resolve: (html: string) => void; + reject: (error: Error) => void; +}; + export const EnrichedTextInput = ({ ref, autoFocus, @@ -194,6 +202,19 @@ export const EnrichedTextInput = ({ }: EnrichedTextInputProps) => { const nativeRef = useRef(null); + const nextHtmlRequestId = useRef(1); + const pendingHtmlRequests = useRef(new Map()); + + useEffect(() => { + const pendingRequests = pendingHtmlRequests.current; + return () => { + pendingRequests.forEach(({ reject }) => { + reject(new Error('Component unmounted')); + }); + pendingRequests.clear(); + }; + }, []); + const normalizedHtmlStyle = useMemo( () => normalizeHtmlStyle(htmlStyle, mentionIndicators), [htmlStyle, mentionIndicators] @@ -229,6 +250,13 @@ export const EnrichedTextInput = ({ setValue: (value: string) => { Commands.setValue(nullthrows(nativeRef.current), value); }, + getHTML: () => { + return new Promise((resolve, reject) => { + const requestId = nextHtmlRequestId.current++; + pendingHtmlRequests.current.set(requestId, { resolve, reject }); + Commands.requestHTML(nullthrows(nativeRef.current), requestId); + }); + }, toggleBold: () => { Commands.toggleBold(nullthrows(nativeRef.current)); }, @@ -323,6 +351,22 @@ export const EnrichedTextInput = ({ onMentionDetected?.({ text, indicator, attributes }); }; + const handleRequestHtmlResult = ( + e: NativeSyntheticEvent + ) => { + const { requestId, html } = e.nativeEvent; + const pending = pendingHtmlRequests.current.get(requestId); + if (!pending) return; + + if (html === null || typeof html !== 'string') { + pending.reject(new Error('Failed to parse HTML')); + } else { + pending.resolve(html); + } + + pendingHtmlRequests.current.delete(requestId); + }; + return ( ; onMention?: DirectEventHandler; onChangeSelection?: DirectEventHandler; + onRequestHtmlResult?: DirectEventHandler; // Style related props - used for generating proper setters in component's manager // These should not be passed as regular props @@ -203,6 +209,10 @@ interface NativeCommands { text: string, payload: string ) => void; + requestHTML: ( + viewRef: React.ElementRef, + requestId: Int32 + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -229,6 +239,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'addImage', 'startMention', 'addMention', + 'requestHTML', ], });