react-native-highlight-text-view 0.1.20 → 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.
|
@@ -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
|
|
32
|
-
internal val
|
|
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
|
-
|
|
46
|
-
val
|
|
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
|
-
|
|
64
|
+
if (text == null) return
|
|
65
|
+
|
|
63
66
|
val bgPaint = Paint().apply {
|
|
64
67
|
color = backgroundColor
|
|
65
68
|
style = Paint.Style.FILL
|
|
@@ -68,83 +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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 -
|
|
94
|
-
insetTop - paddingTop,
|
|
95
|
-
x + width +
|
|
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
|
|
100
|
-
|
|
101
|
-
when {
|
|
102
|
-
isReallyFirst && isReallyLast -> {
|
|
103
|
-
// Single character or isolated group - round all corners (matches iOS)
|
|
104
|
-
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
|
|
105
|
-
}
|
|
106
|
-
isReallyFirst -> {
|
|
107
|
-
// First character (word start or line start) - round left corners only
|
|
108
|
-
val path = android.graphics.Path()
|
|
109
|
-
path.addRoundRect(
|
|
110
|
-
rect,
|
|
111
|
-
floatArrayOf(
|
|
112
|
-
cornerRadius, cornerRadius, // top-left
|
|
113
|
-
0f, 0f, // top-right (flat for connection)
|
|
114
|
-
0f, 0f, // bottom-right (flat for connection)
|
|
115
|
-
cornerRadius, cornerRadius // bottom-left
|
|
116
|
-
),
|
|
117
|
-
android.graphics.Path.Direction.CW
|
|
118
|
-
)
|
|
119
|
-
canvas.drawPath(path, bgPaint)
|
|
120
|
-
}
|
|
121
|
-
isReallyLast -> {
|
|
122
|
-
// Last character (word end or line end) - round right corners only
|
|
123
|
-
val path = android.graphics.Path()
|
|
124
|
-
path.addRoundRect(
|
|
125
|
-
rect,
|
|
126
|
-
floatArrayOf(
|
|
127
|
-
0f, 0f, // top-left (flat for connection)
|
|
128
|
-
cornerRadius, cornerRadius, // top-right
|
|
129
|
-
cornerRadius, cornerRadius, // bottom-right
|
|
130
|
-
0f, 0f // bottom-left (flat for connection)
|
|
131
|
-
),
|
|
132
|
-
android.graphics.Path.Direction.CW
|
|
133
|
-
)
|
|
134
|
-
canvas.drawPath(path, bgPaint)
|
|
135
|
-
}
|
|
136
|
-
else -> {
|
|
137
|
-
// Middle character - no rounded corners for seamless connection
|
|
138
|
-
canvas.drawRect(rect, bgPaint)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
110
|
+
// Draw rounded rect (full corners for all characters - they overlap seamlessly)
|
|
111
|
+
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
|
|
141
112
|
|
|
142
|
-
// Draw text
|
|
113
|
+
// Draw text offset by left padding only if at line start
|
|
143
114
|
val textPaint = Paint(paint).apply {
|
|
144
115
|
color = textColor
|
|
145
116
|
isAntiAlias = true
|
|
146
117
|
}
|
|
147
|
-
canvas.drawText(text
|
|
118
|
+
canvas.drawText(text, start, end, x + leftPad, y.toFloat(), textPaint)
|
|
148
119
|
}
|
|
149
120
|
}
|
|
150
121
|
|
|
@@ -198,12 +169,21 @@ class HighlightTextView : AppCompatEditText {
|
|
|
198
169
|
setHorizontallyScrolling(false)
|
|
199
170
|
|
|
200
171
|
addTextChangedListener(object : TextWatcher {
|
|
201
|
-
|
|
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
|
+
|
|
202
180
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
|
181
|
+
|
|
203
182
|
override fun afterTextChanged(s: Editable?) {
|
|
204
183
|
if (!isUpdatingText) {
|
|
205
184
|
onTextChangeListener?.invoke(s?.toString() ?: "")
|
|
206
|
-
|
|
185
|
+
|
|
186
|
+
applyCharacterBackgroundsIncremental(s, changeStart, changeEnd)
|
|
207
187
|
}
|
|
208
188
|
}
|
|
209
189
|
})
|
|
@@ -360,25 +340,149 @@ class HighlightTextView : AppCompatEditText {
|
|
|
360
340
|
isUpdatingText = true
|
|
361
341
|
setText(text)
|
|
362
342
|
applyCharacterBackgrounds()
|
|
343
|
+
// Move cursor to end of text after setting
|
|
344
|
+
post {
|
|
345
|
+
if (hasFocus()) {
|
|
346
|
+
text.length.let { setSelection(it) }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
363
349
|
isUpdatingText = false
|
|
364
350
|
}
|
|
365
351
|
}
|
|
366
352
|
|
|
367
353
|
fun setAutoFocus(autoFocus: Boolean) {
|
|
368
354
|
if (autoFocus && isFocusable && isFocusableInTouchMode) {
|
|
369
|
-
|
|
355
|
+
postDelayed({
|
|
370
356
|
requestFocus()
|
|
357
|
+
// Move cursor to end of text
|
|
358
|
+
text?.length?.let { setSelection(it) }
|
|
371
359
|
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
|
|
372
|
-
imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.
|
|
360
|
+
imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_FORCED)
|
|
361
|
+
}, 100)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
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')
|
|
378
|
+
|
|
379
|
+
// Expand the region to include entire lines that were affected
|
|
380
|
+
val layout = layout
|
|
381
|
+
val expandedStart: Int
|
|
382
|
+
val expandedEnd: Int
|
|
383
|
+
|
|
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
|
|
373
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()
|
|
374
475
|
}
|
|
375
476
|
}
|
|
376
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
|
+
*/
|
|
377
482
|
private fun applyCharacterBackgrounds() {
|
|
378
|
-
val
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
val spannable = SpannableString(text)
|
|
483
|
+
val editable = editableText ?: return
|
|
484
|
+
val textStr = editable.toString()
|
|
485
|
+
if (textStr.isEmpty()) return
|
|
382
486
|
|
|
383
487
|
// Apply line height if specified
|
|
384
488
|
if (customLineHeight > 0) {
|
|
@@ -386,29 +490,56 @@ class HighlightTextView : AppCompatEditText {
|
|
|
386
490
|
setLineSpacing(0f, lineSpacingMultiplier)
|
|
387
491
|
}
|
|
388
492
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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]
|
|
392
507
|
|
|
393
|
-
// Check if this is a space that should be highlighted
|
|
394
508
|
val shouldHighlight = when {
|
|
395
|
-
char == '\n' || char == '\t' -> false
|
|
509
|
+
char == '\n' || char == '\t' -> false
|
|
396
510
|
char == ' ' -> {
|
|
397
|
-
|
|
398
|
-
val
|
|
399
|
-
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] == ' '
|
|
400
513
|
!hasSpaceBefore && !hasSpaceAfter
|
|
401
514
|
}
|
|
402
|
-
else -> true
|
|
515
|
+
else -> true
|
|
403
516
|
}
|
|
404
517
|
|
|
405
518
|
if (shouldHighlight) {
|
|
406
|
-
//
|
|
407
|
-
val
|
|
408
|
-
val
|
|
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'
|
|
522
|
+
|
|
523
|
+
var isAtLineStart = i == 0 || hasNewlineBefore
|
|
524
|
+
var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
|
|
409
525
|
|
|
410
|
-
//
|
|
411
|
-
|
|
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
|
+
}
|
|
412
543
|
|
|
413
544
|
val span = RoundedBackgroundSpan(
|
|
414
545
|
characterBackgroundColor,
|
|
@@ -422,62 +553,60 @@ class HighlightTextView : AppCompatEditText {
|
|
|
422
553
|
backgroundInsetLeft,
|
|
423
554
|
backgroundInsetRight,
|
|
424
555
|
radius,
|
|
425
|
-
|
|
426
|
-
|
|
556
|
+
isAtLineStart,
|
|
557
|
+
isAtLineEnd
|
|
427
558
|
)
|
|
428
|
-
|
|
559
|
+
editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
429
560
|
}
|
|
430
561
|
}
|
|
431
562
|
|
|
432
|
-
// Save current selection to prevent cursor jumping (smooth editing)
|
|
433
|
-
val currentSelection = selectionStart
|
|
434
|
-
|
|
435
|
-
isUpdatingText = true
|
|
436
|
-
setText(spannable)
|
|
437
|
-
|
|
438
|
-
// Restore cursor position if valid (prevents jerking during editing)
|
|
439
|
-
if (currentSelection >= 0 && currentSelection <= text.length) {
|
|
440
|
-
setSelection(currentSelection)
|
|
441
|
-
}
|
|
442
563
|
isUpdatingText = false
|
|
443
|
-
|
|
444
|
-
// Detect line wraps after layout is ready
|
|
445
|
-
post { detectLineWraps() }
|
|
446
564
|
}
|
|
447
565
|
|
|
448
|
-
|
|
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
|
+
|
|
449
575
|
val layout = layout ?: return
|
|
450
|
-
val
|
|
451
|
-
val textStr =
|
|
452
|
-
|
|
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
|
|
453
588
|
|
|
454
589
|
for (span in spans) {
|
|
455
|
-
val spanStart =
|
|
456
|
-
val spanEnd =
|
|
590
|
+
val spanStart = editable.getSpanStart(span)
|
|
591
|
+
val spanEnd = editable.getSpanEnd(span)
|
|
592
|
+
|
|
593
|
+
if (spanStart < 0 || spanStart >= textStr.length) continue
|
|
457
594
|
|
|
458
|
-
|
|
595
|
+
try {
|
|
459
596
|
val line = layout.getLineForOffset(spanStart)
|
|
460
597
|
val lineStart = layout.getLineStart(line)
|
|
461
598
|
val lineEnd = layout.getLineEnd(line)
|
|
462
599
|
|
|
463
|
-
//
|
|
464
|
-
val
|
|
465
|
-
|
|
466
|
-
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
|
|
467
603
|
|
|
468
|
-
//
|
|
469
|
-
val
|
|
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')
|
|
470
607
|
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
// Check if this char is at end of visual line (wrapped OR before \n OR end of last line)
|
|
475
|
-
// CRITICAL: Ensure last character of entire text gets rounded corners
|
|
476
|
-
val isAtLineEnd = (spanEnd == lineEnd && !span.isLastInGroup) || hasNewlineAfter ||
|
|
477
|
-
(isLastLine && spanEnd == lineEnd)
|
|
478
|
-
|
|
479
|
-
if (isAtLineStart || isAtLineEnd) {
|
|
480
|
-
// 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)) {
|
|
481
610
|
val newSpan = RoundedBackgroundSpan(
|
|
482
611
|
span.backgroundColor,
|
|
483
612
|
span.textColor,
|
|
@@ -490,31 +619,24 @@ class HighlightTextView : AppCompatEditText {
|
|
|
490
619
|
span.backgroundInsetLeft,
|
|
491
620
|
span.backgroundInsetRight,
|
|
492
621
|
span.cornerRadius,
|
|
493
|
-
span.isFirstInGroup,
|
|
494
|
-
span.isLastInGroup,
|
|
495
622
|
isAtLineStart,
|
|
496
623
|
isAtLineEnd
|
|
497
624
|
)
|
|
498
|
-
|
|
499
|
-
|
|
625
|
+
editable.removeSpan(span)
|
|
626
|
+
editable.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
627
|
+
hasChanges = true
|
|
500
628
|
}
|
|
629
|
+
} catch (e: Exception) {
|
|
630
|
+
// Layout state is invalid, skip this update
|
|
631
|
+
continue
|
|
501
632
|
}
|
|
502
633
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
return when {
|
|
511
|
-
char == '\n' || char == '\t' -> false
|
|
512
|
-
char == ' ' -> {
|
|
513
|
-
val hasSpaceBefore = index > 0 && text[index - 1] == ' '
|
|
514
|
-
val hasSpaceAfter = index < text.length - 1 && text[index + 1] == ' '
|
|
515
|
-
!hasSpaceBefore && !hasSpaceAfter
|
|
516
|
-
}
|
|
517
|
-
else -> true
|
|
634
|
+
|
|
635
|
+
isUpdatingText = false
|
|
636
|
+
|
|
637
|
+
// Only invalidate if we actually made changes
|
|
638
|
+
if (hasChanges) {
|
|
639
|
+
invalidate()
|
|
518
640
|
}
|
|
519
641
|
}
|
|
520
642
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-highlight-text-view",
|
|
3
|
-
"version": "0.1.
|
|
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",
|