@openeditor/react-native-prose-editor 0.0.2
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 +180 -0
- package/android/build.gradle +69 -0
- package/android/consumer-rules.pro +18 -0
- package/android/src/androidTest/AndroidManifest.xml +8 -0
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -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/CaretGeometry.kt +50 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
- package/app.plugin.js +62 -0
- package/dist/EditorTheme.d.ts +113 -0
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +191 -0
- package/dist/EditorToolbar.js +1095 -0
- package/dist/NativeEditorBridge.d.ts +325 -0
- package/dist/NativeEditorBridge.js +933 -0
- package/dist/NativeProseViewer.d.ts +53 -0
- package/dist/NativeProseViewer.js +480 -0
- package/dist/NativeRichTextEditor.d.ts +183 -0
- package/dist/NativeRichTextEditor.js +2574 -0
- package/dist/YjsCollaboration.d.ts +90 -0
- package/dist/YjsCollaboration.js +743 -0
- package/dist/addons.d.ts +100 -0
- package/dist/addons.js +82 -0
- package/dist/heightCache.d.ts +6 -0
- package/dist/heightCache.js +45 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +30 -0
- package/dist/schemas.d.ts +37 -0
- package/dist/schemas.js +263 -0
- package/dist/useNativeEditor.d.ts +42 -0
- package/dist/useNativeEditor.js +124 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +327 -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 +774 -0
- package/ios/EditorTheme.swift +515 -0
- package/ios/Generated_editor_core.swift +1513 -0
- package/ios/NativeEditorExpoView.swift +3593 -0
- package/ios/NativeEditorModule.swift +624 -0
- package/ios/NativeProseViewerExpoView.swift +276 -0
- package/ios/PositionBridge.swift +558 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +1708 -0
- package/ios/RichTextEditorView.swift +6150 -0
- package/ios/Tests/NativePerformanceTests.swift +687 -0
- package/ios/Tests/PositionBridgeTests.swift +706 -0
- package/ios/Tests/RenderBridgeTests.swift +2236 -0
- package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1289 -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 +74 -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 +2563 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
package com.openeditor.editor
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.Typeface
|
|
7
|
+
import android.graphics.drawable.GradientDrawable
|
|
8
|
+
import android.util.TypedValue
|
|
9
|
+
import android.view.Gravity
|
|
10
|
+
import android.view.View
|
|
11
|
+
import android.view.ViewOutlineProvider
|
|
12
|
+
import android.widget.HorizontalScrollView
|
|
13
|
+
import android.widget.LinearLayout
|
|
14
|
+
import androidx.appcompat.R as AppCompatR
|
|
15
|
+
import androidx.appcompat.widget.AppCompatButton
|
|
16
|
+
import androidx.appcompat.widget.PopupMenu
|
|
17
|
+
import androidx.appcompat.widget.AppCompatTextView
|
|
18
|
+
import androidx.appcompat.content.res.AppCompatResources
|
|
19
|
+
import androidx.core.view.setPadding
|
|
20
|
+
import com.google.android.material.R as MaterialR
|
|
21
|
+
import com.google.android.material.color.DynamicColors
|
|
22
|
+
import org.json.JSONObject
|
|
23
|
+
import kotlin.math.roundToInt
|
|
24
|
+
|
|
25
|
+
internal data class NativeToolbarState(
|
|
26
|
+
val marks: Map<String, Boolean>,
|
|
27
|
+
val nodes: Map<String, Boolean>,
|
|
28
|
+
val commands: Map<String, Boolean>,
|
|
29
|
+
val allowedMarks: Set<String>,
|
|
30
|
+
val insertableNodes: Set<String>,
|
|
31
|
+
val canUndo: Boolean,
|
|
32
|
+
val canRedo: Boolean
|
|
33
|
+
) {
|
|
34
|
+
companion object {
|
|
35
|
+
val empty = NativeToolbarState(
|
|
36
|
+
marks = emptyMap(),
|
|
37
|
+
nodes = emptyMap(),
|
|
38
|
+
commands = emptyMap(),
|
|
39
|
+
allowedMarks = emptySet(),
|
|
40
|
+
insertableNodes = emptySet(),
|
|
41
|
+
canUndo = false,
|
|
42
|
+
canRedo = false
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
fun fromUpdateJson(updateJson: String): NativeToolbarState? {
|
|
46
|
+
val root = try {
|
|
47
|
+
JSONObject(updateJson)
|
|
48
|
+
} catch (_: Exception) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
val activeState = root.optJSONObject("activeState") ?: JSONObject()
|
|
52
|
+
val historyState = root.optJSONObject("historyState") ?: JSONObject()
|
|
53
|
+
return NativeToolbarState(
|
|
54
|
+
marks = boolMap(activeState.optJSONObject("marks")),
|
|
55
|
+
nodes = boolMap(activeState.optJSONObject("nodes")),
|
|
56
|
+
commands = boolMap(activeState.optJSONObject("commands")),
|
|
57
|
+
allowedMarks = stringSet(activeState.optJSONArray("allowedMarks")),
|
|
58
|
+
insertableNodes = stringSet(activeState.optJSONArray("insertableNodes")),
|
|
59
|
+
canUndo = historyState.optBoolean("canUndo", false),
|
|
60
|
+
canRedo = historyState.optBoolean("canRedo", false)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private fun boolMap(json: JSONObject?): Map<String, Boolean> {
|
|
65
|
+
json ?: return emptyMap()
|
|
66
|
+
val result = mutableMapOf<String, Boolean>()
|
|
67
|
+
val keys = json.keys()
|
|
68
|
+
while (keys.hasNext()) {
|
|
69
|
+
val key = keys.next()
|
|
70
|
+
result[key] = json.optBoolean(key, false)
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fun stringSet(array: org.json.JSONArray?): Set<String> {
|
|
76
|
+
array ?: return emptySet()
|
|
77
|
+
val result = linkedSetOf<String>()
|
|
78
|
+
for (index in 0 until array.length()) {
|
|
79
|
+
array.optString(index, null)?.let { result.add(it) }
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
internal enum class ToolbarCommand {
|
|
87
|
+
indentList,
|
|
88
|
+
outdentList,
|
|
89
|
+
undo,
|
|
90
|
+
redo,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
internal enum class ToolbarListType {
|
|
94
|
+
bulletList,
|
|
95
|
+
orderedList,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
internal enum class ToolbarDefaultIconId {
|
|
99
|
+
bold,
|
|
100
|
+
italic,
|
|
101
|
+
underline,
|
|
102
|
+
strike,
|
|
103
|
+
link,
|
|
104
|
+
image,
|
|
105
|
+
h1,
|
|
106
|
+
h2,
|
|
107
|
+
h3,
|
|
108
|
+
h4,
|
|
109
|
+
h5,
|
|
110
|
+
h6,
|
|
111
|
+
blockquote,
|
|
112
|
+
bulletList,
|
|
113
|
+
orderedList,
|
|
114
|
+
indentList,
|
|
115
|
+
outdentList,
|
|
116
|
+
lineBreak,
|
|
117
|
+
horizontalRule,
|
|
118
|
+
undo,
|
|
119
|
+
redo,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
internal enum class ToolbarItemKind {
|
|
123
|
+
mark,
|
|
124
|
+
heading,
|
|
125
|
+
blockquote,
|
|
126
|
+
list,
|
|
127
|
+
command,
|
|
128
|
+
node,
|
|
129
|
+
action,
|
|
130
|
+
group,
|
|
131
|
+
separator,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
internal enum class ToolbarGroupPresentation {
|
|
135
|
+
expand,
|
|
136
|
+
menu,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
internal enum class ToolbarItemPlacement {
|
|
140
|
+
start,
|
|
141
|
+
scroll,
|
|
142
|
+
end,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
internal data class NativeToolbarIcon(
|
|
146
|
+
val defaultId: ToolbarDefaultIconId? = null,
|
|
147
|
+
val glyphText: String? = null,
|
|
148
|
+
val fallbackText: String? = null,
|
|
149
|
+
val materialIconName: String? = null
|
|
150
|
+
) {
|
|
151
|
+
companion object {
|
|
152
|
+
private val defaultGlyphs = mapOf(
|
|
153
|
+
ToolbarDefaultIconId.bold to "B",
|
|
154
|
+
ToolbarDefaultIconId.italic to "I",
|
|
155
|
+
ToolbarDefaultIconId.underline to "U",
|
|
156
|
+
ToolbarDefaultIconId.strike to "S",
|
|
157
|
+
ToolbarDefaultIconId.link to "🔗",
|
|
158
|
+
ToolbarDefaultIconId.image to "🖼",
|
|
159
|
+
ToolbarDefaultIconId.h1 to "H1",
|
|
160
|
+
ToolbarDefaultIconId.h2 to "H2",
|
|
161
|
+
ToolbarDefaultIconId.h3 to "H3",
|
|
162
|
+
ToolbarDefaultIconId.h4 to "H4",
|
|
163
|
+
ToolbarDefaultIconId.h5 to "H5",
|
|
164
|
+
ToolbarDefaultIconId.h6 to "H6",
|
|
165
|
+
ToolbarDefaultIconId.blockquote to "❝",
|
|
166
|
+
ToolbarDefaultIconId.bulletList to "•≡",
|
|
167
|
+
ToolbarDefaultIconId.orderedList to "1.",
|
|
168
|
+
ToolbarDefaultIconId.indentList to "→",
|
|
169
|
+
ToolbarDefaultIconId.outdentList to "←",
|
|
170
|
+
ToolbarDefaultIconId.lineBreak to "↵",
|
|
171
|
+
ToolbarDefaultIconId.horizontalRule to "—",
|
|
172
|
+
ToolbarDefaultIconId.undo to "↩",
|
|
173
|
+
ToolbarDefaultIconId.redo to "↪"
|
|
174
|
+
)
|
|
175
|
+
private val defaultMaterialIcons = mapOf(
|
|
176
|
+
ToolbarDefaultIconId.bold to "format-bold",
|
|
177
|
+
ToolbarDefaultIconId.italic to "format-italic",
|
|
178
|
+
ToolbarDefaultIconId.underline to "format-underlined",
|
|
179
|
+
ToolbarDefaultIconId.strike to "strikethrough-s",
|
|
180
|
+
ToolbarDefaultIconId.link to "link",
|
|
181
|
+
ToolbarDefaultIconId.image to "image",
|
|
182
|
+
ToolbarDefaultIconId.blockquote to "format-quote",
|
|
183
|
+
ToolbarDefaultIconId.bulletList to "format-list-bulleted",
|
|
184
|
+
ToolbarDefaultIconId.orderedList to "format-list-numbered",
|
|
185
|
+
ToolbarDefaultIconId.indentList to "format-indent-increase",
|
|
186
|
+
ToolbarDefaultIconId.outdentList to "format-indent-decrease",
|
|
187
|
+
ToolbarDefaultIconId.lineBreak to "keyboard-return",
|
|
188
|
+
ToolbarDefaultIconId.horizontalRule to "horizontal-rule",
|
|
189
|
+
ToolbarDefaultIconId.h1 to "title",
|
|
190
|
+
ToolbarDefaultIconId.h2 to "title",
|
|
191
|
+
ToolbarDefaultIconId.h3 to "title",
|
|
192
|
+
ToolbarDefaultIconId.h4 to "title",
|
|
193
|
+
ToolbarDefaultIconId.h5 to "title",
|
|
194
|
+
ToolbarDefaultIconId.h6 to "title",
|
|
195
|
+
ToolbarDefaultIconId.undo to "undo",
|
|
196
|
+
ToolbarDefaultIconId.redo to "redo"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
fun fromJson(raw: JSONObject?): NativeToolbarIcon? {
|
|
200
|
+
raw ?: return null
|
|
201
|
+
return when (raw.optString("type")) {
|
|
202
|
+
"default" -> {
|
|
203
|
+
val id = runCatching {
|
|
204
|
+
ToolbarDefaultIconId.valueOf(raw.getString("id"))
|
|
205
|
+
}.getOrNull() ?: return null
|
|
206
|
+
NativeToolbarIcon(defaultId = id)
|
|
207
|
+
}
|
|
208
|
+
"glyph" -> {
|
|
209
|
+
val text = raw.optString("text")
|
|
210
|
+
if (text.isBlank()) null else NativeToolbarIcon(glyphText = text)
|
|
211
|
+
}
|
|
212
|
+
"platform" -> {
|
|
213
|
+
val materialName = raw.optJSONObject("android")
|
|
214
|
+
?.takeIf { it.optString("type") == "material" }
|
|
215
|
+
?.optNullableString("name")
|
|
216
|
+
val fallback = raw.optNullableString("fallbackText")
|
|
217
|
+
if (materialName.isNullOrBlank() && fallback.isNullOrBlank()) {
|
|
218
|
+
null
|
|
219
|
+
} else {
|
|
220
|
+
NativeToolbarIcon(
|
|
221
|
+
fallbackText = fallback,
|
|
222
|
+
materialIconName = materialName
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else -> null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fun defaultMaterialIconName(defaultId: ToolbarDefaultIconId?): String? =
|
|
231
|
+
defaultId?.let { defaultMaterialIcons[it] }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fun resolvedGlyphText(): String =
|
|
235
|
+
glyphText?.takeIf { it.isNotBlank() }
|
|
236
|
+
?: fallbackText?.takeIf { it.isNotBlank() }
|
|
237
|
+
?: defaultId?.let { defaultGlyphs[it] }
|
|
238
|
+
?: "?"
|
|
239
|
+
|
|
240
|
+
fun resolvedMaterialIconName(): String? =
|
|
241
|
+
materialIconName?.takeIf { it.isNotBlank() }
|
|
242
|
+
?: Companion.defaultMaterialIconName(defaultId)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
internal object MaterialIconRegistry {
|
|
246
|
+
private const val FONT_ASSET_PATH = "editor-icons/MaterialIcons.ttf"
|
|
247
|
+
private const val GLYPHMAP_ASSET_PATH = "editor-icons/MaterialIcons.json"
|
|
248
|
+
|
|
249
|
+
@Volatile
|
|
250
|
+
private var typeface: Typeface? = null
|
|
251
|
+
|
|
252
|
+
@Volatile
|
|
253
|
+
private var glyphMap: Map<String, String>? = null
|
|
254
|
+
|
|
255
|
+
fun typeface(context: Context): Typeface? {
|
|
256
|
+
val cached = typeface
|
|
257
|
+
if (cached != null) return cached
|
|
258
|
+
return runCatching {
|
|
259
|
+
Typeface.createFromAsset(context.assets, FONT_ASSET_PATH)
|
|
260
|
+
}.getOrNull()?.also { loaded ->
|
|
261
|
+
typeface = loaded
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fun glyphForName(context: Context, name: String?): String? {
|
|
266
|
+
if (name.isNullOrBlank()) return null
|
|
267
|
+
val map = glyphMap ?: loadGlyphMap(context).also { loaded ->
|
|
268
|
+
glyphMap = loaded
|
|
269
|
+
}
|
|
270
|
+
return map[name]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private fun loadGlyphMap(context: Context): Map<String, String> {
|
|
274
|
+
val assetText = runCatching {
|
|
275
|
+
context.assets.open(GLYPHMAP_ASSET_PATH).bufferedReader().use { it.readText() }
|
|
276
|
+
}.getOrNull() ?: return emptyMap()
|
|
277
|
+
|
|
278
|
+
val json = runCatching { JSONObject(assetText) }.getOrNull() ?: return emptyMap()
|
|
279
|
+
val result = linkedMapOf<String, String>()
|
|
280
|
+
val keys = json.keys()
|
|
281
|
+
while (keys.hasNext()) {
|
|
282
|
+
val key = keys.next()
|
|
283
|
+
val codePoint = json.optInt(key, -1)
|
|
284
|
+
if (codePoint > 0) {
|
|
285
|
+
result[key] = String(Character.toChars(codePoint))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return result
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
internal data class NativeToolbarResolvedIcon(
|
|
293
|
+
val text: String,
|
|
294
|
+
val typeface: Typeface? = null
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
private fun NativeToolbarIcon.resolveForAndroid(context: Context): NativeToolbarResolvedIcon {
|
|
298
|
+
val materialName = resolvedMaterialIconName()
|
|
299
|
+
val materialGlyph = MaterialIconRegistry.glyphForName(context, materialName)
|
|
300
|
+
val materialTypeface = MaterialIconRegistry.typeface(context)
|
|
301
|
+
if (materialGlyph != null && materialTypeface != null) {
|
|
302
|
+
return NativeToolbarResolvedIcon(
|
|
303
|
+
text = materialGlyph,
|
|
304
|
+
typeface = materialTypeface
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return NativeToolbarResolvedIcon(
|
|
309
|
+
text = resolvedGlyphText(),
|
|
310
|
+
typeface = null
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
internal data class NativeToolbarItem(
|
|
315
|
+
val type: ToolbarItemKind,
|
|
316
|
+
val key: String? = null,
|
|
317
|
+
val label: String? = null,
|
|
318
|
+
val icon: NativeToolbarIcon? = null,
|
|
319
|
+
val mark: String? = null,
|
|
320
|
+
val headingLevel: Int? = null,
|
|
321
|
+
val listType: ToolbarListType? = null,
|
|
322
|
+
val command: ToolbarCommand? = null,
|
|
323
|
+
val nodeType: String? = null,
|
|
324
|
+
val isActive: Boolean = false,
|
|
325
|
+
val isDisabled: Boolean = false,
|
|
326
|
+
val placement: ToolbarItemPlacement? = null,
|
|
327
|
+
val presentation: ToolbarGroupPresentation? = null,
|
|
328
|
+
val items: List<NativeToolbarItem> = emptyList(),
|
|
329
|
+
val parentGroupKey: String? = null
|
|
330
|
+
) {
|
|
331
|
+
companion object {
|
|
332
|
+
val defaults = listOf(
|
|
333
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Bold", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bold), mark = "bold"),
|
|
334
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Italic", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.italic), mark = "italic"),
|
|
335
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Underline", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.underline), mark = "underline"),
|
|
336
|
+
NativeToolbarItem(ToolbarItemKind.mark, label = "Strikethrough", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.strike), mark = "strike"),
|
|
337
|
+
NativeToolbarItem(ToolbarItemKind.blockquote, label = "Blockquote", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.blockquote)),
|
|
338
|
+
NativeToolbarItem(ToolbarItemKind.separator),
|
|
339
|
+
NativeToolbarItem(ToolbarItemKind.list, label = "Bullet List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bulletList), listType = ToolbarListType.bulletList),
|
|
340
|
+
NativeToolbarItem(ToolbarItemKind.list, label = "Ordered List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.orderedList), listType = ToolbarListType.orderedList),
|
|
341
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Indent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.indentList), command = ToolbarCommand.indentList),
|
|
342
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Outdent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.outdentList), command = ToolbarCommand.outdentList),
|
|
343
|
+
NativeToolbarItem(ToolbarItemKind.node, label = "Line Break", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.lineBreak), nodeType = "hardBreak"),
|
|
344
|
+
NativeToolbarItem(ToolbarItemKind.node, label = "Horizontal Rule", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.horizontalRule), nodeType = "horizontalRule"),
|
|
345
|
+
NativeToolbarItem(ToolbarItemKind.separator),
|
|
346
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Undo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.undo), command = ToolbarCommand.undo),
|
|
347
|
+
NativeToolbarItem(ToolbarItemKind.command, label = "Redo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.redo), command = ToolbarCommand.redo)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
private fun parseItem(
|
|
351
|
+
rawItem: JSONObject,
|
|
352
|
+
allowGroup: Boolean = true,
|
|
353
|
+
allowSeparator: Boolean = true
|
|
354
|
+
): NativeToolbarItem? {
|
|
355
|
+
val type = runCatching {
|
|
356
|
+
ToolbarItemKind.valueOf(rawItem.getString("type"))
|
|
357
|
+
}.getOrNull() ?: return null
|
|
358
|
+
val key = rawItem.optNullableString("key")
|
|
359
|
+
return when (type) {
|
|
360
|
+
ToolbarItemKind.separator -> {
|
|
361
|
+
if (!allowSeparator) {
|
|
362
|
+
null
|
|
363
|
+
} else {
|
|
364
|
+
NativeToolbarItem(
|
|
365
|
+
type = type,
|
|
366
|
+
key = key,
|
|
367
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
368
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
ToolbarItemKind.mark -> {
|
|
374
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
375
|
+
val mark = rawItem.optNullableString("mark") ?: return null
|
|
376
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
377
|
+
NativeToolbarItem(
|
|
378
|
+
type,
|
|
379
|
+
key,
|
|
380
|
+
label,
|
|
381
|
+
icon,
|
|
382
|
+
mark = mark,
|
|
383
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
384
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
ToolbarItemKind.heading -> {
|
|
389
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
390
|
+
val level = rawItem.optInt("level", -1)
|
|
391
|
+
if (level !in 1..6) return null
|
|
392
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
393
|
+
NativeToolbarItem(
|
|
394
|
+
type,
|
|
395
|
+
key,
|
|
396
|
+
label,
|
|
397
|
+
icon,
|
|
398
|
+
headingLevel = level,
|
|
399
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
400
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
ToolbarItemKind.blockquote -> {
|
|
405
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
406
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
407
|
+
NativeToolbarItem(
|
|
408
|
+
type,
|
|
409
|
+
key,
|
|
410
|
+
label,
|
|
411
|
+
icon,
|
|
412
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
413
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
ToolbarItemKind.list -> {
|
|
418
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
419
|
+
val listType = runCatching {
|
|
420
|
+
ToolbarListType.valueOf(rawItem.getString("listType"))
|
|
421
|
+
}.getOrNull() ?: return null
|
|
422
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
423
|
+
NativeToolbarItem(
|
|
424
|
+
type,
|
|
425
|
+
key,
|
|
426
|
+
label,
|
|
427
|
+
icon,
|
|
428
|
+
listType = listType,
|
|
429
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
430
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
ToolbarItemKind.command -> {
|
|
435
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
436
|
+
val command = runCatching {
|
|
437
|
+
ToolbarCommand.valueOf(rawItem.getString("command"))
|
|
438
|
+
}.getOrNull() ?: return null
|
|
439
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
440
|
+
NativeToolbarItem(
|
|
441
|
+
type,
|
|
442
|
+
key,
|
|
443
|
+
label,
|
|
444
|
+
icon,
|
|
445
|
+
command = command,
|
|
446
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
447
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
ToolbarItemKind.node -> {
|
|
452
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
453
|
+
val nodeType = rawItem.optNullableString("nodeType") ?: return null
|
|
454
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
455
|
+
NativeToolbarItem(
|
|
456
|
+
type,
|
|
457
|
+
key,
|
|
458
|
+
label,
|
|
459
|
+
icon,
|
|
460
|
+
nodeType = nodeType,
|
|
461
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
462
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
ToolbarItemKind.action -> {
|
|
467
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
468
|
+
val keyValue = rawItem.optNullableString("key") ?: return null
|
|
469
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
470
|
+
NativeToolbarItem(
|
|
471
|
+
type = type,
|
|
472
|
+
key = keyValue,
|
|
473
|
+
label = label,
|
|
474
|
+
icon = icon,
|
|
475
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
476
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
477
|
+
},
|
|
478
|
+
isActive = rawItem.optBoolean("isActive", false),
|
|
479
|
+
isDisabled = rawItem.optBoolean("isDisabled", false)
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
ToolbarItemKind.group -> {
|
|
483
|
+
if (!allowGroup) return null
|
|
484
|
+
val keyValue = rawItem.optNullableString("key") ?: return null
|
|
485
|
+
val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
|
|
486
|
+
val label = rawItem.optNullableString("label") ?: return null
|
|
487
|
+
val presentation = rawItem.optNullableString("presentation")?.let {
|
|
488
|
+
runCatching { ToolbarGroupPresentation.valueOf(it) }.getOrNull()
|
|
489
|
+
} ?: ToolbarGroupPresentation.expand
|
|
490
|
+
val rawChildren = rawItem.optJSONArray("items") ?: return null
|
|
491
|
+
val children = mutableListOf<NativeToolbarItem>()
|
|
492
|
+
for (childIndex in 0 until rawChildren.length()) {
|
|
493
|
+
val rawChild = rawChildren.optJSONObject(childIndex) ?: continue
|
|
494
|
+
parseItem(rawChild, allowGroup = false, allowSeparator = false)?.let {
|
|
495
|
+
children += it
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (children.isEmpty()) return null
|
|
499
|
+
NativeToolbarItem(
|
|
500
|
+
type = type,
|
|
501
|
+
key = keyValue,
|
|
502
|
+
label = label,
|
|
503
|
+
icon = icon,
|
|
504
|
+
placement = rawItem.optNullableString("placement")?.let {
|
|
505
|
+
runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
|
|
506
|
+
},
|
|
507
|
+
presentation = presentation,
|
|
508
|
+
items = children
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
fun fromJson(json: String?): List<NativeToolbarItem> {
|
|
515
|
+
if (json.isNullOrBlank()) return defaults
|
|
516
|
+
val rawArray = try {
|
|
517
|
+
org.json.JSONArray(json)
|
|
518
|
+
} catch (_: Exception) {
|
|
519
|
+
return defaults
|
|
520
|
+
}
|
|
521
|
+
val parsed = mutableListOf<NativeToolbarItem>()
|
|
522
|
+
for (index in 0 until rawArray.length()) {
|
|
523
|
+
val rawItem = rawArray.optJSONObject(index) ?: continue
|
|
524
|
+
parseItem(rawItem)?.let { parsed += it }
|
|
525
|
+
}
|
|
526
|
+
return parsed.ifEmpty { defaults }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollView(context) {
|
|
532
|
+
private companion object {
|
|
533
|
+
private const val NATIVE_CONTAINER_HEIGHT_DP = 64
|
|
534
|
+
private const val NATIVE_CONTAINER_HORIZONTAL_PADDING_DP = 16
|
|
535
|
+
private const val NATIVE_CONTAINER_VERTICAL_PADDING_DP = 12
|
|
536
|
+
private const val NATIVE_BUTTON_SIZE_DP = 40
|
|
537
|
+
private const val NATIVE_BUTTON_ICON_SIZE_SP = 24f
|
|
538
|
+
private const val NATIVE_ITEM_SPACING_DP = 8
|
|
539
|
+
private const val NATIVE_GROUP_SPACING_DP = 12
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private data class ButtonBinding(
|
|
543
|
+
val item: NativeToolbarItem,
|
|
544
|
+
val button: AppCompatButton
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
var onPressItem: ((NativeToolbarItem) -> Unit)? = null
|
|
548
|
+
var onSelectMentionSuggestion: ((NativeMentionSuggestion) -> Unit)? = null
|
|
549
|
+
|
|
550
|
+
private val themedContext: Context = DynamicColors.wrapContextIfAvailable(context)
|
|
551
|
+
private val rootRow = LinearLayout(context)
|
|
552
|
+
private val startRow = LinearLayout(context)
|
|
553
|
+
private val centerScrollView = HorizontalScrollView(context)
|
|
554
|
+
private val contentRow = LinearLayout(context)
|
|
555
|
+
private val endRow = LinearLayout(context)
|
|
556
|
+
private var theme: EditorToolbarTheme? = null
|
|
557
|
+
private var mentionTheme: EditorMentionTheme? = null
|
|
558
|
+
private var state: NativeToolbarState = NativeToolbarState.empty
|
|
559
|
+
private var items: List<NativeToolbarItem> = NativeToolbarItem.defaults
|
|
560
|
+
private var mentionSuggestions: List<NativeMentionSuggestion> = emptyList()
|
|
561
|
+
private var expandedGroupKey: String? = null
|
|
562
|
+
private var rebuildGeneration: Int = 0
|
|
563
|
+
private val bindings = mutableListOf<ButtonBinding>()
|
|
564
|
+
private val separators = mutableListOf<View>()
|
|
565
|
+
private val mentionChips = mutableListOf<MentionSuggestionChipView>()
|
|
566
|
+
private val buttonBackgroundColors = mutableMapOf<AppCompatButton, Int>()
|
|
567
|
+
private val density = resources.displayMetrics.density
|
|
568
|
+
internal var appliedAppearance: EditorToolbarAppearance = EditorToolbarAppearance.CUSTOM
|
|
569
|
+
private set
|
|
570
|
+
internal var appliedChromeCornerRadiusPx: Float = 0f
|
|
571
|
+
private set
|
|
572
|
+
internal var appliedChromeStrokeWidthPx: Int = 0
|
|
573
|
+
private set
|
|
574
|
+
internal var appliedChromeElevationPx: Float = 0f
|
|
575
|
+
private set
|
|
576
|
+
internal var appliedChromeColor: Int = Color.TRANSPARENT
|
|
577
|
+
private set
|
|
578
|
+
internal var appliedButtonCornerRadiusPx: Float = 0f
|
|
579
|
+
private set
|
|
580
|
+
val isShowingMentionSuggestions: Boolean
|
|
581
|
+
get() = mentionSuggestions.isNotEmpty()
|
|
582
|
+
|
|
583
|
+
init {
|
|
584
|
+
isHorizontalScrollBarEnabled = false
|
|
585
|
+
overScrollMode = OVER_SCROLL_NEVER
|
|
586
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
587
|
+
clipToPadding = false
|
|
588
|
+
clipChildren = false
|
|
589
|
+
isFillViewport = true
|
|
590
|
+
|
|
591
|
+
rootRow.orientation = LinearLayout.HORIZONTAL
|
|
592
|
+
rootRow.gravity = Gravity.CENTER_VERTICAL
|
|
593
|
+
rootRow.clipToPadding = false
|
|
594
|
+
rootRow.clipChildren = false
|
|
595
|
+
startRow.orientation = LinearLayout.HORIZONTAL
|
|
596
|
+
startRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
|
|
597
|
+
endRow.orientation = LinearLayout.HORIZONTAL
|
|
598
|
+
endRow.gravity = Gravity.END or Gravity.CENTER_VERTICAL
|
|
599
|
+
centerScrollView.isHorizontalScrollBarEnabled = false
|
|
600
|
+
centerScrollView.overScrollMode = OVER_SCROLL_NEVER
|
|
601
|
+
centerScrollView.clipToPadding = false
|
|
602
|
+
centerScrollView.clipChildren = false
|
|
603
|
+
contentRow.orientation = LinearLayout.HORIZONTAL
|
|
604
|
+
contentRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
|
|
605
|
+
contentRow.clipToPadding = false
|
|
606
|
+
contentRow.clipChildren = false
|
|
607
|
+
centerScrollView.addView(
|
|
608
|
+
contentRow,
|
|
609
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
610
|
+
)
|
|
611
|
+
rootRow.addView(
|
|
612
|
+
startRow,
|
|
613
|
+
LinearLayout.LayoutParams(
|
|
614
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
615
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
rootRow.addView(
|
|
619
|
+
centerScrollView,
|
|
620
|
+
LinearLayout.LayoutParams(
|
|
621
|
+
0,
|
|
622
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
623
|
+
1f
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
rootRow.addView(
|
|
627
|
+
endRow,
|
|
628
|
+
LinearLayout.LayoutParams(
|
|
629
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
630
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
addView(
|
|
634
|
+
rootRow,
|
|
635
|
+
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
|
636
|
+
)
|
|
637
|
+
rebuildContent(preserveScrollPosition = false)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
fun setItems(items: List<NativeToolbarItem>) {
|
|
641
|
+
this.items = compactItems(items)
|
|
642
|
+
if (expandedGroupKey != null && !containsExpandableGroup(this.items, expandedGroupKey)) {
|
|
643
|
+
expandedGroupKey = null
|
|
644
|
+
}
|
|
645
|
+
if (!isShowingMentionSuggestions) {
|
|
646
|
+
rebuildContent()
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
fun applyTheme(theme: EditorToolbarTheme?) {
|
|
651
|
+
this.theme = theme
|
|
652
|
+
updateChrome()
|
|
653
|
+
separators.forEach { separator ->
|
|
654
|
+
separator.setBackgroundColor(resolveSeparatorColor())
|
|
655
|
+
}
|
|
656
|
+
bindings.forEach { binding ->
|
|
657
|
+
updateButtonAppearance(
|
|
658
|
+
binding.button,
|
|
659
|
+
enabled = buttonState(binding.item, state).first,
|
|
660
|
+
active = buttonState(binding.item, state).second
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
mentionChips.forEach { chip ->
|
|
664
|
+
chip.applyTheme(mentionTheme, theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
fun applyMentionTheme(theme: EditorMentionTheme?) {
|
|
669
|
+
mentionTheme = theme
|
|
670
|
+
mentionChips.forEach { chip ->
|
|
671
|
+
chip.applyTheme(theme, this.theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
fun applyState(state: NativeToolbarState) {
|
|
676
|
+
this.state = state
|
|
677
|
+
bindings.forEach { binding ->
|
|
678
|
+
val (enabled, active) = buttonState(binding.item, state)
|
|
679
|
+
binding.button.isEnabled = enabled
|
|
680
|
+
binding.button.isSelected = active
|
|
681
|
+
updateButtonAppearance(binding.button, enabled, active)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
fun setMentionSuggestions(suggestions: List<NativeMentionSuggestion>): Boolean {
|
|
686
|
+
val hadSuggestions = isShowingMentionSuggestions
|
|
687
|
+
mentionSuggestions = suggestions.take(8)
|
|
688
|
+
rebuildContent(preserveScrollPosition = hadSuggestions == isShowingMentionSuggestions)
|
|
689
|
+
return hadSuggestions != isShowingMentionSuggestions
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
fun triggerMentionSuggestionTapForTesting(index: Int) {
|
|
693
|
+
mentionChips.getOrNull(index)?.performClick()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
internal fun buttonAtForTesting(index: Int): AppCompatButton? =
|
|
697
|
+
bindings.getOrNull(index)?.button
|
|
698
|
+
|
|
699
|
+
internal fun buttonCountForTesting(): Int = bindings.size
|
|
700
|
+
|
|
701
|
+
internal fun buttonBackgroundColorAtForTesting(index: Int): Int? =
|
|
702
|
+
bindings.getOrNull(index)?.button?.let { buttonBackgroundColors[it] }
|
|
703
|
+
|
|
704
|
+
internal fun mentionChipAtForTesting(index: Int): MentionSuggestionChipView? =
|
|
705
|
+
mentionChips.getOrNull(index)
|
|
706
|
+
|
|
707
|
+
internal fun separatorAtForTesting(index: Int): View? =
|
|
708
|
+
separators.getOrNull(index)
|
|
709
|
+
|
|
710
|
+
private fun rebuildContent(preserveScrollPosition: Boolean = true) {
|
|
711
|
+
val targetScrollX = if (preserveScrollPosition) centerScrollView.scrollX else 0
|
|
712
|
+
val generation = ++rebuildGeneration
|
|
713
|
+
bindings.clear()
|
|
714
|
+
separators.clear()
|
|
715
|
+
mentionChips.clear()
|
|
716
|
+
contentRow.removeAllViews()
|
|
717
|
+
startRow.removeAllViews()
|
|
718
|
+
endRow.removeAllViews()
|
|
719
|
+
|
|
720
|
+
if (isShowingMentionSuggestions) {
|
|
721
|
+
val visibleItems = visibleItemsByPlacement()
|
|
722
|
+
rebuildButtonPlacement(visibleItems.start, startRow)
|
|
723
|
+
rebuildMentionSuggestions()
|
|
724
|
+
rebuildButtonPlacement(visibleItems.end, endRow)
|
|
725
|
+
} else {
|
|
726
|
+
rebuildButtons()
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
updateChrome()
|
|
730
|
+
applyState(state)
|
|
731
|
+
post {
|
|
732
|
+
if (generation != rebuildGeneration) return@post
|
|
733
|
+
val contentWidth = contentRow.width
|
|
734
|
+
val viewportWidth = (centerScrollView.width - centerScrollView.paddingLeft - centerScrollView.paddingRight).coerceAtLeast(0)
|
|
735
|
+
val maxScrollX = (contentWidth - viewportWidth).coerceAtLeast(0)
|
|
736
|
+
centerScrollView.scrollTo(targetScrollX.coerceIn(0, maxScrollX), 0)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private fun rebuildButtons() {
|
|
741
|
+
val visibleItems = visibleItemsByPlacement()
|
|
742
|
+
rebuildButtonPlacement(visibleItems.start, startRow)
|
|
743
|
+
rebuildButtonPlacement(visibleItems.scroll, contentRow)
|
|
744
|
+
rebuildButtonPlacement(visibleItems.end, endRow)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private fun rebuildButtonPlacement(items: List<NativeToolbarItem>, container: LinearLayout) {
|
|
748
|
+
for (item in items) {
|
|
749
|
+
if (item.type == ToolbarItemKind.separator) {
|
|
750
|
+
val separator = View(context)
|
|
751
|
+
configureSeparator(separator)
|
|
752
|
+
separators.add(separator)
|
|
753
|
+
container.addView(separator)
|
|
754
|
+
continue
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
val button = AppCompatButton(themedContext).apply {
|
|
758
|
+
val resolvedIcon = item.icon?.resolveForAndroid(themedContext)
|
|
759
|
+
?: NativeToolbarResolvedIcon("?")
|
|
760
|
+
text = resolvedIcon.text
|
|
761
|
+
typeface = resolvedIcon.typeface ?: Typeface.DEFAULT
|
|
762
|
+
gravity = Gravity.CENTER
|
|
763
|
+
background = GradientDrawable()
|
|
764
|
+
isAllCaps = false
|
|
765
|
+
includeFontPadding = false
|
|
766
|
+
contentDescription = item.label
|
|
767
|
+
setOnClickListener {
|
|
768
|
+
when (item.type) {
|
|
769
|
+
ToolbarItemKind.group -> handleGroupButtonPress(this, item)
|
|
770
|
+
else -> {
|
|
771
|
+
onPressItem?.invoke(item.copy(parentGroupKey = null))
|
|
772
|
+
if (item.parentGroupKey != null && expandedGroupKey == item.parentGroupKey) {
|
|
773
|
+
expandedGroupKey = null
|
|
774
|
+
rebuildContent()
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
elevation = 0f
|
|
780
|
+
translationZ = 0f
|
|
781
|
+
stateListAnimator = null
|
|
782
|
+
}
|
|
783
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
784
|
+
button.foreground = resolveDrawableAttr(android.R.attr.selectableItemBackgroundBorderless)
|
|
785
|
+
}
|
|
786
|
+
val params = LinearLayout.LayoutParams(
|
|
787
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
788
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
789
|
+
)
|
|
790
|
+
button.layoutParams = params
|
|
791
|
+
applyButtonLayout(button, appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
|
|
792
|
+
bindings.add(ButtonBinding(item, button))
|
|
793
|
+
container.addView(button)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private fun rebuildMentionSuggestions() {
|
|
798
|
+
for (suggestion in mentionSuggestions) {
|
|
799
|
+
val chip = MentionSuggestionChipView(context, suggestion).apply {
|
|
800
|
+
applyTheme(mentionTheme, theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
|
|
801
|
+
setOnClickListener { onSelectMentionSuggestion?.invoke(suggestion) }
|
|
802
|
+
}
|
|
803
|
+
val params = LinearLayout.LayoutParams(
|
|
804
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
805
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
806
|
+
)
|
|
807
|
+
params.marginEnd = dp(8)
|
|
808
|
+
chip.layoutParams = params
|
|
809
|
+
mentionChips.add(chip)
|
|
810
|
+
contentRow.addView(chip)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private fun compactItems(items: List<NativeToolbarItem>): List<NativeToolbarItem> {
|
|
815
|
+
return items.filterIndexed { index, item ->
|
|
816
|
+
if (item.type != ToolbarItemKind.separator) return@filterIndexed true
|
|
817
|
+
index > 0 &&
|
|
818
|
+
index < items.lastIndex &&
|
|
819
|
+
items[index - 1].type != ToolbarItemKind.separator &&
|
|
820
|
+
items[index + 1].type != ToolbarItemKind.separator
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private fun visibleItems(): List<NativeToolbarItem> {
|
|
825
|
+
val visible = mutableListOf<NativeToolbarItem>()
|
|
826
|
+
for (item in compactItems(items)) {
|
|
827
|
+
visible += item
|
|
828
|
+
if (
|
|
829
|
+
item.type == ToolbarItemKind.group &&
|
|
830
|
+
(item.presentation ?: ToolbarGroupPresentation.expand) == ToolbarGroupPresentation.expand &&
|
|
831
|
+
expandedGroupKey == item.key
|
|
832
|
+
) {
|
|
833
|
+
visible += item.items.map { child ->
|
|
834
|
+
child.copy(parentGroupKey = item.key, placement = child.placement ?: item.placement)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return compactItems(visible)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private data class VisibleToolbarItemsByPlacement(
|
|
842
|
+
val start: List<NativeToolbarItem>,
|
|
843
|
+
val scroll: List<NativeToolbarItem>,
|
|
844
|
+
val end: List<NativeToolbarItem>
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
private fun visibleItemsByPlacement(): VisibleToolbarItemsByPlacement {
|
|
848
|
+
val start = mutableListOf<NativeToolbarItem>()
|
|
849
|
+
val scroll = mutableListOf<NativeToolbarItem>()
|
|
850
|
+
val end = mutableListOf<NativeToolbarItem>()
|
|
851
|
+
for (item in visibleItems()) {
|
|
852
|
+
when (item.placement ?: ToolbarItemPlacement.scroll) {
|
|
853
|
+
ToolbarItemPlacement.start -> start += item
|
|
854
|
+
ToolbarItemPlacement.end -> end += item
|
|
855
|
+
ToolbarItemPlacement.scroll -> scroll += item
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return VisibleToolbarItemsByPlacement(
|
|
859
|
+
start = compactItems(start),
|
|
860
|
+
scroll = compactItems(scroll),
|
|
861
|
+
end = compactItems(end)
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private fun containsExpandableGroup(items: List<NativeToolbarItem>, key: String?): Boolean {
|
|
866
|
+
key ?: return false
|
|
867
|
+
return items.any {
|
|
868
|
+
it.type == ToolbarItemKind.group &&
|
|
869
|
+
it.key == key &&
|
|
870
|
+
(it.presentation ?: ToolbarGroupPresentation.expand) == ToolbarGroupPresentation.expand
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private fun handleGroupButtonPress(anchor: View, item: NativeToolbarItem) {
|
|
875
|
+
if (item.items.isEmpty()) return
|
|
876
|
+
when (item.presentation ?: ToolbarGroupPresentation.expand) {
|
|
877
|
+
ToolbarGroupPresentation.expand -> {
|
|
878
|
+
val key = item.key ?: return
|
|
879
|
+
expandedGroupKey = if (expandedGroupKey == key) null else key
|
|
880
|
+
rebuildContent()
|
|
881
|
+
}
|
|
882
|
+
ToolbarGroupPresentation.menu -> showGroupMenu(anchor, item)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private fun showGroupMenu(anchor: View, item: NativeToolbarItem) {
|
|
887
|
+
val popupMenu = PopupMenu(themedContext, anchor)
|
|
888
|
+
item.items.forEachIndexed { index, child ->
|
|
889
|
+
val (enabled, active) = buttonState(child, state)
|
|
890
|
+
val menuItem = popupMenu.menu.add(0, index, index, child.label ?: child.key ?: "Item")
|
|
891
|
+
menuItem.isEnabled = enabled
|
|
892
|
+
menuItem.isCheckable = true
|
|
893
|
+
menuItem.isChecked = active
|
|
894
|
+
}
|
|
895
|
+
popupMenu.setOnMenuItemClickListener { menuItem ->
|
|
896
|
+
val child = item.items.getOrNull(menuItem.itemId) ?: return@setOnMenuItemClickListener false
|
|
897
|
+
onPressItem?.invoke(child)
|
|
898
|
+
true
|
|
899
|
+
}
|
|
900
|
+
popupMenu.show()
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private fun updateChrome() {
|
|
904
|
+
val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
|
|
905
|
+
val cornerRadiusPx = (theme?.resolvedBorderRadius() ?: 0f) * density
|
|
906
|
+
val strokeWidthPx = if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
907
|
+
0
|
|
908
|
+
} else {
|
|
909
|
+
((theme?.resolvedBorderWidth() ?: 1f) * density).roundToInt().coerceAtLeast(1)
|
|
910
|
+
}
|
|
911
|
+
val drawable = GradientDrawable().apply {
|
|
912
|
+
shape = GradientDrawable.RECTANGLE
|
|
913
|
+
cornerRadius = cornerRadiusPx
|
|
914
|
+
setColor(
|
|
915
|
+
if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
916
|
+
resolveColorAttr(
|
|
917
|
+
MaterialR.attr.colorSurfaceContainer,
|
|
918
|
+
MaterialR.attr.colorSurfaceContainerLow,
|
|
919
|
+
MaterialR.attr.colorSurface,
|
|
920
|
+
android.R.attr.colorBackground
|
|
921
|
+
)
|
|
922
|
+
} else {
|
|
923
|
+
theme?.backgroundColor ?: resolveColorAttr(
|
|
924
|
+
MaterialR.attr.colorSurface,
|
|
925
|
+
android.R.attr.colorBackground
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
if (strokeWidthPx > 0) {
|
|
930
|
+
setStroke(strokeWidthPx, theme?.borderColor ?: resolveSeparatorColor())
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
appliedAppearance = appearance
|
|
934
|
+
appliedChromeCornerRadiusPx = cornerRadiusPx
|
|
935
|
+
appliedChromeStrokeWidthPx = strokeWidthPx
|
|
936
|
+
appliedChromeElevationPx = 0f
|
|
937
|
+
appliedChromeColor = if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
938
|
+
resolveColorAttr(
|
|
939
|
+
MaterialR.attr.colorSurfaceContainer,
|
|
940
|
+
MaterialR.attr.colorSurfaceContainerLow,
|
|
941
|
+
MaterialR.attr.colorSurface,
|
|
942
|
+
android.R.attr.colorBackground
|
|
943
|
+
)
|
|
944
|
+
} else {
|
|
945
|
+
theme?.backgroundColor ?: resolveColorAttr(
|
|
946
|
+
MaterialR.attr.colorSurface,
|
|
947
|
+
android.R.attr.colorBackground
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
background = drawable
|
|
951
|
+
outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
952
|
+
clipToOutline = cornerRadiusPx > 0f
|
|
953
|
+
elevation = appliedChromeElevationPx
|
|
954
|
+
updateContainerLayout(appearance)
|
|
955
|
+
separators.forEach(::configureSeparator)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private fun updateButtonAppearance(button: AppCompatButton, enabled: Boolean, active: Boolean) {
|
|
959
|
+
val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
|
|
960
|
+
applyButtonLayout(button, appearance)
|
|
961
|
+
val textColor = if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
962
|
+
when {
|
|
963
|
+
!enabled -> withAlpha(
|
|
964
|
+
resolveColorAttr(
|
|
965
|
+
MaterialR.attr.colorOnSurface,
|
|
966
|
+
android.R.attr.textColorPrimary
|
|
967
|
+
),
|
|
968
|
+
0.38f
|
|
969
|
+
)
|
|
970
|
+
active -> resolveColorAttr(
|
|
971
|
+
MaterialR.attr.colorOnSecondaryContainer,
|
|
972
|
+
MaterialR.attr.colorOnPrimaryContainer,
|
|
973
|
+
MaterialR.attr.colorOnSurface,
|
|
974
|
+
android.R.attr.textColorPrimary
|
|
975
|
+
)
|
|
976
|
+
else -> resolveColorAttr(
|
|
977
|
+
MaterialR.attr.colorOnSurfaceVariant,
|
|
978
|
+
MaterialR.attr.colorOnSurface,
|
|
979
|
+
android.R.attr.textColorSecondary
|
|
980
|
+
)
|
|
981
|
+
}
|
|
982
|
+
} else {
|
|
983
|
+
when {
|
|
984
|
+
!enabled -> theme?.buttonDisabledColor ?: withAlpha(
|
|
985
|
+
resolveColorAttr(MaterialR.attr.colorOnSurface, android.R.attr.textColorPrimary),
|
|
986
|
+
0.38f
|
|
987
|
+
)
|
|
988
|
+
active -> theme?.buttonActiveColor ?: resolveColorAttr(
|
|
989
|
+
AppCompatR.attr.colorPrimary,
|
|
990
|
+
android.R.attr.textColorPrimary
|
|
991
|
+
)
|
|
992
|
+
else -> theme?.buttonColor ?: resolveColorAttr(
|
|
993
|
+
MaterialR.attr.colorOnSurfaceVariant,
|
|
994
|
+
MaterialR.attr.colorOnSurface,
|
|
995
|
+
android.R.attr.textColorSecondary
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
val backgroundColor = if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
1000
|
+
if (active) {
|
|
1001
|
+
resolveColorAttr(
|
|
1002
|
+
MaterialR.attr.colorSecondaryContainer,
|
|
1003
|
+
MaterialR.attr.colorPrimaryContainer,
|
|
1004
|
+
MaterialR.attr.colorSurfaceVariant,
|
|
1005
|
+
android.R.attr.colorAccent
|
|
1006
|
+
)
|
|
1007
|
+
} else {
|
|
1008
|
+
Color.TRANSPARENT
|
|
1009
|
+
}
|
|
1010
|
+
} else if (active) {
|
|
1011
|
+
theme?.buttonActiveBackgroundColor ?: resolveColorAttr(
|
|
1012
|
+
MaterialR.attr.colorPrimaryContainer,
|
|
1013
|
+
MaterialR.attr.colorSecondaryContainer,
|
|
1014
|
+
MaterialR.attr.colorSurfaceVariant,
|
|
1015
|
+
android.R.attr.colorAccent
|
|
1016
|
+
)
|
|
1017
|
+
} else {
|
|
1018
|
+
Color.TRANSPARENT
|
|
1019
|
+
}
|
|
1020
|
+
val buttonCornerRadiusPx = (theme?.resolvedButtonBorderRadius() ?: 6f) * density
|
|
1021
|
+
val drawable = GradientDrawable().apply {
|
|
1022
|
+
shape = GradientDrawable.RECTANGLE
|
|
1023
|
+
cornerRadius = buttonCornerRadiusPx
|
|
1024
|
+
setColor(backgroundColor)
|
|
1025
|
+
}
|
|
1026
|
+
appliedButtonCornerRadiusPx = buttonCornerRadiusPx
|
|
1027
|
+
buttonBackgroundColors[button] = backgroundColor
|
|
1028
|
+
button.background = drawable
|
|
1029
|
+
button.setTextColor(textColor)
|
|
1030
|
+
button.alpha = if (enabled || appearance == EditorToolbarAppearance.NATIVE) 1f else 0.7f
|
|
1031
|
+
button.refreshDrawableState()
|
|
1032
|
+
button.invalidate()
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private fun buttonState(
|
|
1036
|
+
item: NativeToolbarItem,
|
|
1037
|
+
state: NativeToolbarState
|
|
1038
|
+
): Pair<Boolean, Boolean> {
|
|
1039
|
+
val isInList = state.nodes["bulletList"] == true || state.nodes["orderedList"] == true
|
|
1040
|
+
return when (item.type) {
|
|
1041
|
+
ToolbarItemKind.mark -> {
|
|
1042
|
+
val mark = item.mark.orEmpty()
|
|
1043
|
+
Pair(state.allowedMarks.contains(mark), state.marks[mark] == true)
|
|
1044
|
+
}
|
|
1045
|
+
ToolbarItemKind.heading -> {
|
|
1046
|
+
val level = item.headingLevel ?: return Pair(false, false)
|
|
1047
|
+
Pair(
|
|
1048
|
+
state.commands["toggleHeading$level"] == true,
|
|
1049
|
+
state.nodes["h$level"] == true
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
ToolbarItemKind.blockquote -> Pair(
|
|
1053
|
+
state.commands["toggleBlockquote"] == true,
|
|
1054
|
+
state.nodes["blockquote"] == true
|
|
1055
|
+
)
|
|
1056
|
+
ToolbarItemKind.list -> when (item.listType) {
|
|
1057
|
+
ToolbarListType.bulletList -> Pair(
|
|
1058
|
+
state.commands["wrapBulletList"] == true,
|
|
1059
|
+
state.nodes["bulletList"] == true
|
|
1060
|
+
)
|
|
1061
|
+
ToolbarListType.orderedList -> Pair(
|
|
1062
|
+
state.commands["wrapOrderedList"] == true,
|
|
1063
|
+
state.nodes["orderedList"] == true
|
|
1064
|
+
)
|
|
1065
|
+
null -> Pair(false, false)
|
|
1066
|
+
}
|
|
1067
|
+
ToolbarItemKind.command -> when (item.command) {
|
|
1068
|
+
ToolbarCommand.indentList -> Pair(isInList && state.commands["indentList"] == true, false)
|
|
1069
|
+
ToolbarCommand.outdentList -> Pair(isInList && state.commands["outdentList"] == true, false)
|
|
1070
|
+
ToolbarCommand.undo -> Pair(state.canUndo, false)
|
|
1071
|
+
ToolbarCommand.redo -> Pair(state.canRedo, false)
|
|
1072
|
+
null -> Pair(false, false)
|
|
1073
|
+
}
|
|
1074
|
+
ToolbarItemKind.node -> {
|
|
1075
|
+
val nodeType = item.nodeType.orEmpty()
|
|
1076
|
+
Pair(state.insertableNodes.contains(nodeType), state.nodes[nodeType] == true)
|
|
1077
|
+
}
|
|
1078
|
+
ToolbarItemKind.action -> Pair(!item.isDisabled, item.isActive)
|
|
1079
|
+
ToolbarItemKind.group -> Pair(
|
|
1080
|
+
item.items.any { child -> buttonState(child, state).first },
|
|
1081
|
+
item.items.any { child -> buttonState(child, state).second } ||
|
|
1082
|
+
(
|
|
1083
|
+
(item.presentation ?: ToolbarGroupPresentation.expand) ==
|
|
1084
|
+
ToolbarGroupPresentation.expand &&
|
|
1085
|
+
expandedGroupKey == item.key
|
|
1086
|
+
)
|
|
1087
|
+
)
|
|
1088
|
+
ToolbarItemKind.separator -> Pair(false, false)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private fun dp(value: Int): Int = (value * density).toInt()
|
|
1093
|
+
|
|
1094
|
+
private fun resolveColorAttr(vararg attrs: Int): Int =
|
|
1095
|
+
resolveColorAttrOrNull(*attrs) ?: Color.TRANSPARENT
|
|
1096
|
+
|
|
1097
|
+
private fun resolveColorAttrOrNull(vararg attrs: Int): Int? {
|
|
1098
|
+
val typedValue = TypedValue()
|
|
1099
|
+
for (attr in attrs) {
|
|
1100
|
+
if (!themedContext.theme.resolveAttribute(attr, typedValue, true)) {
|
|
1101
|
+
continue
|
|
1102
|
+
}
|
|
1103
|
+
if (typedValue.resourceId != 0) {
|
|
1104
|
+
AppCompatResources.getColorStateList(themedContext, typedValue.resourceId)
|
|
1105
|
+
?.defaultColor
|
|
1106
|
+
?.let { return it }
|
|
1107
|
+
} else if (typedValue.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) {
|
|
1108
|
+
return typedValue.data
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return null
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private fun resolveDrawableAttr(attr: Int) =
|
|
1115
|
+
TypedValue().let { typedValue ->
|
|
1116
|
+
if (!themedContext.theme.resolveAttribute(attr, typedValue, true) || typedValue.resourceId == 0) {
|
|
1117
|
+
null
|
|
1118
|
+
} else {
|
|
1119
|
+
AppCompatResources.getDrawable(themedContext, typedValue.resourceId)
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private fun resolveSeparatorColor(): Int =
|
|
1124
|
+
theme?.separatorColor
|
|
1125
|
+
?: theme?.borderColor
|
|
1126
|
+
?: resolveColorAttr(
|
|
1127
|
+
MaterialR.attr.colorOutlineVariant,
|
|
1128
|
+
MaterialR.attr.colorOutline,
|
|
1129
|
+
android.R.attr.textColorHint
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
private fun updateContainerLayout(appearance: EditorToolbarAppearance) {
|
|
1133
|
+
val isNative = appearance == EditorToolbarAppearance.NATIVE
|
|
1134
|
+
val toolbarHeightDp = resolvedToolbarHeightDp(isNative)
|
|
1135
|
+
val buttonSizeDp = resolvedButtonSizeDp(isNative, toolbarHeightDp)
|
|
1136
|
+
val horizontalPadding = dp(
|
|
1137
|
+
if (isNative) {
|
|
1138
|
+
NATIVE_CONTAINER_HORIZONTAL_PADDING_DP
|
|
1139
|
+
} else {
|
|
1140
|
+
12
|
|
1141
|
+
}
|
|
1142
|
+
)
|
|
1143
|
+
val verticalPadding = dp(resolvedVerticalPaddingDp(isNative, toolbarHeightDp, buttonSizeDp).roundToInt())
|
|
1144
|
+
rootRow.setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
|
|
1145
|
+
rootRow.minimumHeight = dp(toolbarHeightDp.roundToInt())
|
|
1146
|
+
startRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
|
|
1147
|
+
contentRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
|
|
1148
|
+
endRow.gravity = Gravity.END or Gravity.CENTER_VERTICAL
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private fun applyButtonLayout(button: AppCompatButton, appearance: EditorToolbarAppearance) {
|
|
1152
|
+
val isNative = appearance == EditorToolbarAppearance.NATIVE
|
|
1153
|
+
val toolbarHeightDp = resolvedToolbarHeightDp(isNative)
|
|
1154
|
+
val sizePx = dp(resolvedButtonSizeDp(isNative, toolbarHeightDp).roundToInt())
|
|
1155
|
+
button.textSize = if (isNative) NATIVE_BUTTON_ICON_SIZE_SP else 16f
|
|
1156
|
+
button.minWidth = sizePx
|
|
1157
|
+
button.minimumWidth = sizePx
|
|
1158
|
+
button.minHeight = sizePx
|
|
1159
|
+
button.minimumHeight = sizePx
|
|
1160
|
+
button.setPadding(
|
|
1161
|
+
if (isNative) 0 else dp(10),
|
|
1162
|
+
if (isNative) 0 else dp(8),
|
|
1163
|
+
if (isNative) 0 else dp(10),
|
|
1164
|
+
if (isNative) 0 else dp(8)
|
|
1165
|
+
)
|
|
1166
|
+
(button.layoutParams as? LinearLayout.LayoutParams)?.let { params ->
|
|
1167
|
+
params.marginEnd = dp(if (isNative) NATIVE_ITEM_SPACING_DP else 6)
|
|
1168
|
+
button.layoutParams = params
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
private fun configureSeparator(separator: View) {
|
|
1173
|
+
val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
|
|
1174
|
+
val params = if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
1175
|
+
LinearLayout.LayoutParams(dp(1), dp(24)).apply {
|
|
1176
|
+
marginStart = dp(NATIVE_GROUP_SPACING_DP / 2)
|
|
1177
|
+
marginEnd = dp(NATIVE_GROUP_SPACING_DP / 2)
|
|
1178
|
+
}
|
|
1179
|
+
} else {
|
|
1180
|
+
LinearLayout.LayoutParams(dp(1), dp(22)).apply {
|
|
1181
|
+
marginStart = dp(6)
|
|
1182
|
+
marginEnd = dp(6)
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
separator.layoutParams = params
|
|
1186
|
+
separator.setBackgroundColor(
|
|
1187
|
+
if (appearance == EditorToolbarAppearance.NATIVE) {
|
|
1188
|
+
withAlpha(resolveSeparatorColor(), 0.6f)
|
|
1189
|
+
} else {
|
|
1190
|
+
resolveSeparatorColor()
|
|
1191
|
+
}
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private fun resolvedToolbarHeightDp(isNative: Boolean): Float =
|
|
1196
|
+
theme?.height ?: if (isNative) {
|
|
1197
|
+
NATIVE_CONTAINER_HEIGHT_DP.toFloat()
|
|
1198
|
+
} else {
|
|
1199
|
+
60f
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
private fun resolvedButtonSizeDp(isNative: Boolean, toolbarHeightDp: Float): Float {
|
|
1203
|
+
val defaultSizeDp = if (isNative) NATIVE_BUTTON_SIZE_DP.toFloat() else 36f
|
|
1204
|
+
if (theme?.height == null) {
|
|
1205
|
+
return defaultSizeDp
|
|
1206
|
+
}
|
|
1207
|
+
return maxOf(1f, minOf(defaultSizeDp, toolbarHeightDp - 4f))
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
private fun resolvedVerticalPaddingDp(
|
|
1211
|
+
isNative: Boolean,
|
|
1212
|
+
toolbarHeightDp: Float,
|
|
1213
|
+
buttonSizeDp: Float
|
|
1214
|
+
): Float {
|
|
1215
|
+
if (theme?.height == null) {
|
|
1216
|
+
return if (isNative) {
|
|
1217
|
+
NATIVE_CONTAINER_VERTICAL_PADDING_DP.toFloat()
|
|
1218
|
+
} else {
|
|
1219
|
+
12f
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return maxOf(0f, (toolbarHeightDp - buttonSizeDp) / 2f)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
private fun withAlpha(color: Int, alphaFraction: Float): Int {
|
|
1227
|
+
val alpha = (alphaFraction.coerceIn(0f, 1f) * 255).roundToInt()
|
|
1228
|
+
return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
internal class MentionSuggestionChipView(
|
|
1232
|
+
context: Context,
|
|
1233
|
+
val suggestion: NativeMentionSuggestion
|
|
1234
|
+
) : LinearLayout(context) {
|
|
1235
|
+
private val titleView = AppCompatTextView(context)
|
|
1236
|
+
private val subtitleView = AppCompatTextView(context)
|
|
1237
|
+
private var theme: EditorMentionTheme? = null
|
|
1238
|
+
private var toolbarAppearance: EditorToolbarAppearance = EditorToolbarAppearance.CUSTOM
|
|
1239
|
+
private val density = resources.displayMetrics.density
|
|
1240
|
+
|
|
1241
|
+
init {
|
|
1242
|
+
orientation = VERTICAL
|
|
1243
|
+
gravity = Gravity.CENTER_VERTICAL
|
|
1244
|
+
minimumHeight = dp(40)
|
|
1245
|
+
setPadding(dp(12), dp(8), dp(12), dp(8))
|
|
1246
|
+
isClickable = true
|
|
1247
|
+
isFocusable = true
|
|
1248
|
+
|
|
1249
|
+
titleView.apply {
|
|
1250
|
+
text = suggestion.label
|
|
1251
|
+
setTypeface(typeface, Typeface.BOLD)
|
|
1252
|
+
textSize = 14f
|
|
1253
|
+
includeFontPadding = false
|
|
1254
|
+
}
|
|
1255
|
+
addView(
|
|
1256
|
+
titleView,
|
|
1257
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
subtitleView.apply {
|
|
1261
|
+
text = suggestion.subtitle
|
|
1262
|
+
textSize = 12f
|
|
1263
|
+
includeFontPadding = false
|
|
1264
|
+
visibility = if (suggestion.subtitle.isNullOrBlank()) View.GONE else View.VISIBLE
|
|
1265
|
+
}
|
|
1266
|
+
addView(
|
|
1267
|
+
subtitleView,
|
|
1268
|
+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
setOnTouchListener { _, motionEvent ->
|
|
1272
|
+
when (motionEvent.actionMasked) {
|
|
1273
|
+
android.view.MotionEvent.ACTION_DOWN,
|
|
1274
|
+
android.view.MotionEvent.ACTION_MOVE -> updateAppearance(highlighted = true)
|
|
1275
|
+
android.view.MotionEvent.ACTION_CANCEL,
|
|
1276
|
+
android.view.MotionEvent.ACTION_UP -> updateAppearance(highlighted = false)
|
|
1277
|
+
}
|
|
1278
|
+
false
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
applyTheme(null)
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
fun applyTheme(
|
|
1285
|
+
theme: EditorMentionTheme?,
|
|
1286
|
+
toolbarAppearance: EditorToolbarAppearance = EditorToolbarAppearance.CUSTOM
|
|
1287
|
+
) {
|
|
1288
|
+
this.theme = theme
|
|
1289
|
+
this.toolbarAppearance = toolbarAppearance
|
|
1290
|
+
val hasSubtitle = !suggestion.subtitle.isNullOrBlank()
|
|
1291
|
+
subtitleView.visibility = if (hasSubtitle) View.VISIBLE else View.GONE
|
|
1292
|
+
background = GradientDrawable().apply {
|
|
1293
|
+
shape = GradientDrawable.RECTANGLE
|
|
1294
|
+
cornerRadius = (if (toolbarAppearance == EditorToolbarAppearance.NATIVE) 20f else (theme?.borderRadius ?: 12f)) * density
|
|
1295
|
+
setColor(
|
|
1296
|
+
if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
|
|
1297
|
+
Color.TRANSPARENT
|
|
1298
|
+
} else {
|
|
1299
|
+
theme?.backgroundColor ?: resolveColorAttr(
|
|
1300
|
+
MaterialR.attr.colorSurfaceContainerLow,
|
|
1301
|
+
MaterialR.attr.colorSurfaceVariant,
|
|
1302
|
+
MaterialR.attr.colorSurface,
|
|
1303
|
+
android.R.attr.colorBackground
|
|
1304
|
+
)
|
|
1305
|
+
}
|
|
1306
|
+
)
|
|
1307
|
+
val strokeWidth = if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
|
|
1308
|
+
0
|
|
1309
|
+
} else {
|
|
1310
|
+
((theme?.borderWidth ?: 0f) * density).toInt()
|
|
1311
|
+
}
|
|
1312
|
+
if (strokeWidth > 0) {
|
|
1313
|
+
setStroke(strokeWidth, theme?.borderColor ?: Color.TRANSPARENT)
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
updateAppearance(highlighted = false)
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private fun updateAppearance(highlighted: Boolean) {
|
|
1320
|
+
val backgroundDrawable = background as? GradientDrawable
|
|
1321
|
+
val backgroundColor = if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
|
|
1322
|
+
if (highlighted) {
|
|
1323
|
+
resolveColorAttr(
|
|
1324
|
+
MaterialR.attr.colorSecondaryContainer,
|
|
1325
|
+
MaterialR.attr.colorPrimaryContainer,
|
|
1326
|
+
MaterialR.attr.colorSurfaceVariant,
|
|
1327
|
+
android.R.attr.colorAccent
|
|
1328
|
+
)
|
|
1329
|
+
} else {
|
|
1330
|
+
Color.TRANSPARENT
|
|
1331
|
+
}
|
|
1332
|
+
} else if (highlighted) {
|
|
1333
|
+
theme?.optionHighlightedBackgroundColor ?: resolveColorAttr(
|
|
1334
|
+
MaterialR.attr.colorSecondaryContainer,
|
|
1335
|
+
MaterialR.attr.colorPrimaryContainer,
|
|
1336
|
+
MaterialR.attr.colorSurfaceVariant,
|
|
1337
|
+
android.R.attr.colorAccent
|
|
1338
|
+
)
|
|
1339
|
+
} else {
|
|
1340
|
+
theme?.backgroundColor ?: resolveColorAttr(
|
|
1341
|
+
MaterialR.attr.colorSurfaceContainerLow,
|
|
1342
|
+
MaterialR.attr.colorSurfaceVariant,
|
|
1343
|
+
MaterialR.attr.colorSurface,
|
|
1344
|
+
android.R.attr.colorBackground
|
|
1345
|
+
)
|
|
1346
|
+
}
|
|
1347
|
+
backgroundDrawable?.setColor(backgroundColor)
|
|
1348
|
+
titleView.setTextColor(
|
|
1349
|
+
if (toolbarAppearance == EditorToolbarAppearance.NATIVE && !highlighted) {
|
|
1350
|
+
resolveColorAttr(
|
|
1351
|
+
MaterialR.attr.colorOnSurface,
|
|
1352
|
+
android.R.attr.textColorPrimary
|
|
1353
|
+
)
|
|
1354
|
+
} else if (highlighted) {
|
|
1355
|
+
theme?.optionHighlightedTextColor
|
|
1356
|
+
?: theme?.optionTextColor
|
|
1357
|
+
?: resolveColorAttr(
|
|
1358
|
+
MaterialR.attr.colorOnSecondaryContainer,
|
|
1359
|
+
MaterialR.attr.colorOnPrimaryContainer,
|
|
1360
|
+
MaterialR.attr.colorOnSurface,
|
|
1361
|
+
android.R.attr.textColorPrimary
|
|
1362
|
+
)
|
|
1363
|
+
} else {
|
|
1364
|
+
theme?.optionTextColor
|
|
1365
|
+
?: theme?.textColor
|
|
1366
|
+
?: resolveColorAttr(
|
|
1367
|
+
MaterialR.attr.colorOnSurface,
|
|
1368
|
+
android.R.attr.textColorPrimary
|
|
1369
|
+
)
|
|
1370
|
+
}
|
|
1371
|
+
)
|
|
1372
|
+
subtitleView.setTextColor(
|
|
1373
|
+
if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
|
|
1374
|
+
resolveColorAttr(
|
|
1375
|
+
MaterialR.attr.colorOnSurfaceVariant,
|
|
1376
|
+
android.R.attr.textColorSecondary
|
|
1377
|
+
)
|
|
1378
|
+
} else {
|
|
1379
|
+
theme?.optionSecondaryTextColor ?: resolveColorAttr(
|
|
1380
|
+
MaterialR.attr.colorOnSurfaceVariant,
|
|
1381
|
+
android.R.attr.textColorSecondary
|
|
1382
|
+
)
|
|
1383
|
+
}
|
|
1384
|
+
)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
fun usesNativeAppearanceForTesting(): Boolean =
|
|
1388
|
+
toolbarAppearance == EditorToolbarAppearance.NATIVE
|
|
1389
|
+
|
|
1390
|
+
private fun dp(value: Int): Int = (value * density).toInt()
|
|
1391
|
+
|
|
1392
|
+
private fun resolveColorAttr(vararg attrs: Int): Int {
|
|
1393
|
+
val typedValue = TypedValue()
|
|
1394
|
+
for (attr in attrs) {
|
|
1395
|
+
if (!context.theme.resolveAttribute(attr, typedValue, true)) {
|
|
1396
|
+
continue
|
|
1397
|
+
}
|
|
1398
|
+
if (typedValue.resourceId != 0) {
|
|
1399
|
+
AppCompatResources.getColorStateList(context, typedValue.resourceId)
|
|
1400
|
+
?.defaultColor
|
|
1401
|
+
?.let { return it }
|
|
1402
|
+
} else if (typedValue.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) {
|
|
1403
|
+
return typedValue.data
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return Color.TRANSPARENT
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
private fun JSONObject.optNullableString(key: String): String? {
|
|
1411
|
+
if (!has(key) || isNull(key)) return null
|
|
1412
|
+
return optString(key).takeUnless { it == "null" }
|
|
1413
|
+
}
|