@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.
Files changed (91) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +180 -0
  3. package/android/build.gradle +69 -0
  4. package/android/consumer-rules.pro +18 -0
  5. package/android/src/androidTest/AndroidManifest.xml +8 -0
  6. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
  7. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
  8. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
  9. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
  10. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
  11. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
  12. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  13. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  14. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
  15. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
  16. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
  17. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  18. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
  19. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
  20. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  21. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
  22. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
  23. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
  24. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
  25. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
  26. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
  27. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
  28. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
  29. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
  30. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
  31. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
  32. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
  33. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
  34. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
  35. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
  36. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
  37. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
  38. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
  39. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
  40. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
  41. package/app.plugin.js +62 -0
  42. package/dist/EditorTheme.d.ts +113 -0
  43. package/dist/EditorTheme.js +29 -0
  44. package/dist/EditorToolbar.d.ts +191 -0
  45. package/dist/EditorToolbar.js +1095 -0
  46. package/dist/NativeEditorBridge.d.ts +325 -0
  47. package/dist/NativeEditorBridge.js +933 -0
  48. package/dist/NativeProseViewer.d.ts +53 -0
  49. package/dist/NativeProseViewer.js +480 -0
  50. package/dist/NativeRichTextEditor.d.ts +183 -0
  51. package/dist/NativeRichTextEditor.js +2574 -0
  52. package/dist/YjsCollaboration.d.ts +90 -0
  53. package/dist/YjsCollaboration.js +743 -0
  54. package/dist/addons.d.ts +100 -0
  55. package/dist/addons.js +82 -0
  56. package/dist/heightCache.d.ts +6 -0
  57. package/dist/heightCache.js +45 -0
  58. package/dist/index.d.ts +10 -0
  59. package/dist/index.js +30 -0
  60. package/dist/schemas.d.ts +37 -0
  61. package/dist/schemas.js +263 -0
  62. package/dist/useNativeEditor.d.ts +42 -0
  63. package/dist/useNativeEditor.js +124 -0
  64. package/expo-module.config.json +9 -0
  65. package/ios/EditorAddons.swift +327 -0
  66. package/ios/EditorCore.xcframework/Info.plist +44 -0
  67. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  68. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  69. package/ios/EditorLayoutManager.swift +774 -0
  70. package/ios/EditorTheme.swift +515 -0
  71. package/ios/Generated_editor_core.swift +1513 -0
  72. package/ios/NativeEditorExpoView.swift +3593 -0
  73. package/ios/NativeEditorModule.swift +624 -0
  74. package/ios/NativeProseViewerExpoView.swift +276 -0
  75. package/ios/PositionBridge.swift +558 -0
  76. package/ios/ReactNativeProseEditor.podspec +49 -0
  77. package/ios/RenderBridge.swift +1708 -0
  78. package/ios/RichTextEditorView.swift +6150 -0
  79. package/ios/Tests/NativePerformanceTests.swift +687 -0
  80. package/ios/Tests/PositionBridgeTests.swift +706 -0
  81. package/ios/Tests/RenderBridgeTests.swift +2236 -0
  82. package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
  83. package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
  84. package/ios/editor_coreFFI/module.modulemap +7 -0
  85. package/ios/editor_coreFFI.h +904 -0
  86. package/ios/editor_coreFFI.modulemap +7 -0
  87. package/package.json +74 -0
  88. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  89. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  90. package/rust/android/x86_64/libeditor_core.so +0 -0
  91. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2563 -0
