Skip to content

Commit 22103f7

Browse files
qoobesAhmed HaracicszydlovskyexploIF
authored
feat: get HTML value imperatively (#310)
# Summary Adds the ability to use a getHTML method to get the current state of the editor This is incredibly important to my team since we're adding heavy usage of this library into our app and launching the update in about two weeks. The current performance with onChangeHTML is inadequate for us, so this change is very important. ## Test Plan Simplest options is to create an enriched input with a ref, and a button below it that triggers a function which calls getHTML() and logs it out to the console. You should see the logged HTML. ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ✅ | | Android | ✅ | --------- Co-authored-by: Ahmed Haracic <ahmed@Ahmeds-MacBook-Pro.local> Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com> Co-authored-by: Igor Furgala <exploif@icloud.com>
1 parent cfa0b3c commit 22103f7

8 files changed

Lines changed: 144 additions & 0 deletions

File tree

android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
2929
import com.swmansion.enriched.events.MentionHandler
3030
import com.swmansion.enriched.events.OnInputBlurEvent
3131
import com.swmansion.enriched.events.OnInputFocusEvent
32+
import com.swmansion.enriched.events.OnRequestHtmlResultEvent
3233
import com.swmansion.enriched.spans.EnrichedImageSpan
3334
import com.swmansion.enriched.spans.EnrichedSpans
3435
import com.swmansion.enriched.styles.InlineStyles
@@ -566,6 +567,19 @@ class EnrichedTextInputView : AppCompatEditText {
566567
parametrizedStyles?.setMentionSpan(text, indicator, attributes)
567568
}
568569

570+
fun requestHTML(requestId: Int) {
571+
val html = try {
572+
EnrichedParser.toHtmlWithDefault(text)
573+
} catch (e: Exception) {
574+
null
575+
}
576+
577+
val reactContext = context as ReactContext
578+
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
579+
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
580+
dispatcher?.dispatchEvent(OnRequestHtmlResultEvent(surfaceId, id, requestId, html, experimentalSynchronousEvents))
581+
}
582+
569583
// Sometimes setting up style triggers many changes in sequence
570584
// Eg. removing conflicting styles -> changing text -> applying spans
571585
// In such scenario we want to prevent from handling side effects (eg. onTextChanged)

android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.swmansion.enriched.events.OnInputFocusEvent
2424
import com.swmansion.enriched.events.OnLinkDetectedEvent
2525
import com.swmansion.enriched.events.OnMentionDetectedEvent
2626
import com.swmansion.enriched.events.OnMentionEvent
27+
import com.swmansion.enriched.events.OnRequestHtmlResultEvent
2728
import com.swmansion.enriched.spans.EnrichedSpans
2829
import com.swmansion.enriched.styles.HtmlStyle
2930
import com.swmansion.enriched.utils.jsonStringToStringMap
@@ -71,6 +72,7 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
7172
map.put(OnMentionDetectedEvent.EVENT_NAME, mapOf("registrationName" to OnMentionDetectedEvent.EVENT_NAME))
7273
map.put(OnMentionEvent.EVENT_NAME, mapOf("registrationName" to OnMentionEvent.EVENT_NAME))
7374
map.put(OnChangeSelectionEvent.EVENT_NAME, mapOf("registrationName" to OnChangeSelectionEvent.EVENT_NAME))
75+
map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME))
7476

7577
return map
7678
}
@@ -268,6 +270,10 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
268270
view?.addMention(text, indicator, attributes)
269271
}
270272

