@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.
- package/LICENSE +160 -0
- package/README.md +143 -0
- package/android/build.gradle +39 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +228 -0
- package/ios/EditorCore.xcframework/Info.plist +44 -0
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +254 -0
- package/ios/EditorTheme.swift +372 -0
- package/ios/Generated_editor_core.swift +1143 -0
- package/ios/NativeEditorExpoView.swift +1417 -0
- package/ios/NativeEditorModule.swift +263 -0
- package/ios/PositionBridge.swift +278 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +825 -0
- package/ios/RichTextEditorView.swift +1559 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
- package/ios/editor_coreFFI/module.modulemap +7 -0
- package/ios/editor_coreFFI.h +904 -0
- package/ios/editor_coreFFI.modulemap +7 -0
- package/package.json +66 -0
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
- package/src/EditorTheme.ts +130 -0
- package/src/EditorToolbar.tsx +620 -0
- package/src/NativeEditorBridge.ts +607 -0
- package/src/NativeRichTextEditor.tsx +951 -0
- package/src/addons.ts +158 -0
- package/src/index.ts +63 -0
- package/src/schemas.ts +153 -0
- package/src/useNativeEditor.ts +173 -0
|
@@ -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
|
+
}
|