@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.
@@ -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
+ }