@apollohg/react-native-prose-editor 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/android/build.gradle +7 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
- package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +129 -0
- package/dist/EditorToolbar.js +394 -0
- package/dist/NativeEditorBridge.d.ts +242 -0
- package/dist/NativeEditorBridge.js +647 -0
- package/dist/NativeRichTextEditor.d.ts +142 -0
- package/dist/NativeRichTextEditor.js +649 -0
- package/dist/YjsCollaboration.d.ts +83 -0
- package/dist/YjsCollaboration.js +585 -0
- package/dist/addons.d.ts +70 -0
- package/dist/addons.js +77 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +26 -0
- package/dist/schemas.d.ts +35 -0
- package/{src/schemas.ts → dist/schemas.js} +62 -27
- package/dist/useNativeEditor.d.ts +40 -0
- package/dist/useNativeEditor.js +117 -0
- package/ios/EditorAddons.swift +26 -3
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- 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 +236 -0
- package/ios/EditorTheme.swift +51 -1
- package/ios/Generated_editor_core.swift +270 -2
- package/ios/NativeEditorExpoView.swift +612 -45
- package/ios/NativeEditorModule.swift +81 -0
- package/ios/PositionBridge.swift +22 -0
- package/ios/RenderBridge.swift +427 -39
- package/ios/RichTextEditorView.swift +1342 -18
- package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
- package/package.json +80 -64
- 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 +404 -4
- package/src/EditorToolbar.tsx +0 -620
- package/src/NativeEditorBridge.ts +0 -607
- package/src/NativeRichTextEditor.tsx +0 -951
- package/src/addons.ts +0 -158
- package/src/index.ts +0 -63
- package/src/useNativeEditor.ts +0 -173
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# React Native Prose Editor
|
|
1
|
+
# React Native Prose Editor [](https://www.npmjs.com/package/@apollohg/react-native-prose-editor)
|
|
2
2
|
|
|
3
3
|
`@apollohg/react-native-prose-editor` is a native rich text editor for React Native with a Rust document core, native iOS and Android rendering, configurable schemas, and a React-facing toolbar and theme API.
|
|
4
4
|
|
|
5
|
-
This project is currently in alpha and the API, behavior, and packaging may still change.
|
|
5
|
+
This project is currently in `alpha` and the API, behavior, and packaging may still change.
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<img src="./docs/images/example-
|
|
9
|
-
<img src="./docs/images/example-
|
|
8
|
+
<img src="./docs/images/example-ios.png" alt="Example editor Android" width="45%" align="top" />
|
|
9
|
+
<img src="./docs/images/example-android.png" alt="Example editor iOS" width="45%" align="top" />
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
This repository contains three main pieces:
|
|
@@ -21,7 +21,8 @@ The editor already supports:
|
|
|
21
21
|
|
|
22
22
|
- HTML and ProseMirror JSON content input/output
|
|
23
23
|
- configurable schemas
|
|
24
|
-
- marks such as bold, italic, underline, and
|
|
24
|
+
- marks such as bold, italic, underline, strike, and links
|
|
25
|
+
- blockquotes
|
|
25
26
|
- bullet and ordered lists with indent/outdent behavior
|
|
26
27
|
- hard breaks and horizontal rules
|
|
27
28
|
- native @-mentions with themed suggestion UI in the toolbar area
|
|
@@ -99,14 +100,17 @@ export function EditorScreen() {
|
|
|
99
100
|
The main extension points today are:
|
|
100
101
|
|
|
101
102
|
- `schema`: provide a custom schema definition
|
|
102
|
-
- `theme`: style text blocks, lists, horizontal rules, background, and toolbar chrome
|
|
103
|
+
- `theme`: style text blocks, blockquotes, lists, horizontal rules, background, and toolbar chrome, including a native-looking keyboard toolbar mode
|
|
103
104
|
- `toolbarItems`: define the visible toolbar controls and order
|
|
104
105
|
- `onToolbarAction`: handle app-defined toolbar buttons
|
|
106
|
+
- `onRequestLink`: collect or edit hyperlink URLs when a toolbar link item is pressed
|
|
105
107
|
- `addons`: configure optional features like @-mentions
|
|
106
108
|
- `heightBehavior`: switch between internal scrolling and auto-grow
|
|
107
109
|
|
|
108
110
|
For setup and customization details, start with the [Documentation Index](./docs/README.md).
|
|
109
111
|
|
|
112
|
+
For realtime collaboration, including the correct `useYjsCollaboration()` wiring, encoded-state persistence, remote cursors, and automatic reconnect behavior, see the [Collaboration Guide](./docs/modules/collaboration.md).
|
|
113
|
+
|
|
110
114
|
## Development
|
|
111
115
|
|
|
112
116
|
Common commands:
|
|
@@ -132,8 +136,9 @@ npm run android:test # Android Robolectric test
|
|
|
132
136
|
- [Documentation Index](./docs/README.md): main documentation index
|
|
133
137
|
- [Installation Guide](./docs/guides/installation.md): installation and local setup
|
|
134
138
|
- [Getting Started](./docs/guides/getting-started.md): first setup and first editor
|
|
139
|
+
- [Collaboration Guide](./docs/modules/collaboration.md): Yjs collaboration wiring, source-of-truth rules, and persistence
|
|
135
140
|
- [Toolbar Setup](./docs/guides/toolbar-setup.md): toolbar setup patterns and examples
|
|
136
|
-
- [Mentions Guide](./docs/
|
|
141
|
+
- [Mentions Guide](./docs/modules/mentions.md): @-mentions addon setup and configuration
|
|
137
142
|
- [Styling Guide](./docs/guides/styling.md): content, toolbar, and mention styling
|
|
138
143
|
- [NativeRichTextEditor Reference](./docs/reference/native-rich-text-editor.md): component props and ref methods
|
|
139
144
|
- [Design Decisions](./docs/explanations/design-decisions.md): rationale for key API and architecture decisions
|
package/android/build.gradle
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import groovy.json.JsonSlurper
|
|
2
|
+
|
|
1
3
|
plugins {
|
|
2
4
|
id 'com.android.library'
|
|
3
5
|
id 'expo-module-gradle-plugin'
|
|
4
6
|
}
|
|
5
7
|
|
|
8
|
+
def packageJson = new JsonSlurper().parse(file('../package.json'))
|
|
9
|
+
|
|
6
10
|
group = 'com.apollohg'
|
|
7
|
-
version =
|
|
11
|
+
version = packageJson.version
|
|
8
12
|
|
|
9
13
|
android {
|
|
10
14
|
namespace "com.apollohg.editor"
|
|
@@ -13,7 +17,7 @@ android {
|
|
|
13
17
|
}
|
|
14
18
|
defaultConfig {
|
|
15
19
|
versionCode 1
|
|
16
|
-
versionName
|
|
20
|
+
versionName packageJson.version
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
// Include prebuilt Rust .so files from the package's rust/android/ directory
|
|
@@ -32,6 +36,7 @@ android {
|
|
|
32
36
|
|
|
33
37
|
dependencies {
|
|
34
38
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
|
39
|
+
implementation "com.google.android.material:material:1.12.0"
|
|
35
40
|
implementation "net.java.dev.jna:jna:5.14.0@aar"
|
|
36
41
|
|
|
37
42
|
testImplementation "junit:junit:4.13.2"
|
|
@@ -4,8 +4,10 @@ import android.content.ClipboardManager
|
|
|
4
4
|
import android.content.Context
|
|
5
5
|
import android.graphics.Typeface
|
|
6
6
|
import android.graphics.Rect
|
|
7
|
+
import android.graphics.RectF
|
|
7
8
|
import android.text.Editable
|
|
8
9
|
import android.text.Layout
|
|
10
|
+
import android.text.Spanned
|
|
9
11
|
import android.text.StaticLayout
|
|
10
12
|
import android.text.TextWatcher
|
|
11
13
|
import android.util.AttributeSet
|
|
@@ -16,6 +18,7 @@ import android.view.MotionEvent
|
|
|
16
18
|
import android.view.inputmethod.EditorInfo
|
|
17
19
|
import android.view.inputmethod.InputConnection
|
|
18
20
|
import androidx.appcompat.widget.AppCompatEditText
|
|
21
|
+
import kotlin.math.roundToInt
|
|
19
22
|
import uniffi.editor_core.* // UniFFI-generated bindings
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -48,6 +51,16 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
48
51
|
attrs: AttributeSet? = null,
|
|
49
52
|
defStyleAttr: Int = android.R.attr.editTextStyle
|
|
50
53
|
) : AppCompatEditText(context, attrs, defStyleAttr) {
|
|
54
|
+
data class SelectedImageGeometry(
|
|
55
|
+
val docPos: Int,
|
|
56
|
+
val rect: RectF
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
private data class ImageSelectionRange(
|
|
60
|
+
val start: Int,
|
|
61
|
+
val end: Int
|
|
62
|
+
)
|
|
63
|
+
|
|
51
64
|
/**
|
|
52
65
|
* Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
|
|
53
66
|
*/
|
|
@@ -79,6 +92,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
79
92
|
|
|
80
93
|
/** Listener for editor events. */
|
|
81
94
|
var editorListener: EditorListener? = null
|
|
95
|
+
var onSelectionOrContentMayChange: (() -> Unit)? = null
|
|
82
96
|
|
|
83
97
|
/** The base font size in pixels used for unstyled text. */
|
|
84
98
|
private var baseFontSize: Float = textSize
|
|
@@ -93,8 +107,15 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
93
107
|
var theme: EditorTheme? = null
|
|
94
108
|
private set
|
|
95
109
|
|
|
110
|
+
var placeholderText: String = ""
|
|
111
|
+
set(value) {
|
|
112
|
+
field = value
|
|
113
|
+
invalidate()
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
|
|
97
117
|
private set
|
|
118
|
+
private var imageResizingEnabled = true
|
|
98
119
|
|
|
99
120
|
private var contentInsets: EditorContentInsets? = null
|
|
100
121
|
private var viewportBottomInsetPx: Int = 0
|
|
@@ -114,6 +135,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
114
135
|
|
|
115
136
|
private var lastHandledHardwareKeyCode: Int? = null
|
|
116
137
|
private var lastHandledHardwareKeyDownTime: Long? = null
|
|
138
|
+
private var explicitSelectedImageRange: ImageSelectionRange? = null
|
|
117
139
|
|
|
118
140
|
init {
|
|
119
141
|
// Configure for rich text editing.
|
|
@@ -139,6 +161,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
139
161
|
// Strip the default EditText theme drawable which carries implicit padding.
|
|
140
162
|
// Background color is applied in setBaseStyle() / applyTheme().
|
|
141
163
|
background = null
|
|
164
|
+
linksClickable = false
|
|
142
165
|
updateEffectivePadding()
|
|
143
166
|
}
|
|
144
167
|
|
|
@@ -160,7 +183,35 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
160
183
|
return super.dispatchKeyEvent(event)
|
|
161
184
|
}
|
|
162
185
|
|
|
186
|
+
override fun onDraw(canvas: android.graphics.Canvas) {
|
|
187
|
+
super.onDraw(canvas)
|
|
188
|
+
|
|
189
|
+
if (!shouldDisplayPlaceholder()) return
|
|
190
|
+
|
|
191
|
+
val availableWidth = width - compoundPaddingLeft - compoundPaddingRight
|
|
192
|
+
if (availableWidth <= 0) return
|
|
193
|
+
|
|
194
|
+
val previousColor = paint.color
|
|
195
|
+
val saveCount = canvas.save()
|
|
196
|
+
paint.color = currentHintTextColor
|
|
197
|
+
canvas.translate(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat())
|
|
198
|
+
StaticLayout.Builder
|
|
199
|
+
.obtain(placeholderText, 0, placeholderText.length, paint, availableWidth)
|
|
200
|
+
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
|
201
|
+
.setIncludePad(includeFontPadding)
|
|
202
|
+
.build()
|
|
203
|
+
.draw(canvas)
|
|
204
|
+
canvas.restoreToCount(saveCount)
|
|
205
|
+
paint.color = previousColor
|
|
206
|
+
}
|
|
207
|
+
|
|
163
208
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
209
|
+
if (event.actionMasked == MotionEvent.ACTION_DOWN && imageSpanHitAt(event.x, event.y) == null) {
|
|
210
|
+
clearExplicitSelectedImageRange()
|
|
211
|
+
}
|
|
212
|
+
if (handleImageTap(event)) {
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
164
215
|
if (heightBehavior == EditorHeightBehavior.FIXED) {
|
|
165
216
|
val canScroll = canScrollVertically(-1) || canScrollVertically(1)
|
|
166
217
|
if (canScroll) {
|
|
@@ -175,6 +226,30 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
175
226
|
return super.onTouchEvent(event)
|
|
176
227
|
}
|
|
177
228
|
|
|
229
|
+
override fun performClick(): Boolean {
|
|
230
|
+
return super.performClick()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private fun isRenderedContentEmpty(content: CharSequence? = text): Boolean {
|
|
234
|
+
val renderedContent = content ?: return true
|
|
235
|
+
if (renderedContent.isEmpty()) return true
|
|
236
|
+
|
|
237
|
+
for (index in 0 until renderedContent.length) {
|
|
238
|
+
when (renderedContent[index]) {
|
|
239
|
+
EMPTY_BLOCK_PLACEHOLDER, '\n', '\r' -> continue
|
|
240
|
+
else -> return false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private fun shouldDisplayPlaceholder(): Boolean {
|
|
248
|
+
return placeholderText.isNotEmpty() && isRenderedContentEmpty()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fun shouldDisplayPlaceholderForTesting(): Boolean = shouldDisplayPlaceholder()
|
|
252
|
+
|
|
178
253
|
// ── Editor Binding ──────────────────────────────────────────────────
|
|
179
254
|
|
|
180
255
|
/**
|
|
@@ -272,6 +347,16 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
272
347
|
}
|
|
273
348
|
}
|
|
274
349
|
|
|
350
|
+
fun setImageResizingEnabled(enabled: Boolean) {
|
|
351
|
+
if (imageResizingEnabled == enabled) return
|
|
352
|
+
imageResizingEnabled = enabled
|
|
353
|
+
if (!enabled) {
|
|
354
|
+
clearExplicitSelectedImageRange()
|
|
355
|
+
} else {
|
|
356
|
+
onSelectionOrContentMayChange?.invoke()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
275
360
|
fun resolveAutoGrowHeight(): Int {
|
|
276
361
|
val laidOutTextHeight = if (isLaidOut) layout?.height else null
|
|
277
362
|
if (laidOutTextHeight != null && laidOutTextHeight > 0) {
|
|
@@ -669,6 +754,17 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
669
754
|
applyUpdateJSON(updateJSON)
|
|
670
755
|
}
|
|
671
756
|
|
|
757
|
+
fun performToolbarToggleBlockquote() {
|
|
758
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
759
|
+
val selection = currentScalarSelection() ?: return
|
|
760
|
+
val updateJSON = editorToggleBlockquoteAtSelectionScalar(
|
|
761
|
+
editorId.toULong(),
|
|
762
|
+
selection.first.toUInt(),
|
|
763
|
+
selection.second.toUInt()
|
|
764
|
+
)
|
|
765
|
+
applyUpdateJSON(updateJSON)
|
|
766
|
+
}
|
|
767
|
+
|
|
672
768
|
fun performToolbarIndentListItem() {
|
|
673
769
|
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
674
770
|
val selection = currentScalarSelection() ?: return
|
|
@@ -780,7 +876,12 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
780
876
|
*/
|
|
781
877
|
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
|
782
878
|
super.onSelectionChanged(selStart, selEnd)
|
|
879
|
+
val spannable = text as? Spanned
|
|
880
|
+
if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
|
|
881
|
+
explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
|
|
882
|
+
}
|
|
783
883
|
ensureSelectionVisible()
|
|
884
|
+
onSelectionOrContentMayChange?.invoke()
|
|
784
885
|
|
|
785
886
|
if (isApplyingRustState || editorId == 0L) return
|
|
786
887
|
|
|
@@ -840,6 +941,49 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
840
941
|
)
|
|
841
942
|
}
|
|
842
943
|
|
|
944
|
+
fun selectedImageGeometry(): SelectedImageGeometry? {
|
|
945
|
+
if (!imageResizingEnabled) return null
|
|
946
|
+
val spannable = text as? Spanned ?: return null
|
|
947
|
+
val selection = resolvedSelectedImageRange(spannable) ?: return null
|
|
948
|
+
val start = selection.start
|
|
949
|
+
val end = selection.end
|
|
950
|
+
val imageSpan = spannable
|
|
951
|
+
.getSpans(start, end, BlockImageSpan::class.java)
|
|
952
|
+
.firstOrNull() ?: return null
|
|
953
|
+
val spanStart = spannable.getSpanStart(imageSpan)
|
|
954
|
+
val spanEnd = spannable.getSpanEnd(imageSpan)
|
|
955
|
+
if (spanStart != start || spanEnd != end) return null
|
|
956
|
+
|
|
957
|
+
val textLayout = layout ?: return null
|
|
958
|
+
val currentText = text?.toString() ?: return null
|
|
959
|
+
val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
|
|
960
|
+
val docPos = if (editorId != 0L) {
|
|
961
|
+
editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
|
|
962
|
+
} else {
|
|
963
|
+
0
|
|
964
|
+
}
|
|
965
|
+
val line = textLayout.getLineForOffset(spanStart.coerceAtMost(maxOf(spannable.length - 1, 0)))
|
|
966
|
+
val rect = resolvedImageRect(textLayout, imageSpan, spanStart, spanEnd)
|
|
967
|
+
return SelectedImageGeometry(
|
|
968
|
+
docPos = docPos,
|
|
969
|
+
rect = rect
|
|
970
|
+
)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
|
|
974
|
+
if (editorId == 0L) return
|
|
975
|
+
val density = resources.displayMetrics.density
|
|
976
|
+
val widthDp = maxOf(48, (widthPx / density).roundToInt())
|
|
977
|
+
val heightDp = maxOf(48, (heightPx / density).roundToInt())
|
|
978
|
+
val updateJSON = editorResizeImageAtDocPos(
|
|
979
|
+
editorId.toULong(),
|
|
980
|
+
docPos.toUInt(),
|
|
981
|
+
widthDp.toUInt(),
|
|
982
|
+
heightDp.toUInt()
|
|
983
|
+
)
|
|
984
|
+
applyUpdateJSON(updateJSON)
|
|
985
|
+
}
|
|
986
|
+
|
|
843
987
|
private fun isSelectionInsideList(): Boolean {
|
|
844
988
|
if (editorId == 0L) return false
|
|
845
989
|
|
|
@@ -894,12 +1038,14 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
894
1038
|
baseFontSize,
|
|
895
1039
|
baseTextColor,
|
|
896
1040
|
theme,
|
|
897
|
-
resources.displayMetrics.density
|
|
1041
|
+
resources.displayMetrics.density,
|
|
1042
|
+
this
|
|
898
1043
|
)
|
|
899
1044
|
|
|
900
1045
|
val previousScrollX = scrollX
|
|
901
1046
|
val previousScrollY = scrollY
|
|
902
1047
|
|
|
1048
|
+
explicitSelectedImageRange = null
|
|
903
1049
|
isApplyingRustState = true
|
|
904
1050
|
setText(spannable)
|
|
905
1051
|
lastAuthorizedText = spannable.toString()
|
|
@@ -914,6 +1060,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
914
1060
|
if (notifyListener) {
|
|
915
1061
|
editorListener?.onEditorUpdate(updateJSON)
|
|
916
1062
|
}
|
|
1063
|
+
onSelectionOrContentMayChange?.invoke()
|
|
917
1064
|
if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
|
|
918
1065
|
requestLayout()
|
|
919
1066
|
} else {
|
|
@@ -935,16 +1082,19 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
935
1082
|
baseFontSize,
|
|
936
1083
|
baseTextColor,
|
|
937
1084
|
theme,
|
|
938
|
-
resources.displayMetrics.density
|
|
1085
|
+
resources.displayMetrics.density,
|
|
1086
|
+
this
|
|
939
1087
|
)
|
|
940
1088
|
|
|
941
1089
|
val previousScrollX = scrollX
|
|
942
1090
|
val previousScrollY = scrollY
|
|
943
1091
|
|
|
1092
|
+
explicitSelectedImageRange = null
|
|
944
1093
|
isApplyingRustState = true
|
|
945
1094
|
setText(spannable)
|
|
946
1095
|
lastAuthorizedText = spannable.toString()
|
|
947
1096
|
isApplyingRustState = false
|
|
1097
|
+
onSelectionOrContentMayChange?.invoke()
|
|
948
1098
|
if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
|
|
949
1099
|
requestLayout()
|
|
950
1100
|
} else {
|
|
@@ -952,6 +1102,142 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
952
1102
|
}
|
|
953
1103
|
}
|
|
954
1104
|
|
|
1105
|
+
private fun handleImageTap(event: MotionEvent): Boolean {
|
|
1106
|
+
if (!imageResizingEnabled) {
|
|
1107
|
+
return false
|
|
1108
|
+
}
|
|
1109
|
+
if (event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_UP) {
|
|
1110
|
+
return false
|
|
1111
|
+
}
|
|
1112
|
+
val hit = imageSpanHitAt(event.x, event.y) ?: return false
|
|
1113
|
+
requestFocus()
|
|
1114
|
+
selectExplicitImageRange(hit.first, hit.second)
|
|
1115
|
+
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
|
1116
|
+
performClick()
|
|
1117
|
+
}
|
|
1118
|
+
return true
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private fun imageSpanHitAt(x: Float, y: Float): Pair<Int, Int>? {
|
|
1122
|
+
val spannable = text as? Spanned ?: return null
|
|
1123
|
+
imageSpanRangeNearTouchOffset(spannable, x, y)?.let { return it }
|
|
1124
|
+
val textLayout = layout ?: return null
|
|
1125
|
+
return imageSpanRectHit(spannable, textLayout, x, y)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private fun imageSpanRectHit(
|
|
1129
|
+
spannable: Spanned,
|
|
1130
|
+
textLayout: Layout,
|
|
1131
|
+
x: Float,
|
|
1132
|
+
y: Float
|
|
1133
|
+
): Pair<Int, Int>? {
|
|
1134
|
+
val candidateSpans = spannable.getSpans(0, spannable.length, BlockImageSpan::class.java)
|
|
1135
|
+
for (span in candidateSpans) {
|
|
1136
|
+
val spanStart = spannable.getSpanStart(span)
|
|
1137
|
+
val spanEnd = spannable.getSpanEnd(span)
|
|
1138
|
+
if (spanStart < 0 || spanEnd <= spanStart) continue
|
|
1139
|
+
val rect = resolvedImageRect(textLayout, span, spanStart, spanEnd)
|
|
1140
|
+
if (rect.contains(x, y)) {
|
|
1141
|
+
return spanStart to spanEnd
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return null
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
|
|
1148
|
+
super.onFocusChanged(focused, direction, previouslyFocusedRect)
|
|
1149
|
+
if (!focused) {
|
|
1150
|
+
clearExplicitSelectedImageRange()
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
private fun selectExplicitImageRange(start: Int, end: Int) {
|
|
1155
|
+
explicitSelectedImageRange = ImageSelectionRange(start, end)
|
|
1156
|
+
if (selectionStart == start && selectionEnd == end) {
|
|
1157
|
+
onSelectionOrContentMayChange?.invoke()
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
setSelection(start, end)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private fun clearExplicitSelectedImageRange() {
|
|
1164
|
+
if (explicitSelectedImageRange == null) return
|
|
1165
|
+
explicitSelectedImageRange = null
|
|
1166
|
+
onSelectionOrContentMayChange?.invoke()
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private fun resolvedSelectedImageRange(spannable: Spanned): ImageSelectionRange? {
|
|
1170
|
+
explicitSelectedImageRange?.let { explicit ->
|
|
1171
|
+
if (isExactImageSpanRange(spannable, explicit.start, explicit.end)) {
|
|
1172
|
+
return explicit
|
|
1173
|
+
}
|
|
1174
|
+
explicitSelectedImageRange = null
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
val start = selectionStart
|
|
1178
|
+
val end = selectionEnd
|
|
1179
|
+
if (!isExactImageSpanRange(spannable, start, end)) return null
|
|
1180
|
+
return ImageSelectionRange(start, end)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private fun isExactImageSpanRange(spannable: Spanned, start: Int, end: Int): Boolean {
|
|
1184
|
+
if (start < 0 || end != start + 1) return false
|
|
1185
|
+
val imageSpan = spannable
|
|
1186
|
+
.getSpans(start, end, BlockImageSpan::class.java)
|
|
1187
|
+
.firstOrNull() ?: return false
|
|
1188
|
+
return spannable.getSpanStart(imageSpan) == start && spannable.getSpanEnd(imageSpan) == end
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private fun imageSpanRangeNearTouchOffset(
|
|
1192
|
+
spannable: Spanned,
|
|
1193
|
+
x: Float,
|
|
1194
|
+
y: Float
|
|
1195
|
+
): Pair<Int, Int>? {
|
|
1196
|
+
val safeOffset = runCatching { getOffsetForPosition(x, y) }.getOrNull() ?: return null
|
|
1197
|
+
val nearbyOffsets = linkedSetOf(
|
|
1198
|
+
safeOffset,
|
|
1199
|
+
(safeOffset - 1).coerceAtLeast(0),
|
|
1200
|
+
(safeOffset + 1).coerceAtMost(spannable.length)
|
|
1201
|
+
)
|
|
1202
|
+
for (offset in nearbyOffsets) {
|
|
1203
|
+
val searchStart = (offset - 1).coerceAtLeast(0)
|
|
1204
|
+
val searchEnd = (offset + 1).coerceAtMost(spannable.length)
|
|
1205
|
+
val imageSpan = spannable
|
|
1206
|
+
.getSpans(searchStart, searchEnd, BlockImageSpan::class.java)
|
|
1207
|
+
.firstOrNull() ?: continue
|
|
1208
|
+
val spanStart = spannable.getSpanStart(imageSpan)
|
|
1209
|
+
val spanEnd = spannable.getSpanEnd(imageSpan)
|
|
1210
|
+
if (spanStart >= 0 && spanEnd > spanStart) {
|
|
1211
|
+
return spanStart to spanEnd
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return null
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private fun resolvedImageRect(
|
|
1218
|
+
textLayout: Layout,
|
|
1219
|
+
imageSpan: BlockImageSpan,
|
|
1220
|
+
spanStart: Int,
|
|
1221
|
+
spanEnd: Int
|
|
1222
|
+
): RectF {
|
|
1223
|
+
imageSpan.currentDrawRect()?.let { drawnRect ->
|
|
1224
|
+
return drawnRect
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
val safeOffset = spanStart.coerceAtMost(maxOf((text?.length ?: 0) - 1, 0))
|
|
1228
|
+
val line = textLayout.getLineForOffset(safeOffset)
|
|
1229
|
+
val startHorizontal = textLayout.getPrimaryHorizontal(spanStart)
|
|
1230
|
+
val endHorizontal = textLayout.getPrimaryHorizontal(spanEnd)
|
|
1231
|
+
val (widthPx, heightPx) = imageSpan.currentSizePx()
|
|
1232
|
+
val left = compoundPaddingLeft + minOf(startHorizontal, endHorizontal)
|
|
1233
|
+
val right = compoundPaddingLeft + maxOf(
|
|
1234
|
+
maxOf(startHorizontal, endHorizontal),
|
|
1235
|
+
minOf(startHorizontal, endHorizontal) + widthPx
|
|
1236
|
+
)
|
|
1237
|
+
val top = extendedPaddingTop + textLayout.getLineBottom(line) - heightPx
|
|
1238
|
+
return RectF(left, top.toFloat(), right, top + heightPx.toFloat())
|
|
1239
|
+
}
|
|
1240
|
+
|
|
955
1241
|
/**
|
|
956
1242
|
* Apply a selection from a parsed JSON selection object.
|
|
957
1243
|
*
|
|
@@ -1052,6 +1338,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1052
1338
|
}
|
|
1053
1339
|
|
|
1054
1340
|
companion object {
|
|
1341
|
+
private const val EMPTY_BLOCK_PLACEHOLDER = '\u200B'
|
|
1055
1342
|
private const val LOG_TAG = "NativeEditor"
|
|
1056
1343
|
}
|
|
1057
1344
|
}
|
|
@@ -89,6 +89,27 @@ data class EditorHorizontalRuleTheme(
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
data class EditorBlockquoteTheme(
|
|
93
|
+
val text: EditorTextStyle? = null,
|
|
94
|
+
val indent: Float? = null,
|
|
95
|
+
val borderColor: Int? = null,
|
|
96
|
+
val borderWidth: Float? = null,
|
|
97
|
+
val markerGap: Float? = null
|
|
98
|
+
) {
|
|
99
|
+
companion object {
|
|
100
|
+
fun fromJson(json: JSONObject?): EditorBlockquoteTheme? {
|
|
101
|
+
json ?: return null
|
|
102
|
+
return EditorBlockquoteTheme(
|
|
103
|
+
text = EditorTextStyle.fromJson(json.optJSONObject("text")),
|
|
104
|
+
indent = json.optNullableFloat("indent"),
|
|
105
|
+
borderColor = parseColor(json.optNullableString("borderColor")),
|
|
106
|
+
borderWidth = json.optNullableFloat("borderWidth"),
|
|
107
|
+
markerGap = json.optNullableFloat("markerGap")
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
data class EditorMentionTheme(
|
|
93
114
|
val textColor: Int? = null,
|
|
94
115
|
val backgroundColor: Int? = null,
|
|
@@ -130,7 +151,22 @@ data class EditorMentionTheme(
|
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
153
|
|
|
154
|
+
enum class EditorToolbarAppearance {
|
|
155
|
+
CUSTOM,
|
|
156
|
+
NATIVE;
|
|
157
|
+
|
|
158
|
+
companion object {
|
|
159
|
+
fun fromRaw(raw: String?): EditorToolbarAppearance? =
|
|
160
|
+
when (raw?.trim()?.lowercase()) {
|
|
161
|
+
"custom" -> CUSTOM
|
|
162
|
+
"native" -> NATIVE
|
|
163
|
+
else -> null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
133
168
|
data class EditorToolbarTheme(
|
|
169
|
+
val appearance: EditorToolbarAppearance? = null,
|
|
134
170
|
val backgroundColor: Int? = null,
|
|
135
171
|
val borderColor: Int? = null,
|
|
136
172
|
val borderWidth: Float? = null,
|
|
@@ -144,10 +180,21 @@ data class EditorToolbarTheme(
|
|
|
144
180
|
val buttonActiveBackgroundColor: Int? = null,
|
|
145
181
|
val buttonBorderRadius: Float? = null
|
|
146
182
|
) {
|
|
183
|
+
fun resolvedKeyboardOffset(): Float = keyboardOffset ?: if (appearance == EditorToolbarAppearance.NATIVE) 8f else 0f
|
|
184
|
+
|
|
185
|
+
fun resolvedHorizontalInset(): Float = horizontalInset ?: if (appearance == EditorToolbarAppearance.NATIVE) 0f else 0f
|
|
186
|
+
|
|
187
|
+
fun resolvedBorderRadius(): Float = if (appearance == EditorToolbarAppearance.NATIVE) 32f else (borderRadius ?: 0f)
|
|
188
|
+
|
|
189
|
+
fun resolvedBorderWidth(): Float = borderWidth ?: if (appearance == EditorToolbarAppearance.NATIVE) 0f else 1f
|
|
190
|
+
|
|
191
|
+
fun resolvedButtonBorderRadius(): Float = if (appearance == EditorToolbarAppearance.NATIVE) 20f else (buttonBorderRadius ?: 6f)
|
|
192
|
+
|
|
147
193
|
companion object {
|
|
148
194
|
fun fromJson(json: JSONObject?): EditorToolbarTheme? {
|
|
149
195
|
json ?: return null
|
|
150
196
|
return EditorToolbarTheme(
|
|
197
|
+
appearance = EditorToolbarAppearance.fromRaw(json.optNullableString("appearance")),
|
|
151
198
|
backgroundColor = parseColor(json.optNullableString("backgroundColor")),
|
|
152
199
|
borderColor = parseColor(json.optNullableString("borderColor")),
|
|
153
200
|
borderWidth = json.optNullableFloat("borderWidth"),
|
|
@@ -187,6 +234,7 @@ data class EditorContentInsets(
|
|
|
187
234
|
data class EditorTheme(
|
|
188
235
|
val text: EditorTextStyle? = null,
|
|
189
236
|
val paragraph: EditorTextStyle? = null,
|
|
237
|
+
val blockquote: EditorBlockquoteTheme? = null,
|
|
190
238
|
val headings: Map<String, EditorTextStyle> = emptyMap(),
|
|
191
239
|
val list: EditorListTheme? = null,
|
|
192
240
|
val horizontalRule: EditorHorizontalRuleTheme? = null,
|
|
@@ -216,6 +264,7 @@ data class EditorTheme(
|
|
|
216
264
|
return EditorTheme(
|
|
217
265
|
text = EditorTextStyle.fromJson(root.optJSONObject("text")),
|
|
218
266
|
paragraph = EditorTextStyle.fromJson(root.optJSONObject("paragraph")),
|
|
267
|
+
blockquote = EditorBlockquoteTheme.fromJson(root.optJSONObject("blockquote")),
|
|
219
268
|
headings = headings,
|
|
220
269
|
list = EditorListTheme.fromJson(root.optJSONObject("list")),
|
|
221
270
|
horizontalRule = EditorHorizontalRuleTheme.fromJson(root.optJSONObject("horizontalRule")),
|
|
@@ -228,8 +277,9 @@ data class EditorTheme(
|
|
|
228
277
|
}
|
|
229
278
|
}
|
|
230
279
|
|
|
231
|
-
fun effectiveTextStyle(nodeType: String): EditorTextStyle {
|
|
280
|
+
fun effectiveTextStyle(nodeType: String, inBlockquote: Boolean = false): EditorTextStyle {
|
|
232
281
|
var style = text ?: EditorTextStyle()
|
|
282
|
+
style = style.mergedWith(if (inBlockquote) blockquote?.text else null)
|
|
233
283
|
if (nodeType == "paragraph") {
|
|
234
284
|
style = style.mergedWith(paragraph)
|
|
235
285
|
if (paragraph?.lineHeight == null) {
|