@apollohg/react-native-prose-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,14 @@
1
+ package com.apollohg.editor
2
+
3
+ enum class EditorHeightBehavior {
4
+ FIXED,
5
+ AUTO_GROW;
6
+
7
+ companion object {
8
+ fun fromRaw(rawValue: String?): EditorHeightBehavior =
9
+ when (rawValue) {
10
+ "autoGrow" -> AUTO_GROW
11
+ else -> FIXED
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,191 @@
1
+ package com.apollohg.editor
2
+
3
+ import android.view.KeyEvent
4
+ import android.view.inputmethod.InputConnection
5
+ import android.view.inputmethod.InputConnectionWrapper
6
+
7
+ /**
8
+ * Custom [InputConnectionWrapper] that intercepts all text input from the soft keyboard
9
+ * and routes it through the Rust editor-core engine via the hosting [EditorEditText].
10
+ *
11
+ * Instead of letting Android's EditText text storage handle insertions and deletions
12
+ * directly, this class captures the user's intent (typing, deleting, IME composition)
13
+ * and delegates to the Rust editor. The Rust editor returns render elements, which are
14
+ * converted to [android.text.SpannableStringBuilder] via [RenderBridge] and applied
15
+ * back to the EditText.
16
+ *
17
+ * ## Composition (IME) Handling
18
+ *
19
+ * For CJK input methods (and swipe keyboards), [setComposingText] and
20
+ * [finishComposingText] are used. During composition, we let the base [InputConnection]
21
+ * handle composing text normally so the user sees their in-progress input with the
22
+ * composing underline. When composition finalizes ([finishComposingText]), we capture
23
+ * the result and route it through Rust.
24
+ *
25
+ * ## Key Events
26
+ *
27
+ * Hardware keyboard events (backspace, enter) arrive via [sendKeyEvent]. We intercept
28
+ * DEL and ENTER to route through the Rust editor.
29
+ */
30
+ class EditorInputConnection(
31
+ private val editorView: EditorEditText,
32
+ baseConnection: InputConnection
33
+ ) : InputConnectionWrapper(baseConnection, true) {
34
+
35
+ companion object {
36
+ internal fun codePointsToUtf16Length(
37
+ text: String,
38
+ fromUtf16Offset: Int,
39
+ codePointCount: Int,
40
+ forward: Boolean
41
+ ): Int {
42
+ if (codePointCount <= 0 || text.isEmpty()) return 0
43
+
44
+ var remaining = codePointCount
45
+ var utf16Length = 0
46
+
47
+ if (forward) {
48
+ var index = fromUtf16Offset.coerceIn(0, text.length)
49
+ while (index < text.length && remaining > 0) {
50
+ val codePoint = Character.codePointAt(text, index)
51
+ val charCount = Character.charCount(codePoint)
52
+ utf16Length += charCount
53
+ index += charCount
54
+ remaining--
55
+ }
56
+ } else {
57
+ var index = fromUtf16Offset.coerceIn(0, text.length)
58
+ while (index > 0 && remaining > 0) {
59
+ val codePoint = Character.codePointBefore(text, index)
60
+ val charCount = Character.charCount(codePoint)
61
+ utf16Length += charCount
62
+ index -= charCount
63
+ remaining--
64
+ }
65
+ }
66
+
67
+ return utf16Length
68
+ }
69
+ }
70
+
71
+ /** Tracks the current composing text for CJK/swipe input. */
72
+ private var composingText: String? = null
73
+
74
+ /**
75
+ * Called when the IME commits finalized text (single character, word,
76
+ * autocomplete selection, etc.).
77
+ *
78
+ * Routes the text through Rust instead of directly inserting into the EditText.
79
+ */
80
+ override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
81
+ if (!editorView.isEditable) return false
82
+ if (editorView.isApplyingRustState) {
83
+ return super.commitText(text, newCursorPosition)
84
+ }
85
+ text?.toString()?.let { editorView.handleTextCommit(it) }
86
+ return true
87
+ }
88
+
89
+ /**
90
+ * Called when the IME requests deletion of text surrounding the cursor.
91
+ *
92
+ * Routes the deletion through Rust instead of directly modifying the EditText.
93
+ *
94
+ * @param beforeLength Number of UTF-16 code units to delete before the cursor.
95
+ * @param afterLength Number of UTF-16 code units to delete after the cursor.
96
+ */
97
+ override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
98
+ if (!editorView.isEditable) return false
99
+ if (editorView.isApplyingRustState) {
100
+ return super.deleteSurroundingText(beforeLength, afterLength)
101
+ }
102
+ editorView.handleDelete(beforeLength, afterLength)
103
+ return true
104
+ }
105
+
106
+ override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
107
+ if (!editorView.isEditable) return false
108
+ if (editorView.isApplyingRustState) {
109
+ return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
110
+ }
111
+
112
+ val currentText = editorView.text?.toString().orEmpty()
113
+ val cursor = editorView.selectionStart.coerceAtLeast(0)
114
+ val beforeUtf16Length = codePointsToUtf16Length(
115
+ text = currentText,
116
+ fromUtf16Offset = cursor,
117
+ codePointCount = beforeLength,
118
+ forward = false
119
+ )
120
+ val afterUtf16Length = codePointsToUtf16Length(
121
+ text = currentText,
122
+ fromUtf16Offset = editorView.selectionEnd.coerceAtLeast(cursor),
123
+ codePointCount = afterLength,
124
+ forward = true
125
+ )
126
+ editorView.handleDelete(beforeUtf16Length, afterUtf16Length)
127
+ return true
128
+ }
129
+
130
+ /**
131
+ * Called when the IME sets composing (in-progress) text for CJK/swipe input.
132
+ *
133
+ * We let the base InputConnection handle this normally so the user sees
134
+ * the composing text with its underline decoration. The text is NOT sent
135
+ * to Rust during composition — only when [finishComposingText] is called.
136
+ */
137
+ override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
138
+ if (!editorView.isEditable) return super.setComposingText(text, newCursorPosition)
139
+ composingText = text?.toString()
140
+ return super.setComposingText(text, newCursorPosition)
141
+ }
142
+
143
+ /**
144
+ * Called when IME composition is finalized (user selects a candidate or
145
+ * presses space/enter to commit the composing text).
146
+ *
147
+ * At this point, the composed text is final. We notify the [EditorEditText]
148
+ * so it can capture the result and send it to Rust.
149
+ */
150
+ override fun finishComposingText(): Boolean {
151
+ if (!editorView.isEditable) return super.finishComposingText()
152
+ val composed = composingText
153
+ composingText = null
154
+
155
+ // Prevent selection sync while the base connection commits the composed
156
+ // text, since the Rust document doesn't have it yet.
157
+ editorView.isApplyingRustState = true
158
+ val result = super.finishComposingText()
159
+ editorView.isApplyingRustState = false
160
+
161
+ // Now route the composed text through Rust.
162
+ if (!editorView.isApplyingRustState) {
163
+ editorView.handleCompositionFinished(composed)
164
+ }
165
+ return result
166
+ }
167
+
168
+ /**
169
+ * Called for hardware keyboard key events.
170
+ *
171
+ * Intercepts DEL (backspace) and ENTER to route through Rust. Other key
172
+ * events are passed through to the base connection.
173
+ */
174
+ override fun sendKeyEvent(event: KeyEvent?): Boolean {
175
+ if (!editorView.isEditable) return false
176
+ if (event != null && event.action == KeyEvent.ACTION_DOWN) {
177
+ if (editorView.handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
178
+ return true
179
+ }
180
+ }
181
+ if (event != null && event.action == KeyEvent.ACTION_UP) {
182
+ when (event.keyCode) {
183
+ KeyEvent.KEYCODE_DEL,
184
+ KeyEvent.KEYCODE_ENTER,
185
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
186
+ KeyEvent.KEYCODE_TAB -> return true
187
+ }
188
+ }
189
+ return super.sendKeyEvent(event)
190
+ }
191
+ }
@@ -0,0 +1,325 @@
1
+ package com.apollohg.editor
2
+
3
+ import android.graphics.Color
4
+ import android.graphics.Typeface
5
+ import org.json.JSONObject
6
+
7
+ data class EditorTextStyle(
8
+ val fontFamily: String? = null,
9
+ val fontSize: Float? = null,
10
+ val fontWeight: String? = null,
11
+ val fontStyle: String? = null,
12
+ val color: Int? = null,
13
+ val lineHeight: Float? = null,
14
+ val spacingAfter: Float? = null
15
+ ) {
16
+ companion object {
17
+ fun fromJson(json: JSONObject?): EditorTextStyle? {
18
+ json ?: return null
19
+ return EditorTextStyle(
20
+ fontFamily = json.optNullableString("fontFamily"),
21
+ fontSize = json.optNullableFloat("fontSize"),
22
+ fontWeight = json.optNullableString("fontWeight"),
23
+ fontStyle = json.optNullableString("fontStyle"),
24
+ color = parseColor(json.optNullableString("color")),
25
+ lineHeight = json.optNullableFloat("lineHeight"),
26
+ spacingAfter = json.optNullableFloat("spacingAfter")
27
+ )
28
+ }
29
+ }
30
+
31
+ fun mergedWith(other: EditorTextStyle?): EditorTextStyle {
32
+ other ?: return this
33
+ return copy(
34
+ fontFamily = other.fontFamily ?: fontFamily,
35
+ fontSize = other.fontSize ?: fontSize,
36
+ fontWeight = other.fontWeight ?: fontWeight,
37
+ fontStyle = other.fontStyle ?: fontStyle,
38
+ color = other.color ?: color,
39
+ lineHeight = other.lineHeight ?: lineHeight,
40
+ spacingAfter = other.spacingAfter ?: spacingAfter
41
+ )
42
+ }
43
+
44
+ fun typefaceStyle(): Int {
45
+ val bold = fontWeight == "bold" || fontWeight?.toIntOrNull()?.let { it >= 600 } == true
46
+ val italic = fontStyle == "italic"
47
+ return when {
48
+ bold && italic -> Typeface.BOLD_ITALIC
49
+ bold -> Typeface.BOLD
50
+ italic -> Typeface.ITALIC
51
+ else -> Typeface.NORMAL
52
+ }
53
+ }
54
+ }
55
+
56
+ data class EditorListTheme(
57
+ val indent: Float? = null,
58
+ val itemSpacing: Float? = null,
59
+ val markerColor: Int? = null,
60
+ val markerScale: Float? = null
61
+ ) {
62
+ companion object {
63
+ fun fromJson(json: JSONObject?): EditorListTheme? {
64
+ json ?: return null
65
+ return EditorListTheme(
66
+ indent = json.optNullableFloat("indent"),
67
+ itemSpacing = json.optNullableFloat("itemSpacing"),
68
+ markerColor = parseColor(json.optNullableString("markerColor")),
69
+ markerScale = json.optNullableFloat("markerScale")
70
+ )
71
+ }
72
+ }
73
+ }
74
+
75
+ data class EditorHorizontalRuleTheme(
76
+ val color: Int? = null,
77
+ val thickness: Float? = null,
78
+ val verticalMargin: Float? = null
79
+ ) {
80
+ companion object {
81
+ fun fromJson(json: JSONObject?): EditorHorizontalRuleTheme? {
82
+ json ?: return null
83
+ return EditorHorizontalRuleTheme(
84
+ color = parseColor(json.optNullableString("color")),
85
+ thickness = json.optNullableFloat("thickness"),
86
+ verticalMargin = json.optNullableFloat("verticalMargin")
87
+ )
88
+ }
89
+ }
90
+ }
91
+
92
+ data class EditorMentionTheme(
93
+ val textColor: Int? = null,
94
+ val backgroundColor: Int? = null,
95
+ val borderColor: Int? = null,
96
+ val borderWidth: Float? = null,
97
+ val borderRadius: Float? = null,
98
+ val fontWeight: String? = null,
99
+ val popoverBackgroundColor: Int? = null,
100
+ val popoverBorderColor: Int? = null,
101
+ val popoverBorderWidth: Float? = null,
102
+ val popoverBorderRadius: Float? = null,
103
+ val popoverShadowColor: Int? = null,
104
+ val optionTextColor: Int? = null,
105
+ val optionSecondaryTextColor: Int? = null,
106
+ val optionHighlightedBackgroundColor: Int? = null,
107
+ val optionHighlightedTextColor: Int? = null
108
+ ) {
109
+ companion object {
110
+ fun fromJson(json: JSONObject?): EditorMentionTheme? {
111
+ json ?: return null
112
+ return EditorMentionTheme(
113
+ textColor = parseColor(json.optNullableString("textColor")),
114
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
115
+ borderColor = parseColor(json.optNullableString("borderColor")),
116
+ borderWidth = json.optNullableFloat("borderWidth"),
117
+ borderRadius = json.optNullableFloat("borderRadius"),
118
+ fontWeight = json.optNullableString("fontWeight"),
119
+ popoverBackgroundColor = parseColor(json.optNullableString("popoverBackgroundColor")),
120
+ popoverBorderColor = parseColor(json.optNullableString("popoverBorderColor")),
121
+ popoverBorderWidth = json.optNullableFloat("popoverBorderWidth"),
122
+ popoverBorderRadius = json.optNullableFloat("popoverBorderRadius"),
123
+ popoverShadowColor = parseColor(json.optNullableString("popoverShadowColor")),
124
+ optionTextColor = parseColor(json.optNullableString("optionTextColor")),
125
+ optionSecondaryTextColor = parseColor(json.optNullableString("optionSecondaryTextColor")),
126
+ optionHighlightedBackgroundColor = parseColor(json.optNullableString("optionHighlightedBackgroundColor")),
127
+ optionHighlightedTextColor = parseColor(json.optNullableString("optionHighlightedTextColor"))
128
+ )
129
+ }
130
+ }
131
+ }
132
+
133
+ data class EditorToolbarTheme(
134
+ val backgroundColor: Int? = null,
135
+ val borderColor: Int? = null,
136
+ val borderWidth: Float? = null,
137
+ val borderRadius: Float? = null,
138
+ val keyboardOffset: Float? = null,
139
+ val horizontalInset: Float? = null,
140
+ val separatorColor: Int? = null,
141
+ val buttonColor: Int? = null,
142
+ val buttonActiveColor: Int? = null,
143
+ val buttonDisabledColor: Int? = null,
144
+ val buttonActiveBackgroundColor: Int? = null,
145
+ val buttonBorderRadius: Float? = null
146
+ ) {
147
+ companion object {
148
+ fun fromJson(json: JSONObject?): EditorToolbarTheme? {
149
+ json ?: return null
150
+ return EditorToolbarTheme(
151
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
152
+ borderColor = parseColor(json.optNullableString("borderColor")),
153
+ borderWidth = json.optNullableFloat("borderWidth"),
154
+ borderRadius = json.optNullableFloat("borderRadius"),
155
+ keyboardOffset = json.optNullableFloat("keyboardOffset"),
156
+ horizontalInset = json.optNullableFloat("horizontalInset"),
157
+ separatorColor = parseColor(json.optNullableString("separatorColor")),
158
+ buttonColor = parseColor(json.optNullableString("buttonColor")),
159
+ buttonActiveColor = parseColor(json.optNullableString("buttonActiveColor")),
160
+ buttonDisabledColor = parseColor(json.optNullableString("buttonDisabledColor")),
161
+ buttonActiveBackgroundColor = parseColor(json.optNullableString("buttonActiveBackgroundColor")),
162
+ buttonBorderRadius = json.optNullableFloat("buttonBorderRadius")
163
+ )
164
+ }
165
+ }
166
+ }
167
+
168
+ data class EditorContentInsets(
169
+ val top: Float? = null,
170
+ val right: Float? = null,
171
+ val bottom: Float? = null,
172
+ val left: Float? = null
173
+ ) {
174
+ companion object {
175
+ fun fromJson(json: JSONObject?): EditorContentInsets? {
176
+ json ?: return null
177
+ return EditorContentInsets(
178
+ top = json.optNullableFloat("top"),
179
+ right = json.optNullableFloat("right"),
180
+ bottom = json.optNullableFloat("bottom"),
181
+ left = json.optNullableFloat("left")
182
+ )
183
+ }
184
+ }
185
+ }
186
+
187
+ data class EditorTheme(
188
+ val text: EditorTextStyle? = null,
189
+ val paragraph: EditorTextStyle? = null,
190
+ val headings: Map<String, EditorTextStyle> = emptyMap(),
191
+ val list: EditorListTheme? = null,
192
+ val horizontalRule: EditorHorizontalRuleTheme? = null,
193
+ val mentions: EditorMentionTheme? = null,
194
+ val toolbar: EditorToolbarTheme? = null,
195
+ val backgroundColor: Int? = null,
196
+ val borderRadius: Float? = null,
197
+ val contentInsets: EditorContentInsets? = null
198
+ ) {
199
+ companion object {
200
+ fun fromJson(json: String?): EditorTheme? {
201
+ if (json.isNullOrBlank()) return null
202
+ val root = try {
203
+ JSONObject(json)
204
+ } catch (_: Exception) {
205
+ return null
206
+ }
207
+
208
+ val headings = mutableMapOf<String, EditorTextStyle>()
209
+ for (level in listOf("h1", "h2", "h3", "h4", "h5", "h6")) {
210
+ val style = EditorTextStyle.fromJson(root.optJSONObject("headings")?.optJSONObject(level))
211
+ if (style != null) {
212
+ headings[level] = style
213
+ }
214
+ }
215
+
216
+ return EditorTheme(
217
+ text = EditorTextStyle.fromJson(root.optJSONObject("text")),
218
+ paragraph = EditorTextStyle.fromJson(root.optJSONObject("paragraph")),
219
+ headings = headings,
220
+ list = EditorListTheme.fromJson(root.optJSONObject("list")),
221
+ horizontalRule = EditorHorizontalRuleTheme.fromJson(root.optJSONObject("horizontalRule")),
222
+ mentions = EditorMentionTheme.fromJson(root.optJSONObject("mentions")),
223
+ toolbar = EditorToolbarTheme.fromJson(root.optJSONObject("toolbar")),
224
+ backgroundColor = parseColor(root.optNullableString("backgroundColor")),
225
+ borderRadius = root.optNullableFloat("borderRadius"),
226
+ contentInsets = EditorContentInsets.fromJson(root.optJSONObject("contentInsets"))
227
+ )
228
+ }
229
+ }
230
+
231
+ fun effectiveTextStyle(nodeType: String): EditorTextStyle {
232
+ var style = text ?: EditorTextStyle()
233
+ if (nodeType == "paragraph") {
234
+ style = style.mergedWith(paragraph)
235
+ if (paragraph?.lineHeight == null) {
236
+ style = style.copy(lineHeight = null)
237
+ }
238
+ }
239
+ style = style.mergedWith(headings[nodeType])
240
+ return style
241
+ }
242
+ }
243
+
244
+ private fun parseColor(raw: String?): Int? {
245
+ val value = raw?.trim()?.lowercase() ?: return null
246
+ if (value.isEmpty()) return null
247
+
248
+ parseCssHexColor(value)?.let { return it }
249
+
250
+ try {
251
+ return Color.parseColor(value)
252
+ } catch (_: IllegalArgumentException) {
253
+ // Fall through to rgb()/rgba() parsing.
254
+ }
255
+
256
+ return when {
257
+ value.startsWith("rgb(") && value.endsWith(")") -> {
258
+ val parts = value.removePrefix("rgb(").removeSuffix(")")
259
+ .split(',')
260
+ .map { it.trim() }
261
+ if (parts.size != 3) return null
262
+ val red = parts[0].toDoubleOrNull() ?: return null
263
+ val green = parts[1].toDoubleOrNull() ?: return null
264
+ val blue = parts[2].toDoubleOrNull() ?: return null
265
+ Color.argb(255, red.toInt(), green.toInt(), blue.toInt())
266
+ }
267
+ value.startsWith("rgba(") && value.endsWith(")") -> {
268
+ val parts = value.removePrefix("rgba(").removeSuffix(")")
269
+ .split(',')
270
+ .map { it.trim() }
271
+ if (parts.size != 4) return null
272
+ val red = parts[0].toDoubleOrNull() ?: return null
273
+ val green = parts[1].toDoubleOrNull() ?: return null
274
+ val blue = parts[2].toDoubleOrNull() ?: return null
275
+ val alpha = parts[3].toDoubleOrNull() ?: return null
276
+ Color.argb((alpha * 255f).toInt(), red.toInt(), green.toInt(), blue.toInt())
277
+ }
278
+ else -> null
279
+ }
280
+ }
281
+
282
+ private fun parseCssHexColor(value: String): Int? {
283
+ if (!value.startsWith("#")) return null
284
+ val hex = value.removePrefix("#")
285
+
286
+ return when (hex.length) {
287
+ 3 -> {
288
+ val red = "${hex[0]}${hex[0]}".toIntOrNull(16) ?: return null
289
+ val green = "${hex[1]}${hex[1]}".toIntOrNull(16) ?: return null
290
+ val blue = "${hex[2]}${hex[2]}".toIntOrNull(16) ?: return null
291
+ Color.argb(255, red, green, blue)
292
+ }
293
+ 4 -> {
294
+ val red = "${hex[0]}${hex[0]}".toIntOrNull(16) ?: return null
295
+ val green = "${hex[1]}${hex[1]}".toIntOrNull(16) ?: return null
296
+ val blue = "${hex[2]}${hex[2]}".toIntOrNull(16) ?: return null
297
+ val alpha = "${hex[3]}${hex[3]}".toIntOrNull(16) ?: return null
298
+ Color.argb(alpha, red, green, blue)
299
+ }
300
+ 6 -> {
301
+ val red = hex.substring(0, 2).toIntOrNull(16) ?: return null
302
+ val green = hex.substring(2, 4).toIntOrNull(16) ?: return null
303
+ val blue = hex.substring(4, 6).toIntOrNull(16) ?: return null
304
+ Color.argb(255, red, green, blue)
305
+ }
306
+ 8 -> {
307
+ val red = hex.substring(0, 2).toIntOrNull(16) ?: return null
308
+ val green = hex.substring(2, 4).toIntOrNull(16) ?: return null
309
+ val blue = hex.substring(4, 6).toIntOrNull(16) ?: return null
310
+ val alpha = hex.substring(6, 8).toIntOrNull(16) ?: return null
311
+ Color.argb(alpha, red, green, blue)
312
+ }
313
+ else -> null
314
+ }
315
+ }
316
+
317
+ private fun JSONObject.optNullableString(key: String): String? {
318
+ if (!has(key) || isNull(key)) return null
319
+ return optString(key).takeUnless { it == "null" }
320
+ }
321
+
322
+ private fun JSONObject.optNullableFloat(key: String): Float? {
323
+ if (!has(key) || isNull(key)) return null
324
+ return optDouble(key).takeIf { !it.isNaN() }?.toFloat()
325
+ }