@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,714 @@
|
|
|
1
|
+
package com.apollohg.editor
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.graphics.Typeface
|
|
6
|
+
import android.graphics.drawable.GradientDrawable
|
|
7
|
+
import android.view.Gravity
|
|
8
|
+
import android.view.View
|
|
9
|
+
import android.widget.HorizontalScrollView
|
|
10
|
+
import android.widget.LinearLayout
|
|
11
|
+
import androidx.appcompat.widget.AppCompatButton
|
|
12
|
+
import androidx.appcompat.widget.AppCompatTextView
|
|
13
|
+
import androidx.core.view.setPadding
|
|
14
|
+
import org.json.JSONObject
|
|
15
|
+
|
|
16
|
+
internal data class NativeToolbarState(
|
|
17
|
+
val marks: Map<String, Boolean>,
|
|
18
|
+
val nodes: Map<String, Boolean>,
|
|
19
|
+
val commands: Map<String, Boolean>,
|
|
20
|
+
val allowedMarks: Set<String>,
|
|
21
|
+
val insertableNodes: Set<String>,
|
|
22
|
+
val canUndo: Boolean,
|
|
23
|
+
val canRedo: Boolean
|
|
24
|
+
) {
|
|
25
|
+
companion object {
|
|
26
|
+
val empty = NativeToolbarState(
|
|
27
|
+
marks = emptyMap(),
|
|
28
|
+
nodes = emptyMap(),
|
|
29
|
+
commands = emptyMap(),
|
|
30
|
+
allowedMarks = emptySet(),
|
|
31
|
+
insertableNodes = emptySet(),
|
|
32
|
+
canUndo = false,
|
|
33
|
+
canRedo = false
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
fun fromUpdateJson(updateJson: String): NativeToolbarState? {
|
|
37
|
+
val root = try {
|
|
38
|
+
JSONObject(updateJson)
|
|
39
|
+
} catch (_: Exception) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
val activeState = root.optJSONObject("activeState") ?: JSONObject()
|
|
43
|
+
val historyState = root.optJSONObject("historyState") ?: JSONObject()
|
|
44
|
+
return NativeToolbarState(
|
|
45
|
+
marks = boolMap(activeState.optJSONObject("marks")),
|
|
46
|
+
nodes = boolMap(activeState.optJSONObject("nodes")),
|
|
47
|
+
commands = boolMap(activeState.optJSONObject("commands")),
|
|
48
|
+
allowedMarks = stringSet(activeState.optJSONArray("allowedMarks")),
|
|
49
|
+
insertableNodes = stringSet(activeState.optJSONArray("insertableNodes")),
|
|
50
|
+
canUndo = historyState.optBoolean("canUndo", false),
|
|
51
|
+
canRedo = historyState.optBoolean("canRedo", false)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun boolMap(json: JSONObject?): Map<String, Boolean> {
|
|
56
|
+
json ?: return emptyMap()
|
|
57
|
+
val result = mutableMapOf<String, Boolean>()
|
|
58
|
+
val keys = json.keys()
|
|
59
|
+
while (keys.hasNext()) {
|
|
60
|
+
val key = keys.next()
|
|
61
|
+
result[key] = json.optBoolean(key, false)
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun stringSet(array: org.json.JSONArray?): Set<String> {
|
|
67
|
+
array ?: return emptySet()
|
|
68
|
+
val result = linkedSetOf<String>()
|
|
69
|
+
for (index in 0 until array.length()) {
|
|
70
|
+
array.optString(index, null)?.let { result.add(it) }
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
internal enum class ToolbarCommand {
|
|
78
|
+
indentList,
|
|
79
|
+
outdentList,
|
|
80
|
+
undo,
|
|
81
|
+
redo,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
internal enum class ToolbarListType {
|
|
85
|
+
bulletList,
|
|
86
|
+
orderedList,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
internal enum class ToolbarDefaultIconId {
|
|
90
|
+
bold,
|
|
91
|
+
italic,
|
|
92
|
+
underline,
|
|
93
|
+
strike,
|
|
94
|
+
bulletList,
|
|
95
|
+
orderedList,
|
|
96
|
+
indentList,
|
|
97
|
+
outdentList,
|
|
98
|
+
lineBreak,
|
|
99
|
+
horizontalRule,
|
|
100
|
+
undo,
|
|
101
|
+
redo,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
internal enum class ToolbarItemKind {
|
|
105
|
+
mark,
|
|
106
|
+
list,
|
|
107
|
+
command,
|
|
108
|
+
node,
|
|
109
|
+
action,
|
|
110
|
+
separator,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
internal data class NativeToolbarIcon(
|
|
114
|
+
val defaultId: ToolbarDefaultIconId? = null,
|
|
115
|
+
val glyphText: String? = null,
|
|
116
|
+
val fallbackText: String? = null,
|
|
117
|
+
val materialIconName: String? = null
|
|
118
|
+
) {
|
|
119
|
+
companion object {
|
|
120
|
+
private val defaultGlyphs = mapOf(
|
|
121
|
+
ToolbarDefaultIconId.bold to "B",
|
|
122
|
+
ToolbarDefaultIconId.italic to "I",
|
|
123
|
+
ToolbarDefaultIconId.underline to "U",
|
|
124
|
+
ToolbarDefaultIconId.strike to "S",
|
|
125
|
+
ToolbarDefaultIconId.bulletList to "•≡",
|
|
126
|
+
ToolbarDefaultIconId.orderedList to "1.",
|
|
127
|
+
ToolbarDefaultIconId.indentList to "→",
|
|
128
|
+
ToolbarDefaultIconId.outdentList to "←",
|
|
129
|
+
ToolbarDefaultIconId.lineBreak to "↵",
|
|
130
|
+
ToolbarDefaultIconId.horizontalRule to "—",
|
|
131
|
+
ToolbarDefaultIconId.undo to "↩",
|
|
132
|
+
ToolbarDefaultIconId.redo to "↪"
|
|
133
|
+
)
|
|
134
|
+
private val defaultMaterialIcons = mapOf(
|
|
135
|
+
ToolbarDefaultIconId.bold to "format-bold",
|
|
136
|
+
ToolbarDefaultIconId.italic to "format-italic",
|
|
137
|
+
ToolbarDefaultIconId.underline to "format-underlined",
|
|
138
|
+
ToolbarDefaultIconId.strike to "strikethrough-s",
|
|
139
|
+
ToolbarDefaultIconId.bulletList to "format-list-bulleted",
|
|
140
|
+
ToolbarDefaultIconId.orderedList to "format-list-numbered",
|
|
141
|
+
ToolbarDefaultIconId.indentList to "format-indent-increase",
|
|
142
|
+
ToolbarDefaultIconId.outdentList to "format-indent-decrease",
|
|
143
|
+
ToolbarDefaultIconId.lineBreak to "keyboard-return",
|
|
144
|
+
ToolbarDefaultIconId.horizontalRule to "horizontal-rule",
|
|
145
|
+
ToolbarDefaultIconId.undo to "undo",
|
|
146
|
+
ToolbarDefaultIconId.redo to "redo"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
fun fromJson(raw: JSONObject?): NativeToolbarIcon? {
|
|
150
|
+
raw ?: return null
|
|
151
|
+
return when (raw.optString("type")) {
|
|
152
|
+
"default" -> {
|
|
153
|
+
val id = runCatching {
|
|
154
|
+
ToolbarDefaultIconId.valueOf(raw.getString("id"))
|
|
155
|
+
}.getOrNull() ?: return null
|
|
156
|
+
NativeToolbarIcon(defaultId = id)
|
|
157
|
+
}
|
|
158
|
+
"glyph" -> {
|
|
159
|
+
val text = raw.optString("text")
|
|
160
|
+
if (text.isBlank()) null else NativeToolbarIcon(glyphText = text)
|
|
161
|
+
}
|
|
162
|
+
"platform" -> {
|
|
163
|
+
val materialName = raw.optJSONObject("android")
|
|
164
|
+
?.takeIf { it.optString("type") == "material" }
|
|
165
|
+
?.optNullableString("name")
|
|
166
|
+
val fallback = raw.optNullableString("fallbackText")
|
|
167
|
+
if (materialName.isNullOrBlank() && fallback.isNullOrBlank()) {
|
|
168
|
+
null
|
|
169
|
+
} else {
|
|
170
|
+
NativeToolbarIcon(
|
|
171
|
+
fallbackText = fallback,
|
|
172
|
+
materialIconName = materialName
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else -> null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fun defaultMaterialIconName(defaultId: ToolbarDefaultIconId?): String? =
|
|
181
|
+
defaultId?.let { defaultMaterialIcons[it] }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fun resolvedGlyphText(): String =
|
|
185
|
+
glyphText?.takeIf { it.isNotBlank() }
|
|
186
|
+
?: fallbackText?.takeIf { it.isNotBlank() }
|
|
187
|
+
?: defaultId?.let { defaultGlyphs[it] }
|
|
188
|
+
?: "?"
|
|
189
|
+
|
|
190
|
+
fun resolvedMaterialIconName(): String? =
|
|
191
|
+
materialIconName?.takeIf { it.isNotBlank() }
|
|
192
|
+
?: Companion.defaultMaterialIconName(defaultId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
internal object MaterialIconRegistry {
|
|
196
|
+
private const val FONT_ASSET_PATH = "editor-icons/MaterialIcons.ttf"
|
|
197
|
+
private const val GLYPHMAP_ASSET_PATH = "editor-icons/MaterialIcons.json"
|
|
198
|
+
|
|
199
|
+
@Volatile
|
|
200
|
+
private var typeface: Typeface? = null
|
|
201
|
+
|
|
202
|
+
@Volatile
|
|
203
|
+
private var glyphMap: Map<String, String>? = null
|
|
204
|
+
|
|
205
|
+
fun typeface(context: Context): Typeface? {
|
|
206
|
+
val cached = typeface
|
|
207
|
+
if (cached != null) return cached
|
|
208
|
+
return runCatching {
|
|
209
|
+
Typeface.createFromAsset(context.assets, FONT_ASSET_PATH)
|
|
210
|
+
}.getOrNull()?.also { loaded ->
|
|
211
|
+
typeface = loaded
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fun glyphForName(context: Context, name: String?): String? {
|
|
216
|
+
if (name.isNullOrBlank()) return null
|
|
217
|
+
val map = glyphMap ?: loadGlyphMap(context).also { loaded ->
|
|
218
|
+
glyphMap = loaded
|
|
219
|
+
}
|
|
220
|
+
return map[name]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun loadGlyphMap(context: Context): Map<String, String> {
|
|
224
|
+
val assetText = runCatching {
|
|
225
|
+
context.assets.open(GLYPHMAP_ASSET_PATH).bufferedReader().use { it.readText() }
|
|
226
|
+
}.getOrNull() ?: return emptyMap()
|
|
227
|
+
|
|
228
|
+
val json = runCatching { JSONObject(assetText) }.getOrNull() ?: return emptyMap()
|
|
229
|
+
val result = linkedMapOf<String, String>()
|
|
230
|
+
val keys = json.keys()
|
|
231
|
+
while (keys.hasNext()) {
|
|
232
|
+
val key = keys.next()
|
|
233
|
+
val codePoint = json.optInt(key, -1)
|
|
234
|
+
if (codePoint > 0) {
|
|
235
|
+
result[key] = String(Character.toChars(codePoint))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return result
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
internal data class NativeToolbarResolvedIcon(
|
|
243
|
+
val text: String,
|
|
244
|
+
val typeface: Typeface? = null
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
private fun NativeToolbarIcon.resolveForAndroid(context: Context): NativeToolbarResolvedIcon {
|
|
248
|
+
val materialName = resolvedMaterialIconName()
|
|
249
|
+
val materialGlyph = MaterialIconRegistry.glyphForName(context, materialName)
|
|
250
|
+
val materialTypeface = MaterialIconRegistry.typeface(context)
|
|
251
|
+
if (materialGlyph != null && materialTypeface != null) {
|
|
252
|
+
return NativeToolbarResolvedIcon(
|
|
253
|
+
text = materialGlyph,
|
|
254
|
+
typeface = materialTypeface
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return NativeToolbarResolvedIcon(
|
|
259
|
+
text = resolvedGlyphText(),
|
|
260
|
+
typeface = null
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
internal data class NativeToolbarItem(
|
|
265
|
+
val type: ToolbarItemKind,
|
|
266
|
+
val key: String? = null,
|
|
267
|
+
val label: String? = null,
|
|
268
|
+
val icon: NativeToolbarIcon? = null,
|
|
269
|
+
val mark: String? = null,
|
|
270
|
+
val listType: ToolbarListType? = null,
|
|
271
|
+
val command: ToolbarCommand? = null,
|
|
272
|
+
val nodeType: String? = null,
|
|
273
|
+
val isActive: Boolean = false,
|
|
274
|
+
val isDisabled: Boolean = false
|
|
275
|
+
) {
|
|
276
|
+
companion object {
|
|
277
|
+
val defaults = listOf(
|
|
278
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Bold", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bold), mark = "bold"),
|
|
279
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Italic", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.italic), mark = "italic"),
|
|
280
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Underline", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.underline), mark = "underline"),
|
|
281
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Strikethrough", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.strike), mark = "strike"),
|
|
282
|
+
NativeToolbarItem(ToolbarItemKind.separator),
|
|
283
|
+
NativeToolbarItem(ToolbarItemKind.list, label = "Bullet List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bulletList), listType = ToolbarListType.bulletList),
|
|
284
|
+
NativeToolbarItem(ToolbarItemKind.list, label = "Ordered List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.orderedList), listType = ToolbarListType.orderedList),
|
|
285
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Indent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.indentList), command = ToolbarCommand.indentList),
|
|
286
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Outdent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.outdentList), command = ToolbarCommand.outdentList),
|
|
287
|
+
NativeToolbarItem(ToolbarItemKind.node, label = "Line Break", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.lineBreak), nodeType = "hardBreak"),
|
|
288
|
+
NativeToolbarItem(ToolbarItemKind.node, label = "Horizontal Rule", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.horizontalRule), nodeType = "horizontalRule"),
|
|
289
|
+
NativeToolbarItem(ToolbarItemKind.separator),
|
|
290
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Undo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.undo), command = ToolbarCommand.undo),
|
|
291
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Redo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.redo), command = ToolbarCommand.redo)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
fun fromJson(json: String?): List<NativeToolbarItem> {
|
|
295
|
+
if (json.isNullOrBlank()) return defaults
|
|
296
|
+
val rawArray = try {
|
|
297
|
+
org.json.JSONArray(json)
|
|
298
|
+
} catch (_: Exception) {
|
|
299
|
+
return defaults
|
|
300
|
+
}
|
|
301
|
+
val parsed = mutableListOf<NativeToolbarItem>()
|
|
302
|
+
for (index in 0 until rawArray.length()) {
|
|
303
|
+
val rawItem = rawArray.optJSONObject(index) ?: continue
|
|
304
|
+
val type = runCatching {
|
|
305
|
+
ToolbarItemKind.valueOf(rawItem.getString("type"))
|
|
306
|
+
}.getOrNull() ?: continue
|
|
307
|
+
val key = rawItem.optNullableString("key")
|
|
308
|
+
when (type) {
|
|
309
|
+
ToolbarItemKind.separator -> parsed.add(NativeToolbarItem(type = type, key = key))
|
|
310
|
+
ToolbarItemKind.mark -> {
|
|
311
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
|
|
312
|
+
val mark = rawItem.optNullableString("mark") ?: continue
|
|
313
|
+
val label = rawItem.optNullableString("label") ?: continue
|
|
314
|
+
parsed.add(NativeToolbarItem(type, key, label, icon, mark = mark))
|
|
315
|
+
}
|
|
316
|
+
ToolbarItemKind.list -> {
|
|
317
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
|
|
318
|
+
val listType = runCatching {
|
|
319
|
+
ToolbarListType.valueOf(rawItem.getString("listType"))
|
|
320
|
+
}.getOrNull() ?: continue
|
|
321
|
+
val label = rawItem.optNullableString("label") ?: continue
|
|
322
|
+
parsed.add(NativeToolbarItem(type, key, label, icon, listType = listType))
|
|
323
|
+
}
|
|
324
|
+
ToolbarItemKind.command -> {
|
|
325
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
|
|
326
|
+
val command = runCatching {
|
|
327
|
+
ToolbarCommand.valueOf(rawItem.getString("command"))
|
|
328
|
+
}.getOrNull() ?: continue
|
|
329
|
+
val label = rawItem.optNullableString("label") ?: continue
|
|
330
|
+
parsed.add(NativeToolbarItem(type, key, label, icon, command = command))
|
|
331
|
+
}
|
|
332
|
+
ToolbarItemKind.node -> {
|
|
333
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
|
|
334
|
+
val nodeType = rawItem.optNullableString("nodeType") ?: continue
|
|
335
|
+
val label = rawItem.optNullableString("label") ?: continue
|
|
336
|
+
parsed.add(NativeToolbarItem(type, key, label, icon, nodeType = nodeType))
|
|
337
|
+
}
|
|
338
|
+
ToolbarItemKind.action -> {
|
|
339
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
|
|
340
|
+
val keyValue = rawItem.optNullableString("key") ?: continue
|
|
341
|
+
val label = rawItem.optNullableString("label") ?: continue
|
|
342
|
+
parsed.add(
|
|
343
|
+
NativeToolbarItem(
|
|
344
|
+
type = type,
|
|
345
|
+
key = keyValue,
|
|
346
|
+
label = label,
|
|
347
|
+
icon = icon,
|
|
348
|
+
isActive = rawItem.optBoolean("isActive", false),
|
|
349
|
+
isDisabled = rawItem.optBoolean("isDisabled", false)
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return parsed.ifEmpty { defaults }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollView(context) {
|
|
361
|
+
private data class ButtonBinding(
|
|
362
|
+
val item: NativeToolbarItem,
|
|
363
|
+
val button: AppCompatButton
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
var onPressItem: ((NativeToolbarItem) -> Unit)? = null
|
|
367
|
+
var onSelectMentionSuggestion: ((NativeMentionSuggestion) -> Unit)? = null
|
|
368
|
+
|
|
369
|
+
private val contentRow = LinearLayout(context)
|
|
370
|
+
private var theme: EditorToolbarTheme? = null
|
|
371
|
+
private var mentionTheme: EditorMentionTheme? = null
|
|
372
|
+
private var state: NativeToolbarState = NativeToolbarState.empty
|
|
373
|
+
private var items: List<NativeToolbarItem> = NativeToolbarItem.defaults
|
|
374
|
+
private var mentionSuggestions: List<NativeMentionSuggestion> = emptyList()
|
|
375
|
+
private val bindings = mutableListOf<ButtonBinding>()
|
|
376
|
+
private val separators = mutableListOf<View>()
|
|
377
|
+
private val mentionChips = mutableListOf<MentionSuggestionChipView>()
|
|
378
|
+
private val density = resources.displayMetrics.density
|
|
379
|
+
internal var appliedChromeCornerRadiusPx: Float = 0f
|
|
380
|
+
private set
|
|
381
|
+
internal var appliedChromeStrokeWidthPx: Int = 0
|
|
382
|
+
private set
|
|
383
|
+
internal var appliedButtonCornerRadiusPx: Float = 0f
|
|
384
|
+
private set
|
|
385
|
+
val isShowingMentionSuggestions: Boolean
|
|
386
|
+
get() = mentionSuggestions.isNotEmpty()
|
|
387
|
+
|
|
388
|
+
init {
|
|
389
|
+
isHorizontalScrollBarEnabled = false
|
|
390
|
+
overScrollMode = OVER_SCROLL_NEVER
|
|
391
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
392
|
+
clipToPadding = false
|
|
393
|
+
|
|
394
|
+
contentRow.orientation = LinearLayout.HORIZONTAL
|
|
395
|
+
contentRow.gravity = Gravity.CENTER_VERTICAL
|
|
396
|
+
contentRow.setPadding(dp(12))
|
|
397
|
+
addView(
|
|
398
|
+
contentRow,
|
|
399
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
400
|
+
)
|
|
401
|
+
rebuildContent()
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
fun setItems(items: List<NativeToolbarItem>) {
|
|
405
|
+
this.items = compactItems(items)
|
|
406
|
+
if (!isShowingMentionSuggestions) {
|
|
407
|
+
rebuildContent()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
fun applyTheme(theme: EditorToolbarTheme?) {
|
|
412
|
+
this.theme = theme
|
|
413
|
+
updateChrome()
|
|
414
|
+
separators.forEach { separator ->
|
|
415
|
+
separator.setBackgroundColor(theme?.separatorColor ?: Color.parseColor("#E5E5EA"))
|
|
416
|
+
}
|
|
417
|
+
bindings.forEach { binding ->
|
|
418
|
+
updateButtonAppearance(
|
|
419
|
+
binding.button,
|
|
420
|
+
enabled = buttonState(binding.item, state).first,
|
|
421
|
+
active = buttonState(binding.item, state).second
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
mentionChips.forEach { chip ->
|
|
425
|
+
chip.applyTheme(mentionTheme)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
fun applyMentionTheme(theme: EditorMentionTheme?) {
|
|
430
|
+
mentionTheme = theme
|
|
431
|
+
mentionChips.forEach { chip ->
|
|
432
|
+
chip.applyTheme(theme)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
fun applyState(state: NativeToolbarState) {
|
|
437
|
+
this.state = state
|
|
438
|
+
bindings.forEach { binding ->
|
|
439
|
+
val (enabled, active) = buttonState(binding.item, state)
|
|
440
|
+
binding.button.isEnabled = enabled
|
|
441
|
+
binding.button.isSelected = active
|
|
442
|
+
updateButtonAppearance(binding.button, enabled, active)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fun setMentionSuggestions(suggestions: List<NativeMentionSuggestion>): Boolean {
|
|
447
|
+
val hadSuggestions = isShowingMentionSuggestions
|
|
448
|
+
mentionSuggestions = suggestions.take(8)
|
|
449
|
+
rebuildContent()
|
|
450
|
+
return hadSuggestions != isShowingMentionSuggestions
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
fun triggerMentionSuggestionTapForTesting(index: Int) {
|
|
454
|
+
mentionChips.getOrNull(index)?.performClick()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private fun rebuildContent() {
|
|
458
|
+
bindings.clear()
|
|
459
|
+
separators.clear()
|
|
460
|
+
mentionChips.clear()
|
|
461
|
+
contentRow.removeAllViews()
|
|
462
|
+
|
|
463
|
+
if (isShowingMentionSuggestions) {
|
|
464
|
+
rebuildMentionSuggestions()
|
|
465
|
+
} else {
|
|
466
|
+
rebuildButtons()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
updateChrome()
|
|
470
|
+
applyState(state)
|
|
471
|
+
scrollTo(0, 0)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private fun rebuildButtons() {
|
|
475
|
+
for (item in compactItems(items)) {
|
|
476
|
+
if (item.type == ToolbarItemKind.separator) {
|
|
477
|
+
val separator = View(context)
|
|
478
|
+
val params = LinearLayout.LayoutParams(dp(1), dp(22))
|
|
479
|
+
params.marginStart = dp(6)
|
|
480
|
+
params.marginEnd = dp(6)
|
|
481
|
+
separator.layoutParams = params
|
|
482
|
+
separator.setBackgroundColor(theme?.separatorColor ?: Color.parseColor("#E5E5EA"))
|
|
483
|
+
separators.add(separator)
|
|
484
|
+
contentRow.addView(separator)
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
val button = AppCompatButton(context).apply {
|
|
489
|
+
val resolvedIcon = item.icon?.resolveForAndroid(context)
|
|
490
|
+
?: NativeToolbarResolvedIcon("?")
|
|
491
|
+
text = resolvedIcon.text
|
|
492
|
+
typeface = resolvedIcon.typeface ?: Typeface.DEFAULT
|
|
493
|
+
textSize = 16f
|
|
494
|
+
minWidth = dp(36)
|
|
495
|
+
minimumWidth = dp(36)
|
|
496
|
+
minHeight = dp(36)
|
|
497
|
+
minimumHeight = dp(36)
|
|
498
|
+
gravity = Gravity.CENTER
|
|
499
|
+
setPadding(dp(10), dp(8), dp(10), dp(8))
|
|
500
|
+
background = GradientDrawable()
|
|
501
|
+
isAllCaps = false
|
|
502
|
+
includeFontPadding = false
|
|
503
|
+
contentDescription = item.label
|
|
504
|
+
setOnClickListener { onPressItem?.invoke(item) }
|
|
505
|
+
}
|
|
506
|
+
val params = LinearLayout.LayoutParams(
|
|
507
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
508
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
509
|
+
)
|
|
510
|
+
params.marginEnd = dp(6)
|
|
511
|
+
button.layoutParams = params
|
|
512
|
+
bindings.add(ButtonBinding(item, button))
|
|
513
|
+
contentRow.addView(button)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private fun rebuildMentionSuggestions() {
|
|
518
|
+
for (suggestion in mentionSuggestions) {
|
|
519
|
+
val chip = MentionSuggestionChipView(context, suggestion).apply {
|
|
520
|
+
applyTheme(mentionTheme)
|
|
521
|
+
setOnClickListener { onSelectMentionSuggestion?.invoke(suggestion) }
|
|
522
|
+
}
|
|
523
|
+
val params = LinearLayout.LayoutParams(
|
|
524
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
525
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
526
|
+
)
|
|
527
|
+
params.marginEnd = dp(8)
|
|
528
|
+
chip.layoutParams = params
|
|
529
|
+
mentionChips.add(chip)
|
|
530
|
+
contentRow.addView(chip)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private fun compactItems(items: List<NativeToolbarItem>): List<NativeToolbarItem> {
|
|
535
|
+
return items.filterIndexed { index, item ->
|
|
536
|
+
if (item.type != ToolbarItemKind.separator) return@filterIndexed true
|
|
537
|
+
index > 0 &&
|
|
538
|
+
index < items.lastIndex &&
|
|
539
|
+
items[index - 1].type != ToolbarItemKind.separator &&
|
|
540
|
+
items[index + 1].type != ToolbarItemKind.separator
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private fun updateChrome() {
|
|
545
|
+
val cornerRadiusPx = (theme?.borderRadius ?: 0f) * density
|
|
546
|
+
val strokeWidthPx = ((theme?.borderWidth ?: 1f) * density).toInt().coerceAtLeast(1)
|
|
547
|
+
val drawable = GradientDrawable().apply {
|
|
548
|
+
shape = GradientDrawable.RECTANGLE
|
|
549
|
+
cornerRadius = cornerRadiusPx
|
|
550
|
+
setColor(theme?.backgroundColor ?: Color.WHITE)
|
|
551
|
+
setStroke(strokeWidthPx, theme?.borderColor ?: Color.parseColor("#E5E5EA"))
|
|
552
|
+
}
|
|
553
|
+
appliedChromeCornerRadiusPx = cornerRadiusPx
|
|
554
|
+
appliedChromeStrokeWidthPx = strokeWidthPx
|
|
555
|
+
background = drawable
|
|
556
|
+
elevation = 0f
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private fun updateButtonAppearance(button: AppCompatButton, enabled: Boolean, active: Boolean) {
|
|
560
|
+
val textColor = when {
|
|
561
|
+
!enabled -> theme?.buttonDisabledColor ?: Color.parseColor("#C7C7CC")
|
|
562
|
+
active -> theme?.buttonActiveColor ?: Color.parseColor("#007AFF")
|
|
563
|
+
else -> theme?.buttonColor ?: Color.parseColor("#666666")
|
|
564
|
+
}
|
|
565
|
+
val backgroundColor = if (active) {
|
|
566
|
+
theme?.buttonActiveBackgroundColor ?: Color.parseColor("#1F007AFF")
|
|
567
|
+
} else {
|
|
568
|
+
Color.TRANSPARENT
|
|
569
|
+
}
|
|
570
|
+
val buttonCornerRadiusPx = (theme?.buttonBorderRadius ?: 6f) * density
|
|
571
|
+
val drawable = GradientDrawable().apply {
|
|
572
|
+
shape = GradientDrawable.RECTANGLE
|
|
573
|
+
cornerRadius = buttonCornerRadiusPx
|
|
574
|
+
setColor(backgroundColor)
|
|
575
|
+
}
|
|
576
|
+
appliedButtonCornerRadiusPx = buttonCornerRadiusPx
|
|
577
|
+
button.background = drawable
|
|
578
|
+
button.setTextColor(textColor)
|
|
579
|
+
button.alpha = if (enabled) 1f else 0.7f
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private fun buttonState(
|
|
583
|
+
item: NativeToolbarItem,
|
|
584
|
+
state: NativeToolbarState
|
|
585
|
+
): Pair<Boolean, Boolean> {
|
|
586
|
+
val isInList = state.nodes["bulletList"] == true || state.nodes["orderedList"] == true
|
|
587
|
+
return when (item.type) {
|
|
588
|
+
ToolbarItemKind.mark -> {
|
|
589
|
+
val mark = item.mark.orEmpty()
|
|
590
|
+
Pair(state.allowedMarks.contains(mark), state.marks[mark] == true)
|
|
591
|
+
}
|
|
592
|
+
ToolbarItemKind.list -> when (item.listType) {
|
|
593
|
+
ToolbarListType.bulletList -> Pair(
|
|
594
|
+
state.commands["wrapBulletList"] == true,
|
|
595
|
+
state.nodes["bulletList"] == true
|
|
596
|
+
)
|
|
597
|
+
ToolbarListType.orderedList -> Pair(
|
|
598
|
+
state.commands["wrapOrderedList"] == true,
|
|
599
|
+
state.nodes["orderedList"] == true
|
|
600
|
+
)
|
|
601
|
+
null -> Pair(false, false)
|
|
602
|
+
}
|
|
603
|
+
ToolbarItemKind.command -> when (item.command) {
|
|
604
|
+
ToolbarCommand.indentList -> Pair(isInList && state.commands["indentList"] == true, false)
|
|
605
|
+
ToolbarCommand.outdentList -> Pair(isInList && state.commands["outdentList"] == true, false)
|
|
606
|
+
ToolbarCommand.undo -> Pair(state.canUndo, false)
|
|
607
|
+
ToolbarCommand.redo -> Pair(state.canRedo, false)
|
|
608
|
+
null -> Pair(false, false)
|
|
609
|
+
}
|
|
610
|
+
ToolbarItemKind.node -> {
|
|
611
|
+
val nodeType = item.nodeType.orEmpty()
|
|
612
|
+
Pair(state.insertableNodes.contains(nodeType), state.nodes[nodeType] == true)
|
|
613
|
+
}
|
|
614
|
+
ToolbarItemKind.action -> Pair(!item.isDisabled, item.isActive)
|
|
615
|
+
ToolbarItemKind.separator -> Pair(false, false)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private fun dp(value: Int): Int = (value * density).toInt()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private class MentionSuggestionChipView(
|
|
623
|
+
context: Context,
|
|
624
|
+
val suggestion: NativeMentionSuggestion
|
|
625
|
+
) : LinearLayout(context) {
|
|
626
|
+
private val titleView = AppCompatTextView(context)
|
|
627
|
+
private val subtitleView = AppCompatTextView(context)
|
|
628
|
+
private var theme: EditorMentionTheme? = null
|
|
629
|
+
private val density = resources.displayMetrics.density
|
|
630
|
+
|
|
631
|
+
init {
|
|
632
|
+
orientation = VERTICAL
|
|
633
|
+
gravity = Gravity.CENTER_VERTICAL
|
|
634
|
+
minimumHeight = dp(40)
|
|
635
|
+
setPadding(dp(12), dp(8), dp(12), dp(8))
|
|
636
|
+
isClickable = true
|
|
637
|
+
isFocusable = true
|
|
638
|
+
|
|
639
|
+
titleView.apply {
|
|
640
|
+
text = suggestion.label
|
|
641
|
+
setTypeface(typeface, Typeface.BOLD)
|
|
642
|
+
textSize = 14f
|
|
643
|
+
includeFontPadding = false
|
|
644
|
+
}
|
|
645
|
+
addView(
|
|
646
|
+
titleView,
|
|
647
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
subtitleView.apply {
|
|
651
|
+
text = suggestion.subtitle
|
|
652
|
+
textSize = 12f
|
|
653
|
+
includeFontPadding = false
|
|
654
|
+
visibility = if (suggestion.subtitle.isNullOrBlank()) View.GONE else View.VISIBLE
|
|
655
|
+
}
|
|
656
|
+
addView(
|
|
657
|
+
subtitleView,
|
|
658
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
setOnTouchListener { _, motionEvent ->
|
|
662
|
+
when (motionEvent.actionMasked) {
|
|
663
|
+
android.view.MotionEvent.ACTION_DOWN,
|
|
664
|
+
android.view.MotionEvent.ACTION_MOVE -> updateAppearance(highlighted = true)
|
|
665
|
+
android.view.MotionEvent.ACTION_CANCEL,
|
|
666
|
+
android.view.MotionEvent.ACTION_UP -> updateAppearance(highlighted = false)
|
|
667
|
+
}
|
|
668
|
+
false
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
applyTheme(null)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fun applyTheme(theme: EditorMentionTheme?) {
|
|
675
|
+
this.theme = theme
|
|
676
|
+
val hasSubtitle = !suggestion.subtitle.isNullOrBlank()
|
|
677
|
+
subtitleView.visibility = if (hasSubtitle) View.VISIBLE else View.GONE
|
|
678
|
+
background = GradientDrawable().apply {
|
|
679
|
+
shape = GradientDrawable.RECTANGLE
|
|
680
|
+
cornerRadius = (theme?.borderRadius ?: 12f) * density
|
|
681
|
+
setColor(theme?.backgroundColor ?: Color.parseColor("#F2F2F7"))
|
|
682
|
+
val strokeWidth = ((theme?.borderWidth ?: 0f) * density).toInt()
|
|
683
|
+
if (strokeWidth > 0) {
|
|
684
|
+
setStroke(strokeWidth, theme?.borderColor ?: Color.TRANSPARENT)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
updateAppearance(highlighted = false)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private fun updateAppearance(highlighted: Boolean) {
|
|
691
|
+
val backgroundDrawable = background as? GradientDrawable
|
|
692
|
+
val backgroundColor = if (highlighted) {
|
|
693
|
+
theme?.optionHighlightedBackgroundColor ?: Color.parseColor("#1F007AFF")
|
|
694
|
+
} else {
|
|
695
|
+
theme?.backgroundColor ?: Color.parseColor("#F2F2F7")
|
|
696
|
+
}
|
|
697
|
+
backgroundDrawable?.setColor(backgroundColor)
|
|
698
|
+
titleView.setTextColor(
|
|
699
|
+
if (highlighted) {
|
|
700
|
+
theme?.optionHighlightedTextColor ?: theme?.optionTextColor ?: Color.BLACK
|
|
701
|
+
} else {
|
|
702
|
+
theme?.optionTextColor ?: theme?.textColor ?: Color.BLACK
|
|
703
|
+
}
|
|
704
|
+
)
|
|
705
|
+
subtitleView.setTextColor(theme?.optionSecondaryTextColor ?: Color.DKGRAY)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private fun dp(value: Int): Int = (value * density).toInt()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private fun JSONObject.optNullableString(key: String): String? {
|
|
712
|
+
if (!has(key) || isNull(key)) return null
|
|
713
|
+
return optString(key).takeUnless { it == "null" }
|
|
714
|
+
}
|