@@ -0,0 +1,474 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.graphics.Color
4
+ import android.graphics.Typeface
5
+ import org.json.JSONObject
6
+
7
+ data class EditorTextStyle(
8
+ val fontFamily: String? = null,
9
+ val fontSize: Float? = null,
10
+ val fontWeight: String? = null,
11
+ val fontStyle: String? = null,
12
+ val color: Int? = null,
13
+ val lineHeight: Float? = null,
14
+ val spacingAfter: Float? = null
15
+ ) {
16
+ companion object {
17
+ fun fromJson(json: JSONObject?): EditorTextStyle? {
18
+ json ?: return null
19
+ return EditorTextStyle(
20
+ fontFamily = json.optNullableString("fontFamily"),
21
+ fontSize = json.optNullableFloat("fontSize"),
22
+ fontWeight = json.optNullableString("fontWeight"),
23
+ fontStyle = json.optNullableString("fontStyle"),
24
+ color = parseColor(json.optNullableString("color")),
25
+ lineHeight = json.optNullableFloat("lineHeight"),
26
+ spacingAfter = json.optNullableFloat("spacingAfter")
27
+ )
28
+ }
29
+ }
30
+
31
+ fun mergedWith(other: EditorTextStyle?): EditorTextStyle {
32
+ other ?: return this
33
+ return copy(
34
+ fontFamily = other.fontFamily ?: fontFamily,
35
+ fontSize = other.fontSize ?: fontSize,
36
+ fontWeight = other.fontWeight ?: fontWeight,
37
+ fontStyle = other.fontStyle ?: fontStyle,
38
+ color = other.color ?: color,
39
+ lineHeight = other.lineHeight ?: lineHeight,
40
+ spacingAfter = other.spacingAfter ?: spacingAfter
41
+ )
42
+ }
43
+
44
+ fun typefaceStyle(): Int {
45
+ val bold = fontWeight == "bold" || fontWeight?.toIntOrNull()?.let { it >= 600 } == true
46
+ val italic = fontStyle == "italic"
47
+ return when {
48
+ bold && italic -> Typeface.BOLD_ITALIC
49
+ bold -> Typeface.BOLD
50
+ italic -> Typeface.ITALIC
51
+ else -> Typeface.NORMAL
52
+ }
53
+ }
54
+ }
55
+
56
+ data class EditorListTheme(
57
+ val indent: Float? = null,
58
+ val baseIndentMultiplier: Float? = null,
59
+ val itemSpacing: Float? = null,
60
+ val markerColor: Int? = null,
61
+ val markerScale: Float? = null
62
+ ) {
63
+ companion object {
64
+ fun fromJson(json: JSONObject?): EditorListTheme? {
65
+ json ?: return null
66
+ return EditorListTheme(
67
+ indent = json.optNullableFloat("indent"),
68
+ baseIndentMultiplier = json.optNullableFloat("baseIndentMultiplier"),
69
+ itemSpacing = json.optNullableFloat("itemSpacing"),
70
+ markerColor = parseColor(json.optNullableString("markerColor")),
71
+ markerScale = json.optNullableFloat("markerScale")
72
+ )
73
+ }
74
+ }
75
+ }
76
+
77
+ data class EditorHorizontalRuleTheme(
78
+ val color: Int? = null,
79
+ val thickness: Float? = null,
80
+ val verticalMargin: Float? = null
81
+ ) {
82
+ companion object {
83
+ fun fromJson(json: JSONObject?): EditorHorizontalRuleTheme? {
84
+ json ?: return null
85
+ return EditorHorizontalRuleTheme(
86
+ color = parseColor(json.optNullableString("color")),
87
+ thickness = json.optNullableFloat("thickness"),
88
+ verticalMargin = json.optNullableFloat("verticalMargin")
89
+ )
90
+ }
91
+ }
92
+ }
93
+
94
+ data class EditorBlockquoteTheme(
95
+ val text: EditorTextStyle? = null,
96
+ val indent: Float? = null,
97
+ val borderColor: Int? = null,
98
+ val borderWidth: Float? = null,
99
+ val markerGap: Float? = null
100
+ ) {
101
+ companion object {
102
+ fun fromJson(json: JSONObject?): EditorBlockquoteTheme? {
103
+ json ?: return null
104
+ return EditorBlockquoteTheme(
105
+ text = EditorTextStyle.fromJson(json.optJSONObject("text")),
106
+ indent = json.optNullableFloat("indent"),
107
+ borderColor = parseColor(json.optNullableString("borderColor")),
108
+ borderWidth = json.optNullableFloat("borderWidth"),
109
+ markerGap = json.optNullableFloat("markerGap")
110
+ )
111
+ }
112
+ }
113
+ }
114
+
115
+ data class EditorCodeBlockTheme(
116
+ val text: EditorTextStyle? = null,
117
+ val backgroundColor: Int? = null,
118
+ val borderRadius: Float? = null,
119
+ val paddingHorizontal: Float? = null,
120
+ val paddingVertical: Float? = null
121
+ ) {
122
+ companion object {
123
+ fun fromJson(json: JSONObject?): EditorCodeBlockTheme? {
124
+ json ?: return null
125
+ return EditorCodeBlockTheme(
126
+ text = EditorTextStyle.fromJson(json.optJSONObject("text")),
127
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
128
+ borderRadius = json.optNullableFloat("borderRadius"),
129
+ paddingHorizontal = json.optNullableFloat("paddingHorizontal"),
130
+ paddingVertical = json.optNullableFloat("paddingVertical")
131
+ )
132
+ }
133
+ }
134
+ }
135
+
136
+ data class EditorLinkTheme(
137
+ val fontFamily: String? = null,
138
+ val fontSize: Float? = null,
139
+ val fontWeight: String? = null,
140
+ val fontStyle: String? = null,
141
+ val color: Int? = null,
142
+ val backgroundColor: Int? = null,
143
+ val underline: Boolean? = null
144
+ ) {
145
+ companion object {
146
+ fun fromJson(json: JSONObject?): EditorLinkTheme? {
147
+ json ?: return null
148
+ return EditorLinkTheme(
149
+ fontFamily = json.optNullableString("fontFamily"),
150
+ fontSize = json.optNullableFloat("fontSize"),
151
+ fontWeight = json.optNullableString("fontWeight"),
152
+ fontStyle = json.optNullableString("fontStyle"),
153
+ color = parseColor(json.optNullableString("color")),
154
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
155
+ underline = if (json.has("underline")) json.optBoolean("underline") else null
156
+ )
157
+ }
158
+ }
159
+
160
+ fun asTextStyle(): EditorTextStyle =
161
+ EditorTextStyle(
162
+ fontFamily = fontFamily,
163
+ fontSize = fontSize,
164
+ fontWeight = fontWeight,
165
+ fontStyle = fontStyle,
166
+ color = color
167
+ )
168
+ }
169
+
170
+ data class EditorMentionTheme(
171
+ val textColor: Int? = null,
172
+ val backgroundColor: Int? = null,
173
+ val borderColor: Int? = null,
174
+ val borderWidth: Float? = null,
175
+ val borderRadius: Float? = null,
176
+ val fontWeight: String? = null,
177
+ val popoverBackgroundColor: Int? = null,
178
+ val popoverBorderColor: Int? = null,
179
+ val popoverBorderWidth: Float? = null,
180
+ val popoverBorderRadius: Float? = null,
181
+ val popoverShadowColor: Int? = null,
182
+ val optionTextColor: Int? = null,
183
+ val optionSecondaryTextColor: Int? = null,
184
+ val optionHighlightedBackgroundColor: Int? = null,
185
+ val optionHighlightedTextColor: Int? = null
186
+ ) {
187
+ fun mergedWith(other: EditorMentionTheme?): EditorMentionTheme {
188
+ other ?: return this
189
+ return copy(
190
+ textColor = other.textColor ?: textColor,
191
+ backgroundColor = other.backgroundColor ?: backgroundColor,
192
+ borderColor = other.borderColor ?: borderColor,
193
+ borderWidth = other.borderWidth ?: borderWidth,
194
+ borderRadius = other.borderRadius ?: borderRadius,
195
+ fontWeight = other.fontWeight ?: fontWeight,
196
+ popoverBackgroundColor = other.popoverBackgroundColor ?: popoverBackgroundColor,
197
+ popoverBorderColor = other.popoverBorderColor ?: popoverBorderColor,
198
+ popoverBorderWidth = other.popoverBorderWidth ?: popoverBorderWidth,
199
+ popoverBorderRadius = other.popoverBorderRadius ?: popoverBorderRadius,
200
+ popoverShadowColor = other.popoverShadowColor ?: popoverShadowColor,
201
+ optionTextColor = other.optionTextColor ?: optionTextColor,
202
+ optionSecondaryTextColor = other.optionSecondaryTextColor ?: optionSecondaryTextColor,
203
+ optionHighlightedBackgroundColor =
204
+ other.optionHighlightedBackgroundColor ?: optionHighlightedBackgroundColor,
205
+ optionHighlightedTextColor =
206
+ other.optionHighlightedTextColor ?: optionHighlightedTextColor
207
+ )
208
+ }
209
+
210
+ companion object {
211
+ fun fromJson(json: JSONObject?): EditorMentionTheme? {
212
+ json ?: return null
213
+ return EditorMentionTheme(
214
+ textColor = parseColor(json.optNullableString("textColor")),
215
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
216
+ borderColor = parseColor(json.optNullableString("borderColor")),
217
+ borderWidth = json.optNullableFloat("borderWidth"),
218
+ borderRadius = json.optNullableFloat("borderRadius"),
219
+ fontWeight = json.optNullableString("fontWeight"),
220
+ popoverBackgroundColor = parseColor(json.optNullableString("popoverBackgroundColor")),
221
+ popoverBorderColor = parseColor(json.optNullableString("popoverBorderColor")),
222
+ popoverBorderWidth = json.optNullableFloat("popoverBorderWidth"),
223
+ popoverBorderRadius = json.optNullableFloat("popoverBorderRadius"),
224
+ popoverShadowColor = parseColor(json.optNullableString("popoverShadowColor")),
225
+ optionTextColor = parseColor(json.optNullableString("optionTextColor")),
226
+ optionSecondaryTextColor = parseColor(json.optNullableString("optionSecondaryTextColor")),
227
+ optionHighlightedBackgroundColor = parseColor(json.optNullableString("optionHighlightedBackgroundColor")),
228
+ optionHighlightedTextColor = parseColor(json.optNullableString("optionHighlightedTextColor"))
229
+ )
230
+ }
231
+ }
232
+ }
233
+
234
+ enum class EditorToolbarAppearance {
235
+ CUSTOM,
236
+ NATIVE;
237
+
238
+ companion object {
239
+ fun fromRaw(raw: String?): EditorToolbarAppearance? =
240
+ when (raw?.trim()?.lowercase()) {
241
+ "custom" -> CUSTOM
242
+ "native" -> NATIVE
243
+ else -> null
244
+ }
245
+ }
246
+ }
247
+
248
+ data class EditorToolbarTheme(
249
+ val appearance: EditorToolbarAppearance? = null,
250
+ val height: Float? = null,
251
+ val backgroundColor: Int? = null,
252
+ val borderColor: Int? = null,
253
+ val borderWidth: Float? = null,
254
+ val borderRadius: Float? = null,
255
+ val marginTop: Float? = null,
256
+ val showTopBorder: Boolean? = null,
257
+ val keyboardOffset: Float? = null,
258
+ val horizontalInset: Float? = null,
259
+ val separatorColor: Int? = null,
260
+ val buttonColor: Int? = null,
261
+ val buttonActiveColor: Int? = null,
262
+ val buttonDisabledColor: Int? = null,
263
+ val buttonActiveBackgroundColor: Int? = null,
264
+ val buttonBorderRadius: Float? = null
265
+ ) {
266
+ fun resolvedKeyboardOffset(): Float = keyboardOffset ?: if (appearance == EditorToolbarAppearance.NATIVE) 8f else 0f
267
+
268
+ fun resolvedHorizontalInset(): Float = horizontalInset ?: if (appearance == EditorToolbarAppearance.NATIVE) 0f else 0f
269
+
270
+ fun resolvedBorderRadius(): Float = if (appearance == EditorToolbarAppearance.NATIVE) 32f else (borderRadius ?: 0f)
271
+
272
+ fun resolvedBorderWidth(): Float = borderWidth ?: if (appearance == EditorToolbarAppearance.NATIVE) 0f else 1f
273
+
274
+ fun resolvedButtonBorderRadius(): Float = if (appearance == EditorToolbarAppearance.NATIVE) 20f else (buttonBorderRadius ?: 6f)
275
+
276
+ companion object {
277
+ fun fromJson(json: JSONObject?): EditorToolbarTheme? {
278
+ json ?: return null
279
+ return EditorToolbarTheme(
280
+ appearance = EditorToolbarAppearance.fromRaw(json.optNullableString("appearance")),
281
+ height = json.optNullableFloat("height"),
282
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
283
+ borderColor = parseColor(json.optNullableString("borderColor")),
284
+ borderWidth = json.optNullableFloat("borderWidth"),
285
+ borderRadius = json.optNullableFloat("borderRadius"),
286
+ marginTop = json.optNullableFloat("marginTop"),
287
+ showTopBorder = if (json.has("showTopBorder")) json.optBoolean("showTopBorder") else null,
288
+ keyboardOffset = json.optNullableFloat("keyboardOffset"),
289
+ horizontalInset = json.optNullableFloat("horizontalInset"),
290
+ separatorColor = parseColor(json.optNullableString("separatorColor")),
291
+ buttonColor = parseColor(json.optNullableString("buttonColor")),
292
+ buttonActiveColor = parseColor(json.optNullableString("buttonActiveColor")),
293
+ buttonDisabledColor = parseColor(json.optNullableString("buttonDisabledColor")),
294
+ buttonActiveBackgroundColor = parseColor(json.optNullableString("buttonActiveBackgroundColor")),
295
+ buttonBorderRadius = json.optNullableFloat("buttonBorderRadius")
296
+ )
297
+ }
298
+ }
299
+ }
300
+
301
+ data class EditorContentInsets(
302
+ val top: Float? = null,
303
+ val right: Float? = null,
304
+ val bottom: Float? = null,
305
+ val left: Float? = null
306
+ ) {
307
+ companion object {
308
+ fun fromJson(json: JSONObject?): EditorContentInsets? {
309
+ json ?: return null
310
+ return EditorContentInsets(
311
+ top = json.optNullableFloat("top"),
312
+ right = json.optNullableFloat("right"),
313
+ bottom = json.optNullableFloat("bottom"),
314
+ left = json.optNullableFloat("left")
315
+ )
316
+ }
317
+ }
318
+ }
319
+
320
+ data class EditorTheme(
321
+ val text: EditorTextStyle? = null,
322
+ val paragraph: EditorTextStyle? = null,
323
+ val blockquote: EditorBlockquoteTheme? = null,
324
+ val codeBlock: EditorCodeBlockTheme? = null,
325
+ val headings: Map<String, EditorTextStyle> = emptyMap(),
326
+ val list: EditorListTheme? = null,
327
+ val horizontalRule: EditorHorizontalRuleTheme? = null,
328
+ val mentions: EditorMentionTheme? = null,
329
+ val links: EditorLinkTheme? = null,
330
+ val toolbar: EditorToolbarTheme? = null,
331
+ val placeholderColor: Int? = null,
332
+ val backgroundColor: Int? = null,
333
+ val borderRadius: Float? = null,
334
+ val contentInsets: EditorContentInsets? = null
335
+ ) {
336
+ companion object {
337
+ fun fromJson(json: String?): EditorTheme? {
338
+ if (json.isNullOrBlank()) return null
339
+ val root = try {
340
+ JSONObject(json)
341
+ } catch (_: Exception) {
342
+ return null
343
+ }
344
+
345
+ val headings = mutableMapOf<String, EditorTextStyle>()
346
+ for (level in listOf("h1", "h2", "h3", "h4", "h5", "h6")) {
347
+ val style = EditorTextStyle.fromJson(root.optJSONObject("headings")?.optJSONObject(level))
348
+ if (style != null) {
349
+ headings[level] = style
350
+ }
351
+ }
352
+
353
+ return EditorTheme(
354
+ text = EditorTextStyle.fromJson(root.optJSONObject("text")),
355
+ paragraph = EditorTextStyle.fromJson(root.optJSONObject("paragraph")),
356
+ blockquote = EditorBlockquoteTheme.fromJson(root.optJSONObject("blockquote")),
357
+ codeBlock = EditorCodeBlockTheme.fromJson(root.optJSONObject("codeBlock")),
358
+ headings = headings,
359
+ list = EditorListTheme.fromJson(root.optJSONObject("list")),
360
+ horizontalRule = EditorHorizontalRuleTheme.fromJson(root.optJSONObject("horizontalRule")),
361
+ mentions = EditorMentionTheme.fromJson(root.optJSONObject("mentions")),
362
+ links = EditorLinkTheme.fromJson(root.optJSONObject("links")),
363
+ toolbar = EditorToolbarTheme.fromJson(root.optJSONObject("toolbar")),
364
+ placeholderColor = parseColor(root.optNullableString("placeholderColor")),
365
+ backgroundColor = parseColor(root.optNullableString("backgroundColor")),
366
+ borderRadius = root.optNullableFloat("borderRadius"),
367
+ contentInsets = EditorContentInsets.fromJson(root.optJSONObject("contentInsets"))
368
+ )
369
+ }
370
+ }
371
+
372
+ fun effectiveTextStyle(nodeType: String, inBlockquote: Boolean = false): EditorTextStyle {
373
+ var style = text ?: EditorTextStyle()
374
+ style = style.mergedWith(if (inBlockquote) blockquote?.text else null)
375
+ if (nodeType == "paragraph") {
376
+ style = style.mergedWith(paragraph)
377
+ if (paragraph?.lineHeight == null) {
378
+ style = style.copy(lineHeight = null)
379
+ }
380
+ }
381
+ if (nodeType == "codeBlock") {
382
+ style = style.mergedWith(codeBlock?.text)
383
+ }
384
+ style = style.mergedWith(headings[nodeType])
385
+ return style
386
+ }
387
+ }
388
+
389
+ private fun parseColor(raw: String?): Int? {
390
+ val value = raw?.trim()?.lowercase() ?: return null
391
+ if (value.isEmpty()) return null
392
+
393
+ when (value) {
394
+ "clear", "transparent" -> return Color.TRANSPARENT
395
+ }
396
+
397
+ parseCssHexColor(value)?.let { return it }
398
+
399
+ try {
400
+ return Color.parseColor(value)
401
+ } catch (_: IllegalArgumentException) {
402
+ // Fall through to rgb()/rgba() parsing.
403
+ }
404
+
405
+ return when {
406
+ value.startsWith("rgb(") && value.endsWith(")") -> {
407
+ val parts = value.removePrefix("rgb(").removeSuffix(")")
408
+ .split(',')
409
+ .map { it.trim() }
410
+ if (parts.size != 3) return null
411
+ val red = parts[0].toDoubleOrNull() ?: return null
412
+ val green = parts[1].toDoubleOrNull() ?: return null
413
+ val blue = parts[2].toDoubleOrNull() ?: return null
414
+ Color.argb(255, red.toInt(), green.toInt(), blue.toInt())
415
+ }
416
+ value.startsWith("rgba(") && value.endsWith(")") -> {
417
+ val parts = value.removePrefix("rgba(").removeSuffix(")")
418
+ .split(',')
419
+ .map { it.trim() }
420
+ if (parts.size != 4) return null
421
+ val red = parts[0].toDoubleOrNull() ?: return null
422
+ val green = parts[1].toDoubleOrNull() ?: return null
423
+ val blue = parts[2].toDoubleOrNull() ?: return null
424
+ val alpha = parts[3].toDoubleOrNull() ?: return null
425
+ Color.argb((alpha * 255f).toInt(), red.toInt(), green.toInt(), blue.toInt())
426
+ }
427
+ else -> null
428
+ }
429
+ }
430
+
431
+ private fun parseCssHexColor(value: String): Int? {
432
+ if (!value.startsWith("#")) return null
433
+ val hex = value.removePrefix("#")
434
+
435
+ return when (hex.length) {
436
+ 3 -> {
437
+ val red = "${hex[0]}${hex[0]}".toIntOrNull(16) ?: return null
438
+ val green = "${hex[1]}${hex[1]}".toIntOrNull(16) ?: return null
439
+ val blue = "${hex[2]}${hex[2]}".toIntOrNull(16) ?: return null
440
+ Color.argb(255, red, green, blue)
441
+ }
442
+ 4 -> {
443
+ val red = "${hex[0]}${hex[0]}".toIntOrNull(16) ?: return null
444
+ val green = "${hex[1]}${hex[1]}".toIntOrNull(16) ?: return null
445
+ val blue = "${hex[2]}${hex[2]}".toIntOrNull(16) ?: return null
446
+ val alpha = "${hex[3]}${hex[3]}".toIntOrNull(16) ?: return null
447
+ Color.argb(alpha, red, green, blue)
448
+ }
449
+ 6 -> {
450
+ val red = hex.substring(0, 2).toIntOrNull(16) ?: return null
451
+ val green = hex.substring(2, 4).toIntOrNull(16) ?: return null
452
+ val blue = hex.substring(4, 6).toIntOrNull(16) ?: return null
453
+ Color.argb(255, red, green, blue)
454
+ }
455
+ 8 -> {
456
+ val red = hex.substring(0, 2).toIntOrNull(16) ?: return null
457
+ val green = hex.substring(2, 4).toIntOrNull(16) ?: return null
458
+ val blue = hex.substring(4, 6).toIntOrNull(16) ?: return null
459
+ val alpha = hex.substring(6, 8).toIntOrNull(16) ?: return null
460
+ Color.argb(alpha, red, green, blue)
461
+ }
462
+ else -> null
463
+ }
464
+ }
465
+
466
+ private fun JSONObject.optNullableString(key: String): String? {
467
+ if (!has(key) || isNull(key)) return null
468
+ return optString(key).takeUnless { it == "null" }
469
+ }
470
+
471
+ private fun JSONObject.optNullableFloat(key: String): Float? {
472
+ if (!has(key) || isNull(key)) return null
473
+ return optDouble(key).takeIf { !it.isNaN() }?.toFloat()
474
+ }
@@ -0,0 +1,199 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.RectF
8
+ import android.util.AttributeSet
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+ import kotlin.math.max
12
+
13
+ internal class ImageResizeOverlayView @JvmOverloads constructor(
14
+ context: Context,
15
+ attrs: AttributeSet? = null,
16
+ defStyleAttr: Int = 0
17
+ ) : View(context, attrs, defStyleAttr) {
18
+ private enum class Corner {
19
+ TOP_LEFT,
20
+ TOP_RIGHT,
21
+ BOTTOM_LEFT,
22
+ BOTTOM_RIGHT
23
+ }
24
+
25
+ private data class DragState(
26
+ val corner: Corner,
27
+ val originalRect: RectF,
28
+ val docPos: Int,
29
+ val maximumWidthPx: Float,
30
+ var previewRect: RectF
31
+ )
32
+
33
+ private var editorView: RichTextEditorView? = null
34
+ private var currentGeometry: EditorEditText.SelectedImageGeometry? = null
35
+ private var dragState: DragState? = null
36
+
37
+ private val density = resources.displayMetrics.density
38
+ private val handleRadiusPx = 10f * density
39
+ private val minimumImageSizePx = 48f * density
40
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
41
+ color = Color.parseColor("#0A84FF")
42
+ style = Paint.Style.STROKE
43
+ strokeWidth = max(2f, density)
44
+ }
45
+ private val handleFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
46
+ color = Color.WHITE
47
+ style = Paint.Style.FILL
48
+ }
49
+ private val handleStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
50
+ color = Color.parseColor("#0A84FF")
51
+ style = Paint.Style.STROKE
52
+ strokeWidth = max(2f, density)
53
+ }
54
+
55
+ init {
56
+ setWillNotDraw(false)
57
+ visibility = INVISIBLE
58
+ }
59
+
60
+ fun bind(editorView: RichTextEditorView) {
61
+ this.editorView = editorView
62
+ }
63
+
64
+ fun refresh() {
65
+ currentGeometry = editorView?.selectedImageGeometry()
66
+ visibility = if (currentGeometry == null) INVISIBLE else VISIBLE
67
+ if (currentGeometry != null) {
68
+ bringToFront()
69
+ }
70
+ invalidate()
71
+ }
72
+
73
+ fun visibleRectForTesting(): RectF? =
74
+ currentGeometry?.rect?.let(::RectF)
75
+
76
+ fun simulateResizeForTesting(widthPx: Float, heightPx: Float) {
77
+ val geometry = currentGeometry ?: return
78
+ editorView?.resizeImage(geometry.docPos, widthPx, heightPx)
79
+ }
80
+
81
+ override fun onDraw(canvas: Canvas) {
82
+ super.onDraw(canvas)
83
+ val geometry = currentGeometry ?: return
84
+ canvas.drawRoundRect(geometry.rect, 8f * density, 8f * density, borderPaint)
85
+ for (corner in Corner.entries) {
86
+ val center = handleCenter(corner, geometry.rect)
87
+ canvas.drawCircle(center.x, center.y, handleRadiusPx, handleFillPaint)
88
+ canvas.drawCircle(center.x, center.y, handleRadiusPx, handleStrokePaint)
89
+ }
90
+ }
91
+
92
+ override fun onTouchEvent(event: MotionEvent): Boolean {
93
+ val geometry = currentGeometry ?: return false
94
+
95
+ when (event.actionMasked) {
96
+ MotionEvent.ACTION_DOWN -> {
97
+ val corner = cornerAt(event.x, event.y, geometry.rect) ?: return false
98
+ dragState = DragState(
99
+ corner = corner,
100
+ originalRect = RectF(geometry.rect),
101
+ docPos = geometry.docPos,
102
+ maximumWidthPx = editorView?.maximumImageWidthPx() ?: geometry.rect.width(),
103
+ previewRect = RectF(geometry.rect)
104
+ )
105
+ parent?.requestDisallowInterceptTouchEvent(true)
106
+ return true
107
+ }
108
+
109
+ MotionEvent.ACTION_MOVE -> {
110
+ val state = dragState ?: return false
111
+ val nextRect = resizedRect(
112
+ originalRect = state.originalRect,
113
+ corner = state.corner,
114
+ deltaX = event.x - handleCenter(state.corner, state.originalRect).x,
115
+ deltaY = event.y - handleCenter(state.corner, state.originalRect).y,
116
+ maximumWidthPx = state.maximumWidthPx
117
+ )
118
+ state.previewRect = RectF(nextRect)
119
+ currentGeometry = EditorEditText.SelectedImageGeometry(state.docPos, nextRect)
120
+ invalidate()
121
+ return true
122
+ }
123
+
124
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
125
+ val state = dragState ?: return false
126
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
127
+ editorView?.resizeImage(
128
+ state.docPos,
129
+ state.previewRect.width(),
130
+ state.previewRect.height()
131
+ )
132
+ }
133
+ dragState = null
134
+ parent?.requestDisallowInterceptTouchEvent(false)
135
+ post { refresh() }
136
+ return true
137
+ }
138
+ }
139
+
140
+ return false
141
+ }
142
+
143
+ private fun cornerAt(x: Float, y: Float, rect: RectF): Corner? {
144
+ return Corner.entries.firstOrNull { corner ->
145
+ val center = handleCenter(corner, rect)
146
+ val dx = x - center.x
147
+ val dy = y - center.y
148
+ (dx * dx) + (dy * dy) <= handleRadiusPx * handleRadiusPx * 2
149
+ }
150
+ }
151
+
152
+ private fun handleCenter(corner: Corner, rect: RectF) = when (corner) {
153
+ Corner.TOP_LEFT -> android.graphics.PointF(rect.left, rect.top)
154
+ Corner.TOP_RIGHT -> android.graphics.PointF(rect.right, rect.top)
155
+ Corner.BOTTOM_LEFT -> android.graphics.PointF(rect.left, rect.bottom)
156
+ Corner.BOTTOM_RIGHT -> android.graphics.PointF(rect.right, rect.bottom)
157
+ }
158
+
159
+ private fun anchorPoint(corner: Corner, rect: RectF) = when (corner) {
160
+ Corner.TOP_LEFT -> android.graphics.PointF(rect.right, rect.bottom)
161
+ Corner.TOP_RIGHT -> android.graphics.PointF(rect.left, rect.bottom)
162
+ Corner.BOTTOM_LEFT -> android.graphics.PointF(rect.right, rect.top)
163
+ Corner.BOTTOM_RIGHT -> android.graphics.PointF(rect.left, rect.top)
164
+ }
165
+
166
+ private fun resizedRect(
167
+ originalRect: RectF,
168
+ corner: Corner,
169
+ deltaX: Float,
170
+ deltaY: Float,
171
+ maximumWidthPx: Float?
172
+ ): RectF {
173
+ val aspectRatio = max(originalRect.width() / max(originalRect.height(), 1f), 0.1f)
174
+ val signedDx = if (corner == Corner.TOP_RIGHT || corner == Corner.BOTTOM_RIGHT) deltaX else -deltaX
175
+ val signedDy = if (corner == Corner.BOTTOM_LEFT || corner == Corner.BOTTOM_RIGHT) deltaY else -deltaY
176
+ val widthScale = (originalRect.width() + signedDx) / max(originalRect.width(), 1f)
177
+ val heightScale = (originalRect.height() + signedDy) / max(originalRect.height(), 1f)
178
+ val scale = max(max(widthScale, heightScale), minimumImageSizePx / max(originalRect.width(), 1f))
179
+ val unclampedWidth = max(minimumImageSizePx, originalRect.width() * scale)
180
+ val unclampedHeight = max(minimumImageSizePx / aspectRatio, unclampedWidth / aspectRatio)
181
+ val (width, height) = editorView?.let { boundEditor ->
182
+ maximumWidthPx?.let { maxWidth ->
183
+ boundEditor.clampImageSize(
184
+ widthPx = unclampedWidth,
185
+ heightPx = unclampedHeight,
186
+ maximumWidthPx = maxWidth
187
+ )
188
+ } ?: boundEditor.clampImageSize(unclampedWidth, unclampedHeight)
189
+ } ?: (unclampedWidth to unclampedHeight)
190
+ val anchor = anchorPoint(corner, originalRect)
191
+
192
+ return when (corner) {
193
+ Corner.TOP_LEFT -> RectF(anchor.x - width, anchor.y - height, anchor.x, anchor.y)
194
+ Corner.TOP_RIGHT -> RectF(anchor.x, anchor.y - height, anchor.x + width, anchor.y)
195
+ Corner.BOTTOM_LEFT -> RectF(anchor.x - width, anchor.y, anchor.x, anchor.y + height)
196
+ Corner.BOTTOM_RIGHT -> RectF(anchor.x, anchor.y, anchor.x + width, anchor.y + height)
197
+ }
198
+ }
199
+ }