react-native-highlight-text-view 0.1.20 → 0.1.22
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,99 @@ 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
|
|
|
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
|
+
// SELECTIVE ROUNDING STRATEGY:
|
|
88
|
+
// 1. Line Start: Round Left corners.
|
|
89
|
+
// 2. Line End: Round Right corners.
|
|
90
|
+
// 3. Middle: Square (no rounding).
|
|
91
|
+
// 4. Overlap: Minimal (1px) to seal seams.
|
|
92
|
+
|
|
93
|
+
val overlapExtension = 1f
|
|
94
|
+
|
|
95
|
+
// No extension needed for start/end boundaries
|
|
96
|
+
val leftExtend = 0f
|
|
97
|
+
|
|
98
|
+
// Extend right slightly for middle characters to seal the gap
|
|
99
|
+
val rightExtend = if (!isLineEnd) {
|
|
100
|
+
if (isLineStart) leftPad + overlapExtension else overlapExtension
|
|
101
|
+
} else {
|
|
102
|
+
0f
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Vertical overlap to eliminate gaps
|
|
106
|
+
val topExtend = 4f
|
|
107
|
+
val bottomExtend = 4f
|
|
108
|
+
|
|
91
109
|
// Calculate background rect
|
|
110
|
+
// NOTE: Since this is a ReplacementSpan, 'x' is the start of the span (including padding).
|
|
111
|
+
// So we draw from 'x', not 'x - leftPad'.
|
|
92
112
|
val rect = RectF(
|
|
93
|
-
x
|
|
94
|
-
insetTop - paddingTop,
|
|
95
|
-
x +
|
|
96
|
-
insetTop + insetHeight + paddingBottom
|
|
113
|
+
x + backgroundInsetLeft - leftExtend,
|
|
114
|
+
insetTop - paddingTop - topExtend,
|
|
115
|
+
x + leftPad + width + rightPad - backgroundInsetRight + rightExtend,
|
|
116
|
+
insetTop + insetHeight + paddingBottom + bottomExtend
|
|
97
117
|
)
|
|
98
118
|
|
|
99
|
-
// Draw
|
|
100
|
-
// iOS draws per-character backgrounds with full corner radius, so we do the same
|
|
119
|
+
// Draw based on position
|
|
101
120
|
when {
|
|
102
|
-
|
|
103
|
-
// Single character
|
|
121
|
+
isLineStart && isLineEnd -> {
|
|
122
|
+
// Single character - round all corners
|
|
104
123
|
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
|
|
105
124
|
}
|
|
106
|
-
|
|
107
|
-
//
|
|
125
|
+
isLineStart -> {
|
|
126
|
+
// Line START - round LEFT corners only
|
|
108
127
|
val path = android.graphics.Path()
|
|
109
128
|
path.addRoundRect(
|
|
110
129
|
rect,
|
|
111
130
|
floatArrayOf(
|
|
112
131
|
cornerRadius, cornerRadius, // top-left
|
|
113
|
-
0f, 0f, // top-right
|
|
114
|
-
0f, 0f, // bottom-right
|
|
132
|
+
0f, 0f, // top-right
|
|
133
|
+
0f, 0f, // bottom-right
|
|
115
134
|
cornerRadius, cornerRadius // bottom-left
|
|
116
135
|
),
|
|
117
136
|
android.graphics.Path.Direction.CW
|
|
118
137
|
)
|
|
119
138
|
canvas.drawPath(path, bgPaint)
|
|
120
139
|
}
|
|
121
|
-
|
|
122
|
-
//
|
|
140
|
+
isLineEnd -> {
|
|
141
|
+
// Line END - round RIGHT corners only
|
|
123
142
|
val path = android.graphics.Path()
|
|
124
143
|
path.addRoundRect(
|
|
125
144
|
rect,
|
|
126
145
|
floatArrayOf(
|
|
127
|
-
0f, 0f, // top-left
|
|
146
|
+
0f, 0f, // top-left
|
|
128
147
|
cornerRadius, cornerRadius, // top-right
|
|
129
148
|
cornerRadius, cornerRadius, // bottom-right
|
|
130
|
-
0f, 0f // bottom-left
|
|
149
|
+
0f, 0f // bottom-left
|
|
131
150
|
),
|
|
132
151
|
android.graphics.Path.Direction.CW
|
|
133
152
|
)
|
|
134
153
|
canvas.drawPath(path, bgPaint)
|
|
135
154
|
}
|
|
136
155
|
else -> {
|
|
137
|
-
// Middle
|
|
156
|
+
// Middle - NO rounded corners (Square)
|
|
138
157
|
canvas.drawRect(rect, bgPaint)
|
|
139
158
|
}
|
|
140
159
|
}
|
|
141
160
|
|
|
142
|
-
// Draw text
|
|
161
|
+
// Draw text offset by left padding only if at line start
|
|
143
162
|
val textPaint = Paint(paint).apply {
|
|
144
163
|
color = textColor
|
|
145
164
|
isAntiAlias = true
|
|
146
165
|
}
|
|
147
|
-
canvas.drawText(text
|
|
166
|
+
canvas.drawText(text, start, end, x + leftPad, y.toFloat(), textPaint)
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
169
|
|
|
@@ -198,12 +217,21 @@ class HighlightTextView : AppCompatEditText {
|
|
|
198
217
|
setHorizontallyScrolling(false)
|
|
199
218
|
|
|
200
219
|
addTextChangedListener(object : TextWatcher {
|
|
201
|
-
|
|
220
|
+
private var changeStart = 0
|
|
221
|
+
private var changeEnd = 0
|
|
222
|
+
|
|
223
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
224
|
+
changeStart = start
|
|
225
|
+
changeEnd = start + after
|
|
226
|
+
}
|
|
227
|
+
|
|
202
228
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
|
229
|
+
|
|
203
230
|
override fun afterTextChanged(s: Editable?) {
|
|
204
231
|
if (!isUpdatingText) {
|
|
205
232
|
onTextChangeListener?.invoke(s?.toString() ?: "")
|
|
206
|
-
|
|
233
|
+
|
|
234
|
+
applyCharacterBackgroundsIncremental(s, changeStart, changeEnd)
|
|
207
235
|
}
|
|
208
236
|
}
|
|
209
237
|
})
|
|
@@ -225,29 +253,44 @@ class HighlightTextView : AppCompatEditText {
|
|
|
225
253
|
charPaddingTop = top
|
|
226
254
|
charPaddingRight = right
|
|
227
255
|
charPaddingBottom = bottom
|
|
256
|
+
updateViewPadding()
|
|
228
257
|
applyCharacterBackgrounds()
|
|
229
258
|
}
|
|
230
259
|
|
|
231
260
|
fun setCharPaddingLeft(padding: Float) {
|
|
232
261
|
charPaddingLeft = padding
|
|
262
|
+
updateViewPadding()
|
|
233
263
|
applyCharacterBackgrounds()
|
|
234
264
|
}
|
|
235
265
|
|
|
236
266
|
fun setCharPaddingRight(padding: Float) {
|
|
237
267
|
charPaddingRight = padding
|
|
268
|
+
updateViewPadding()
|
|
238
269
|
applyCharacterBackgrounds()
|
|
239
270
|
}
|
|
240
271
|
|
|
241
272
|
fun setCharPaddingTop(padding: Float) {
|
|
242
273
|
charPaddingTop = padding
|
|
274
|
+
updateViewPadding()
|
|
243
275
|
applyCharacterBackgrounds()
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
fun setCharPaddingBottom(padding: Float) {
|
|
247
279
|
charPaddingBottom = padding
|
|
280
|
+
updateViewPadding()
|
|
248
281
|
applyCharacterBackgrounds()
|
|
249
282
|
}
|
|
250
283
|
|
|
284
|
+
private fun updateViewPadding() {
|
|
285
|
+
// Sync View padding with char padding to prevent clipping of background
|
|
286
|
+
setPadding(
|
|
287
|
+
charPaddingLeft.toInt(),
|
|
288
|
+
charPaddingTop.toInt(),
|
|
289
|
+
charPaddingRight.toInt(),
|
|
290
|
+
charPaddingBottom.toInt()
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
251
294
|
fun setCornerRadius(radius: Float) {
|
|
252
295
|
cornerRadius = radius
|
|
253
296
|
applyCharacterBackgrounds()
|
|
@@ -360,25 +403,149 @@ class HighlightTextView : AppCompatEditText {
|
|
|
360
403
|
isUpdatingText = true
|
|
361
404
|
setText(text)
|
|
362
405
|
applyCharacterBackgrounds()
|
|
406
|
+
// Move cursor to end of text after setting
|
|
407
|
+
post {
|
|
408
|
+
if (hasFocus()) {
|
|
409
|
+
text.length.let { setSelection(it) }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
363
412
|
isUpdatingText = false
|
|
364
413
|
}
|
|
365
414
|
}
|
|
366
415
|
|
|
367
416
|
fun setAutoFocus(autoFocus: Boolean) {
|
|
368
417
|
if (autoFocus && isFocusable && isFocusableInTouchMode) {
|
|
369
|
-
|
|
418
|
+
postDelayed({
|
|
370
419
|
requestFocus()
|
|
420
|
+
// Move cursor to end of text
|
|
421
|
+
text?.length?.let { setSelection(it) }
|
|
371
422
|
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
|
|
372
|
-
imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.
|
|
423
|
+
imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_FORCED)
|
|
424
|
+
}, 100)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* iOS-style incremental update: Only update spans for changed region.
|
|
430
|
+
* This is called during typing and only touches the modified characters.
|
|
431
|
+
*/
|
|
432
|
+
private fun applyCharacterBackgroundsIncremental(editable: Editable?, start: Int, end: Int) {
|
|
433
|
+
if (editable == null) return
|
|
434
|
+
val textStr = editable.toString()
|
|
435
|
+
if (textStr.isEmpty()) return
|
|
436
|
+
|
|
437
|
+
isUpdatingText = true
|
|
438
|
+
|
|
439
|
+
// Check if a newline was inserted - if so, expand region to include char before it
|
|
440
|
+
val hasNewline = textStr.substring(start, minOf(end, textStr.length)).contains('\n')
|
|
441
|
+
|
|
442
|
+
// Expand the region to include entire lines that were affected
|
|
443
|
+
val layout = layout
|
|
444
|
+
val expandedStart: Int
|
|
445
|
+
val expandedEnd: Int
|
|
446
|
+
|
|
447
|
+
if (layout != null && textStr.isNotEmpty()) {
|
|
448
|
+
val startLine = layout.getLineForOffset(minOf(start, textStr.length - 1))
|
|
449
|
+
val endLine = layout.getLineForOffset(minOf(end, textStr.length - 1))
|
|
450
|
+
expandedStart = layout.getLineStart(startLine)
|
|
451
|
+
expandedEnd = layout.getLineEnd(endLine)
|
|
452
|
+
} else {
|
|
453
|
+
// If newline inserted, include character before it
|
|
454
|
+
expandedStart = if (hasNewline) maxOf(0, start - 2) else maxOf(0, start - 1)
|
|
455
|
+
expandedEnd = minOf(textStr.length, end + 1)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Remove existing spans in the affected lines
|
|
459
|
+
val existingSpans = editable.getSpans(expandedStart, expandedEnd, RoundedBackgroundSpan::class.java)
|
|
460
|
+
for (span in existingSpans) {
|
|
461
|
+
editable.removeSpan(span)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Apply spans with correct line boundary flags immediately
|
|
465
|
+
val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
|
|
466
|
+
|
|
467
|
+
for (i in expandedStart until expandedEnd) {
|
|
468
|
+
if (i >= textStr.length) break
|
|
469
|
+
|
|
470
|
+
val char = textStr[i]
|
|
471
|
+
val shouldHighlight = when {
|
|
472
|
+
char == '\n' || char == '\t' -> false
|
|
473
|
+
char == ' ' -> {
|
|
474
|
+
val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
|
|
475
|
+
val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
|
|
476
|
+
!hasSpaceBefore && !hasSpaceAfter
|
|
477
|
+
}
|
|
478
|
+
else -> true
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (shouldHighlight) {
|
|
482
|
+
// ALWAYS check newlines first (for manual line breaks)
|
|
483
|
+
val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
|
|
484
|
+
val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
|
|
485
|
+
|
|
486
|
+
var isAtLineStart = i == 0 || hasNewlineBefore
|
|
487
|
+
var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
|
|
488
|
+
|
|
489
|
+
// Only use layout for auto-wrapped lines (not manual newlines)
|
|
490
|
+
if (!hasNewlineBefore && !hasNewlineAfter && layout != null && i < textStr.length) {
|
|
491
|
+
try {
|
|
492
|
+
val line = layout.getLineForOffset(i)
|
|
493
|
+
val lineStart = layout.getLineStart(line)
|
|
494
|
+
val lineEnd = layout.getLineEnd(line)
|
|
495
|
+
// Only override if this is an auto-wrapped boundary
|
|
496
|
+
if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
|
|
497
|
+
isAtLineStart = true
|
|
498
|
+
}
|
|
499
|
+
if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
|
|
500
|
+
isAtLineEnd = true
|
|
501
|
+
}
|
|
502
|
+
} catch (e: Exception) {
|
|
503
|
+
// Layout might not be ready, keep newline-based detection
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
val span = RoundedBackgroundSpan(
|
|
508
|
+
characterBackgroundColor,
|
|
509
|
+
textColorValue,
|
|
510
|
+
charPaddingLeft,
|
|
511
|
+
charPaddingRight,
|
|
512
|
+
charPaddingTop,
|
|
513
|
+
charPaddingBottom,
|
|
514
|
+
backgroundInsetTop,
|
|
515
|
+
backgroundInsetBottom,
|
|
516
|
+
backgroundInsetLeft,
|
|
517
|
+
backgroundInsetRight,
|
|
518
|
+
radius,
|
|
519
|
+
isAtLineStart,
|
|
520
|
+
isAtLineEnd
|
|
521
|
+
)
|
|
522
|
+
editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
373
523
|
}
|
|
374
524
|
}
|
|
525
|
+
|
|
526
|
+
isUpdatingText = false
|
|
527
|
+
|
|
528
|
+
// JERK FIX: Skip the post-update during fast typing to prevent layout thrashing
|
|
529
|
+
// Only update boundaries when user stops typing (reduces update frequency)
|
|
530
|
+
removeCallbacks(boundaryUpdateCheck)
|
|
531
|
+
postDelayed(boundaryUpdateCheck, 200)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Runnable for delayed boundary check
|
|
535
|
+
private val boundaryUpdateCheck = Runnable {
|
|
536
|
+
if (!isUpdatingText) {
|
|
537
|
+
updateAutoWrappedLineBoundaries()
|
|
538
|
+
}
|
|
375
539
|
}
|
|
376
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Full re-application of spans (used when props change, not during typing).
|
|
543
|
+
* iOS-style: Work directly with editable, no setText() call.
|
|
544
|
+
*/
|
|
377
545
|
private fun applyCharacterBackgrounds() {
|
|
378
|
-
val
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
val spannable = SpannableString(text)
|
|
546
|
+
val editable = editableText ?: return
|
|
547
|
+
val textStr = editable.toString()
|
|
548
|
+
if (textStr.isEmpty()) return
|
|
382
549
|
|
|
383
550
|
// Apply line height if specified
|
|
384
551
|
if (customLineHeight > 0) {
|
|
@@ -386,29 +553,56 @@ class HighlightTextView : AppCompatEditText {
|
|
|
386
553
|
setLineSpacing(0f, lineSpacingMultiplier)
|
|
387
554
|
}
|
|
388
555
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
556
|
+
isUpdatingText = true
|
|
557
|
+
|
|
558
|
+
// Remove all existing spans
|
|
559
|
+
val existingSpans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
|
|
560
|
+
for (span in existingSpans) {
|
|
561
|
+
editable.removeSpan(span)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Apply spans to all characters with correct line boundary flags
|
|
565
|
+
val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
|
|
566
|
+
val layoutObj = layout
|
|
567
|
+
|
|
568
|
+
for (i in textStr.indices) {
|
|
569
|
+
val char = textStr[i]
|
|
392
570
|
|
|
393
|
-
// Check if this is a space that should be highlighted
|
|
394
571
|
val shouldHighlight = when {
|
|
395
|
-
char == '\n' || char == '\t' -> false
|
|
572
|
+
char == '\n' || char == '\t' -> false
|
|
396
573
|
char == ' ' -> {
|
|
397
|
-
|
|
398
|
-
val
|
|
399
|
-
val hasSpaceAfter = i < text.length - 1 && text[i + 1] == ' '
|
|
574
|
+
val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
|
|
575
|
+
val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
|
|
400
576
|
!hasSpaceBefore && !hasSpaceAfter
|
|
401
577
|
}
|
|
402
|
-
else -> true
|
|
578
|
+
else -> true
|
|
403
579
|
}
|
|
404
580
|
|
|
405
581
|
if (shouldHighlight) {
|
|
406
|
-
//
|
|
407
|
-
val
|
|
408
|
-
val
|
|
582
|
+
// ALWAYS check newlines first (for manual line breaks)
|
|
583
|
+
val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
|
|
584
|
+
val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
|
|
585
|
+
|
|
586
|
+
var isAtLineStart = i == 0 || hasNewlineBefore
|
|
587
|
+
var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
|
|
409
588
|
|
|
410
|
-
//
|
|
411
|
-
|
|
589
|
+
// Only use layout for auto-wrapped lines (not manual newlines)
|
|
590
|
+
if (!hasNewlineBefore && !hasNewlineAfter && layoutObj != null && i < textStr.length) {
|
|
591
|
+
try {
|
|
592
|
+
val line = layoutObj.getLineForOffset(i)
|
|
593
|
+
val lineStart = layoutObj.getLineStart(line)
|
|
594
|
+
val lineEnd = layoutObj.getLineEnd(line)
|
|
595
|
+
// Only override if this is an auto-wrapped boundary
|
|
596
|
+
if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
|
|
597
|
+
isAtLineStart = true
|
|
598
|
+
}
|
|
599
|
+
if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
|
|
600
|
+
isAtLineEnd = true
|
|
601
|
+
}
|
|
602
|
+
} catch (e: Exception) {
|
|
603
|
+
// Layout might not be ready, keep newline-based detection
|
|
604
|
+
}
|
|
605
|
+
}
|
|
412
606
|
|
|
413
607
|
val span = RoundedBackgroundSpan(
|
|
414
608
|
characterBackgroundColor,
|
|
@@ -422,62 +616,60 @@ class HighlightTextView : AppCompatEditText {
|
|
|
422
616
|
backgroundInsetLeft,
|
|
423
617
|
backgroundInsetRight,
|
|
424
618
|
radius,
|
|
425
|
-
|
|
426
|
-
|
|
619
|
+
isAtLineStart,
|
|
620
|
+
isAtLineEnd
|
|
427
621
|
)
|
|
428
|
-
|
|
622
|
+
editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
429
623
|
}
|
|
430
624
|
}
|
|
431
625
|
|
|
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
626
|
isUpdatingText = false
|
|
443
|
-
|
|
444
|
-
// Detect line wraps after layout is ready
|
|
445
|
-
post { detectLineWraps() }
|
|
446
627
|
}
|
|
447
628
|
|
|
448
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Update line boundary flags only for auto-wrapped lines.
|
|
631
|
+
* This is called after layout completes to handle text wrapping.
|
|
632
|
+
* Only updates spans that are at auto-wrapped line boundaries.
|
|
633
|
+
* Optimized to skip updates when layout hasn't changed.
|
|
634
|
+
*/
|
|
635
|
+
private fun updateAutoWrappedLineBoundaries() {
|
|
636
|
+
if (isUpdatingText) return
|
|
637
|
+
|
|
449
638
|
val layout = layout ?: return
|
|
450
|
-
val
|
|
451
|
-
val textStr =
|
|
452
|
-
|
|
639
|
+
val editable = editableText ?: return
|
|
640
|
+
val textStr = editable.toString()
|
|
641
|
+
if (textStr.isEmpty()) return
|
|
642
|
+
|
|
643
|
+
// Validate that layout is ready and has valid dimensions
|
|
644
|
+
if (width <= 0 || layout.lineCount == 0) return
|
|
645
|
+
|
|
646
|
+
val spans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
|
|
647
|
+
if (spans.isEmpty()) return
|
|
648
|
+
|
|
649
|
+
isUpdatingText = true
|
|
650
|
+
var hasChanges = false
|
|
453
651
|
|
|
454
652
|
for (span in spans) {
|
|
455
|
-
val spanStart =
|
|
456
|
-
val spanEnd =
|
|
653
|
+
val spanStart = editable.getSpanStart(span)
|
|
654
|
+
val spanEnd = editable.getSpanEnd(span)
|
|
655
|
+
|
|
656
|
+
if (spanStart < 0 || spanStart >= textStr.length) continue
|
|
457
657
|
|
|
458
|
-
|
|
658
|
+
try {
|
|
459
659
|
val line = layout.getLineForOffset(spanStart)
|
|
460
660
|
val lineStart = layout.getLineStart(line)
|
|
461
661
|
val lineEnd = layout.getLineEnd(line)
|
|
462
662
|
|
|
463
|
-
//
|
|
464
|
-
val
|
|
465
|
-
|
|
466
|
-
val hasNewlineAfter = spanEnd < textStr.length && textStr[spanEnd] == '\n'
|
|
663
|
+
// Determine actual line boundaries (includes auto-wrap)
|
|
664
|
+
val isAtLineStart = spanStart == lineStart
|
|
665
|
+
val isAtLineEnd = spanEnd == lineEnd
|
|
467
666
|
|
|
468
|
-
//
|
|
469
|
-
val
|
|
667
|
+
// Only update if this is an auto-wrapped line boundary (not a newline boundary)
|
|
668
|
+
val isNewlineBoundary = (spanStart > 0 && textStr[spanStart - 1] == '\n') ||
|
|
669
|
+
(spanEnd < textStr.length && textStr[spanEnd] == '\n')
|
|
470
670
|
|
|
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
|
|
671
|
+
// Only recreate span if it's at an auto-wrapped boundary and flags are wrong
|
|
672
|
+
if (!isNewlineBoundary && (isAtLineStart != span.isLineStart || isAtLineEnd != span.isLineEnd)) {
|
|
481
673
|
val newSpan = RoundedBackgroundSpan(
|
|
482
674
|
span.backgroundColor,
|
|
483
675
|
span.textColor,
|
|
@@ -490,31 +682,24 @@ class HighlightTextView : AppCompatEditText {
|
|
|
490
682
|
span.backgroundInsetLeft,
|
|
491
683
|
span.backgroundInsetRight,
|
|
492
684
|
span.cornerRadius,
|
|
493
|
-
span.isFirstInGroup,
|
|
494
|
-
span.isLastInGroup,
|
|
495
685
|
isAtLineStart,
|
|
496
686
|
isAtLineEnd
|
|
497
687
|
)
|
|
498
|
-
|
|
499
|
-
|
|
688
|
+
editable.removeSpan(span)
|
|
689
|
+
editable.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
690
|
+
hasChanges = true
|
|
500
691
|
}
|
|
692
|
+
} catch (e: Exception) {
|
|
693
|
+
// Layout state is invalid, skip this update
|
|
694
|
+
continue
|
|
501
695
|
}
|
|
502
696
|
}
|
|
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
|
|
697
|
+
|
|
698
|
+
isUpdatingText = false
|
|
699
|
+
|
|
700
|
+
// Only invalidate if we actually made changes
|
|
701
|
+
if (hasChanges) {
|
|
702
|
+
invalidate()
|
|
518
703
|
}
|
|
519
704
|
}
|
|
520
705
|
}
|
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.22",
|
|
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",
|