react-native-highlight-text-view 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,6 +128,46 @@ To automatically open the keyboard when the component mounts, use the `autoFocus
128
128
 
129
129
  This eliminates the need for double-tapping to open the keyboard - it will open on first render.
130
130
 
131
+ ### Dynamic Font Family Changes
132
+
133
+ **IMPORTANT**: When changing `fontFamily` dynamically at runtime (especially to fonts with different ascender/descender values like Eczar, Georgia, etc.), you must use the `key` prop to force React to remount the component. This ensures the native layout recalculates with the new font metrics.
134
+
135
+ **Why this is needed**: Fonts like Eczar have significantly larger vertical metrics than system fonts. Without remounting, the highlight background may appear cut off at the bottom or lose corner radius.
136
+
137
+ **Solution**: Pass the `fontFamily` as the `key` prop:
138
+
139
+ ```jsx
140
+ const [fontFamily, setFontFamily] = useState('system');
141
+
142
+ return (
143
+ <HighlightTextView
144
+ key={fontFamily}
145
+ fontFamily={fontFamily}
146
+ fontSize="32"
147
+ color="#00A4A3"
148
+ textColor="#FFFFFF"
149
+ paddingLeft="8"
150
+ paddingRight="8"
151
+ paddingTop="4"
152
+ paddingBottom="4"
153
+ backgroundInsetTop="6"
154
+ backgroundInsetBottom="6"
155
+ highlightBorderRadius="8"
156
+ text="Beautiful Eczar Font"
157
+ style={{ width: '100%', height: 150 }}
158
+ />
159
+ );
160
+ ```
161
+
162
+ **What happens**:
163
+
164
+ - Font changes → `key` changes → React unmounts old component and mounts new one
165
+ - New mount → Native component calculates fresh layout with correct font metrics
166
+ - Perfect rendering → Background highlights render correctly without cutting
167
+
168
+ **Without key prop**: Background may cut off, corner radius may disappear
169
+ **With key prop**: Perfect rendering every time ✅
170
+
131
171
  ## Contributing
132
172
 
133
173
  - [Development workflow](CONTRIBUTING.md#development-workflow)
@@ -16,6 +16,10 @@ import android.util.TypedValue
16
16
  import android.view.Gravity
17
17
  import androidx.appcompat.widget.AppCompatEditText
18
18
 
19
+ /**
20
+ * iOS-style span: Draws rounded background for each character.
21
+ * Padding is only applied at line boundaries (first/last character of each line).
22
+ */
19
23
  class RoundedBackgroundSpan(
20
24
  internal val backgroundColor: Int,
21
25
  internal val textColor: Int,
@@ -28,10 +32,8 @@ class RoundedBackgroundSpan(
28
32
  internal val backgroundInsetLeft: Float,
29
33
  internal val backgroundInsetRight: Float,
30
34
  internal val cornerRadius: Float,
31
- internal val isFirstInGroup: Boolean = false,
32
- internal val isLastInGroup: Boolean = false,
33
- private val isStartOfLine: Boolean = false,
34
- private val isEndOfLine: Boolean = false
35
+ internal val isLineStart: Boolean = false,
36
+ internal val isLineEnd: Boolean = false
35
37
  ) : ReplacementSpan() {
36
38
 
37
39
  override fun getSize(
@@ -41,10 +43,10 @@ class RoundedBackgroundSpan(
41
43
  end: Int,
42
44
  fm: Paint.FontMetricsInt?
43
45
  ): Int {
46
+ // Only add padding at line boundaries (matches iOS behavior)
44
47
  val width = paint.measureText(text, start, end)
45
- // Add padding for word boundaries AND line boundaries (for consistent alignment)
46
- val leftPad = if (isFirstInGroup || isStartOfLine) paddingLeft else 0f
47
- val rightPad = if (isLastInGroup || isEndOfLine) paddingRight else 0f
48
+ val leftPad = if (isLineStart) paddingLeft else 0f
49
+ val rightPad = if (isLineEnd) paddingRight else 0f
48
50
  return (width + leftPad + rightPad).toInt()
49
51
  }
50
52
 
@@ -59,7 +61,8 @@ class RoundedBackgroundSpan(
59
61
  bottom: Int,
60
62
  paint: Paint
61
63
  ) {
62
- // Draw background with padding
64
+ if (text == null) return
65
+
63
66
  val bgPaint = Paint().apply {
64
67
  color = backgroundColor
65
68
  style = Paint.Style.FILL
@@ -68,82 +71,51 @@ class RoundedBackgroundSpan(
68
71
 
69
72
  val width = paint.measureText(text, start, end)
70
73
 
71
- // Use font metrics for consistent height (matches iOS behavior)
74
+ // Use font metrics for consistent height (matches iOS)
72
75
  val fontMetrics = paint.fontMetrics
73
76
  val textHeight = fontMetrics.descent - fontMetrics.ascent
74
77
  val textTop = y + fontMetrics.ascent
75
78
 
76
- // Add padding for word AND line boundaries (consistent alignment)
77
- val isReallyFirst = isFirstInGroup || isStartOfLine
78
- val isReallyLast = isLastInGroup || isEndOfLine
79
- val leftPad = if (isReallyFirst) paddingLeft else 0f
80
- val rightPad = if (isReallyLast) paddingRight else 0f
81
-
82
- // Small overlap to eliminate gaps between characters
83
- val overlapExtension = 2f
84
- val leftOverlap = if (!isReallyFirst) overlapExtension else 0f
85
- val rightOverlap = if (!isReallyLast) overlapExtension else 0f
86
-
87
- // Apply background insets first (shrinks from line box)
79
+ // Apply background insets first (shrinks from line box - EXACTLY like iOS line 45-48)
88
80
  val insetTop = textTop + backgroundInsetTop
89
81
  val insetHeight = textHeight - (backgroundInsetTop + backgroundInsetBottom)
90
82
 
91
- // Calculate background rect
83
+ // Only apply padding at line boundaries (matches iOS behavior)
84
+ val leftPad = if (isLineStart) paddingLeft else 0f
85
+ val rightPad = if (isLineEnd) paddingRight else 0f
86
+
87
+ // Aggressive overlap to ensure completely seamless connection (no gaps)
88
+ // Extended both horizontally AND vertically for complete coverage
89
+ val overlapExtension = 4f
90
+ val leftExtend = if (!isLineStart) overlapExtension else 0f
91
+ val rightExtend = if (!isLineEnd) {
92
+ // If this is line start, extend by padding amount to bridge the gap
93
+ if (isLineStart) leftPad + overlapExtension else overlapExtension
94
+ } else {
95
+ 0f
96
+ }
97
+
98
+ // Vertical overlap to eliminate gaps at top/bottom edges
99
+ val topExtend = overlapExtension
100
+ val bottomExtend = overlapExtension
101
+
102
+ // Calculate background rect with padding only at line boundaries
92
103
  val rect = RectF(
93
- x - leftOverlap + backgroundInsetLeft,
94
- insetTop - paddingTop,
95
- x + width + leftPad + rightPad + rightOverlap - backgroundInsetRight,
96
- insetTop + insetHeight + paddingBottom
104
+ x - leftPad + backgroundInsetLeft - leftExtend,
105
+ insetTop - paddingTop - topExtend,
106
+ x + width + rightPad - backgroundInsetRight + rightExtend,
107
+ insetTop + insetHeight + paddingBottom + bottomExtend
97
108
  )
98
109
 
99
- // Draw background with selective corner rounding (respects line wraps)
100
- when {
101
- isReallyFirst && isReallyLast -> {
102
- // Single character or isolated group - round all corners
103
- canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
104
- }
105
- isReallyFirst -> {
106
- // First character (word start or line start) - round left corners only
107
- val path = android.graphics.Path()
108
- path.addRoundRect(
109
- rect,
110
- floatArrayOf(
111
- cornerRadius, cornerRadius, // top-left
112
- 0f, 0f, // top-right
113
- 0f, 0f, // bottom-right
114
- cornerRadius, cornerRadius // bottom-left
115
- ),
116
- android.graphics.Path.Direction.CW
117
- )
118
- canvas.drawPath(path, bgPaint)
119
- }
120
- isReallyLast -> {
121
- // Last character (word end or line end) - round right corners only
122
- val path = android.graphics.Path()
123
- path.addRoundRect(
124
- rect,
125
- floatArrayOf(
126
- 0f, 0f, // top-left
127
- cornerRadius, cornerRadius, // top-right
128
- cornerRadius, cornerRadius, // bottom-right
129
- 0f, 0f // bottom-left
130
- ),
131
- android.graphics.Path.Direction.CW
132
- )
133
- canvas.drawPath(path, bgPaint)
134
- }
135
- else -> {
136
- // Middle character - no rounded corners, just rectangle
137
- canvas.drawRect(rect, bgPaint)
138
- }
139
- }
110
+ // Draw rounded rect (full corners for all characters - they overlap seamlessly)
111
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
140
112
 
141
- // Draw text with left padding offset only if first in group
113
+ // Draw text offset by left padding only if at line start
142
114
  val textPaint = Paint(paint).apply {
143
115
  color = textColor
144
116
  isAntiAlias = true
145
117
  }
146
- canvas.drawText(text!!, start, end, x + leftPad, y.toFloat(), textPaint)
118
+ canvas.drawText(text, start, end, x + leftPad, y.toFloat(), textPaint)
147
119
  }
148
120
  }
149
121
 
@@ -197,22 +169,24 @@ class HighlightTextView : AppCompatEditText {
197
169
  setHorizontallyScrolling(false)
198
170
 
199
171
  addTextChangedListener(object : TextWatcher {
200
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
172
+ private var changeStart = 0
173
+ private var changeEnd = 0
174
+
175
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
176
+ changeStart = start
177
+ changeEnd = start + after
178
+ }
179
+
201
180
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
181
+
202
182
  override fun afterTextChanged(s: Editable?) {
203
183
  if (!isUpdatingText) {
204
184
  onTextChangeListener?.invoke(s?.toString() ?: "")
205
- applyCharacterBackgrounds()
185
+
186
+ applyCharacterBackgroundsIncremental(s, changeStart, changeEnd)
206
187
  }
207
188
  }
208
189
  })
209
-
210
- // Set cursor at end when view gains focus
211
- setOnFocusChangeListener { _, hasFocus ->
212
- if (hasFocus) {
213
- text?.length?.let { setSelection(it) }
214
- }
215
- }
216
190
  }
217
191
 
218
192
  fun setCharacterBackgroundColor(color: Int) {
@@ -315,61 +289,8 @@ class HighlightTextView : AppCompatEditText {
315
289
  Typeface.create(baseTypeface, style)
316
290
  }
317
291
 
318
- // Save current text and selection
319
- val currentText = text?.toString() ?: ""
320
- val currentSelection = selectionStart
321
-
322
- // Apply new typeface
323
292
  this.typeface = typeface
324
-
325
- // CRITICAL: Force complete layout rebuild for fonts with different metrics (like Eczar)
326
- // Multiple post calls ensure all layout phases complete with new font
327
- post {
328
- isUpdatingText = true
329
-
330
- // Phase 1: Clear everything
331
- setText("")
332
- paint.typeface = typeface // Ensure paint also has new typeface
333
-
334
- // Force measure with empty text and new font
335
- measure(
336
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
337
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
338
- )
339
- layout(left, top, right, bottom)
340
-
341
- // Phase 2: Restore text and rebuild (next frame)
342
- post {
343
- setText(currentText)
344
-
345
- // Force another measure/layout cycle with actual text
346
- measure(
347
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
348
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
349
- )
350
- layout(left, top, right, bottom)
351
-
352
- // Phase 3: Apply backgrounds and finalize (next frame)
353
- post {
354
- applyCharacterBackgrounds()
355
-
356
- // Restore cursor position
357
- val safePosition = currentSelection.coerceIn(0, currentText.length)
358
- setSelection(safePosition)
359
-
360
- isUpdatingText = false
361
-
362
- // Final layout pass
363
- requestLayout()
364
- invalidate()
365
-
366
- // Extra invalidate to ensure rendering
367
- post {
368
- invalidate()
369
- }
370
- }
371
- }
372
- }
293
+ applyCharacterBackgrounds()
373
294
  }
374
295
 
375
296
  fun setVerticalAlign(align: String?) {
@@ -419,30 +340,149 @@ class HighlightTextView : AppCompatEditText {
419
340
  isUpdatingText = true
420
341
  setText(text)
421
342
  applyCharacterBackgrounds()
343
+ // Move cursor to end of text after setting
344
+ post {
345
+ if (hasFocus()) {
346
+ text.length.let { setSelection(it) }
347
+ }
348
+ }
422
349
  isUpdatingText = false
423
350
  }
424
351
  }
425
352
 
426
353
  fun setAutoFocus(autoFocus: Boolean) {
427
354
  if (autoFocus && isFocusable && isFocusableInTouchMode) {
428
- post {
355
+ postDelayed({
429
356
  requestFocus()
430
- // Set cursor at the end of text
357
+ // Move cursor to end of text
431
358
  text?.length?.let { setSelection(it) }
432
359
  val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
433
- imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
434
- }
360
+ imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_FORCED)
361
+ }, 100)
435
362
  }
436
363
  }
437
364
 
438
- private fun applyCharacterBackgrounds() {
439
- val text = text?.toString() ?: return
440
- if (text.isEmpty()) return
365
+ /**
366
+ * iOS-style incremental update: Only update spans for changed region.
367
+ * This is called during typing and only touches the modified characters.
368
+ */
369
+ private fun applyCharacterBackgroundsIncremental(editable: Editable?, start: Int, end: Int) {
370
+ if (editable == null) return
371
+ val textStr = editable.toString()
372
+ if (textStr.isEmpty()) return
373
+
374
+ isUpdatingText = true
375
+
376
+ // Check if a newline was inserted - if so, expand region to include char before it
377
+ val hasNewline = textStr.substring(start, minOf(end, textStr.length)).contains('\n')
441
378
 
442
- // Save current cursor position
443
- val currentSelection = selectionStart
379
+ // Expand the region to include entire lines that were affected
380
+ val layout = layout
381
+ val expandedStart: Int
382
+ val expandedEnd: Int
444
383
 
445
- val spannable = SpannableString(text)
384
+ if (layout != null && textStr.isNotEmpty()) {
385
+ val startLine = layout.getLineForOffset(minOf(start, textStr.length - 1))
386
+ val endLine = layout.getLineForOffset(minOf(end, textStr.length - 1))
387
+ expandedStart = layout.getLineStart(startLine)
388
+ expandedEnd = layout.getLineEnd(endLine)
389
+ } else {
390
+ // If newline inserted, include character before it
391
+ expandedStart = if (hasNewline) maxOf(0, start - 2) else maxOf(0, start - 1)
392
+ expandedEnd = minOf(textStr.length, end + 1)
393
+ }
394
+
395
+ // Remove existing spans in the affected lines
396
+ val existingSpans = editable.getSpans(expandedStart, expandedEnd, RoundedBackgroundSpan::class.java)
397
+ for (span in existingSpans) {
398
+ editable.removeSpan(span)
399
+ }
400
+
401
+ // Apply spans with correct line boundary flags immediately
402
+ val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
403
+
404
+ for (i in expandedStart until expandedEnd) {
405
+ if (i >= textStr.length) break
406
+
407
+ val char = textStr[i]
408
+ val shouldHighlight = when {
409
+ char == '\n' || char == '\t' -> false
410
+ char == ' ' -> {
411
+ val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
412
+ val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
413
+ !hasSpaceBefore && !hasSpaceAfter
414
+ }
415
+ else -> true
416
+ }
417
+
418
+ if (shouldHighlight) {
419
+ // ALWAYS check newlines first (for manual line breaks)
420
+ val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
421
+ val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
422
+
423
+ var isAtLineStart = i == 0 || hasNewlineBefore
424
+ var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
425
+
426
+ // Only use layout for auto-wrapped lines (not manual newlines)
427
+ if (!hasNewlineBefore && !hasNewlineAfter && layout != null && i < textStr.length) {
428
+ try {
429
+ val line = layout.getLineForOffset(i)
430
+ val lineStart = layout.getLineStart(line)
431
+ val lineEnd = layout.getLineEnd(line)
432
+ // Only override if this is an auto-wrapped boundary
433
+ if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
434
+ isAtLineStart = true
435
+ }
436
+ if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
437
+ isAtLineEnd = true
438
+ }
439
+ } catch (e: Exception) {
440
+ // Layout might not be ready, keep newline-based detection
441
+ }
442
+ }
443
+
444
+ val span = RoundedBackgroundSpan(
445
+ characterBackgroundColor,
446
+ textColorValue,
447
+ charPaddingLeft,
448
+ charPaddingRight,
449
+ charPaddingTop,
450
+ charPaddingBottom,
451
+ backgroundInsetTop,
452
+ backgroundInsetBottom,
453
+ backgroundInsetLeft,
454
+ backgroundInsetRight,
455
+ radius,
456
+ isAtLineStart,
457
+ isAtLineEnd
458
+ )
459
+ editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
460
+ }
461
+ }
462
+
463
+ isUpdatingText = false
464
+
465
+ // JERK FIX: Skip the post-update during fast typing to prevent layout thrashing
466
+ // Only update boundaries when user stops typing (reduces update frequency)
467
+ removeCallbacks(boundaryUpdateCheck)
468
+ postDelayed(boundaryUpdateCheck, 200)
469
+ }
470
+
471
+ // Runnable for delayed boundary check
472
+ private val boundaryUpdateCheck = Runnable {
473
+ if (!isUpdatingText) {
474
+ updateAutoWrappedLineBoundaries()
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Full re-application of spans (used when props change, not during typing).
480
+ * iOS-style: Work directly with editable, no setText() call.
481
+ */
482
+ private fun applyCharacterBackgrounds() {
483
+ val editable = editableText ?: return
484
+ val textStr = editable.toString()
485
+ if (textStr.isEmpty()) return
446
486
 
447
487
  // Apply line height if specified
448
488
  if (customLineHeight > 0) {
@@ -450,29 +490,56 @@ class HighlightTextView : AppCompatEditText {
450
490
  setLineSpacing(0f, lineSpacingMultiplier)
451
491
  }
452
492
 
453
- // Apply character-by-character for proper line wrapping
454
- for (i in text.indices) {
455
- val char = text[i]
493
+ isUpdatingText = true
494
+
495
+ // Remove all existing spans
496
+ val existingSpans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
497
+ for (span in existingSpans) {
498
+ editable.removeSpan(span)
499
+ }
500
+
501
+ // Apply spans to all characters with correct line boundary flags
502
+ val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
503
+ val layoutObj = layout
504
+
505
+ for (i in textStr.indices) {
506
+ val char = textStr[i]
456
507
 
457
- // Check if this is a space that should be highlighted
458
508
  val shouldHighlight = when {
459
- char == '\n' || char == '\t' -> false // Never highlight newlines or tabs
509
+ char == '\n' || char == '\t' -> false
460
510
  char == ' ' -> {
461
- // Highlight space only if it's a single space (not multiple consecutive)
462
- val hasSpaceBefore = i > 0 && text[i - 1] == ' '
463
- val hasSpaceAfter = i < text.length - 1 && text[i + 1] == ' '
511
+ val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
512
+ val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
464
513
  !hasSpaceBefore && !hasSpaceAfter
465
514
  }
466
- else -> true // Highlight all other characters
515
+ else -> true
467
516
  }
468
517
 
469
518
  if (shouldHighlight) {
470
- // Determine if this is the first or last character in a word group
471
- val isFirst = i == 0 || !shouldHighlightChar(text, i - 1)
472
- val isLast = i == text.length - 1 || !shouldHighlightChar(text, i + 1)
519
+ // ALWAYS check newlines first (for manual line breaks)
520
+ val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
521
+ val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
473
522
 
474
- // Use highlightBorderRadius if specified, otherwise use cornerRadius (matches iOS)
475
- val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
523
+ var isAtLineStart = i == 0 || hasNewlineBefore
524
+ var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
525
+
526
+ // Only use layout for auto-wrapped lines (not manual newlines)
527
+ if (!hasNewlineBefore && !hasNewlineAfter && layoutObj != null && i < textStr.length) {
528
+ try {
529
+ val line = layoutObj.getLineForOffset(i)
530
+ val lineStart = layoutObj.getLineStart(line)
531
+ val lineEnd = layoutObj.getLineEnd(line)
532
+ // Only override if this is an auto-wrapped boundary
533
+ if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
534
+ isAtLineStart = true
535
+ }
536
+ if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
537
+ isAtLineEnd = true
538
+ }
539
+ } catch (e: Exception) {
540
+ // Layout might not be ready, keep newline-based detection
541
+ }
542
+ }
476
543
 
477
544
  val span = RoundedBackgroundSpan(
478
545
  characterBackgroundColor,
@@ -486,51 +553,60 @@ class HighlightTextView : AppCompatEditText {
486
553
  backgroundInsetLeft,
487
554
  backgroundInsetRight,
488
555
  radius,
489
- isFirst,
490
- isLast
556
+ isAtLineStart,
557
+ isAtLineEnd
491
558
  )
492
- spannable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
559
+ editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
493
560
  }
494
561
  }
495
562
 
496
- isUpdatingText = true
497
- setText(spannable)
498
- // Restore cursor position, or set to end if position is invalid
499
- val safePosition = currentSelection.coerceIn(0, text.length)
500
- setSelection(safePosition)
501
563
  isUpdatingText = false
502
-
503
- // Detect line wraps after layout is ready
504
- post { detectLineWraps() }
505
564
  }
506
565
 
507
- private fun detectLineWraps() {
566
+ /**
567
+ * Update line boundary flags only for auto-wrapped lines.
568
+ * This is called after layout completes to handle text wrapping.
569
+ * Only updates spans that are at auto-wrapped line boundaries.
570
+ * Optimized to skip updates when layout hasn't changed.
571
+ */
572
+ private fun updateAutoWrappedLineBoundaries() {
573
+ if (isUpdatingText) return
574
+
508
575
  val layout = layout ?: return
509
- val text = text as? Spannable ?: return
510
- val textStr = text.toString()
511
- val spans = text.getSpans(0, text.length, RoundedBackgroundSpan::class.java)
576
+ val editable = editableText ?: return
577
+ val textStr = editable.toString()
578
+ if (textStr.isEmpty()) return
579
+
580
+ // Validate that layout is ready and has valid dimensions
581
+ if (width <= 0 || layout.lineCount == 0) return
582
+
583
+ val spans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
584
+ if (spans.isEmpty()) return
585
+
586
+ isUpdatingText = true
587
+ var hasChanges = false
512
588
 
513
589
  for (span in spans) {
514
- val spanStart = text.getSpanStart(span)
515
- val spanEnd = text.getSpanEnd(span)
590
+ val spanStart = editable.getSpanStart(span)
591
+ val spanEnd = editable.getSpanEnd(span)
516
592
 
517
- if (spanStart >= 0 && spanStart < text.length) {
593
+ if (spanStart < 0 || spanStart >= textStr.length) continue
594
+
595
+ try {
518
596
  val line = layout.getLineForOffset(spanStart)
519
597
  val lineStart = layout.getLineStart(line)
520
598
  val lineEnd = layout.getLineEnd(line)
521
599
 
522
- // Check for manual line break (\n) before this character
523
- val hasNewlineBefore = spanStart > 0 && textStr[spanStart - 1] == '\n'
524
- // Check for manual line break (\n) after this character
525
- val hasNewlineAfter = spanEnd < textStr.length && textStr[spanEnd] == '\n'
600
+ // Determine actual line boundaries (includes auto-wrap)
601
+ val isAtLineStart = spanStart == lineStart
602
+ val isAtLineEnd = spanEnd == lineEnd
526
603
 
527
- // Check if this char is at start of visual line (wrapped OR after \n)
528
- val isAtLineStart = (spanStart == lineStart && !span.isFirstInGroup) || hasNewlineBefore
529
- // Check if this char is at end of visual line (wrapped OR before \n)
530
- val isAtLineEnd = (spanEnd == lineEnd && !span.isLastInGroup) || hasNewlineAfter
604
+ // Only update if this is an auto-wrapped line boundary (not a newline boundary)
605
+ val isNewlineBoundary = (spanStart > 0 && textStr[spanStart - 1] == '\n') ||
606
+ (spanEnd < textStr.length && textStr[spanEnd] == '\n')
531
607
 
532
- if (isAtLineStart || isAtLineEnd) {
533
- // Create new span with line boundary flags
608
+ // Only recreate span if it's at an auto-wrapped boundary and flags are wrong
609
+ if (!isNewlineBoundary && (isAtLineStart != span.isLineStart || isAtLineEnd != span.isLineEnd)) {
534
610
  val newSpan = RoundedBackgroundSpan(
535
611
  span.backgroundColor,
536
612
  span.textColor,
@@ -543,31 +619,24 @@ class HighlightTextView : AppCompatEditText {
543
619
  span.backgroundInsetLeft,
544
620
  span.backgroundInsetRight,
545
621
  span.cornerRadius,
546
- span.isFirstInGroup,
547
- span.isLastInGroup,
548
622
  isAtLineStart,
549
623
  isAtLineEnd
550
624
  )
551
- text.removeSpan(span)
552
- text.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
625
+ editable.removeSpan(span)
626
+ editable.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
627
+ hasChanges = true
553
628
  }
629
+ } catch (e: Exception) {
630
+ // Layout state is invalid, skip this update
631
+ continue
554
632
  }
555
633
  }
556
- invalidate()
557
- }
558
-
559
- private fun shouldHighlightChar(text: String, index: Int): Boolean {
560
- if (index < 0 || index >= text.length) return false
561
- val char = text[index]
562
-
563
- return when {
564
- char == '\n' || char == '\t' -> false
565
- char == ' ' -> {
566
- val hasSpaceBefore = index > 0 && text[index - 1] == ' '
567
- val hasSpaceAfter = index < text.length - 1 && text[index + 1] == ' '
568
- !hasSpaceBefore && !hasSpaceAfter
569
- }
570
- else -> true
634
+
635
+ isUpdatingText = false
636
+
637
+ // Only invalidate if we actually made changes
638
+ if (hasChanges) {
639
+ invalidate()
571
640
  }
572
641
  }
573
642
  }
@@ -430,9 +430,6 @@ using namespace facebook::react;
430
430
  if (newViewProps.autoFocus && _textView.isEditable) {
431
431
  dispatch_async(dispatch_get_main_queue(), ^{
432
432
  [self->_textView becomeFirstResponder];
433
- // Set cursor at the end of text
434
- NSUInteger textLength = self->_textView.text.length;
435
- self->_textView.selectedRange = NSMakeRange(textLength, 0);
436
433
  });
437
434
  }
438
435
  }
@@ -471,13 +468,6 @@ Class<RCTComponentViewProtocol> HighlightTextViewCls(void)
471
468
  }
472
469
  }
473
470
 
474
- - (void)textViewDidBeginEditing:(UITextView *)textView
475
- {
476
- // Set cursor at the end when editing begins
477
- NSUInteger textLength = textView.text.length;
478
- textView.selectedRange = NSMakeRange(textLength, 0);
479
- }
480
-
481
471
  - (void)updateFont
482
472
  {
483
473
  CGFloat fontSize = _fontSize > 0 ? _fontSize : 32.0;
@@ -528,51 +518,8 @@ Class<RCTComponentViewProtocol> HighlightTextViewCls(void)
528
518
  newFont = [UIFont systemFontOfSize:fontSize weight:fontWeight];
529
519
  }
530
520
 
531
- // Save current text and selection
532
- NSString *currentText = [_textView.text copy];
533
- NSRange currentSelection = _textView.selectedRange;
534
-
535
- // Update font on text view
536
521
  _textView.font = newFont;
537
-
538
- // CRITICAL: Force complete rebuild by removing and re-adding the text container
539
- // This ensures all glyph and layout caches are cleared for the new font metrics
540
- NSTextContainer *textContainer = _textView.textContainer;
541
- NSTextStorage *textStorage = _textView.textStorage;
542
-
543
- // Remove text container from layout manager
544
- [_layoutManager removeTextContainerAtIndex:0];
545
-
546
- // Completely invalidate all layout
547
- [_layoutManager invalidateLayoutForCharacterRange:NSMakeRange(0, textStorage.length) actualCharacterRange:NULL];
548
- [_layoutManager invalidateDisplayForCharacterRange:NSMakeRange(0, textStorage.length)];
549
-
550
- // Re-add text container - forces complete layout recalculation
551
- [_layoutManager addTextContainer:textContainer];
552
-
553
- // Force immediate layout with new font metrics
554
- dispatch_async(dispatch_get_main_queue(), ^{
555
- // Restore text through applyCharacterBackgrounds which rebuilds attributed string
556
- [self applyCharacterBackgrounds];
557
-
558
- // Ensure layout is calculated with new font
559
- [self->_layoutManager ensureLayoutForTextContainer:self->_textView.textContainer];
560
-
561
- // Restore selection if valid
562
- if (currentSelection.location <= currentText.length) {
563
- self->_textView.selectedRange = currentSelection;
564
- }
565
-
566
- // Force complete redraw
567
- [self->_textView setNeedsDisplay];
568
- [self->_textView setNeedsLayout];
569
- [self->_textView layoutIfNeeded];
570
-
571
- // Trigger another display update to ensure rendering
572
- dispatch_async(dispatch_get_main_queue(), ^{
573
- [self->_textView setNeedsDisplay];
574
- });
575
- });
522
+ [self applyCharacterBackgrounds];
576
523
  }
577
524
 
578
525
  - (void)applyCharacterBackgrounds
@@ -582,9 +529,6 @@ Class<RCTComponentViewProtocol> HighlightTextViewCls(void)
582
529
  return;
583
530
  }
584
531
 
585
- // Save current cursor position
586
- NSRange currentSelection = _textView.selectedRange;
587
-
588
532
  NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text];
589
533
  UIColor *bgColor = [self hexStringToColor:_characterBackgroundColor];
590
534
 
@@ -633,12 +577,6 @@ Class<RCTComponentViewProtocol> HighlightTextViewCls(void)
633
577
  }
634
578
 
635
579
  _textView.attributedText = attributedString;
636
-
637
- // Restore cursor position if valid
638
- if (currentSelection.location <= text.length) {
639
- _textView.selectedRange = currentSelection;
640
- }
641
-
642
580
  [_textView setNeedsDisplay];
643
581
  }
644
582
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-highlight-text-view",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "A native text input for React Native that supports inline text highlighting",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",