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
|
|
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,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
|
|
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
|
-
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
+
postDelayed({
|
|
429
356
|
requestFocus()
|
|
430
|
-
//
|
|
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.
|
|
434
|
-
}
|
|
360
|
+
imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_FORCED)
|
|
361
|
+
}, 100)
|
|
435
362
|
}
|
|
436
363
|
}
|
|
437
364
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
//
|
|
443
|
-
val
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
509
|
+
char == '\n' || char == '\t' -> false
|
|
460
510
|
char == ' ' -> {
|
|
461
|
-
|
|
462
|
-
val
|
|
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
|
|
515
|
+
else -> true
|
|
467
516
|
}
|
|
468
517
|
|
|
469
518
|
if (shouldHighlight) {
|
|
470
|
-
//
|
|
471
|
-
val
|
|
472
|
-
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'
|
|
473
522
|
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
490
|
-
|
|
556
|
+
isAtLineStart,
|
|
557
|
+
isAtLineEnd
|
|
491
558
|
)
|
|
492
|
-
|
|
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
|
-
|
|
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
|
|
510
|
-
val textStr =
|
|
511
|
-
|
|
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 =
|
|
515
|
-
val spanEnd =
|
|
590
|
+
val spanStart = editable.getSpanStart(span)
|
|
591
|
+
val spanEnd = editable.getSpanEnd(span)
|
|
516
592
|
|
|
517
|
-
if (spanStart
|
|
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
|
-
//
|
|
523
|
-
val
|
|
524
|
-
|
|
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
|
-
//
|
|
528
|
-
val
|
|
529
|
-
|
|
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
|
|
533
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (
|
|
561
|
-
|
|
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
|
}
|
package/ios/HighlightTextView.mm
CHANGED
|
@@ -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.
|
|
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",
|