@apollohg/react-native-prose-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +160 -0
- package/README.md +143 -0
- package/android/build.gradle +39 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +228 -0
- package/ios/EditorCore.xcframework/Info.plist +44 -0
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +254 -0
- package/ios/EditorTheme.swift +372 -0
- package/ios/Generated_editor_core.swift +1143 -0
- package/ios/NativeEditorExpoView.swift +1417 -0
- package/ios/NativeEditorModule.swift +263 -0
- package/ios/PositionBridge.swift +278 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +825 -0
- package/ios/RichTextEditorView.swift +1559 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
- package/ios/editor_coreFFI/module.modulemap +7 -0
- package/ios/editor_coreFFI.h +904 -0
- package/ios/editor_coreFFI.modulemap +7 -0
- package/package.json +66 -0
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
- package/src/EditorTheme.ts +130 -0
- package/src/EditorToolbar.tsx +620 -0
- package/src/NativeEditorBridge.ts +607 -0
- package/src/NativeRichTextEditor.tsx +951 -0
- package/src/addons.ts +158 -0
- package/src/index.ts +63 -0
- package/src/schemas.ts +153 -0
- package/src/useNativeEditor.ts +173 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
package com.apollohg.editor
|
|
2
|
+
|
|
3
|
+
import android.content.ClipboardManager
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Typeface
|
|
6
|
+
import android.graphics.Rect
|
|
7
|
+
import android.text.Editable
|
|
8
|
+
import android.text.Layout
|
|
9
|
+
import android.text.StaticLayout
|
|
10
|
+
import android.text.TextWatcher
|
|
11
|
+
import android.util.AttributeSet
|
|
12
|
+
import android.util.Log
|
|
13
|
+
import android.util.TypedValue
|
|
14
|
+
import android.view.KeyEvent
|
|
15
|
+
import android.view.MotionEvent
|
|
16
|
+
import android.view.inputmethod.EditorInfo
|
|
17
|
+
import android.view.inputmethod.InputConnection
|
|
18
|
+
import androidx.appcompat.widget.AppCompatEditText
|
|
19
|
+
import uniffi.editor_core.* // UniFFI-generated bindings
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Custom [AppCompatEditText] subclass that intercepts all text input and routes it
|
|
23
|
+
* through the Rust editor-core engine via UniFFI bindings.
|
|
24
|
+
*
|
|
25
|
+
* Instead of letting Android's EditText internal text storage handle insertions
|
|
26
|
+
* and deletions, this class captures the user's intent (typing, deleting,
|
|
27
|
+
* pasting, autocorrect) and sends it to the Rust editor. The Rust editor
|
|
28
|
+
* returns render elements, which are converted to [android.text.SpannableStringBuilder]
|
|
29
|
+
* via [RenderBridge] and applied back to the EditText.
|
|
30
|
+
*
|
|
31
|
+
* This is the "input interception" pattern: the EditText is effectively
|
|
32
|
+
* a rendering surface, not a text editing engine.
|
|
33
|
+
*
|
|
34
|
+
* ## Composition Handling
|
|
35
|
+
*
|
|
36
|
+
* For CJK input methods, composing text is handled normally by the base
|
|
37
|
+
* [InputConnection]. When composition finalizes, we capture the result and
|
|
38
|
+
* route it through Rust.
|
|
39
|
+
*
|
|
40
|
+
* ## Thread Safety
|
|
41
|
+
*
|
|
42
|
+
* All EditText methods are called on the main thread. The UniFFI calls
|
|
43
|
+
* (`editor_insert_text`, `editor_delete_range`, etc.) are synchronous and
|
|
44
|
+
* fast enough for main-thread use.
|
|
45
|
+
*/
|
|
46
|
+
class EditorEditText @JvmOverloads constructor(
|
|
47
|
+
context: Context,
|
|
48
|
+
attrs: AttributeSet? = null,
|
|
49
|
+
defStyleAttr: Int = android.R.attr.editTextStyle
|
|
50
|
+
) : AppCompatEditText(context, attrs, defStyleAttr) {
|
|
51
|
+
/**
|
|
52
|
+
* Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
|
|
53
|
+
*/
|
|
54
|
+
interface EditorListener {
|
|
55
|
+
/** Called when the editor's selection changes (anchor and head as scalar offsets). */
|
|
56
|
+
fun onSelectionChanged(anchor: Int, head: Int)
|
|
57
|
+
|
|
58
|
+
/** Called when the editor content is updated after a Rust operation. */
|
|
59
|
+
fun onEditorUpdate(updateJSON: String)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The Rust editor instance ID (from editor_create / editor_create_with_max_length). */
|
|
63
|
+
var editorId: Long = 0
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Controls whether user input is accepted.
|
|
67
|
+
*
|
|
68
|
+
* When false, all user-input mutation entry points (typing, deletion,
|
|
69
|
+
* paste, composition) are blocked. Unlike [isEnabled], this preserves
|
|
70
|
+
* focus, text selection, and copy capability.
|
|
71
|
+
*/
|
|
72
|
+
var isEditable: Boolean = true
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Guard flag to prevent re-entrant input interception while we're
|
|
76
|
+
* applying state from Rust (calling [setText] or modifying text storage).
|
|
77
|
+
*/
|
|
78
|
+
var isApplyingRustState = false
|
|
79
|
+
|
|
80
|
+
/** Listener for editor events. */
|
|
81
|
+
var editorListener: EditorListener? = null
|
|
82
|
+
|
|
83
|
+
/** The base font size in pixels used for unstyled text. */
|
|
84
|
+
private var baseFontSize: Float = textSize
|
|
85
|
+
|
|
86
|
+
/** The base text color as an ARGB int. */
|
|
87
|
+
private var baseTextColor: Int = currentTextColor
|
|
88
|
+
|
|
89
|
+
/** The base background color before theme overrides. */
|
|
90
|
+
private var baseBackgroundColor: Int = android.graphics.Color.WHITE
|
|
91
|
+
|
|
92
|
+
/** Optional render theme supplied by React. */
|
|
93
|
+
var theme: EditorTheme? = null
|
|
94
|
+
private set
|
|
95
|
+
|
|
96
|
+
var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
|
|
97
|
+
private set
|
|
98
|
+
|
|
99
|
+
private var contentInsets: EditorContentInsets? = null
|
|
100
|
+
private var viewportBottomInsetPx: Int = 0
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The plain text from the last Rust-authorized render.
|
|
104
|
+
* Used by [ReconciliationWatcher] to detect unauthorized divergence.
|
|
105
|
+
*/
|
|
106
|
+
private var lastAuthorizedText: String = ""
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Number of reconciliation events triggered during this EditText's lifetime.
|
|
110
|
+
* Useful for monitoring and kill-condition analysis.
|
|
111
|
+
*/
|
|
112
|
+
var reconciliationCount: Int = 0
|
|
113
|
+
private set
|
|
114
|
+
|
|
115
|
+
private var lastHandledHardwareKeyCode: Int? = null
|
|
116
|
+
private var lastHandledHardwareKeyDownTime: Long? = null
|
|
117
|
+
|
|
118
|
+
init {
|
|
119
|
+
// Configure for rich text editing.
|
|
120
|
+
inputType = EditorInfo.TYPE_CLASS_TEXT or
|
|
121
|
+
EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE or
|
|
122
|
+
EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT or
|
|
123
|
+
EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES
|
|
124
|
+
|
|
125
|
+
// Disable built-in spell checking to avoid conflicts with Rust state.
|
|
126
|
+
// The Rust editor is the source of truth for text content.
|
|
127
|
+
isSaveEnabled = false
|
|
128
|
+
|
|
129
|
+
// Watch for unauthorized text mutations (IME, accessibility, etc.)
|
|
130
|
+
// and reconcile back to Rust's authoritative state.
|
|
131
|
+
addTextChangedListener(ReconciliationWatcher())
|
|
132
|
+
baseBackgroundColor = android.graphics.Color.WHITE
|
|
133
|
+
isVerticalScrollBarEnabled = true
|
|
134
|
+
overScrollMode = OVER_SCROLL_IF_CONTENT_SCROLLS
|
|
135
|
+
|
|
136
|
+
// Pin content to top-start to prevent theme-dependent vertical centering.
|
|
137
|
+
gravity = android.view.Gravity.TOP or android.view.Gravity.START
|
|
138
|
+
|
|
139
|
+
// Strip the default EditText theme drawable which carries implicit padding.
|
|
140
|
+
// Background color is applied in setBaseStyle() / applyTheme().
|
|
141
|
+
background = null
|
|
142
|
+
updateEffectivePadding()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── InputConnection Override ────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a custom [EditorInputConnection] that intercepts all input
|
|
149
|
+
* from the soft keyboard.
|
|
150
|
+
*/
|
|
151
|
+
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
|
152
|
+
val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
|
|
153
|
+
return EditorInputConnection(this, baseConnection)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
|
157
|
+
if (handleHardwareKeyEvent(event)) {
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
return super.dispatchKeyEvent(event)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
164
|
+
if (heightBehavior == EditorHeightBehavior.FIXED) {
|
|
165
|
+
val canScroll = canScrollVertically(-1) || canScrollVertically(1)
|
|
166
|
+
if (canScroll) {
|
|
167
|
+
when (event.actionMasked) {
|
|
168
|
+
MotionEvent.ACTION_DOWN,
|
|
169
|
+
MotionEvent.ACTION_MOVE -> parent?.requestDisallowInterceptTouchEvent(true)
|
|
170
|
+
MotionEvent.ACTION_UP,
|
|
171
|
+
MotionEvent.ACTION_CANCEL -> parent?.requestDisallowInterceptTouchEvent(false)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return super.onTouchEvent(event)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Editor Binding ──────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bind this EditText to a Rust editor instance and optionally apply initial content.
|
|
182
|
+
*
|
|
183
|
+
* @param id The editor ID from `editor_create()`.
|
|
184
|
+
* @param initialHTML Optional HTML to set as initial content.
|
|
185
|
+
*/
|
|
186
|
+
fun bindEditor(id: Long, initialHTML: String? = null) {
|
|
187
|
+
editorId = id
|
|
188
|
+
|
|
189
|
+
if (!initialHTML.isNullOrEmpty()) {
|
|
190
|
+
val renderJSON = editorSetHtml(editorId.toULong(), initialHTML)
|
|
191
|
+
applyRenderJSON(renderJSON)
|
|
192
|
+
} else {
|
|
193
|
+
// Pull current state from Rust (content may already be loaded via bridge).
|
|
194
|
+
val stateJSON = editorGetCurrentState(editorId.toULong())
|
|
195
|
+
applyUpdateJSON(stateJSON)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Unbind from the current editor instance.
|
|
201
|
+
*/
|
|
202
|
+
fun unbindEditor() {
|
|
203
|
+
editorId = 0
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fun setBaseStyle(fontSizePx: Float, textColor: Int, backgroundColor: Int) {
|
|
207
|
+
baseFontSize = fontSizePx
|
|
208
|
+
baseTextColor = textColor
|
|
209
|
+
baseBackgroundColor = backgroundColor
|
|
210
|
+
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx)
|
|
211
|
+
setTextColor(textColor)
|
|
212
|
+
setBackgroundColor(theme?.backgroundColor ?: backgroundColor)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fun applyTheme(theme: EditorTheme?) {
|
|
216
|
+
this.theme = theme
|
|
217
|
+
setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
|
|
218
|
+
applyContentInsets(theme?.contentInsets)
|
|
219
|
+
if (editorId != 0L) {
|
|
220
|
+
val previousScrollX = scrollX
|
|
221
|
+
val previousScrollY = scrollY
|
|
222
|
+
val stateJSON = editorGetCurrentState(editorId.toULong())
|
|
223
|
+
applyUpdateJSON(stateJSON, notifyListener = false)
|
|
224
|
+
if (heightBehavior == EditorHeightBehavior.FIXED) {
|
|
225
|
+
preserveScrollPosition(previousScrollX, previousScrollY)
|
|
226
|
+
} else {
|
|
227
|
+
requestLayout()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fun setHeightBehavior(heightBehavior: EditorHeightBehavior) {
|
|
233
|
+
if (this.heightBehavior == heightBehavior) return
|
|
234
|
+
this.heightBehavior = heightBehavior
|
|
235
|
+
isVerticalScrollBarEnabled = heightBehavior == EditorHeightBehavior.FIXED
|
|
236
|
+
overScrollMode = if (heightBehavior == EditorHeightBehavior.FIXED) {
|
|
237
|
+
OVER_SCROLL_IF_CONTENT_SCROLLS
|
|
238
|
+
} else {
|
|
239
|
+
OVER_SCROLL_NEVER
|
|
240
|
+
}
|
|
241
|
+
updateEffectivePadding()
|
|
242
|
+
ensureSelectionVisible()
|
|
243
|
+
requestLayout()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun applyContentInsets(contentInsets: EditorContentInsets?) {
|
|
247
|
+
this.contentInsets = contentInsets
|
|
248
|
+
updateEffectivePadding()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fun setViewportBottomInsetPx(bottomInsetPx: Int) {
|
|
252
|
+
val clampedInset = bottomInsetPx.coerceAtLeast(0)
|
|
253
|
+
if (viewportBottomInsetPx == clampedInset) return
|
|
254
|
+
viewportBottomInsetPx = clampedInset
|
|
255
|
+
updateEffectivePadding()
|
|
256
|
+
ensureSelectionVisible()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private fun updateEffectivePadding() {
|
|
260
|
+
val density = resources.displayMetrics.density
|
|
261
|
+
val left = ((contentInsets?.left ?: 0f) * density).toInt()
|
|
262
|
+
val top = ((contentInsets?.top ?: 0f) * density).toInt()
|
|
263
|
+
val right = ((contentInsets?.right ?: 0f) * density).toInt()
|
|
264
|
+
val bottom = ((contentInsets?.bottom ?: 0f) * density).toInt()
|
|
265
|
+
|
|
266
|
+
if (heightBehavior == EditorHeightBehavior.FIXED) {
|
|
267
|
+
setPadding(left, 0, right, 0)
|
|
268
|
+
setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
|
|
269
|
+
} else {
|
|
270
|
+
setPadding(left, top, right, bottom)
|
|
271
|
+
setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
fun resolveAutoGrowHeight(): Int {
|
|
276
|
+
val laidOutTextHeight = if (isLaidOut) layout?.height else null
|
|
277
|
+
if (laidOutTextHeight != null && laidOutTextHeight > 0) {
|
|
278
|
+
return laidOutTextHeight + compoundPaddingTop + compoundPaddingBottom
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
val availableWidth = (measuredWidth - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
|
|
282
|
+
val currentText = text
|
|
283
|
+
if (availableWidth > 0 && currentText != null) {
|
|
284
|
+
val staticLayout = StaticLayout.Builder
|
|
285
|
+
.obtain(currentText, 0, currentText.length, paint, availableWidth)
|
|
286
|
+
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
|
287
|
+
.setIncludePad(includeFontPadding)
|
|
288
|
+
.build()
|
|
289
|
+
val textHeight = staticLayout.height.takeIf { it > 0 } ?: lineHeight
|
|
290
|
+
return textHeight + compoundPaddingTop + compoundPaddingBottom
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
val minimumHeight = suggestedMinimumHeight.coerceAtLeast(minHeight)
|
|
294
|
+
return (lineHeight + compoundPaddingTop + compoundPaddingBottom).coerceAtLeast(minimumHeight)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private fun preserveScrollPosition(previousScrollX: Int, previousScrollY: Int) {
|
|
298
|
+
val restore = {
|
|
299
|
+
val maxScrollX = maxOf(0, computeHorizontalScrollRange() - width)
|
|
300
|
+
val maxScrollY = maxOf(0, computeVerticalScrollRange() - height)
|
|
301
|
+
scrollTo(
|
|
302
|
+
previousScrollX.coerceIn(0, maxScrollX),
|
|
303
|
+
previousScrollY.coerceIn(0, maxScrollY)
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
restore()
|
|
308
|
+
post { restore() }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private fun ensureSelectionVisible() {
|
|
312
|
+
if (heightBehavior != EditorHeightBehavior.FIXED) return
|
|
313
|
+
if (!isLaidOut || width <= 0 || height <= 0) return
|
|
314
|
+
val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return
|
|
315
|
+
|
|
316
|
+
post {
|
|
317
|
+
if (!isLaidOut || layout == null) return@post
|
|
318
|
+
bringPointIntoView(selectionOffset)
|
|
319
|
+
|
|
320
|
+
val textLayout = layout ?: return@post
|
|
321
|
+
val clampedOffset = selectionOffset.coerceAtMost(textLayout.text.length)
|
|
322
|
+
val line = textLayout.getLineForOffset(clampedOffset)
|
|
323
|
+
val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset).toInt()
|
|
324
|
+
val rect = Rect(
|
|
325
|
+
caretLeft + totalPaddingLeft,
|
|
326
|
+
textLayout.getLineTop(line) + totalPaddingTop,
|
|
327
|
+
caretLeft + totalPaddingLeft + 1,
|
|
328
|
+
textLayout.getLineBottom(line) + totalPaddingTop
|
|
329
|
+
)
|
|
330
|
+
requestRectangleOnScreen(rect)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Input Handling: Text Commit ─────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Handle committed text from the IME (typed characters, autocomplete).
|
|
338
|
+
*
|
|
339
|
+
* Called by [EditorInputConnection.commitText]. Routes the text through
|
|
340
|
+
* the Rust editor instead of directly inserting into the EditText.
|
|
341
|
+
*/
|
|
342
|
+
fun handleTextCommit(text: String) {
|
|
343
|
+
if (!isEditable) return
|
|
344
|
+
if (isApplyingRustState) return
|
|
345
|
+
if (editorId == 0L) {
|
|
346
|
+
// No Rust editor bound — fall through to direct editing (dev mode).
|
|
347
|
+
val editable = this.text ?: return
|
|
348
|
+
val start = selectionStart
|
|
349
|
+
val end = selectionEnd
|
|
350
|
+
editable.replace(start, end, text)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Handle Enter/Return as a block split operation.
|
|
355
|
+
if (text == "\n") {
|
|
356
|
+
handleReturnKey()
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
val currentText = this.text?.toString() ?: ""
|
|
361
|
+
val start = selectionStart
|
|
362
|
+
val end = selectionEnd
|
|
363
|
+
|
|
364
|
+
if (start != end) {
|
|
365
|
+
// Range selection: atomic replace via Rust.
|
|
366
|
+
val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
|
|
367
|
+
val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
|
|
368
|
+
val updateJSON = editorReplaceTextScalar(
|
|
369
|
+
editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt(), text
|
|
370
|
+
)
|
|
371
|
+
applyUpdateJSON(updateJSON)
|
|
372
|
+
} else {
|
|
373
|
+
val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
|
|
374
|
+
insertTextInRust(text, scalarPos)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Input Handling: Deletion ────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Handle surrounding text deletion from the IME.
|
|
382
|
+
*
|
|
383
|
+
* Called by [EditorInputConnection.deleteSurroundingText].
|
|
384
|
+
*
|
|
385
|
+
* @param beforeLength Number of UTF-16 code units to delete before the cursor.
|
|
386
|
+
* @param afterLength Number of UTF-16 code units to delete after the cursor.
|
|
387
|
+
*/
|
|
388
|
+
fun handleDelete(beforeLength: Int, afterLength: Int) {
|
|
389
|
+
if (!isEditable) return
|
|
390
|
+
if (isApplyingRustState) return
|
|
391
|
+
if (editorId == 0L) {
|
|
392
|
+
// Dev mode: direct editing.
|
|
393
|
+
val editable = this.text ?: return
|
|
394
|
+
val cursor = selectionStart
|
|
395
|
+
val delStart = maxOf(0, cursor - beforeLength)
|
|
396
|
+
val delEnd = minOf(editable.length, cursor + afterLength)
|
|
397
|
+
editable.delete(delStart, delEnd)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
val currentText = text?.toString() ?: ""
|
|
402
|
+
val cursor = selectionStart
|
|
403
|
+
val delStart = maxOf(0, cursor - beforeLength)
|
|
404
|
+
val delEnd = minOf(currentText.length, cursor + afterLength)
|
|
405
|
+
|
|
406
|
+
val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
|
|
407
|
+
val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
|
|
408
|
+
|
|
409
|
+
if (scalarStart < scalarEnd) {
|
|
410
|
+
deleteRangeInRust(scalarStart, scalarEnd)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Handle backspace key press (hardware keyboard or key event).
|
|
416
|
+
*
|
|
417
|
+
* If there's a range selection, deletes the range. Otherwise deletes
|
|
418
|
+
* the grapheme cluster before the cursor.
|
|
419
|
+
*/
|
|
420
|
+
fun handleBackspace() {
|
|
421
|
+
if (!isEditable) return
|
|
422
|
+
if (isApplyingRustState) return
|
|
423
|
+
if (editorId == 0L) {
|
|
424
|
+
// Dev mode: direct editing.
|
|
425
|
+
val editable = this.text ?: return
|
|
426
|
+
val start = selectionStart
|
|
427
|
+
val end = selectionEnd
|
|
428
|
+
if (start != end) {
|
|
429
|
+
editable.delete(start, end)
|
|
430
|
+
} else if (start > 0) {
|
|
431
|
+
// Delete one grapheme cluster backward.
|
|
432
|
+
val prevBoundary = PositionBridge.snapToGraphemeBoundary(start - 1, text?.toString() ?: "")
|
|
433
|
+
val adjustedPrev = if (prevBoundary >= start) maxOf(0, start - 1) else prevBoundary
|
|
434
|
+
editable.delete(adjustedPrev, start)
|
|
435
|
+
}
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
val currentText = text?.toString() ?: ""
|
|
440
|
+
val start = selectionStart
|
|
441
|
+
val end = selectionEnd
|
|
442
|
+
|
|
443
|
+
if (start != end) {
|
|
444
|
+
// Range selection: delete the range.
|
|
445
|
+
val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
|
|
446
|
+
val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
|
|
447
|
+
deleteRangeInRust(scalarStart, scalarEnd)
|
|
448
|
+
} else if (start > 0) {
|
|
449
|
+
// Cursor: delete one grapheme cluster backward.
|
|
450
|
+
// Find the previous grapheme boundary by snapping (start - 1).
|
|
451
|
+
val breakIter = java.text.BreakIterator.getCharacterInstance()
|
|
452
|
+
breakIter.setText(currentText)
|
|
453
|
+
val prevBoundary = breakIter.preceding(start)
|
|
454
|
+
val prevUtf16 = if (prevBoundary == java.text.BreakIterator.DONE) 0 else prevBoundary
|
|
455
|
+
|
|
456
|
+
val scalarStart = PositionBridge.utf16ToScalar(prevUtf16, currentText)
|
|
457
|
+
val scalarEnd = PositionBridge.utf16ToScalar(start, currentText)
|
|
458
|
+
deleteRangeInRust(scalarStart, scalarEnd)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Input Handling: Composition ─────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Handle finalization of IME composition (CJK input, swipe keyboard).
|
|
466
|
+
*
|
|
467
|
+
* Called by [EditorInputConnection.finishComposingText] after the base
|
|
468
|
+
* InputConnection has finalized the composing text.
|
|
469
|
+
*/
|
|
470
|
+
/**
|
|
471
|
+
* Handle finalization of IME composition.
|
|
472
|
+
*
|
|
473
|
+
* @param composedText The finalized composed text captured from the InputConnection.
|
|
474
|
+
*/
|
|
475
|
+
fun handleCompositionFinished(composedText: String?) {
|
|
476
|
+
if (!isEditable) return
|
|
477
|
+
if (isApplyingRustState) return
|
|
478
|
+
if (editorId == 0L) return
|
|
479
|
+
if (composedText.isNullOrEmpty()) return
|
|
480
|
+
|
|
481
|
+
// The cursor is at the end of the composed text. Calculate the insert
|
|
482
|
+
// position as cursor - composed_length (in scalar offsets).
|
|
483
|
+
val currentText = text?.toString() ?: ""
|
|
484
|
+
val cursorUtf16 = selectionStart
|
|
485
|
+
val cursorScalar = PositionBridge.utf16ToScalar(cursorUtf16, currentText)
|
|
486
|
+
val composedScalarLen = composedText.codePointCount(0, composedText.length)
|
|
487
|
+
val insertPos = if (cursorScalar >= composedScalarLen) cursorScalar - composedScalarLen else 0
|
|
488
|
+
insertTextInRust(composedText, insertPos)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Input Handling: Return Key ──────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Handle return/enter key as a block split operation.
|
|
495
|
+
*/
|
|
496
|
+
fun handleReturnKey() {
|
|
497
|
+
if (!isEditable) return
|
|
498
|
+
if (isApplyingRustState) return
|
|
499
|
+
|
|
500
|
+
val currentText = text?.toString() ?: ""
|
|
501
|
+
val start = selectionStart
|
|
502
|
+
val end = selectionEnd
|
|
503
|
+
|
|
504
|
+
if (editorId == 0L) {
|
|
505
|
+
// Dev mode: insert newline directly.
|
|
506
|
+
val editable = this.text ?: return
|
|
507
|
+
editable.replace(start, end, "\n")
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (start != end) {
|
|
512
|
+
// Range selection: atomic delete-and-split via Rust.
|
|
513
|
+
val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
|
|
514
|
+
val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
|
|
515
|
+
val updateJSON = editorDeleteAndSplitScalar(
|
|
516
|
+
editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt()
|
|
517
|
+
)
|
|
518
|
+
applyUpdateJSON(updateJSON)
|
|
519
|
+
} else {
|
|
520
|
+
val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
|
|
521
|
+
splitBlockInRust(scalarPos)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Handle Shift+Enter as an inline hard break insertion.
|
|
527
|
+
*/
|
|
528
|
+
fun handleHardBreak() {
|
|
529
|
+
if (!isEditable) return
|
|
530
|
+
if (isApplyingRustState) return
|
|
531
|
+
|
|
532
|
+
if (editorId == 0L) {
|
|
533
|
+
val editable = this.text ?: return
|
|
534
|
+
val start = selectionStart
|
|
535
|
+
val end = selectionEnd
|
|
536
|
+
editable.replace(start, end, "\n")
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
val selection = currentScalarSelection() ?: return
|
|
541
|
+
val updateJSON = editorInsertNodeAtSelectionScalar(
|
|
542
|
+
editorId.toULong(),
|
|
543
|
+
selection.first.toUInt(),
|
|
544
|
+
selection.second.toUInt(),
|
|
545
|
+
"hardBreak"
|
|
546
|
+
)
|
|
547
|
+
applyUpdateJSON(updateJSON)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Handle hardware Tab / Shift+Tab as list indent / outdent when the caret is in a list.
|
|
552
|
+
*/
|
|
553
|
+
fun handleTab(shiftPressed: Boolean): Boolean {
|
|
554
|
+
if (!isEditable) return false
|
|
555
|
+
if (isApplyingRustState) return false
|
|
556
|
+
if (editorId == 0L) return false
|
|
557
|
+
if (!isSelectionInsideList()) return false
|
|
558
|
+
val selection = currentScalarSelection() ?: return false
|
|
559
|
+
|
|
560
|
+
val updateJSON = if (shiftPressed) {
|
|
561
|
+
editorOutdentListItemAtSelectionScalar(
|
|
562
|
+
editorId.toULong(),
|
|
563
|
+
selection.first.toUInt(),
|
|
564
|
+
selection.second.toUInt()
|
|
565
|
+
)
|
|
566
|
+
} else {
|
|
567
|
+
editorIndentListItemAtSelectionScalar(
|
|
568
|
+
editorId.toULong(),
|
|
569
|
+
selection.first.toUInt(),
|
|
570
|
+
selection.second.toUInt()
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
applyUpdateJSON(updateJSON)
|
|
574
|
+
return true
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
fun handleHardwareKeyDown(keyCode: Int, shiftPressed: Boolean): Boolean {
|
|
578
|
+
if (!isEditable || isApplyingRustState) return false
|
|
579
|
+
return when (keyCode) {
|
|
580
|
+
KeyEvent.KEYCODE_DEL -> {
|
|
581
|
+
handleBackspace()
|
|
582
|
+
true
|
|
583
|
+
}
|
|
584
|
+
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
|
585
|
+
if (shiftPressed) {
|
|
586
|
+
handleHardBreak()
|
|
587
|
+
} else {
|
|
588
|
+
handleReturnKey()
|
|
589
|
+
}
|
|
590
|
+
true
|
|
591
|
+
}
|
|
592
|
+
KeyEvent.KEYCODE_TAB -> handleTab(shiftPressed)
|
|
593
|
+
else -> false
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
fun handleHardwareKeyEvent(event: KeyEvent?): Boolean {
|
|
598
|
+
if (event == null || !isEditable || isApplyingRustState) return false
|
|
599
|
+
|
|
600
|
+
return when (event.action) {
|
|
601
|
+
KeyEvent.ACTION_DOWN -> {
|
|
602
|
+
val supported = when (event.keyCode) {
|
|
603
|
+
KeyEvent.KEYCODE_DEL,
|
|
604
|
+
KeyEvent.KEYCODE_ENTER,
|
|
605
|
+
KeyEvent.KEYCODE_NUMPAD_ENTER,
|
|
606
|
+
KeyEvent.KEYCODE_TAB -> true
|
|
607
|
+
else -> false
|
|
608
|
+
}
|
|
609
|
+
if (!supported) return false
|
|
610
|
+
|
|
611
|
+
if (lastHandledHardwareKeyCode == event.keyCode &&
|
|
612
|
+
lastHandledHardwareKeyDownTime == event.downTime) {
|
|
613
|
+
return true
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
|
|
617
|
+
lastHandledHardwareKeyCode = event.keyCode
|
|
618
|
+
lastHandledHardwareKeyDownTime = event.downTime
|
|
619
|
+
true
|
|
620
|
+
} else {
|
|
621
|
+
false
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
KeyEvent.ACTION_UP -> {
|
|
626
|
+
if (lastHandledHardwareKeyCode == event.keyCode &&
|
|
627
|
+
lastHandledHardwareKeyDownTime == event.downTime) {
|
|
628
|
+
lastHandledHardwareKeyCode = null
|
|
629
|
+
lastHandledHardwareKeyDownTime = null
|
|
630
|
+
true
|
|
631
|
+
} else {
|
|
632
|
+
false
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
else -> false
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
fun performToolbarToggleMark(markName: String) {
|
|
641
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
642
|
+
val selection = currentScalarSelection() ?: return
|
|
643
|
+
val updateJSON = editorToggleMarkAtSelectionScalar(
|
|
644
|
+
editorId.toULong(),
|
|
645
|
+
selection.first.toUInt(),
|
|
646
|
+
selection.second.toUInt(),
|
|
647
|
+
markName
|
|
648
|
+
)
|
|
649
|
+
applyUpdateJSON(updateJSON)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
fun performToolbarToggleList(listType: String, isActive: Boolean) {
|
|
653
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
654
|
+
val selection = currentScalarSelection() ?: return
|
|
655
|
+
val updateJSON = if (isActive) {
|
|
656
|
+
editorUnwrapFromListAtSelectionScalar(
|
|
657
|
+
editorId.toULong(),
|
|
658
|
+
selection.first.toUInt(),
|
|
659
|
+
selection.second.toUInt()
|
|
660
|
+
)
|
|
661
|
+
} else {
|
|
662
|
+
editorWrapInListAtSelectionScalar(
|
|
663
|
+
editorId.toULong(),
|
|
664
|
+
selection.first.toUInt(),
|
|
665
|
+
selection.second.toUInt(),
|
|
666
|
+
listType
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
applyUpdateJSON(updateJSON)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
fun performToolbarIndentListItem() {
|
|
673
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
674
|
+
val selection = currentScalarSelection() ?: return
|
|
675
|
+
val updateJSON = editorIndentListItemAtSelectionScalar(
|
|
676
|
+
editorId.toULong(),
|
|
677
|
+
selection.first.toUInt(),
|
|
678
|
+
selection.second.toUInt()
|
|
679
|
+
)
|
|
680
|
+
applyUpdateJSON(updateJSON)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
fun performToolbarOutdentListItem() {
|
|
684
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
685
|
+
val selection = currentScalarSelection() ?: return
|
|
686
|
+
val updateJSON = editorOutdentListItemAtSelectionScalar(
|
|
687
|
+
editorId.toULong(),
|
|
688
|
+
selection.first.toUInt(),
|
|
689
|
+
selection.second.toUInt()
|
|
690
|
+
)
|
|
691
|
+
applyUpdateJSON(updateJSON)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
fun performToolbarInsertNode(nodeType: String) {
|
|
695
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
696
|
+
val selection = currentScalarSelection() ?: return
|
|
697
|
+
val updateJSON = editorInsertNodeAtSelectionScalar(
|
|
698
|
+
editorId.toULong(),
|
|
699
|
+
selection.first.toUInt(),
|
|
700
|
+
selection.second.toUInt(),
|
|
701
|
+
nodeType
|
|
702
|
+
)
|
|
703
|
+
applyUpdateJSON(updateJSON)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
fun performToolbarUndo() {
|
|
707
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
708
|
+
applyUpdateJSON(editorUndo(editorId.toULong()))
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fun performToolbarRedo() {
|
|
712
|
+
if (!isEditable || isApplyingRustState || editorId == 0L) return
|
|
713
|
+
applyUpdateJSON(editorRedo(editorId.toULong()))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── Input Handling: Paste ────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Intercept paste operations to route content through Rust.
|
|
720
|
+
*
|
|
721
|
+
* Attempts to extract HTML from the clipboard first (for rich text paste),
|
|
722
|
+
* falling back to plain text.
|
|
723
|
+
*/
|
|
724
|
+
override fun onTextContextMenuItem(id: Int): Boolean {
|
|
725
|
+
if (!isEditable && id == android.R.id.paste) return true
|
|
726
|
+
if (id == android.R.id.paste) {
|
|
727
|
+
handlePaste()
|
|
728
|
+
return true
|
|
729
|
+
}
|
|
730
|
+
return super.onTextContextMenuItem(id)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Block accessibility-initiated text mutations (paste, set text) when not editable.
|
|
735
|
+
* Selection and copy actions remain available.
|
|
736
|
+
*/
|
|
737
|
+
override fun performAccessibilityAction(action: Int, arguments: android.os.Bundle?): Boolean {
|
|
738
|
+
if (!isEditable && (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT
|
|
739
|
+
|| action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE)) {
|
|
740
|
+
return false
|
|
741
|
+
}
|
|
742
|
+
return super.performAccessibilityAction(action, arguments)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private fun handlePaste() {
|
|
746
|
+
if (editorId == 0L) {
|
|
747
|
+
// Dev mode: default paste behavior.
|
|
748
|
+
super.onTextContextMenuItem(android.R.id.paste)
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
|
753
|
+
?: return
|
|
754
|
+
val clip = clipboard.primaryClip ?: return
|
|
755
|
+
if (clip.itemCount == 0) return
|
|
756
|
+
|
|
757
|
+
val item = clip.getItemAt(0)
|
|
758
|
+
|
|
759
|
+
// Try HTML first for rich paste.
|
|
760
|
+
val htmlText = item.htmlText
|
|
761
|
+
if (htmlText != null) {
|
|
762
|
+
pasteHTML(htmlText)
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Fallback to plain text.
|
|
767
|
+
val plainText = item.text?.toString()
|
|
768
|
+
if (plainText != null) {
|
|
769
|
+
pastePlainText(plainText)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── Selection Change ────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Override to notify the listener when selection changes.
|
|
777
|
+
*
|
|
778
|
+
* Converts the EditText selection to scalar offsets and notifies both
|
|
779
|
+
* the listener and the Rust editor.
|
|
780
|
+
*/
|
|
781
|
+
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
|
782
|
+
super.onSelectionChanged(selStart, selEnd)
|
|
783
|
+
ensureSelectionVisible()
|
|
784
|
+
|
|
785
|
+
if (isApplyingRustState || editorId == 0L) return
|
|
786
|
+
|
|
787
|
+
val currentText = text?.toString() ?: ""
|
|
788
|
+
if (currentText != lastAuthorizedText) return
|
|
789
|
+
val scalarAnchor = PositionBridge.utf16ToScalar(selStart, currentText)
|
|
790
|
+
val scalarHead = PositionBridge.utf16ToScalar(selEnd, currentText)
|
|
791
|
+
|
|
792
|
+
// Sync selection to Rust (converts scalar→doc internally).
|
|
793
|
+
editorSetSelectionScalar(
|
|
794
|
+
editorId.toULong(),
|
|
795
|
+
scalarAnchor.toUInt(),
|
|
796
|
+
scalarHead.toUInt()
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
// Emit doc positions (not scalar offsets) to match the Selection contract.
|
|
800
|
+
val docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
|
|
801
|
+
val docHead = editorScalarToDoc(editorId.toULong(), scalarHead.toUInt()).toInt()
|
|
802
|
+
editorListener?.onSelectionChanged(docAnchor, docHead)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ── Rust Integration ────────────────────────────────────────────────
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Insert text at a scalar position via the Rust editor.
|
|
809
|
+
*/
|
|
810
|
+
private fun insertTextInRust(text: String, atScalarPos: Int) {
|
|
811
|
+
val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
|
|
812
|
+
applyUpdateJSON(updateJSON)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Delete a scalar range via the Rust editor.
|
|
817
|
+
*
|
|
818
|
+
* @param scalarFrom Start scalar offset (inclusive).
|
|
819
|
+
* @param scalarTo End scalar offset (exclusive).
|
|
820
|
+
*/
|
|
821
|
+
private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
|
|
822
|
+
if (scalarFrom >= scalarTo) return
|
|
823
|
+
val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
|
|
824
|
+
applyUpdateJSON(updateJSON)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Split a block at a scalar position via the Rust editor.
|
|
829
|
+
*/
|
|
830
|
+
private fun splitBlockInRust(atScalarPos: Int) {
|
|
831
|
+
val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
|
|
832
|
+
applyUpdateJSON(updateJSON)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private fun currentScalarSelection(): Pair<Int, Int>? {
|
|
836
|
+
val currentText = text?.toString() ?: return null
|
|
837
|
+
return Pair(
|
|
838
|
+
PositionBridge.utf16ToScalar(selectionStart, currentText),
|
|
839
|
+
PositionBridge.utf16ToScalar(selectionEnd, currentText)
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private fun isSelectionInsideList(): Boolean {
|
|
844
|
+
if (editorId == 0L) return false
|
|
845
|
+
|
|
846
|
+
return try {
|
|
847
|
+
val state = org.json.JSONObject(editorGetCurrentState(editorId.toULong()))
|
|
848
|
+
val nodes = state.optJSONObject("activeState")?.optJSONObject("nodes")
|
|
849
|
+
nodes?.optBoolean("bulletList", false) == true ||
|
|
850
|
+
nodes?.optBoolean("orderedList", false) == true
|
|
851
|
+
} catch (_: Exception) {
|
|
852
|
+
false
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Paste HTML content through Rust.
|
|
858
|
+
*/
|
|
859
|
+
private fun pasteHTML(html: String) {
|
|
860
|
+
val updateJSON = editorInsertContentHtml(editorId.toULong(), html)
|
|
861
|
+
applyUpdateJSON(updateJSON)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Paste plain text through Rust.
|
|
866
|
+
*/
|
|
867
|
+
private fun pastePlainText(text: String) {
|
|
868
|
+
val currentText = this.text?.toString() ?: ""
|
|
869
|
+
val scalarPos = PositionBridge.utf16ToScalar(selectionStart, currentText)
|
|
870
|
+
insertTextInRust(text, scalarPos)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ── Applying Rust State ─────────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Apply a full render update from Rust to the EditText.
|
|
877
|
+
*
|
|
878
|
+
* Parses the update JSON, converts render elements to [android.text.SpannableStringBuilder]
|
|
879
|
+
* via [RenderBridge], and replaces the EditText's content.
|
|
880
|
+
*
|
|
881
|
+
* @param updateJSON The JSON string from editor_insert_text, etc.
|
|
882
|
+
*/
|
|
883
|
+
fun applyUpdateJSON(updateJSON: String, notifyListener: Boolean = true) {
|
|
884
|
+
val update = try {
|
|
885
|
+
org.json.JSONObject(updateJSON)
|
|
886
|
+
} catch (_: Exception) {
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
val renderElements = update.optJSONArray("renderElements") ?: return
|
|
891
|
+
|
|
892
|
+
val spannable = RenderBridge.buildSpannableFromArray(
|
|
893
|
+
renderElements,
|
|
894
|
+
baseFontSize,
|
|
895
|
+
baseTextColor,
|
|
896
|
+
theme,
|
|
897
|
+
resources.displayMetrics.density
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
val previousScrollX = scrollX
|
|
901
|
+
val previousScrollY = scrollY
|
|
902
|
+
|
|
903
|
+
isApplyingRustState = true
|
|
904
|
+
setText(spannable)
|
|
905
|
+
lastAuthorizedText = spannable.toString()
|
|
906
|
+
isApplyingRustState = false
|
|
907
|
+
|
|
908
|
+
// Apply the selection from the update.
|
|
909
|
+
val selection = update.optJSONObject("selection")
|
|
910
|
+
if (selection != null) {
|
|
911
|
+
applySelectionFromJSON(selection)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (notifyListener) {
|
|
915
|
+
editorListener?.onEditorUpdate(updateJSON)
|
|
916
|
+
}
|
|
917
|
+
if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
|
|
918
|
+
requestLayout()
|
|
919
|
+
} else {
|
|
920
|
+
preserveScrollPosition(previousScrollX, previousScrollY)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Apply a render JSON string (just render elements, no update wrapper).
|
|
926
|
+
*
|
|
927
|
+
* Used for initial content loading (set_html / set_json return render
|
|
928
|
+
* elements directly, not wrapped in an EditorUpdate).
|
|
929
|
+
*
|
|
930
|
+
* @param renderJSON The JSON array string of render elements.
|
|
931
|
+
*/
|
|
932
|
+
fun applyRenderJSON(renderJSON: String) {
|
|
933
|
+
val spannable = RenderBridge.buildSpannable(
|
|
934
|
+
renderJSON,
|
|
935
|
+
baseFontSize,
|
|
936
|
+
baseTextColor,
|
|
937
|
+
theme,
|
|
938
|
+
resources.displayMetrics.density
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
val previousScrollX = scrollX
|
|
942
|
+
val previousScrollY = scrollY
|
|
943
|
+
|
|
944
|
+
isApplyingRustState = true
|
|
945
|
+
setText(spannable)
|
|
946
|
+
lastAuthorizedText = spannable.toString()
|
|
947
|
+
isApplyingRustState = false
|
|
948
|
+
if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
|
|
949
|
+
requestLayout()
|
|
950
|
+
} else {
|
|
951
|
+
preserveScrollPosition(previousScrollX, previousScrollY)
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Apply a selection from a parsed JSON selection object.
|
|
957
|
+
*
|
|
958
|
+
* The selection JSON matches the format from `serialize_editor_update`:
|
|
959
|
+
* ```json
|
|
960
|
+
* {"type": "text", "anchor": 5, "head": 5}
|
|
961
|
+
* {"type": "node", "pos": 10}
|
|
962
|
+
* {"type": "all"}
|
|
963
|
+
* ```
|
|
964
|
+
*
|
|
965
|
+
* anchor/head from Rust are **document positions** (include structural tokens).
|
|
966
|
+
* We convert doc→scalar via [editorDocToScalar] before converting to UTF-16.
|
|
967
|
+
*/
|
|
968
|
+
private fun applySelectionFromJSON(selection: org.json.JSONObject) {
|
|
969
|
+
val type = selection.optString("type", "") ?: return
|
|
970
|
+
|
|
971
|
+
isApplyingRustState = true
|
|
972
|
+
try {
|
|
973
|
+
val currentText = text?.toString() ?: ""
|
|
974
|
+
when (type) {
|
|
975
|
+
"text" -> {
|
|
976
|
+
val docAnchor = selection.optInt("anchor", 0)
|
|
977
|
+
val docHead = selection.optInt("head", 0)
|
|
978
|
+
// Convert doc positions to scalar offsets.
|
|
979
|
+
val scalarAnchor = editorDocToScalar(editorId.toULong(), docAnchor.toUInt()).toInt()
|
|
980
|
+
val scalarHead = editorDocToScalar(editorId.toULong(), docHead.toUInt()).toInt()
|
|
981
|
+
val startUtf16 = PositionBridge.scalarToUtf16(minOf(scalarAnchor, scalarHead), currentText)
|
|
982
|
+
val endUtf16 = PositionBridge.scalarToUtf16(maxOf(scalarAnchor, scalarHead), currentText)
|
|
983
|
+
val len = text?.length ?: 0
|
|
984
|
+
setSelection(
|
|
985
|
+
startUtf16.coerceIn(0, len),
|
|
986
|
+
endUtf16.coerceIn(0, len)
|
|
987
|
+
)
|
|
988
|
+
}
|
|
989
|
+
"node" -> {
|
|
990
|
+
val docPos = selection.optInt("pos", 0)
|
|
991
|
+
// Convert doc position to scalar offset.
|
|
992
|
+
val scalarPos = editorDocToScalar(editorId.toULong(), docPos.toUInt()).toInt()
|
|
993
|
+
val startUtf16 = PositionBridge.scalarToUtf16(scalarPos, currentText)
|
|
994
|
+
val len = text?.length ?: 0
|
|
995
|
+
val clamped = startUtf16.coerceIn(0, len)
|
|
996
|
+
// Select one character (the void node placeholder).
|
|
997
|
+
val endClamped = (clamped + 1).coerceAtMost(len)
|
|
998
|
+
setSelection(clamped, endClamped)
|
|
999
|
+
}
|
|
1000
|
+
"all" -> {
|
|
1001
|
+
selectAll()
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
} finally {
|
|
1005
|
+
isApplyingRustState = false
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ── Reconciliation ─────────────────────────────────────────────────
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* [TextWatcher] that detects when the EditText's text diverges from the
|
|
1013
|
+
* last Rust-authorized content (e.g., due to IME autocorrect, accessibility
|
|
1014
|
+
* services, or other Android framework mutations that bypass our
|
|
1015
|
+
* [EditorInputConnection]).
|
|
1016
|
+
*
|
|
1017
|
+
* When divergence is detected, Rust's current state is re-fetched and
|
|
1018
|
+
* re-applied — "Rust wins" — to maintain the invariant that the Rust
|
|
1019
|
+
* editor-core is the single source of truth for document content.
|
|
1020
|
+
*/
|
|
1021
|
+
private inner class ReconciliationWatcher : TextWatcher {
|
|
1022
|
+
|
|
1023
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
1024
|
+
// No-op: we only need afterTextChanged.
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
1028
|
+
// No-op: we only need afterTextChanged.
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
override fun afterTextChanged(s: Editable?) {
|
|
1032
|
+
if (isApplyingRustState) return
|
|
1033
|
+
if (editorId == 0L) return
|
|
1034
|
+
|
|
1035
|
+
val currentText = s?.toString() ?: ""
|
|
1036
|
+
if (currentText == lastAuthorizedText) return
|
|
1037
|
+
|
|
1038
|
+
// Text has diverged from Rust's authorized state.
|
|
1039
|
+
reconciliationCount++
|
|
1040
|
+
Log.w(
|
|
1041
|
+
LOG_TAG,
|
|
1042
|
+
"reconciliation: EditText diverged from Rust state" +
|
|
1043
|
+
" (count=$reconciliationCount," +
|
|
1044
|
+
" editText=${currentText.length} chars," +
|
|
1045
|
+
" authorized=${lastAuthorizedText.length} chars)"
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
// Re-fetch Rust's current state and re-apply ("Rust wins").
|
|
1049
|
+
val stateJSON = editorGetCurrentState(editorId.toULong())
|
|
1050
|
+
applyUpdateJSON(stateJSON)
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
companion object {
|
|
1055
|
+
private const val LOG_TAG = "NativeEditor"
|
|
1056
|
+
}
|
|
1057
|
+
}
|