273+
override fun requestHTML(view: EnrichedTextInputView?, requestId: Int) {
274+
view?.requestHTML(requestId)
275+
}
276+
271277
override fun measure(
272278
context: Context,
273279
localData: ReadableMap?,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.swmansion.enriched.events
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
7+
class OnRequestHtmlResultEvent(
8+
surfaceId: Int,
9+
viewId: Int,
10+
private val requestId: Int,
11+
private val html: String?,
12+
private val experimentalSynchronousEvents: Boolean
13+
) : Event<OnRequestHtmlResultEvent>(surfaceId, viewId) {
14+
15+
override fun getEventName(): String = EVENT_NAME
16+
17+
override fun getEventData(): WritableMap {
18+
val eventData: WritableMap = Arguments.createMap()
19+
eventData.putInt("requestId", requestId)
20+
if (html != null) {
21+
eventData.putString("html", html)
22+
} else {
23+
eventData.putNull("html")
24+
}
25+
return eventData
26+
}
27+
28+
override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents
29+
30+
companion object {
31+
const val EVENT_NAME: String = "onRequestHtmlResult"
32+
}
33+
}

android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ public static String toHtml(Spanned text) {
105105
String normalizedBlockQuote = normalizedCodeBlock.replaceAll("</blockquote>\\n<br>", "</blockquote>");
106106
return "<html>\n" + normalizedBlockQuote + "</html>";
107107
}
108+
109+
public static String toHtmlWithDefault(CharSequence text) {
110+
if (text instanceof Spanned) {
111+
return toHtml((Spanned) text);
112+
}
113+
return "<html>\n<p></p>\n</html>";
114+
}
115+
108116
/**
109117
* Returns an HTML escaped representation of the given plain text.
110118
*/

docs/API_REFERENCE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,14 @@ focus: () => void;
333333

334334
Focuses the input.
335335

336+
### `.getHTML()`
337+
338+
```ts
339+
getHTML: () => Promise<string>;
340+
```
341+
342+
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`.
343+
336344
### `.setImage()`
337345

338346
```ts

ios/EnrichedTextInputView.mm

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
974974
CGFloat imgHeight = [(NSNumber *)args[2] floatValue];
975975

976976
[self addImage:uri width:imgWidth height:imgHeight];
977+
} else if ([commandName isEqualToString:@"requestHTML"]) {
978+
NSInteger requestId = [((NSNumber *)args[0]) integerValue];
979+
[self requestHTML:requestId];
977980
}
978981
}
979982

@@ -1072,6 +1075,22 @@ - (void)tryEmittingOnChangeHtmlEvent {
10721075
}
10731076
}
10741077

1078+
- (void)requestHTML:(NSInteger)requestId {
1079+
auto emitter = [self getEventEmitter];
1080+
if (emitter != nullptr) {
1081+
@try {
1082+
NSString *htmlOutput = [parser
1083+
parseToHtmlFromRange:NSMakeRange(0,
1084+
textView.textStorage.string.length)];
1085+
emitter->onRequestHtmlResult({.requestId = static_cast<int>(requestId),
1086+
.html = [htmlOutput toCppString]});
1087+
} @catch (NSException *exception) {
1088+
emitter->onRequestHtmlResult({.requestId = static_cast<int>(requestId),
1089+
.html = folly::dynamic(nullptr)});
1090+
}
1091+
}
1092+
}
1093+
10751094
// MARK: - Styles manipulation
10761095

