Skip to content

Commit 3351bac

Browse files
authored
feat: android html normalizer (#442)
# Summary Implements html normalizer for Android. ## Test Plan Ensure that HTML pasted from different/external text editors works fine. ## Screenshots / Videos https://github.com/user-attachments/assets/71d5a4a3-c3c0-43af-8d56-ec869782c9d2 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ✅ |
1 parent 2db8aa6 commit 3351bac

File tree

6 files changed

+65
-19
lines changed

6 files changed

+65
-19
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.swmansion.enriched.common
2+
3+
object GumboNormalizer {
4+
external fun normalizeHtml(html: String): String?
5+
}

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

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles
3636
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle
3737
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
3838
import com.swmansion.enriched.common.EnrichedConstants
39+
import com.swmansion.enriched.common.GumboNormalizer
3940
import com.swmansion.enriched.common.parser.EnrichedParser
4041
import com.swmansion.enriched.textinput.events.MentionHandler
4142
import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent
@@ -98,6 +99,7 @@ class EnrichedTextInputView : AppCompatEditText {
9899
var shouldEmitHtml: Boolean = false
99100
var shouldEmitOnChangeText: Boolean = false
100101
var experimentalSynchronousEvents: Boolean = false
102+
var useHtmlNormalizer: Boolean = false
101103

102104
var fontSize: Float? = null
103105
private var lineHeight: Float? = null
@@ -277,7 +279,7 @@ class EnrichedTextInputView : AppCompatEditText {
277279
val parsedText = parseText(htmlText)
278280
if (parsedText is Spannable) {
279281
val finalText = currentText.mergeSpannables(start, end, parsedText)
280-
setValue(finalText)
282+
setValue(finalText, false)
281283
return
282284
}
283285
}
@@ -298,25 +300,43 @@ class EnrichedTextInputView : AppCompatEditText {
298300
setSelection(selection?.start ?: text?.length ?: 0)
299301
}
300302

301-
private fun parseText(text: CharSequence): CharSequence {
302-
val isHtml = text.startsWith("<html>") && text.endsWith("</html>")
303-
if (!isHtml) return text
303+
private fun normalizeHtmlIfNeeded(text: CharSequence): CharSequence {
304+
if (!useHtmlNormalizer) return text
305+
val normalized = GumboNormalizer.normalizeHtml(text.toString()) ?: return text
304306

305-
try {
306-
val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory)
307-
val withoutLastNewLine = parsed.trimEnd('\n')
308-
return withoutLastNewLine
307+
return try {
308+
val parsed = EnrichedParser.fromHtml(normalized, htmlStyle, spannableFactory)
309+
parsed.trimEnd('\n')
309310
} catch (e: Exception) {
310-
Log.e("EnrichedTextInputView", "Error parsing HTML: ${e.message}")
311-
return text
311+
Log.e(TAG, "Error parsing normalized HTML: ${e.message}")
312+
text
312313
}
313314
}
314315

315-
fun setValue(value: CharSequence?) {
316+
private fun parseText(text: CharSequence): CharSequence {
317+
val isInternalHtml = text.startsWith("<html>") && text.endsWith("</html>")
318+
319+
if (isInternalHtml) {
320+
try {
321+
val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory)
322+
return parsed.trimEnd('\n')
323+
} catch (e: Exception) {
324+
Log.e(TAG, "Error parsing HTML: ${e.message}")
325+
return normalizeHtmlIfNeeded(text)
326+
}
327+
}
328+
329+
return normalizeHtmlIfNeeded(text)
330+
}
331+
332+
fun setValue(
333+
value: CharSequence?,
334+
shouldParseHtml: Boolean = true,
335+
) {
316336
if (value == null) return
317337

318338
runAsATransaction {
319-
val newText = parseText(value)
339+
val newText = if (shouldParseHtml) parseText(value) else value
320340
setText(newText)
321341
applyLineSpacing()
322342

@@ -519,7 +539,7 @@ class EnrichedTextInputView : AppCompatEditText {
519539
try {
520540
linkRegex = Pattern.compile("(?s).*?($patternStr).*", flags)
521541
} catch (_: PatternSyntaxException) {
522-
Log.w("EnrichedTextInputView", "Invalid link regex pattern: $patternStr")
542+
Log.w(TAG, "Invalid link regex pattern: $patternStr")
523543
linkRegex = Patterns.WEB_URL
524544
}
525545
}
@@ -666,7 +686,7 @@ class EnrichedTextInputView : AppCompatEditText {
666686
EnrichedSpans.ORDERED_LIST -> listStyles?.toggleStyle(EnrichedSpans.ORDERED_LIST)
667687
EnrichedSpans.UNORDERED_LIST -> listStyles?.toggleStyle(EnrichedSpans.UNORDERED_LIST)
668688
EnrichedSpans.CHECKBOX_LIST -> listStyles?.toggleStyle(EnrichedSpans.CHECKBOX_LIST)
669-
else -> Log.w("EnrichedTextInputView", "Unknown style: $name")
689+
else -> Log.w(TAG, "Unknown style: $name")
670690
}
671691

672692
layoutManager.invalidateLayout()
@@ -962,6 +982,7 @@ class EnrichedTextInputView : AppCompatEditText {
962982
}
963983

964984
companion object {
985+
const val TAG = "EnrichedTextInputView"
965986
const val CLIPBOARD_TAG = "react-native-enriched-clipboard"
966987
private const val CONTEXT_MENU_ITEM_ID = 10000
967988
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ class EnrichedTextInputViewManager :
278278
view: EnrichedTextInputView?,
279279
value: Boolean,
280280
) {
281-
// no-op
281+
view?.useHtmlNormalizer = value
282282
}
283283

284284
override fun focus(view: EnrichedTextInputView?) {

android/src/main/new_arch/CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ set(LIB_ANDROID_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
88
set(LIB_ANDROID_GENERATED_JNI_DIR ${LIB_ANDROID_DIR}/generated/jni)
99
set(LIB_ANDROID_GENERATED_COMPONENTS_DIR ${LIB_ANDROID_GENERATED_JNI_DIR}/react/renderer/components/${LIB_LITERAL})
1010

11+
set(LIB_CPP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../cpp)
12+
1113
file(GLOB LIB_MODULE_SRCS CONFIGURE_DEPENDS *.cpp react/renderer/components/${LIB_LITERAL}/*.cpp)
1214
file(GLOB LIB_CODEGEN_SRCS CONFIGURE_DEPENDS ${LIB_ANDROID_GENERATED_COMPONENTS_DIR}/*.cpp)
15+
file(GLOB LIB_CPP_SRCS CONFIGURE_DEPENDS ${LIB_CPP_DIR}/parser/GumboParser.cpp ${LIB_CPP_DIR}/parser/GumboNormalizer.c)
16+
17+
set_source_files_properties(${LIB_CPP_DIR}/parser/GumboNormalizer.c PROPERTIES LANGUAGE C COMPILE_FLAGS "-std=c99")
1318

1419
if(NOT DEFINED REACT_NATIVE_MINOR_VERSION)
1520
set(REACT_NATIVE_MINOR_VERSION ${ReactAndroid_VERSION_MINOR})
@@ -23,6 +28,7 @@ add_library(
2328
${LIB_MODULE_SRCS}
2429
${LIB_CUSTOM_SRCS}
2530
${LIB_CODEGEN_SRCS}
31+
${LIB_CPP_SRCS}
2632
)
2733

2834
target_include_directories(
@@ -33,6 +39,8 @@ target_include_directories(
3339
${LIB_ANDROID_GENERATED_JNI_DIR}
3440
${LIB_ANDROID_GENERATED_COMPONENTS_DIR}
3541
${LIB_CPP_DIR}
42+
${LIB_CPP_DIR}/parser
43+
${LIB_CPP_DIR}/GumboParser
3644
)
3745

3846
find_package(fbjni REQUIRED CONFIG)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#include "GumboParser.hpp"
2+
#include <jni.h>
3+
#include <string>
4+
5+
extern "C" JNIEXPORT jstring JNICALL
6+
Java_com_swmansion_enriched_common_GumboNormalizer_normalizeHtml(
7+
JNIEnv *env, jclass /*cls*/, jstring htmlJString) {
8+
const char *htmlChars = env->GetStringUTFChars(htmlJString, nullptr);
9+
std::string result = GumboParser::normalizeHtml(htmlChars);
10+
env->ReleaseStringUTFChars(htmlJString, htmlChars);
11+
if (result.empty())
12+
return nullptr;
13+
return env->NewStringUTF(result.c_str());
14+
}

docs/API_REFERENCE.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -499,11 +499,9 @@ If true, Android will use experimental synchronous events. This will prevent fro
499499

500500
If true, external HTML pasted/inserted into the input (e.g. from Google Docs, Word, or web pages) will be normalized into the canonical tag subset that the enriched parser understands. However, this is an experimental feature, which has not been thoroughly tested. We may decide to enable it by default in a future release.
501501

502-
> **Note:** Currently only supported on iOS.
503-
504502
| Type | Default Value | Platform |
505-
| ------ | ------------- | -------- |
506-
| `bool` | `false` | iOS |
503+
| ------ | ------------- |----------|
504+
| `bool` | `false` | Both |
507505

508506
## Ref Methods
509507

0 commit comments

Comments
 (0)