@chaitrabhairappa/react-native-rich-text-editor 1.0.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 +21 -0
- package/README.md +220 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/richtext/editor/FloatingToolbar.kt +350 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorPackage.kt +16 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorView.kt +1292 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorViewManager.kt +236 -0
- package/ios/RichTextEditorView.swift +1574 -0
- package/ios/RichTextEditorViewManager.m +45 -0
- package/ios/RichTextEditorViewManager.swift +235 -0
- package/lib/commonjs/index.js +156 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/types.js +8 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/index.js +143 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/src/index.d.ts +7 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +76 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +78 -0
- package/react-native-richtext-editor.podspec +21 -0
- package/src/index.tsx +199 -0
- package/src/types.ts +125 -0
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
package com.richtext.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.Typeface
|
|
8
|
+
import android.graphics.drawable.GradientDrawable
|
|
9
|
+
import android.text.Editable
|
|
10
|
+
import android.text.SpannableStringBuilder
|
|
11
|
+
import android.text.Spanned
|
|
12
|
+
import android.text.TextWatcher
|
|
13
|
+
import android.text.Layout
|
|
14
|
+
import android.text.method.ScrollingMovementMethod
|
|
15
|
+
import android.text.style.*
|
|
16
|
+
import android.view.GestureDetector
|
|
17
|
+
import android.view.Gravity
|
|
18
|
+
import android.view.MotionEvent
|
|
19
|
+
import android.view.View
|
|
20
|
+
import android.view.ViewGroup
|
|
21
|
+
import android.view.WindowManager
|
|
22
|
+
import android.view.inputmethod.EditorInfo
|
|
23
|
+
import android.widget.PopupWindow
|
|
24
|
+
import android.widget.FrameLayout
|
|
25
|
+
import android.app.AlertDialog
|
|
26
|
+
import android.widget.EditText
|
|
27
|
+
import android.widget.LinearLayout
|
|
28
|
+
import com.facebook.react.bridge.Arguments
|
|
29
|
+
import com.facebook.react.bridge.ReactContext
|
|
30
|
+
import com.facebook.react.bridge.WritableArray
|
|
31
|
+
import com.facebook.react.bridge.WritableMap
|
|
32
|
+
import com.facebook.react.uimanager.events.RCTEventEmitter
|
|
33
|
+
|
|
34
|
+
class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompatEditText(context),
|
|
35
|
+
FloatingToolbar.ToolbarActionListener {
|
|
36
|
+
|
|
37
|
+
private var placeholder: String = ""
|
|
38
|
+
private var maxHeightValue: Int = 0
|
|
39
|
+
private var showToolbar: Boolean = true
|
|
40
|
+
private var variant: String = "outlined"
|
|
41
|
+
private var density: Float = 1f
|
|
42
|
+
private var isInternalChange = false
|
|
43
|
+
private var lastReportedHeight: Float = 0f
|
|
44
|
+
private var calculatedHeight: Float = 0f
|
|
45
|
+
private var minHeightPx: Float = 0f
|
|
46
|
+
private var isInitialized = false
|
|
47
|
+
|
|
48
|
+
// For flat variant bottom border
|
|
49
|
+
private val bottomBorderPaint = Paint().apply {
|
|
50
|
+
color = Color.parseColor("#E0E0E0")
|
|
51
|
+
strokeWidth = 1f
|
|
52
|
+
style = Paint.Style.STROKE
|
|
53
|
+
}
|
|
54
|
+
private var drawBottomBorder = false
|
|
55
|
+
|
|
56
|
+
// Undo/Redo stacks
|
|
57
|
+
private val undoStack = mutableListOf<CharSequence>()
|
|
58
|
+
private val redoStack = mutableListOf<CharSequence>()
|
|
59
|
+
private var lastSavedText: CharSequence = ""
|
|
60
|
+
|
|
61
|
+
// Floating toolbar
|
|
62
|
+
private var floatingToolbar: FloatingToolbar? = null
|
|
63
|
+
private var toolbarPopup: PopupWindow? = null
|
|
64
|
+
private var toolbarOptions: List<String>? = null
|
|
65
|
+
|
|
66
|
+
// Store selection for toolbar actions (selection might be lost when clicking toolbar)
|
|
67
|
+
private var savedSelectionStart: Int = 0
|
|
68
|
+
private var savedSelectionEnd: Int = 0
|
|
69
|
+
|
|
70
|
+
// Gesture detector for double-tap word selection
|
|
71
|
+
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
|
72
|
+
override fun onDoubleTap(e: MotionEvent): Boolean {
|
|
73
|
+
selectWordAtPosition(e.x, e.y)
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
|
78
|
+
// Hide toolbar on single tap (deselect)
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
init {
|
|
84
|
+
density = context.resources.displayMetrics.density
|
|
85
|
+
minHeightPx = 44 * density
|
|
86
|
+
calculatedHeight = minHeightPx
|
|
87
|
+
bottomBorderPaint.strokeWidth = density
|
|
88
|
+
|
|
89
|
+
val paddingHorizontal = (12 * density).toInt()
|
|
90
|
+
val paddingVertical = (10 * density).toInt()
|
|
91
|
+
|
|
92
|
+
setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
|
|
93
|
+
textSize = 16f
|
|
94
|
+
setTextColor(Color.BLACK)
|
|
95
|
+
setHintTextColor(Color.parseColor("#9E9E9E"))
|
|
96
|
+
gravity = Gravity.TOP or Gravity.START
|
|
97
|
+
isFocusable = true
|
|
98
|
+
isFocusableInTouchMode = true
|
|
99
|
+
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE
|
|
100
|
+
|
|
101
|
+
// Disable vertical scrolling by default
|
|
102
|
+
isVerticalScrollBarEnabled = false
|
|
103
|
+
|
|
104
|
+
// Set white background by default
|
|
105
|
+
setBackgroundColor(Color.WHITE)
|
|
106
|
+
|
|
107
|
+
// Default outlined style
|
|
108
|
+
applyVariantStyle()
|
|
109
|
+
|
|
110
|
+
// Setup toolbar
|
|
111
|
+
setupToolbar()
|
|
112
|
+
|
|
113
|
+
addTextChangedListener(object : TextWatcher {
|
|
114
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
|
115
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
|
116
|
+
override fun afterTextChanged(s: Editable?) {
|
|
117
|
+
if (!isInternalChange) {
|
|
118
|
+
sendContentChange()
|
|
119
|
+
saveToUndoStack()
|
|
120
|
+
}
|
|
121
|
+
post { updateContentSize() }
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
setOnFocusChangeListener { _, hasFocus ->
|
|
126
|
+
if (hasFocus) {
|
|
127
|
+
sendEvent("onEditorFocus", Arguments.createMap())
|
|
128
|
+
} else {
|
|
129
|
+
hideToolbar()
|
|
130
|
+
sendEvent("onEditorBlur", Arguments.createMap())
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
isInitialized = true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private fun setupToolbar() {
|
|
138
|
+
floatingToolbar = FloatingToolbar(context).apply {
|
|
139
|
+
listener = this@RichTextEditorView
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
toolbarPopup = PopupWindow(context).apply {
|
|
143
|
+
contentView = floatingToolbar
|
|
144
|
+
width = floatingToolbar?.getToolbarWidth() ?: WindowManager.LayoutParams.WRAP_CONTENT
|
|
145
|
+
height = floatingToolbar?.getToolbarHeight() ?: WindowManager.LayoutParams.WRAP_CONTENT
|
|
146
|
+
isOutsideTouchable = true
|
|
147
|
+
isFocusable = false // Don't take focus away from EditText
|
|
148
|
+
isTouchable = true
|
|
149
|
+
elevation = 10 * density
|
|
150
|
+
|
|
151
|
+
// Don't dim the background
|
|
152
|
+
setBackgroundDrawable(null)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Disable the default text selection action mode (cut/copy/paste bar)
|
|
156
|
+
customSelectionActionModeCallback = object : android.view.ActionMode.Callback {
|
|
157
|
+
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
|
158
|
+
// Return true to create the action mode, but we'll clear the menu
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
|
163
|
+
// Clear the default menu items
|
|
164
|
+
menu?.clear()
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean {
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
override fun onDestroyActionMode(mode: android.view.ActionMode?) {
|
|
173
|
+
// Do nothing
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
customInsertionActionModeCallback = object : android.view.ActionMode.Callback {
|
|
178
|
+
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
|
183
|
+
menu?.clear()
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
override fun onDestroyActionMode(mode: android.view.ActionMode?) {}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
|
196
|
+
super.onSelectionChanged(selStart, selEnd)
|
|
197
|
+
|
|
198
|
+
// Skip if not initialized yet (during construction)
|
|
199
|
+
if (!isInitialized) return
|
|
200
|
+
|
|
201
|
+
android.util.Log.d("RichTextEditor", "onSelectionChanged: start=$selStart, end=$selEnd, showToolbar=$showToolbar, hasFocus=${hasFocus()}")
|
|
202
|
+
|
|
203
|
+
// Save selection for toolbar actions
|
|
204
|
+
if (selStart != selEnd) {
|
|
205
|
+
savedSelectionStart = selStart
|
|
206
|
+
savedSelectionEnd = selEnd
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Send selection change event
|
|
210
|
+
val map = Arguments.createMap()
|
|
211
|
+
map.putInt("start", selStart)
|
|
212
|
+
map.putInt("end", selEnd)
|
|
213
|
+
sendEvent("onSelectionChange", map)
|
|
214
|
+
|
|
215
|
+
// Show/hide toolbar based on selection
|
|
216
|
+
if (selStart != selEnd && showToolbar && hasFocus()) {
|
|
217
|
+
android.util.Log.d("RichTextEditor", "Should show toolbar - selection exists")
|
|
218
|
+
removeCallbacks(hideToolbarRunnable)
|
|
219
|
+
// Use postDelayed to ensure layout is complete
|
|
220
|
+
postDelayed({ showToolbarAtSelection() }, 50)
|
|
221
|
+
} else {
|
|
222
|
+
// Delay hiding to prevent flicker during selection changes
|
|
223
|
+
android.util.Log.d("RichTextEditor", "Scheduling hide toolbar")
|
|
224
|
+
postDelayed(hideToolbarRunnable, 200)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Update toolbar button states
|
|
228
|
+
if (selStart != selEnd) {
|
|
229
|
+
updateToolbarButtonStates()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private val hideToolbarRunnable = Runnable {
|
|
234
|
+
android.util.Log.d("RichTextEditor", "hideToolbarRunnable: selectionStart=$selectionStart, selectionEnd=$selectionEnd")
|
|
235
|
+
if (selectionStart == selectionEnd) {
|
|
236
|
+
hideToolbar()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private fun showToolbarAtSelection() {
|
|
241
|
+
if (!showToolbar || toolbarPopup == null || floatingToolbar == null) return
|
|
242
|
+
if (!isAttachedToWindow) return
|
|
243
|
+
|
|
244
|
+
val selStart = selectionStart
|
|
245
|
+
val selEnd = selectionEnd
|
|
246
|
+
if (selStart == selEnd) return
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
val textLayout = layout ?: return
|
|
250
|
+
|
|
251
|
+
// Get the line of the end of selection
|
|
252
|
+
val endLine = textLayout.getLineForOffset(selEnd)
|
|
253
|
+
val lineBottom = textLayout.getLineBottom(endLine)
|
|
254
|
+
|
|
255
|
+
val location = IntArray(2)
|
|
256
|
+
getLocationOnScreen(location)
|
|
257
|
+
|
|
258
|
+
val toolbarWidth = floatingToolbar?.getToolbarWidth() ?: (300 * density).toInt()
|
|
259
|
+
val toolbarHeight = floatingToolbar?.getToolbarHeight() ?: (52 * density).toInt()
|
|
260
|
+
|
|
261
|
+
// Center horizontally
|
|
262
|
+
val screenWidth = context.resources.displayMetrics.widthPixels
|
|
263
|
+
var x = (screenWidth - toolbarWidth) / 2
|
|
264
|
+
|
|
265
|
+
// Ensure x is not negative
|
|
266
|
+
if (x < 0) x = 0
|
|
267
|
+
|
|
268
|
+
// Position BELOW the selection (like iOS: convertedRect.maxY + 8)
|
|
269
|
+
var y = location[1] + lineBottom + paddingTop + (8 * density).toInt()
|
|
270
|
+
|
|
271
|
+
// If toolbar would go off screen at bottom, show above selection
|
|
272
|
+
val screenHeight = context.resources.displayMetrics.heightPixels
|
|
273
|
+
if (y + toolbarHeight > screenHeight - (100 * density).toInt()) {
|
|
274
|
+
val startLine = textLayout.getLineForOffset(selStart)
|
|
275
|
+
val lineTop = textLayout.getLineTop(startLine)
|
|
276
|
+
y = location[1] + lineTop + paddingTop - toolbarHeight - (8 * density).toInt()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Ensure y is not negative
|
|
280
|
+
if (y < 0) y = (8 * density).toInt()
|
|
281
|
+
|
|
282
|
+
android.util.Log.d("RichTextEditor", "Showing toolbar at x=$x, y=$y, width=$toolbarWidth, height=$toolbarHeight")
|
|
283
|
+
|
|
284
|
+
toolbarPopup?.width = toolbarWidth
|
|
285
|
+
toolbarPopup?.height = toolbarHeight
|
|
286
|
+
|
|
287
|
+
// Use windowToken to get the activity's window
|
|
288
|
+
val token = windowToken
|
|
289
|
+
if (token == null) {
|
|
290
|
+
android.util.Log.e("RichTextEditor", "Window token is null, cannot show popup")
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (toolbarPopup?.isShowing == true) {
|
|
295
|
+
toolbarPopup?.update(x, y, toolbarWidth, toolbarHeight)
|
|
296
|
+
} else {
|
|
297
|
+
// Show at the root window using absolute coordinates
|
|
298
|
+
val decorView = (context as? android.app.Activity)?.window?.decorView
|
|
299
|
+
?: rootView
|
|
300
|
+
toolbarPopup?.showAtLocation(decorView, Gravity.NO_GRAVITY, x, y)
|
|
301
|
+
android.util.Log.d("RichTextEditor", "Toolbar popup shown: ${toolbarPopup?.isShowing}")
|
|
302
|
+
}
|
|
303
|
+
} catch (e: Exception) {
|
|
304
|
+
android.util.Log.e("RichTextEditor", "Error showing toolbar", e)
|
|
305
|
+
e.printStackTrace()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private fun hideToolbar() {
|
|
310
|
+
try {
|
|
311
|
+
if (toolbarPopup?.isShowing == true) {
|
|
312
|
+
android.util.Log.d("RichTextEditor", "Hiding toolbar")
|
|
313
|
+
toolbarPopup?.dismiss()
|
|
314
|
+
}
|
|
315
|
+
} catch (e: Exception) {
|
|
316
|
+
android.util.Log.e("RichTextEditor", "Error hiding toolbar", e)
|
|
317
|
+
e.printStackTrace()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private fun updateToolbarButtonStates() {
|
|
322
|
+
val start = selectionStart
|
|
323
|
+
val end = selectionEnd
|
|
324
|
+
if (start == end || text == null) {
|
|
325
|
+
floatingToolbar?.updateButtonStates()
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
val spannable = text as? Spanned ?: return
|
|
330
|
+
|
|
331
|
+
var hasBold = false
|
|
332
|
+
var hasItalic = false
|
|
333
|
+
var hasUnderline = false
|
|
334
|
+
var hasStrikethrough = false
|
|
335
|
+
var hasCode = false
|
|
336
|
+
var hasHighlight = false
|
|
337
|
+
|
|
338
|
+
// Check for style spans in selection
|
|
339
|
+
spannable.getSpans(start, end, StyleSpan::class.java).forEach { span ->
|
|
340
|
+
when (span.style) {
|
|
341
|
+
Typeface.BOLD -> hasBold = true
|
|
342
|
+
Typeface.ITALIC -> hasItalic = true
|
|
343
|
+
Typeface.BOLD_ITALIC -> {
|
|
344
|
+
hasBold = true
|
|
345
|
+
hasItalic = true
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
hasUnderline = spannable.getSpans(start, end, UnderlineSpan::class.java).isNotEmpty()
|
|
351
|
+
hasStrikethrough = spannable.getSpans(start, end, StrikethroughSpan::class.java).isNotEmpty()
|
|
352
|
+
hasCode = spannable.getSpans(start, end, TypefaceSpan::class.java).any { it.family == "monospace" }
|
|
353
|
+
hasHighlight = spannable.getSpans(start, end, BackgroundColorSpan::class.java).isNotEmpty()
|
|
354
|
+
|
|
355
|
+
// Check for list prefixes
|
|
356
|
+
val lineText = getCurrentLineText()
|
|
357
|
+
val hasBullet = lineText.startsWith("• ")
|
|
358
|
+
val hasNumbered = lineText.matches(Regex("^\\d+\\.\\s.*"))
|
|
359
|
+
val hasQuote = lineText.startsWith("\"") && lineText.endsWith("\"")
|
|
360
|
+
val hasChecklist = lineText.startsWith("☐ ") || lineText.startsWith("☑ ")
|
|
361
|
+
|
|
362
|
+
// Check alignment
|
|
363
|
+
var alignLeft = true
|
|
364
|
+
var alignCenter = false
|
|
365
|
+
var alignRight = false
|
|
366
|
+
|
|
367
|
+
spannable.getSpans(start, end, AlignmentSpan.Standard::class.java).firstOrNull()?.let { span ->
|
|
368
|
+
when (span.alignment) {
|
|
369
|
+
Layout.Alignment.ALIGN_CENTER -> {
|
|
370
|
+
alignLeft = false
|
|
371
|
+
alignCenter = true
|
|
372
|
+
}
|
|
373
|
+
Layout.Alignment.ALIGN_OPPOSITE -> {
|
|
374
|
+
alignLeft = false
|
|
375
|
+
alignRight = true
|
|
376
|
+
}
|
|
377
|
+
else -> {}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
floatingToolbar?.updateButtonStates(
|
|
382
|
+
bold = hasBold,
|
|
383
|
+
italic = hasItalic,
|
|
384
|
+
underline = hasUnderline,
|
|
385
|
+
strikethrough = hasStrikethrough,
|
|
386
|
+
code = hasCode,
|
|
387
|
+
highlight = hasHighlight,
|
|
388
|
+
bullet = hasBullet,
|
|
389
|
+
numbered = hasNumbered,
|
|
390
|
+
quote = hasQuote,
|
|
391
|
+
checklist = hasChecklist,
|
|
392
|
+
alignLeft = alignLeft,
|
|
393
|
+
alignCenter = alignCenter,
|
|
394
|
+
alignRight = alignRight
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private fun getCurrentLineText(): String {
|
|
399
|
+
val text = text?.toString() ?: return ""
|
|
400
|
+
val cursorPos = selectionStart
|
|
401
|
+
if (cursorPos < 0) return ""
|
|
402
|
+
|
|
403
|
+
var lineStart = cursorPos
|
|
404
|
+
while (lineStart > 0 && text[lineStart - 1] != '\n') {
|
|
405
|
+
lineStart--
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
var lineEnd = cursorPos
|
|
409
|
+
while (lineEnd < text.length && text[lineEnd] != '\n') {
|
|
410
|
+
lineEnd++
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return text.substring(lineStart, lineEnd)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private fun getLineRange(): Pair<Int, Int> {
|
|
417
|
+
val text = text?.toString() ?: return Pair(0, 0)
|
|
418
|
+
val start = selectionStart
|
|
419
|
+
val end = selectionEnd
|
|
420
|
+
|
|
421
|
+
var lineStart = start
|
|
422
|
+
while (lineStart > 0 && text[lineStart - 1] != '\n') {
|
|
423
|
+
lineStart--
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
var lineEnd = end
|
|
427
|
+
while (lineEnd < text.length && text[lineEnd] != '\n') {
|
|
428
|
+
lineEnd++
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return Pair(lineStart, lineEnd)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
435
|
+
gestureDetector.onTouchEvent(event)
|
|
436
|
+
return super.onTouchEvent(event)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private fun selectWordAtPosition(x: Float, y: Float) {
|
|
440
|
+
val layout = layout ?: return
|
|
441
|
+
val textContent = text?.toString() ?: return
|
|
442
|
+
|
|
443
|
+
// Convert touch position to text offset
|
|
444
|
+
val line = layout.getLineForVertical(y.toInt() - paddingTop)
|
|
445
|
+
val offset = layout.getOffsetForHorizontal(line, x - paddingLeft)
|
|
446
|
+
|
|
447
|
+
if (offset < 0 || offset > textContent.length) return
|
|
448
|
+
|
|
449
|
+
// Find word boundaries
|
|
450
|
+
var start = offset
|
|
451
|
+
var end = offset
|
|
452
|
+
|
|
453
|
+
// Move start to beginning of word
|
|
454
|
+
while (start > 0 && !Character.isWhitespace(textContent[start - 1])) {
|
|
455
|
+
start--
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Move end to end of word
|
|
459
|
+
while (end < textContent.length && !Character.isWhitespace(textContent[end])) {
|
|
460
|
+
end++
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Select the word if valid
|
|
464
|
+
if (start < end) {
|
|
465
|
+
setSelection(start, end)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
override fun onDraw(canvas: Canvas) {
|
|
470
|
+
super.onDraw(canvas)
|
|
471
|
+
|
|
472
|
+
if (drawBottomBorder) {
|
|
473
|
+
val y = height.toFloat() - bottomBorderPaint.strokeWidth / 2
|
|
474
|
+
canvas.drawLine(0f, y, width.toFloat(), y, bottomBorderPaint)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
479
|
+
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
|
480
|
+
|
|
481
|
+
// After super.onMeasure, we have layout info
|
|
482
|
+
val textLayout = layout
|
|
483
|
+
if (textLayout != null) {
|
|
484
|
+
val lineCount = textLayout.lineCount
|
|
485
|
+
if (lineCount > 0) {
|
|
486
|
+
val textHeight = textLayout.getLineTop(lineCount).toFloat()
|
|
487
|
+
var desiredHeight = textHeight + paddingTop + paddingBottom
|
|
488
|
+
|
|
489
|
+
if (desiredHeight < minHeightPx) {
|
|
490
|
+
desiredHeight = minHeightPx
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (maxHeightValue > 0) {
|
|
494
|
+
val maxHeightPx = maxHeightValue * density
|
|
495
|
+
if (desiredHeight > maxHeightPx) {
|
|
496
|
+
desiredHeight = maxHeightPx
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
calculatedHeight = desiredHeight
|
|
501
|
+
|
|
502
|
+
// Apply the calculated height
|
|
503
|
+
val measuredWidth = MeasureSpec.getSize(widthMeasureSpec)
|
|
504
|
+
setMeasuredDimension(measuredWidth, desiredHeight.toInt())
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
510
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
511
|
+
updateContentSize()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private fun updateContentSize() {
|
|
515
|
+
val textLayout = layout ?: return
|
|
516
|
+
|
|
517
|
+
val lineCount = textLayout.lineCount
|
|
518
|
+
if (lineCount == 0) return
|
|
519
|
+
|
|
520
|
+
val textHeight = textLayout.getLineTop(lineCount).toFloat()
|
|
521
|
+
var newHeightPx = textHeight + paddingTop + paddingBottom
|
|
522
|
+
|
|
523
|
+
if (newHeightPx < minHeightPx) {
|
|
524
|
+
newHeightPx = minHeightPx
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (maxHeightValue > 0) {
|
|
528
|
+
val maxHeightPx = maxHeightValue * density
|
|
529
|
+
if (newHeightPx > maxHeightPx) {
|
|
530
|
+
isVerticalScrollBarEnabled = true
|
|
531
|
+
movementMethod = ScrollingMovementMethod.getInstance()
|
|
532
|
+
newHeightPx = maxHeightPx
|
|
533
|
+
} else {
|
|
534
|
+
isVerticalScrollBarEnabled = false
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
val previousHeight = calculatedHeight
|
|
539
|
+
calculatedHeight = newHeightPx
|
|
540
|
+
|
|
541
|
+
// Convert pixels to dp for React Native
|
|
542
|
+
val newHeightDp = newHeightPx / density
|
|
543
|
+
|
|
544
|
+
if (kotlin.math.abs(newHeightDp - lastReportedHeight) > 0.5f) {
|
|
545
|
+
lastReportedHeight = newHeightDp
|
|
546
|
+
val map = Arguments.createMap()
|
|
547
|
+
map.putInt("height", newHeightDp.toInt())
|
|
548
|
+
sendEvent("onSizeChange", map)
|
|
549
|
+
|
|
550
|
+
// Request re-layout if height changed
|
|
551
|
+
if (kotlin.math.abs(previousHeight - calculatedHeight) > 1f) {
|
|
552
|
+
requestLayout()
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private fun applyVariantStyle() {
|
|
558
|
+
if (variant == "flat") {
|
|
559
|
+
background = null
|
|
560
|
+
setBackgroundColor(Color.WHITE)
|
|
561
|
+
drawBottomBorder = true
|
|
562
|
+
} else {
|
|
563
|
+
drawBottomBorder = false
|
|
564
|
+
val bg = GradientDrawable().apply {
|
|
565
|
+
setColor(Color.WHITE)
|
|
566
|
+
cornerRadius = 8 * density
|
|
567
|
+
setStroke((1 * density).toInt(), Color.parseColor("#E0E0E0"))
|
|
568
|
+
}
|
|
569
|
+
background = bg
|
|
570
|
+
}
|
|
571
|
+
invalidate()
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private fun sendEvent(eventName: String, params: WritableMap) {
|
|
575
|
+
try {
|
|
576
|
+
val reactContext = context as? ReactContext ?: return
|
|
577
|
+
reactContext.getJSModule(RCTEventEmitter::class.java)
|
|
578
|
+
?.receiveEvent(id, eventName, params)
|
|
579
|
+
} catch (e: Exception) {
|
|
580
|
+
e.printStackTrace()
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private fun sendContentChange() {
|
|
585
|
+
try {
|
|
586
|
+
val map = Arguments.createMap()
|
|
587
|
+
map.putString("text", text.toString())
|
|
588
|
+
map.putArray("blocks", getBlocksArray())
|
|
589
|
+
sendEvent("onContentChange", map)
|
|
590
|
+
} catch (e: Exception) {
|
|
591
|
+
e.printStackTrace()
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fun saveToUndoStack() {
|
|
596
|
+
val currentText = text?.toString() ?: ""
|
|
597
|
+
if (currentText != lastSavedText) {
|
|
598
|
+
undoStack.add(SpannableStringBuilder(text))
|
|
599
|
+
if (undoStack.size > 50) {
|
|
600
|
+
undoStack.removeAt(0)
|
|
601
|
+
}
|
|
602
|
+
redoStack.clear()
|
|
603
|
+
lastSavedText = currentText
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ==================== Public API ====================
|
|
608
|
+
|
|
609
|
+
fun setPlaceholderText(value: String) {
|
|
610
|
+
placeholder = value
|
|
611
|
+
hint = value
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
fun setVariant(value: String) {
|
|
615
|
+
variant = value
|
|
616
|
+
applyVariantStyle()
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
fun setEditable(value: Boolean) {
|
|
620
|
+
isEnabled = value
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
fun setMaxHeightValue(value: Int) {
|
|
624
|
+
maxHeightValue = value
|
|
625
|
+
post { updateContentSize() }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fun setShowToolbar(value: Boolean) {
|
|
629
|
+
showToolbar = value
|
|
630
|
+
if (!value) hideToolbar()
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
fun setToolbarOptions(options: List<String>?) {
|
|
634
|
+
toolbarOptions = options
|
|
635
|
+
floatingToolbar?.setToolbarOptions(options)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
fun setContent(blocks: List<Map<String, Any>>) {
|
|
639
|
+
val spannable = SpannableStringBuilder()
|
|
640
|
+
var currentOffset = 0
|
|
641
|
+
var numberedListCounter = 1
|
|
642
|
+
|
|
643
|
+
blocks.forEachIndexed { index, block ->
|
|
644
|
+
val textContent = block["text"] as? String ?: ""
|
|
645
|
+
val blockType = block["type"] as? String ?: "paragraph"
|
|
646
|
+
|
|
647
|
+
// Add list prefix based on block type
|
|
648
|
+
val prefix = when (blockType) {
|
|
649
|
+
"bullet", "bulletList" -> "• "
|
|
650
|
+
"numbered", "numberedList" -> "${numberedListCounter++}. "
|
|
651
|
+
"checklist" -> "☐ "
|
|
652
|
+
"quote" -> "\""
|
|
653
|
+
else -> {
|
|
654
|
+
// Reset numbered list counter when not in numbered list
|
|
655
|
+
if (blockType != "numbered" && blockType != "numberedList") numberedListCounter = 1
|
|
656
|
+
""
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Add suffix for quotes
|
|
661
|
+
val suffix = if (blockType == "quote") "\"" else ""
|
|
662
|
+
|
|
663
|
+
val blockStart = currentOffset
|
|
664
|
+
spannable.append(prefix)
|
|
665
|
+
currentOffset += prefix.length
|
|
666
|
+
|
|
667
|
+
val textStart = currentOffset
|
|
668
|
+
spannable.append(textContent)
|
|
669
|
+
currentOffset += textContent.length
|
|
670
|
+
|
|
671
|
+
spannable.append(suffix)
|
|
672
|
+
currentOffset += suffix.length
|
|
673
|
+
|
|
674
|
+
// Apply heading style
|
|
675
|
+
if (blockType == "heading") {
|
|
676
|
+
spannable.setSpan(RelativeSizeSpan(1.5f), blockStart, currentOffset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
677
|
+
spannable.setSpan(StyleSpan(Typeface.BOLD), blockStart, currentOffset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Apply styles from the block
|
|
681
|
+
@Suppress("UNCHECKED_CAST")
|
|
682
|
+
val styles = block["styles"] as? List<Map<String, Any>> ?: emptyList()
|
|
683
|
+
for (styleInfo in styles) {
|
|
684
|
+
val styleName = styleInfo["style"] as? String ?: continue
|
|
685
|
+
val start = (styleInfo["start"] as? Number)?.toInt() ?: 0
|
|
686
|
+
val end = (styleInfo["end"] as? Number)?.toInt() ?: textContent.length
|
|
687
|
+
|
|
688
|
+
val absoluteStart = textStart + start
|
|
689
|
+
val absoluteEnd = textStart + end
|
|
690
|
+
|
|
691
|
+
when (styleName) {
|
|
692
|
+
"bold" -> spannable.setSpan(StyleSpan(Typeface.BOLD), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
693
|
+
"italic" -> spannable.setSpan(StyleSpan(Typeface.ITALIC), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
694
|
+
"underline" -> spannable.setSpan(UnderlineSpan(), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
695
|
+
"strikethrough" -> spannable.setSpan(StrikethroughSpan(), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
696
|
+
"code" -> {
|
|
697
|
+
spannable.setSpan(TypefaceSpan("monospace"), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
698
|
+
spannable.setSpan(BackgroundColorSpan(Color.parseColor("#F5F5F5")), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
699
|
+
}
|
|
700
|
+
"highlight" -> spannable.setSpan(BackgroundColorSpan(Color.parseColor("#80FFFF00")), absoluteStart, absoluteEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (index < blocks.size - 1) {
|
|
705
|
+
spannable.append("\n")
|
|
706
|
+
currentOffset += 1
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
isInternalChange = true
|
|
711
|
+
setText(spannable)
|
|
712
|
+
setSelection(spannable.length)
|
|
713
|
+
isInternalChange = false
|
|
714
|
+
post { updateContentSize() }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
fun getTextContent(): String = text.toString()
|
|
718
|
+
|
|
719
|
+
fun getBlocksArray(): WritableArray {
|
|
720
|
+
val blocks = Arguments.createArray()
|
|
721
|
+
val textContent = text.toString()
|
|
722
|
+
val lines = textContent.split("\n")
|
|
723
|
+
lines.forEach { line ->
|
|
724
|
+
val block = Arguments.createMap()
|
|
725
|
+
block.putString("type", "paragraph")
|
|
726
|
+
block.putString("text", line)
|
|
727
|
+
block.putArray("styles", Arguments.createArray())
|
|
728
|
+
blocks.pushMap(block)
|
|
729
|
+
}
|
|
730
|
+
return blocks
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
fun clearContent() {
|
|
734
|
+
isInternalChange = true
|
|
735
|
+
text?.clear()
|
|
736
|
+
isInternalChange = false
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
fun focusEditor() {
|
|
740
|
+
requestFocus()
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
fun blurEditor() {
|
|
744
|
+
clearFocus()
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ==================== Text Styling (ToolbarActionListener) ====================
|
|
748
|
+
|
|
749
|
+
override fun onBoldClick() {
|
|
750
|
+
android.util.Log.d("RichTextEditor", "onBoldClick called")
|
|
751
|
+
toggleStyle(Typeface.BOLD)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
override fun onItalicClick() {
|
|
755
|
+
android.util.Log.d("RichTextEditor", "onItalicClick called")
|
|
756
|
+
toggleStyle(Typeface.ITALIC)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
override fun onUnderlineClick() {
|
|
760
|
+
android.util.Log.d("RichTextEditor", "onUnderlineClick called")
|
|
761
|
+
toggleSpan(UnderlineSpan::class.java) { UnderlineSpan() }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
override fun onStrikethroughClick() {
|
|
765
|
+
android.util.Log.d("RichTextEditor", "onStrikethroughClick called")
|
|
766
|
+
toggleSpan(StrikethroughSpan::class.java) { StrikethroughSpan() }
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
override fun onCodeClick() {
|
|
770
|
+
// Use saved selection if current selection is empty
|
|
771
|
+
var start = selectionStart
|
|
772
|
+
var end = selectionEnd
|
|
773
|
+
|
|
774
|
+
if (start >= end && savedSelectionStart < savedSelectionEnd) {
|
|
775
|
+
start = savedSelectionStart
|
|
776
|
+
end = savedSelectionEnd
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (start >= end) return
|
|
780
|
+
|
|
781
|
+
val spannable = text as? Editable ?: return
|
|
782
|
+
val existingSpans = spannable.getSpans(start, end, TypefaceSpan::class.java)
|
|
783
|
+
.filter { it.family == "monospace" }
|
|
784
|
+
|
|
785
|
+
isInternalChange = true
|
|
786
|
+
if (existingSpans.isNotEmpty()) {
|
|
787
|
+
existingSpans.forEach { spannable.removeSpan(it) }
|
|
788
|
+
// Remove background
|
|
789
|
+
spannable.getSpans(start, end, BackgroundColorSpan::class.java)
|
|
790
|
+
.filter { spannable.getSpanStart(it) >= start && spannable.getSpanEnd(it) <= end }
|
|
791
|
+
.forEach { spannable.removeSpan(it) }
|
|
792
|
+
} else {
|
|
793
|
+
spannable.setSpan(TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
794
|
+
spannable.setSpan(BackgroundColorSpan(Color.parseColor("#F5F5F5")), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
795
|
+
}
|
|
796
|
+
setSelection(start, end)
|
|
797
|
+
isInternalChange = false
|
|
798
|
+
invalidate()
|
|
799
|
+
sendContentChange()
|
|
800
|
+
updateToolbarButtonStates()
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
override fun onHighlightClick() {
|
|
804
|
+
// Use saved selection if current selection is empty
|
|
805
|
+
var start = selectionStart
|
|
806
|
+
var end = selectionEnd
|
|
807
|
+
|
|
808
|
+
if (start >= end && savedSelectionStart < savedSelectionEnd) {
|
|
809
|
+
start = savedSelectionStart
|
|
810
|
+
end = savedSelectionEnd
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (start >= end) return
|
|
814
|
+
|
|
815
|
+
val spannable = text as? Editable ?: return
|
|
816
|
+
val existingSpans = spannable.getSpans(start, end, BackgroundColorSpan::class.java)
|
|
817
|
+
.filter {
|
|
818
|
+
val color = it.backgroundColor
|
|
819
|
+
color == Color.parseColor("#80FFFF00") || color == Color.YELLOW
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
isInternalChange = true
|
|
823
|
+
if (existingSpans.isNotEmpty()) {
|
|
824
|
+
existingSpans.forEach { spannable.removeSpan(it) }
|
|
825
|
+
} else {
|
|
826
|
+
spannable.setSpan(BackgroundColorSpan(Color.parseColor("#80FFFF00")), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
827
|
+
}
|
|
828
|
+
setSelection(start, end)
|
|
829
|
+
isInternalChange = false
|
|
830
|
+
invalidate()
|
|
831
|
+
sendContentChange()
|
|
832
|
+
updateToolbarButtonStates()
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
override fun onHeadingClick() {
|
|
836
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
837
|
+
if (lineStart >= lineEnd) return
|
|
838
|
+
|
|
839
|
+
val spannable = text as? Editable ?: return
|
|
840
|
+
|
|
841
|
+
// Check if already a heading
|
|
842
|
+
val existingSpans = spannable.getSpans(lineStart, lineEnd, RelativeSizeSpan::class.java)
|
|
843
|
+
val isHeading = existingSpans.any { it.sizeChange > 1.2f }
|
|
844
|
+
|
|
845
|
+
isInternalChange = true
|
|
846
|
+
existingSpans.forEach { spannable.removeSpan(it) }
|
|
847
|
+
spannable.getSpans(lineStart, lineEnd, StyleSpan::class.java)
|
|
848
|
+
.filter { it.style == Typeface.BOLD }
|
|
849
|
+
.forEach { spannable.removeSpan(it) }
|
|
850
|
+
|
|
851
|
+
if (!isHeading) {
|
|
852
|
+
spannable.setSpan(RelativeSizeSpan(1.5f), lineStart, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
853
|
+
spannable.setSpan(StyleSpan(Typeface.BOLD), lineStart, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
854
|
+
}
|
|
855
|
+
isInternalChange = false
|
|
856
|
+
sendContentChange()
|
|
857
|
+
updateToolbarButtonStates()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
override fun onBulletListClick() {
|
|
861
|
+
toggleListPrefix("• ")
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
override fun onNumberedListClick() {
|
|
865
|
+
applyNumberedList()
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
override fun onQuoteClick() {
|
|
869
|
+
toggleQuote()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
override fun onChecklistClick() {
|
|
873
|
+
toggleChecklistPrefix()
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
override fun onLinkClick() {
|
|
877
|
+
promptInsertLink()
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
override fun onUndoClick() {
|
|
881
|
+
undo()
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
override fun onRedoClick() {
|
|
885
|
+
redo()
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
override fun onClearFormattingClick() {
|
|
889
|
+
clearFormatting()
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
override fun onIndentClick() {
|
|
893
|
+
indent()
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
override fun onOutdentClick() {
|
|
897
|
+
outdent()
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
override fun onAlignLeftClick() {
|
|
901
|
+
setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
override fun onAlignCenterClick() {
|
|
905
|
+
setAlignment(Layout.Alignment.ALIGN_CENTER)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
override fun onAlignRightClick() {
|
|
909
|
+
setAlignment(Layout.Alignment.ALIGN_OPPOSITE)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ==================== Helper Methods ====================
|
|
913
|
+
|
|
914
|
+
private fun toggleStyle(style: Int) {
|
|
915
|
+
// Use saved selection if current selection is empty (can happen when clicking toolbar)
|
|
916
|
+
var start = selectionStart
|
|
917
|
+
var end = selectionEnd
|
|
918
|
+
|
|
919
|
+
if (start >= end && savedSelectionStart < savedSelectionEnd) {
|
|
920
|
+
start = savedSelectionStart
|
|
921
|
+
end = savedSelectionEnd
|
|
922
|
+
android.util.Log.d("RichTextEditor", "Using saved selection: start=$start, end=$end")
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
android.util.Log.d("RichTextEditor", "toggleStyle called: style=$style, start=$start, end=$end")
|
|
926
|
+
|
|
927
|
+
if (start >= end) {
|
|
928
|
+
android.util.Log.d("RichTextEditor", "No selection, skipping style toggle")
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
val spannable = text as? Editable ?: return
|
|
933
|
+
val existingSpans = spannable.getSpans(start, end, StyleSpan::class.java)
|
|
934
|
+
.filter { it.style == style || it.style == Typeface.BOLD_ITALIC }
|
|
935
|
+
|
|
936
|
+
val hasStyle = existingSpans.any { it.style == style }
|
|
937
|
+
|
|
938
|
+
isInternalChange = true
|
|
939
|
+
if (hasStyle) {
|
|
940
|
+
android.util.Log.d("RichTextEditor", "Removing style $style")
|
|
941
|
+
existingSpans.filter { it.style == style }.forEach { spannable.removeSpan(it) }
|
|
942
|
+
} else {
|
|
943
|
+
android.util.Log.d("RichTextEditor", "Applying style $style")
|
|
944
|
+
spannable.setSpan(StyleSpan(style), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
945
|
+
}
|
|
946
|
+
// Restore and maintain selection after applying style
|
|
947
|
+
setSelection(start, end)
|
|
948
|
+
isInternalChange = false
|
|
949
|
+
invalidate()
|
|
950
|
+
sendContentChange()
|
|
951
|
+
updateToolbarButtonStates()
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private fun <T : Any> toggleSpan(spanClass: Class<T>, createSpan: () -> Any) {
|
|
955
|
+
// Use saved selection if current selection is empty
|
|
956
|
+
var start = selectionStart
|
|
957
|
+
var end = selectionEnd
|
|
958
|
+
|
|
959
|
+
if (start >= end && savedSelectionStart < savedSelectionEnd) {
|
|
960
|
+
start = savedSelectionStart
|
|
961
|
+
end = savedSelectionEnd
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (start >= end) return
|
|
965
|
+
|
|
966
|
+
val spannable = text as? Editable ?: return
|
|
967
|
+
val existingSpans = spannable.getSpans(start, end, spanClass)
|
|
968
|
+
|
|
969
|
+
isInternalChange = true
|
|
970
|
+
if (existingSpans.isNotEmpty()) {
|
|
971
|
+
existingSpans.forEach { spannable.removeSpan(it) }
|
|
972
|
+
} else {
|
|
973
|
+
spannable.setSpan(createSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
974
|
+
}
|
|
975
|
+
// Restore and maintain selection after applying style
|
|
976
|
+
setSelection(start, end)
|
|
977
|
+
isInternalChange = false
|
|
978
|
+
invalidate()
|
|
979
|
+
sendContentChange()
|
|
980
|
+
updateToolbarButtonStates()
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private fun toggleListPrefix(prefix: String) {
|
|
984
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
985
|
+
val currentText = text?.toString() ?: return
|
|
986
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
987
|
+
|
|
988
|
+
isInternalChange = true
|
|
989
|
+
val editable = text ?: return
|
|
990
|
+
|
|
991
|
+
if (lineText.startsWith(prefix)) {
|
|
992
|
+
// Remove prefix
|
|
993
|
+
editable.delete(lineStart, lineStart + prefix.length)
|
|
994
|
+
} else {
|
|
995
|
+
// Remove other prefixes first
|
|
996
|
+
val cleanLine = removeExistingPrefix(lineText)
|
|
997
|
+
editable.replace(lineStart, lineEnd, prefix + cleanLine)
|
|
998
|
+
}
|
|
999
|
+
isInternalChange = false
|
|
1000
|
+
sendContentChange()
|
|
1001
|
+
updateToolbarButtonStates()
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private fun applyNumberedList() {
|
|
1005
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1006
|
+
val currentText = text?.toString() ?: return
|
|
1007
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
1008
|
+
|
|
1009
|
+
isInternalChange = true
|
|
1010
|
+
val editable = text ?: return
|
|
1011
|
+
|
|
1012
|
+
val numberedRegex = Regex("^(\\d+)\\.\\s")
|
|
1013
|
+
val match = numberedRegex.find(lineText)
|
|
1014
|
+
|
|
1015
|
+
if (match != null) {
|
|
1016
|
+
// Remove numbered prefix
|
|
1017
|
+
editable.delete(lineStart, lineStart + match.value.length)
|
|
1018
|
+
} else {
|
|
1019
|
+
// Add numbered prefix
|
|
1020
|
+
val cleanLine = removeExistingPrefix(lineText)
|
|
1021
|
+
editable.replace(lineStart, lineEnd, "1. $cleanLine")
|
|
1022
|
+
}
|
|
1023
|
+
isInternalChange = false
|
|
1024
|
+
sendContentChange()
|
|
1025
|
+
updateToolbarButtonStates()
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
private fun toggleQuote() {
|
|
1029
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1030
|
+
val currentText = text?.toString() ?: return
|
|
1031
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
1032
|
+
|
|
1033
|
+
isInternalChange = true
|
|
1034
|
+
val editable = text ?: return
|
|
1035
|
+
|
|
1036
|
+
if (lineText.startsWith("\"") && lineText.endsWith("\"") && lineText.length >= 2) {
|
|
1037
|
+
// Remove quotes
|
|
1038
|
+
val unquoted = lineText.substring(1, lineText.length - 1)
|
|
1039
|
+
editable.replace(lineStart, lineEnd, unquoted)
|
|
1040
|
+
} else {
|
|
1041
|
+
// Add quotes
|
|
1042
|
+
val cleanLine = removeExistingPrefix(lineText)
|
|
1043
|
+
editable.replace(lineStart, lineEnd, "\"$cleanLine\"")
|
|
1044
|
+
}
|
|
1045
|
+
isInternalChange = false
|
|
1046
|
+
sendContentChange()
|
|
1047
|
+
updateToolbarButtonStates()
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private fun toggleChecklistPrefix() {
|
|
1051
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1052
|
+
val currentText = text?.toString() ?: return
|
|
1053
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
1054
|
+
|
|
1055
|
+
isInternalChange = true
|
|
1056
|
+
val editable = text ?: return
|
|
1057
|
+
|
|
1058
|
+
when {
|
|
1059
|
+
lineText.startsWith("☐ ") -> {
|
|
1060
|
+
// Remove checklist
|
|
1061
|
+
editable.delete(lineStart, lineStart + 2)
|
|
1062
|
+
}
|
|
1063
|
+
lineText.startsWith("☑ ") -> {
|
|
1064
|
+
// Remove checklist
|
|
1065
|
+
editable.delete(lineStart, lineStart + 2)
|
|
1066
|
+
}
|
|
1067
|
+
else -> {
|
|
1068
|
+
// Add checklist
|
|
1069
|
+
val cleanLine = removeExistingPrefix(lineText)
|
|
1070
|
+
editable.replace(lineStart, lineEnd, "☐ $cleanLine")
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
isInternalChange = false
|
|
1074
|
+
sendContentChange()
|
|
1075
|
+
updateToolbarButtonStates()
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private fun removeExistingPrefix(line: String): String {
|
|
1079
|
+
return when {
|
|
1080
|
+
line.startsWith("• ") -> line.substring(2)
|
|
1081
|
+
line.startsWith("☐ ") || line.startsWith("☑ ") -> line.substring(2)
|
|
1082
|
+
line.matches(Regex("^\\d+\\.\\s.*")) -> line.replace(Regex("^\\d+\\.\\s"), "")
|
|
1083
|
+
line.startsWith("\"") && line.endsWith("\"") && line.length >= 2 -> line.substring(1, line.length - 1)
|
|
1084
|
+
else -> line
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
private fun promptInsertLink() {
|
|
1089
|
+
val context = context
|
|
1090
|
+
val builder = AlertDialog.Builder(context)
|
|
1091
|
+
builder.setTitle("Insert Link")
|
|
1092
|
+
|
|
1093
|
+
val layout = LinearLayout(context).apply {
|
|
1094
|
+
orientation = LinearLayout.VERTICAL
|
|
1095
|
+
setPadding(50, 40, 50, 10)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
val textInput = EditText(context).apply {
|
|
1099
|
+
hint = "Link text"
|
|
1100
|
+
// Pre-fill with selected text
|
|
1101
|
+
val selectedText = text?.subSequence(selectionStart, selectionEnd)?.toString() ?: ""
|
|
1102
|
+
setText(selectedText)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
val urlInput = EditText(context).apply {
|
|
1106
|
+
hint = "URL"
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
layout.addView(textInput)
|
|
1110
|
+
layout.addView(urlInput)
|
|
1111
|
+
builder.setView(layout)
|
|
1112
|
+
|
|
1113
|
+
builder.setPositiveButton("Insert") { _, _ ->
|
|
1114
|
+
val linkText = textInput.text.toString()
|
|
1115
|
+
val url = urlInput.text.toString()
|
|
1116
|
+
if (linkText.isNotEmpty() && url.isNotEmpty()) {
|
|
1117
|
+
insertLink(url, linkText)
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
builder.setNegativeButton("Cancel", null)
|
|
1121
|
+
builder.show()
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
fun insertLink(url: String, linkText: String) {
|
|
1125
|
+
val start = selectionStart
|
|
1126
|
+
val end = selectionEnd
|
|
1127
|
+
val editable = text ?: return
|
|
1128
|
+
|
|
1129
|
+
isInternalChange = true
|
|
1130
|
+
if (start != end) {
|
|
1131
|
+
editable.delete(start, end)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
val linkSpannable = SpannableStringBuilder(linkText)
|
|
1135
|
+
linkSpannable.setSpan(URLSpan(url), 0, linkText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
1136
|
+
linkSpannable.setSpan(ForegroundColorSpan(Color.parseColor("#2196F3")), 0, linkText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
1137
|
+
linkSpannable.setSpan(UnderlineSpan(), 0, linkText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
1138
|
+
|
|
1139
|
+
editable.insert(start, linkSpannable)
|
|
1140
|
+
isInternalChange = false
|
|
1141
|
+
sendContentChange()
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
fun undo() {
|
|
1145
|
+
if (undoStack.size > 1) {
|
|
1146
|
+
val current = undoStack.removeAt(undoStack.size - 1)
|
|
1147
|
+
redoStack.add(current)
|
|
1148
|
+
val previous = undoStack.last()
|
|
1149
|
+
|
|
1150
|
+
isInternalChange = true
|
|
1151
|
+
setText(previous)
|
|
1152
|
+
setSelection(previous.length)
|
|
1153
|
+
lastSavedText = previous.toString()
|
|
1154
|
+
isInternalChange = false
|
|
1155
|
+
sendContentChange()
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
fun redo() {
|
|
1160
|
+
if (redoStack.isNotEmpty()) {
|
|
1161
|
+
val next = redoStack.removeAt(redoStack.size - 1)
|
|
1162
|
+
undoStack.add(next)
|
|
1163
|
+
|
|
1164
|
+
isInternalChange = true
|
|
1165
|
+
setText(next)
|
|
1166
|
+
setSelection(next.length)
|
|
1167
|
+
lastSavedText = next.toString()
|
|
1168
|
+
isInternalChange = false
|
|
1169
|
+
sendContentChange()
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
fun clearFormatting() {
|
|
1174
|
+
val start = selectionStart
|
|
1175
|
+
val end = selectionEnd
|
|
1176
|
+
if (start >= end) return
|
|
1177
|
+
|
|
1178
|
+
val spannable = text as? Editable ?: return
|
|
1179
|
+
val plainText = spannable.subSequence(start, end).toString()
|
|
1180
|
+
|
|
1181
|
+
isInternalChange = true
|
|
1182
|
+
// Remove all spans in selection
|
|
1183
|
+
spannable.getSpans(start, end, Any::class.java).forEach { span ->
|
|
1184
|
+
if (span is StyleSpan || span is UnderlineSpan || span is StrikethroughSpan ||
|
|
1185
|
+
span is BackgroundColorSpan || span is ForegroundColorSpan || span is TypefaceSpan ||
|
|
1186
|
+
span is RelativeSizeSpan || span is URLSpan) {
|
|
1187
|
+
spannable.removeSpan(span)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
isInternalChange = false
|
|
1191
|
+
sendContentChange()
|
|
1192
|
+
updateToolbarButtonStates()
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
fun indent() {
|
|
1196
|
+
val (lineStart, _) = getLineRange()
|
|
1197
|
+
val editable = text ?: return
|
|
1198
|
+
|
|
1199
|
+
isInternalChange = true
|
|
1200
|
+
editable.insert(lineStart, " ")
|
|
1201
|
+
isInternalChange = false
|
|
1202
|
+
sendContentChange()
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
fun outdent() {
|
|
1206
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1207
|
+
val currentText = text?.toString() ?: return
|
|
1208
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
1209
|
+
val editable = text ?: return
|
|
1210
|
+
|
|
1211
|
+
isInternalChange = true
|
|
1212
|
+
when {
|
|
1213
|
+
lineText.startsWith(" ") -> editable.delete(lineStart, lineStart + 4)
|
|
1214
|
+
lineText.startsWith("\t") -> editable.delete(lineStart, lineStart + 1)
|
|
1215
|
+
lineText.startsWith(" ") -> {
|
|
1216
|
+
var spaces = 0
|
|
1217
|
+
for (c in lineText) {
|
|
1218
|
+
if (c == ' ' && spaces < 4) spaces++ else break
|
|
1219
|
+
}
|
|
1220
|
+
if (spaces > 0) editable.delete(lineStart, lineStart + spaces)
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
isInternalChange = false
|
|
1224
|
+
sendContentChange()
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
fun setAlignment(alignment: Layout.Alignment) {
|
|
1228
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1229
|
+
val spannable = text as? Editable ?: return
|
|
1230
|
+
|
|
1231
|
+
isInternalChange = true
|
|
1232
|
+
// Remove existing alignment spans
|
|
1233
|
+
spannable.getSpans(lineStart, lineEnd, AlignmentSpan.Standard::class.java)
|
|
1234
|
+
.forEach { spannable.removeSpan(it) }
|
|
1235
|
+
|
|
1236
|
+
spannable.setSpan(AlignmentSpan.Standard(alignment), lineStart, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
1237
|
+
isInternalChange = false
|
|
1238
|
+
sendContentChange()
|
|
1239
|
+
updateToolbarButtonStates()
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Legacy method names for ViewManager commands
|
|
1243
|
+
fun toggleBold() = onBoldClick()
|
|
1244
|
+
fun toggleItalic() = onItalicClick()
|
|
1245
|
+
fun toggleUnderline() = onUnderlineClick()
|
|
1246
|
+
fun toggleStrikethrough() = onStrikethroughClick()
|
|
1247
|
+
fun toggleCode() = onCodeClick()
|
|
1248
|
+
fun toggleHighlight(color: String?) = onHighlightClick()
|
|
1249
|
+
fun setHeading() = onHeadingClick()
|
|
1250
|
+
fun toggleBulletList() = onBulletListClick()
|
|
1251
|
+
fun toggleNumberedList() = applyNumberedList()
|
|
1252
|
+
fun setQuote() = onQuoteClick()
|
|
1253
|
+
fun setChecklist() = onChecklistClick()
|
|
1254
|
+
fun setParagraph() {
|
|
1255
|
+
// Remove all block-level formatting from current line
|
|
1256
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1257
|
+
val currentText = text?.toString() ?: return
|
|
1258
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
1259
|
+
val cleanLine = removeExistingPrefix(lineText)
|
|
1260
|
+
|
|
1261
|
+
isInternalChange = true
|
|
1262
|
+
text?.replace(lineStart, lineEnd, cleanLine)
|
|
1263
|
+
isInternalChange = false
|
|
1264
|
+
sendContentChange()
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
fun toggleChecklistItem() {
|
|
1268
|
+
val (lineStart, lineEnd) = getLineRange()
|
|
1269
|
+
val currentText = text?.toString() ?: return
|
|
1270
|
+
val lineText = currentText.substring(lineStart, lineEnd)
|
|
1271
|
+
val editable = text ?: return
|
|
1272
|
+
|
|
1273
|
+
isInternalChange = true
|
|
1274
|
+
when {
|
|
1275
|
+
lineText.startsWith("☐ ") -> {
|
|
1276
|
+
editable.replace(lineStart, lineStart + 1, "☑")
|
|
1277
|
+
}
|
|
1278
|
+
lineText.startsWith("☑ ") -> {
|
|
1279
|
+
editable.replace(lineStart, lineStart + 1, "☐")
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
isInternalChange = false
|
|
1283
|
+
sendContentChange()
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
override fun onDetachedFromWindow() {
|
|
1287
|
+
super.onDetachedFromWindow()
|
|
1288
|
+
hideToolbar()
|
|
1289
|
+
toolbarPopup = null
|
|
1290
|
+
floatingToolbar = null
|
|
1291
|
+
}
|
|
1292
|
+
}
|