10771096
- (void)toggleRegularStyle:(StyleType)type {

src/EnrichedTextInput.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
type Component,
33
type RefObject,
4+
useEffect,
45
useImperativeHandle,
56
useMemo,
67
useRef,
@@ -16,6 +17,7 @@ import EnrichedTextInputNativeComponent, {
1617
type OnMentionEvent,
1718
type OnMentionDetected,
1819
type OnMentionDetectedInternal,
20+
type OnRequestHtmlResultEvent,
1921
type MentionStyleProperties,
2022
} from './EnrichedTextInputNativeComponent';
2123
import type {
@@ -37,6 +39,7 @@ export interface EnrichedTextInputInstance extends NativeMethods {
3739
focus: () => void;
3840
blur: () => void;
3941
setValue: (value: string) => void;
42+
getHTML: () => Promise<string>;
4043

4144
// Text formatting commands
4245
toggleBold: () => void;
@@ -164,6 +167,11 @@ const warnAboutMissconfiguredMentions = (indicator: string) => {
164167

165168
type ComponentType = (Component<NativeProps, {}, any> & NativeMethods) | null;
166169

170+
type HtmlRequest = {
171+
resolve: (html: string) => void;
172+
reject: (error: Error) => void;
173+
};
174+
167175
export const EnrichedTextInput = ({
168176
ref,
169177
autoFocus,
@@ -194,6 +202,19 @@ export const EnrichedTextInput = ({
194202
}: EnrichedTextInputProps) => {
195203
const nativeRef = useRef<ComponentType | null>(null);
196204

205+
const nextHtmlRequestId = useRef(1);
206+
const pendingHtmlRequests = useRef(new Map<number, HtmlRequest>());
207+
208+
useEffect(() => {
209+
const pendingRequests = pendingHtmlRequests.current;
210+
return () => {
211+
pendingRequests.forEach(({ reject }) => {
212+
reject(new Error('Component unmounted'));
213+
});
214+
pendingRequests.clear();
215+
};
216+
}, []);
217+
197218
const normalizedHtmlStyle = useMemo(
198219
() => normalizeHtmlStyle(htmlStyle, mentionIndicators),
199220
[htmlStyle, mentionIndicators]
@@ -229,6 +250,13 @@ export const EnrichedTextInput = ({
229250
setValue: (value: string) => {
230251
Commands.setValue(nullthrows(nativeRef.current), value);
231252
},
253+
getHTML: () => {
254+
return new Promise<string>((resolve, reject) => {
255+
const requestId = nextHtmlRequestId.current++;
256+
pendingHtmlRequests.current.set(requestId, { resolve, reject });
257+
Commands.requestHTML(nullthrows(nativeRef.current), requestId);
258+
});
259+
},
232260
toggleBold: () => {
233261
Commands.toggleBold(nullthrows(nativeRef.current));
234262
},
@@ -323,6 +351,22 @@ export const EnrichedTextInput = ({
323351
onMentionDetected?.({ text, indicator, attributes });
324352
};
325353

354+
const handleRequestHtmlResult = (
355+
e: NativeSyntheticEvent<OnRequestHtmlResultEvent>
356+
) => {
357+
const { requestId, html } = e.nativeEvent;
358+
const pending = pendingHtmlRequests.current.get(requestId);
359+
if (!pending) return;
360+
361+
if (html === null || typeof html !== 'string') {
362+
pending.reject(new Error('Failed to parse HTML'));
363+
} else {
364+
pending.resolve(html);
365+
}
366+
367+
pendingHtmlRequests.current.delete(requestId);
368+
};
369+
326370
return (
327371
<EnrichedTextInputNativeComponent
328372
ref={nativeRef}
@@ -347,6 +391,7 @@ export const EnrichedTextInput = ({
347391
onMentionDetected={handleMentionDetected}
348392
onMention={handleMentionEvent}
349393
onChangeSelection={onChangeSelection}
394+
onRequestHtmlResult={handleRequestHtmlResult}
350395
androidExperimentalSynchronousEvents={
351396
androidExperimentalSynchronousEvents
352397
}

src/EnrichedTextInputNativeComponent.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export interface OnChangeSelectionEvent {
6464
text: string;
6565
}
6666

67+
export interface OnRequestHtmlResultEvent {
68+
requestId: Int32;
69+
html: UnsafeMixed;
70+
}
71+
6772
export interface MentionStyleProperties {
6873
color?: ColorValue;
6974
backgroundColor?: ColorValue;
@@ -143,6 +148,7 @@ export interface NativeProps extends ViewProps {
143148
onMentionDetected?: DirectEventHandler<OnMentionDetectedInternal>;
144149
onMention?: DirectEventHandler<OnMentionEvent>;
145150
onChangeSelection?: DirectEventHandler<OnChangeSelectionEvent>;
151+
onRequestHtmlResult?: DirectEventHandler<OnRequestHtmlResultEvent>;
146152

147153
// Style related props - used for generating proper setters in component's manager
148154
// These should not be passed as regular props
@@ -203,6 +209,10 @@ interface NativeCommands {
203209
text: string,
204210
payload: string
205211
) => void;
212+
requestHTML: (
213+
viewRef: React.ElementRef<ComponentType>,
214+
requestId: Int32
215+
) => void;
206216
}
207217

208218
export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
@@ -229,6 +239,7 @@ export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
229239
'addImage',
230240
'startMention',
231241
'addMention',
242+
'requestHTML',
232243
],
233244
});
234245

0 commit comments

Comments
 (0)