react-native-highlight-text-view 0.1.24 → 0.1.26

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
@@ -48,6 +48,7 @@ export default function App() {
48
48
  | `verticalAlign` | `'top' \| 'center' \| 'middle' \| 'bottom'` | - | Vertical alignment (iOS only). Alternative to using combined `textAlign` values. **Note:** Android does not support vertical alignment and will use default vertical positioning. |
49
49
  | `fontFamily` | `string` | - | Font family name |
50
50
  | `fontSize` | `string` | `32` | Font size in points |
51
+ | `letterSpacing` | `string` | `0` | Extra space between characters, in layout points (same semantics as React Native's `letterSpacing`). |
51
52
  | `lineHeight` | `string` | `0` | Line height override (0 means use default line height) |
52
53
  | `highlightBorderRadius` | `string` | `0` | Border radius for the highlight background |
53
54
  | `padding` | `string` | `4` | Padding around each character highlight (expands background outward) |
@@ -7,197 +7,68 @@ import android.graphics.Paint
7
7
  import android.graphics.RectF
8
8
  import android.graphics.Typeface
9
9
  import android.text.Editable
10
- import android.text.Spannable
11
- import android.text.SpannableString
12
10
  import android.text.TextWatcher
13
- import android.text.style.ReplacementSpan
14
11
  import android.util.AttributeSet
15
12
  import android.util.TypedValue
16
13
  import android.view.Gravity
17
14
  import androidx.appcompat.widget.AppCompatEditText
15
+ import com.facebook.react.common.assets.ReactFontManager
18
16
 
19
17
  /**
20
- * iOS-style span: Draws rounded background for each character.
21
- * Padding is only applied at line boundaries (first/last character of each line).
18
+ * Custom EditText that mimics the iOS implementation by drawing per-character
19
+ * rounded highlights directly in onDraw(), instead of using spans.
20
+ *
21
+ * This avoids layout thrashing/flicker when lines auto-wrap and keeps padding
22
+ * logic independent from Android's line breaking.
22
23
  */
23
- class RoundedBackgroundSpan(
24
- internal val backgroundColor: Int,
25
- internal val textColor: Int,
26
- internal val paddingLeft: Float,
27
- internal val paddingRight: Float,
28
- internal val paddingTop: Float,
29
- internal val paddingBottom: Float,
30
- internal val backgroundInsetTop: Float,
31
- internal val backgroundInsetBottom: Float,
32
- internal val backgroundInsetLeft: Float,
33
- internal val backgroundInsetRight: Float,
34
- internal val cornerRadius: Float,
35
- internal val isLineStart: Boolean = false,
36
- internal val isLineEnd: Boolean = false,
37
- internal val isFirstLine: Boolean = false,
38
- internal val isLastLine: Boolean = false
39
- ) : ReplacementSpan() {
40
-
41
- override fun getSize(
42
- paint: Paint,
43
- text: CharSequence?,
44
- start: Int,
45
- end: Int,
46
- fm: Paint.FontMetricsInt?
47
- ): Int {
48
- // Only add padding at line boundaries (matches iOS behavior)
49
- val width = paint.measureText(text, start, end)
50
- val leftPad = if (isLineStart) paddingLeft else 0f
51
- val rightPad = if (isLineEnd) paddingRight else 0f
52
- return (width + leftPad + rightPad).toInt()
53
- }
54
-
55
- override fun draw(
56
- canvas: Canvas,
57
- text: CharSequence?,
58
- start: Int,
59
- end: Int,
60
- x: Float,
61
- top: Int,
62
- y: Int,
63
- bottom: Int,
64
- paint: Paint
65
- ) {
66
- if (text == null) return
67
-
68
- val bgPaint = Paint().apply {
69
- color = backgroundColor
70
- style = Paint.Style.FILL
71
- isAntiAlias = true
72
- }
73
-
74
- val width = paint.measureText(text, start, end)
75
-
76
- // Use font metrics for consistent height (matches iOS)
77
- val fontMetrics = paint.fontMetrics
78
- val textHeight = fontMetrics.descent - fontMetrics.ascent
79
- val textTop = y + fontMetrics.ascent
80
-
81
- // Apply background insets first (shrinks from line box - EXACTLY like iOS line 45-48)
82
- val insetTop = textTop + backgroundInsetTop
83
- val insetHeight = textHeight - (backgroundInsetTop + backgroundInsetBottom)
84
-
85
- // Only apply padding at line boundaries (matches iOS behavior)
86
- val leftPad = if (isLineStart) paddingLeft else 0f
87
- val rightPad = if (isLineEnd) paddingRight else 0f
88
-
89
- // SELECTIVE ROUNDING STRATEGY:
90
- // 1. Line Start: Round Left corners.
91
- // 2. Line End: Round Right corners.
92
- // 3. Middle: Square (no rounding).
93
- // 4. Overlap: Minimal (1px) to seal seams.
94
-
95
- val overlapExtension = 1f
96
-
97
- // No extension needed for start/end boundaries
98
- val leftExtend = 0f
99
-
100
- // Extend right slightly for middle characters to seal the gap
101
- val rightExtend = if (!isLineEnd) {
102
- if (isLineStart) leftPad + overlapExtension else overlapExtension
103
- } else {
104
- 0f
105
- }
106
-
107
- // Vertical overlap to eliminate gaps (reduced to prevent descender clipping)
108
- val topExtend = 0f
109
- val bottomExtend = 0f
110
-
111
- // Calculate background rect
112
- // NOTE: Since this is a ReplacementSpan, 'x' is the start of the span (including padding).
113
- // So we draw from 'x', not 'x - leftPad'.
114
- val rect = RectF(
115
- x + backgroundInsetLeft - leftExtend,
116
- insetTop - paddingTop - topExtend,
117
- x + leftPad + width + rightPad - backgroundInsetRight + rightExtend,
118
- insetTop + insetHeight + paddingBottom + bottomExtend
119
- )
120
-
121
- // Draw based on position
122
- when {
123
- isLineStart && isLineEnd -> {
124
- // Single character - round all corners
125
- canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
126
- }
127
- isLineStart -> {
128
- // Line START - round LEFT corners (top and bottom)
129
- val path = android.graphics.Path()
130
- path.addRoundRect(
131
- rect,
132
- floatArrayOf(
133
- cornerRadius, cornerRadius, // top-left
134
- 0f, 0f, // top-right
135
- 0f, 0f, // bottom-right
136
- cornerRadius, cornerRadius // bottom-left
137
- ),
138
- android.graphics.Path.Direction.CW
139
- )
140
- canvas.drawPath(path, bgPaint)
141
- }
142
- isLineEnd -> {
143
- // Line END - round RIGHT corners (top and bottom)
144
- val path = android.graphics.Path()
145
- path.addRoundRect(
146
- rect,
147
- floatArrayOf(
148
- 0f, 0f, // top-left
149
- cornerRadius, cornerRadius, // top-right
150
- cornerRadius, cornerRadius, // bottom-right
151
- 0f, 0f // bottom-left
152
- ),
153
- android.graphics.Path.Direction.CW
154
- )
155
- canvas.drawPath(path, bgPaint)
156
- }
157
- else -> {
158
- // Middle characters - NO rounded corners (square) for smooth edges
159
- canvas.drawRect(rect, bgPaint)
160
- }
161
- }
162
-
163
- // Draw text offset by left padding only if at line start
164
- val textPaint = Paint(paint).apply {
165
- color = textColor
166
- isAntiAlias = true
167
- }
168
- canvas.drawText(text, start, end, x + leftPad, y.toFloat(), textPaint)
169
- }
170
- }
171
-
172
24
  class HighlightTextView : AppCompatEditText {
25
+ // Visual props
173
26
  private var characterBackgroundColor: Int = Color.parseColor("#FFFF00")
174
27
  private var textColorValue: Int = Color.BLACK
175
28
  private var cornerRadius: Float = 4f
176
29
  private var highlightBorderRadius: Float = 0f
30
+
31
+ // Per-character padding
177
32
  private var charPaddingLeft: Float = 4f
178
33
  private var charPaddingRight: Float = 4f
179
34
  private var charPaddingTop: Float = 4f
180
35
  private var charPaddingBottom: Float = 4f
36
+
37
+ // Background insets (shrink from line box)
181
38
  private var backgroundInsetTop: Float = 0f
182
39
  private var backgroundInsetBottom: Float = 0f
183
40
  private var backgroundInsetLeft: Float = 0f
184
41
  private var backgroundInsetRight: Float = 0f
42
+
43
+ // Line height control
185
44
  private var customLineHeight: Float = 0f
45
+
46
+ // Font + alignment state
186
47
  private var currentFontFamily: String? = null
187
48
  private var currentFontWeight: String = "normal"
188
49
  private var currentVerticalAlign: String? = null
50
+ // Letter spacing in layout points (same semantics as React Native's letterSpacing prop)
51
+ private var letterSpacingPoints: Float = 0f
52
+
53
+ // Internal flags
189
54
  private var isUpdatingText: Boolean = false
190
-
55
+
56
+ // Drawing helpers
57
+ private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
58
+ style = Paint.Style.FILL
59
+ }
60
+ private val backgroundRect = RectF()
61
+
191
62
  var onTextChangeListener: ((String) -> Unit)? = null
192
63
 
193
64
  constructor(context: Context?) : super(context!!) {
194
65
  init()
195
66
  }
196
-
67
+
197
68
  constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) {
198
69
  init()
199
70
  }
200
-
71
+
201
72
  constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
202
73
  context!!,
203
74
  attrs,
@@ -212,42 +83,120 @@ class HighlightTextView : AppCompatEditText {
212
83
  gravity = Gravity.CENTER
213
84
  setPadding(20, 20, 20, 20)
214
85
  textColorValue = currentTextColor
215
-
86
+
216
87
  // Enable text wrapping
217
88
  maxLines = Int.MAX_VALUE
218
89
  isSingleLine = false
219
90
  setHorizontallyScrolling(false)
220
-
91
+
92
+ applyLineHeightAndSpacing()
93
+
221
94
  addTextChangedListener(object : TextWatcher {
222
- private var changeStart = 0
223
- private var changeEnd = 0
224
-
225
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
226
- changeStart = start
227
- changeEnd = start + after
228
- }
229
-
95
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
96
+
230
97
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
231
-
98
+
232
99
  override fun afterTextChanged(s: Editable?) {
233
100
  if (!isUpdatingText) {
234
101
  onTextChangeListener?.invoke(s?.toString() ?: "")
235
-
236
- applyCharacterBackgroundsIncremental(s, changeStart, changeEnd)
102
+ // Text changed → redraw backgrounds
103
+ invalidate()
237
104
  }
238
105
  }
239
106
  })
240
107
  }
241
108
 
109
+ // --- Drawing -----------------------------------------------------------------
110
+
111
+ override fun onDraw(canvas: Canvas) {
112
+ val layout = layout
113
+ val text = text
114
+
115
+ if (layout != null && text != null && text.isNotEmpty()) {
116
+ val save = canvas.save()
117
+
118
+ // Canvas is already translated by scrollX/scrollY in View.draw().
119
+ // Here we only need to account for view padding so our coordinates
120
+ // match the Layout's internal coordinate system.
121
+ val translateX = totalPaddingLeft
122
+ val translateY = totalPaddingTop
123
+ canvas.translate(translateX.toFloat(), translateY.toFloat())
124
+
125
+ drawCharacterBackgrounds(canvas, text, layout)
126
+
127
+ canvas.restoreToCount(save)
128
+ }
129
+
130
+ // Let EditText draw text, cursor, selection, etc. on top of backgrounds
131
+ super.onDraw(canvas)
132
+ }
133
+
134
+ private fun drawCharacterBackgrounds(canvas: Canvas, text: CharSequence, layout: android.text.Layout) {
135
+ backgroundPaint.color = characterBackgroundColor
136
+ val paint = paint
137
+ val radius = if (highlightBorderRadius > 0f) highlightBorderRadius else cornerRadius
138
+
139
+ val length = text.length
140
+ if (length == 0) return
141
+
142
+ for (i in 0 until length) {
143
+ val ch = text[i]
144
+ // Match iOS: skip spaces and control characters for background
145
+ if (ch == '\n' || ch == '\t' || ch == ' ') continue
146
+
147
+ val line = layout.getLineForOffset(i)
148
+ val lineStart = layout.getLineStart(line)
149
+ val lineEnd = layout.getLineEnd(line)
150
+
151
+ // Horizontal bounds based on layout positions
152
+ val xStart = layout.getPrimaryHorizontal(i)
153
+ val isLastCharInLine = i == lineEnd - 1
154
+ val xEnd = if (!isLastCharInLine && i + 1 < length) {
155
+ layout.getPrimaryHorizontal(i + 1)
156
+ } else {
157
+ // Fallback for last character in the line/text
158
+ xStart + paint.measureText(text, i, i + 1)
159
+ }
160
+
161
+ // Vertical bounds based on line box (includes line spacing)
162
+ val lineTop = layout.getLineTop(line).toFloat()
163
+ val lineBottom = layout.getLineBottom(line).toFloat()
164
+
165
+ var left = xStart
166
+ var right = xEnd
167
+ var top = lineTop
168
+ var bottom = lineBottom
169
+
170
+ // First shrink by background insets (from the line box)
171
+ top += backgroundInsetTop
172
+ bottom -= backgroundInsetBottom
173
+ left += backgroundInsetLeft
174
+ right -= backgroundInsetRight
175
+
176
+ // Then expand outward by per-character padding
177
+ left -= charPaddingLeft
178
+ right += charPaddingRight
179
+ top -= charPaddingTop
180
+ bottom += charPaddingBottom
181
+
182
+ if (right <= left || bottom <= top) continue
183
+
184
+ backgroundRect.set(left, top, right, bottom)
185
+ canvas.drawRoundRect(backgroundRect, radius, radius, backgroundPaint)
186
+ }
187
+ }
188
+
189
+ // --- Public API used from the ViewManager ------------------------------------
190
+
242
191
  fun setCharacterBackgroundColor(color: Int) {
243
192
  characterBackgroundColor = color
244
- applyCharacterBackgrounds()
193
+ invalidate()
245
194
  }
246
-
195
+
247
196
  override fun setTextColor(color: Int) {
248
197
  super.setTextColor(color)
249
198
  textColorValue = color
250
- applyCharacterBackgrounds()
199
+ invalidate()
251
200
  }
252
201
 
253
202
  fun setCharPadding(left: Float, top: Float, right: Float, bottom: Float) {
@@ -256,35 +205,40 @@ class HighlightTextView : AppCompatEditText {
256
205
  charPaddingRight = right
257
206
  charPaddingBottom = bottom
258
207
  updateViewPadding()
259
- applyCharacterBackgrounds()
208
+ applyLineHeightAndSpacing()
209
+ invalidate()
260
210
  }
261
-
211
+
262
212
  fun setCharPaddingLeft(padding: Float) {
263
213
  charPaddingLeft = padding
264
214
  updateViewPadding()
265
- applyCharacterBackgrounds()
215
+ applyLineHeightAndSpacing()
216
+ invalidate()
266
217
  }
267
-
218
+
268
219
  fun setCharPaddingRight(padding: Float) {
269
220
  charPaddingRight = padding
270
221
  updateViewPadding()
271
- applyCharacterBackgrounds()
222
+ applyLineHeightAndSpacing()
223
+ invalidate()
272
224
  }
273
-
225
+
274
226
  fun setCharPaddingTop(padding: Float) {
275
227
  charPaddingTop = padding
276
228
  updateViewPadding()
277
- applyCharacterBackgrounds()
229
+ applyLineHeightAndSpacing()
230
+ invalidate()
278
231
  }
279
-
232
+
280
233
  fun setCharPaddingBottom(padding: Float) {
281
234
  charPaddingBottom = padding
282
235
  updateViewPadding()
283
- applyCharacterBackgrounds()
236
+ applyLineHeightAndSpacing()
237
+ invalidate()
284
238
  }
285
-
239
+
286
240
  private fun updateViewPadding() {
287
- // Sync View padding with char padding to prevent clipping of background
241
+ // Keep view padding in sync so backgrounds are not clipped at the edges
288
242
  setPadding(
289
243
  charPaddingLeft.toInt(),
290
244
  charPaddingTop.toInt(),
@@ -292,27 +246,27 @@ class HighlightTextView : AppCompatEditText {
292
246
  charPaddingBottom.toInt()
293
247
  )
294
248
  }
295
-
249
+
296
250
  fun setCornerRadius(radius: Float) {
297
251
  cornerRadius = radius
298
- applyCharacterBackgrounds()
252
+ invalidate()
299
253
  }
300
-
254
+
301
255
  fun setHighlightBorderRadius(radius: Float) {
302
256
  highlightBorderRadius = radius
303
- applyCharacterBackgrounds()
257
+ invalidate()
304
258
  }
305
-
259
+
306
260
  fun setFontWeight(weight: String) {
307
261
  currentFontWeight = weight
308
262
  updateFont()
309
263
  }
310
-
264
+
311
265
  fun setFontFamilyProp(family: String?) {
312
266
  currentFontFamily = family
313
267
  updateFont()
314
268
  }
315
-
269
+
316
270
  private fun updateFont() {
317
271
  // Parse font weight to integer (100-900)
318
272
  val weight = when (currentFontWeight) {
@@ -327,16 +281,21 @@ class HighlightTextView : AppCompatEditText {
327
281
  "900" -> 900
328
282
  else -> 400
329
283
  }
330
-
331
- // Get base typeface
332
- val baseTypeface = if (currentFontFamily != null) {
333
- when (currentFontFamily?.lowercase()) {
284
+
285
+ // Capture currentFontFamily as local variable to avoid smart cast issues
286
+ val fontFamily = currentFontFamily
287
+
288
+ // Get base typeface using ReactFontManager for custom fonts
289
+ val baseTypeface = if (fontFamily != null) {
290
+ when (fontFamily.lowercase()) {
334
291
  "system" -> Typeface.DEFAULT
335
292
  "sans-serif" -> Typeface.SANS_SERIF
336
293
  "serif" -> Typeface.SERIF
337
294
  "monospace" -> Typeface.MONOSPACE
338
295
  else -> try {
339
- Typeface.create(currentFontFamily, Typeface.NORMAL)
296
+ // Use ReactFontManager to load custom fonts from assets
297
+ val style = if (weight >= 600) Typeface.BOLD else Typeface.NORMAL
298
+ ReactFontManager.getInstance().getTypeface(fontFamily, style, context.assets)
340
299
  } catch (e: Exception) {
341
300
  Typeface.DEFAULT
342
301
  }
@@ -344,7 +303,7 @@ class HighlightTextView : AppCompatEditText {
344
303
  } else {
345
304
  Typeface.DEFAULT
346
305
  }
347
-
306
+
348
307
  // Apply font weight - use API 28+ method for better weight support
349
308
  val typeface = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
350
309
  Typeface.create(baseTypeface, weight, false)
@@ -353,16 +312,18 @@ class HighlightTextView : AppCompatEditText {
353
312
  val style = if (weight >= 600) Typeface.BOLD else Typeface.NORMAL
354
313
  Typeface.create(baseTypeface, style)
355
314
  }
356
-
315
+
357
316
  this.typeface = typeface
358
- applyCharacterBackgrounds()
317
+ applyLineHeightAndSpacing()
318
+ invalidate()
359
319
  }
360
-
320
+
361
321
  fun setVerticalAlign(align: String?) {
362
322
  currentVerticalAlign = align
363
323
  updateVerticalAlignment()
324
+ invalidate()
364
325
  }
365
-
326
+
366
327
  private fun updateVerticalAlignment() {
367
328
  // Preserve horizontal alignment when updating vertical
368
329
  val horizontalGravity = gravity and Gravity.HORIZONTAL_GRAVITY_MASK
@@ -371,50 +332,63 @@ class HighlightTextView : AppCompatEditText {
371
332
  "bottom" -> Gravity.BOTTOM
372
333
  else -> Gravity.CENTER_VERTICAL
373
334
  }
374
-
335
+
375
336
  gravity = horizontalGravity or verticalGravity
376
337
  }
377
-
338
+
378
339
  fun setBackgroundInsetTop(inset: Float) {
379
340
  backgroundInsetTop = inset
380
- applyCharacterBackgrounds()
341
+ invalidate()
381
342
  }
382
-
343
+
383
344
  fun setBackgroundInsetBottom(inset: Float) {
384
345
  backgroundInsetBottom = inset
385
- applyCharacterBackgrounds()
346
+ invalidate()
386
347
  }
387
-
348
+
388
349
  fun setBackgroundInsetLeft(inset: Float) {
389
350
  backgroundInsetLeft = inset
390
- applyCharacterBackgrounds()
351
+ invalidate()
391
352
  }
392
-
353
+
393
354
  fun setBackgroundInsetRight(inset: Float) {
394
355
  backgroundInsetRight = inset
395
- applyCharacterBackgrounds()
356
+ invalidate()
396
357
  }
397
-
358
+
398
359
  fun setCustomLineHeight(lineHeight: Float) {
399
360
  customLineHeight = lineHeight
400
- applyCharacterBackgrounds()
361
+ applyLineHeightAndSpacing()
362
+ invalidate()
363
+ }
364
+
365
+ fun setLetterSpacingProp(points: Float) {
366
+ letterSpacingPoints = points
367
+ applyLetterSpacing()
368
+ invalidate()
401
369
  }
402
370
 
403
- fun setTextProp(text: String) {
404
- if (this.text?.toString() != text) {
371
+ override fun setTextSize(unit: Int, size: Float) {
372
+ super.setTextSize(unit, size)
373
+ applyLineHeightAndSpacing()
374
+ applyLetterSpacing()
375
+ }
376
+
377
+ fun setTextProp(newText: String) {
378
+ if (this.text?.toString() != newText) {
405
379
  isUpdatingText = true
406
- setText(text)
407
- applyCharacterBackgrounds()
380
+ setText(newText)
408
381
  // Move cursor to end of text after setting
409
382
  post {
410
383
  if (hasFocus()) {
411
- text.length.let { setSelection(it) }
384
+ text?.length?.let { setSelection(it) }
412
385
  }
413
386
  }
414
387
  isUpdatingText = false
388
+ invalidate()
415
389
  }
416
390
  }
417
-
391
+
418
392
  fun setAutoFocus(autoFocus: Boolean) {
419
393
  if (autoFocus && isFocusable && isFocusableInTouchMode) {
420
394
  postDelayed({
@@ -426,319 +400,36 @@ class HighlightTextView : AppCompatEditText {
426
400
  }, 100)
427
401
  }
428
402
  }
429
-
430
- /**
431
- * iOS-style incremental update: Only update spans for changed region.
432
- * This is called during typing and only touches the modified characters.
433
- */
434
- private fun applyCharacterBackgroundsIncremental(editable: Editable?, start: Int, end: Int) {
435
- if (editable == null) return
436
- val textStr = editable.toString()
437
- if (textStr.isEmpty()) return
438
-
439
- isUpdatingText = true
440
-
441
- // Check if a newline was inserted - if so, expand region to include char before it
442
- val hasNewline = textStr.substring(start, minOf(end, textStr.length)).contains('\n')
443
-
444
- // Expand the region to include entire lines that were affected
445
- val layout = layout
446
- val expandedStart: Int
447
- val expandedEnd: Int
448
-
449
- if (layout != null && textStr.isNotEmpty()) {
450
- val startLine = layout.getLineForOffset(minOf(start, textStr.length - 1))
451
- val endLine = layout.getLineForOffset(minOf(end, textStr.length - 1))
452
- expandedStart = layout.getLineStart(startLine)
453
- expandedEnd = layout.getLineEnd(endLine)
454
- } else {
455
- // If newline inserted, include character before it
456
- expandedStart = if (hasNewline) maxOf(0, start - 2) else maxOf(0, start - 1)
457
- expandedEnd = minOf(textStr.length, end + 1)
458
- }
459
-
460
- // Remove existing spans in the affected lines
461
- val existingSpans = editable.getSpans(expandedStart, expandedEnd, RoundedBackgroundSpan::class.java)
462
- for (span in existingSpans) {
463
- editable.removeSpan(span)
464
- }
465
-
466
- // Apply spans with correct line boundary flags immediately
467
- val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
468
-
469
- for (i in expandedStart until expandedEnd) {
470
- if (i >= textStr.length) break
471
-
472
- val char = textStr[i]
473
- val shouldHighlight = when {
474
- char == '\n' || char == '\t' -> false
475
- char == ' ' -> {
476
- val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
477
- val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
478
- !hasSpaceBefore && !hasSpaceAfter
479
- }
480
- else -> true
481
- }
482
-
483
- if (shouldHighlight) {
484
- // ALWAYS check newlines first (for manual line breaks)
485
- val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
486
- val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
487
-
488
- var isAtLineStart = i == 0 || hasNewlineBefore
489
- var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
490
-
491
- // Determine if this is the first or last line
492
- var isOnFirstLine = i == 0 || hasNewlineBefore
493
- var isOnLastLine = i == textStr.length - 1 || hasNewlineAfter
494
-
495
- // Only use layout for auto-wrapped lines (not manual newlines)
496
- if (!hasNewlineBefore && !hasNewlineAfter && layout != null && i < textStr.length) {
497
- try {
498
- val line = layout.getLineForOffset(i)
499
- val lineStart = layout.getLineStart(line)
500
- val lineEnd = layout.getLineEnd(line)
501
- // Check if this is the first or last line
502
- isOnFirstLine = line == 0
503
- isOnLastLine = line == layout.lineCount - 1
504
- // Only override if this is an auto-wrapped boundary
505
- if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
506
- isAtLineStart = true
507
- }
508
- if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
509
- isAtLineEnd = true
510
- }
511
- } catch (e: Exception) {
512
- // Layout might not be ready, keep newline-based detection
513
- }
514
- }
515
-
516
- val span = RoundedBackgroundSpan(
517
- characterBackgroundColor,
518
- textColorValue,
519
- charPaddingLeft,
520
- charPaddingRight,
521
- charPaddingTop,
522
- charPaddingBottom,
523
- backgroundInsetTop,
524
- backgroundInsetBottom,
525
- backgroundInsetLeft,
526
- backgroundInsetRight,
527
- radius,
528
- isAtLineStart,
529
- isAtLineEnd,
530
- isOnFirstLine,
531
- isOnLastLine
532
- )
533
- editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
403
+
404
+ // --- Layout helpers ----------------------------------------------------------
405
+
406
+ private fun applyLineHeightAndSpacing() {
407
+ if (customLineHeight > 0f) {
408
+ // customLineHeight comes from JS as "points"; convert to px using scaledDensity
409
+ val metrics = resources.displayMetrics
410
+ val desiredLineHeightPx = customLineHeight * metrics.scaledDensity
411
+ val textHeightPx = textSize
412
+ if (textHeightPx > 0f) {
413
+ val multiplier = desiredLineHeightPx / textHeightPx
414
+ setLineSpacing(0f, multiplier)
534
415
  }
535
- }
536
-
537
- isUpdatingText = false
538
-
539
- // JERK FIX: Skip the post-update during fast typing to prevent layout thrashing
540
- // Only update boundaries when user stops typing (reduces update frequency)
541
- removeCallbacks(boundaryUpdateCheck)
542
- postDelayed(boundaryUpdateCheck, 200)
543
- }
544
-
545
- // Runnable for delayed boundary check
546
- private val boundaryUpdateCheck = Runnable {
547
- if (!isUpdatingText) {
548
- updateAutoWrappedLineBoundaries()
549
- }
550
- }
551
-
552
- /**
553
- * Full re-application of spans (used when props change, not during typing).
554
- * iOS-style: Work directly with editable, no setText() call.
555
- */
556
- private fun applyCharacterBackgrounds() {
557
- val editable = editableText ?: return
558
- val textStr = editable.toString()
559
- if (textStr.isEmpty()) return
560
-
561
- // Apply line height if specified, or add spacing for padding
562
- if (customLineHeight > 0) {
563
- val lineSpacingMultiplier = customLineHeight / textSize
564
- setLineSpacing(0f, lineSpacingMultiplier)
565
416
  } else {
566
- // Add line spacing to accommodate vertical padding and prevent overlap
417
+ // Default: add extra spacing equal to vertical padding so backgrounds don't collide
567
418
  val extraSpacing = charPaddingTop + charPaddingBottom
568
419
  setLineSpacing(extraSpacing, 1.0f)
569
420
  }
570
-
571
- isUpdatingText = true
572
-
573
- // Remove all existing spans
574
- val existingSpans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
575
- for (span in existingSpans) {
576
- editable.removeSpan(span)
577
- }
578
-
579
- // Apply spans to all characters with correct line boundary flags
580
- val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
581
- val layoutObj = layout
582
-
583
- for (i in textStr.indices) {
584
- val char = textStr[i]
585
-
586
- val shouldHighlight = when {
587
- char == '\n' || char == '\t' -> false
588
- char == ' ' -> {
589
- val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
590
- val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
591
- !hasSpaceBefore && !hasSpaceAfter
592
- }
593
- else -> true
594
- }
595
-
596
- if (shouldHighlight) {
597
- // ALWAYS check newlines first (for manual line breaks)
598
- val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
599
- val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
600
-
601
- var isAtLineStart = i == 0 || hasNewlineBefore
602
- var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
603
-
604
- // Determine if this is the first or last line
605
- var isOnFirstLine = i == 0 || hasNewlineBefore
606
- var isOnLastLine = i == textStr.length - 1 || hasNewlineAfter
607
-
608
- // Only use layout for auto-wrapped lines (not manual newlines)
609
- if (!hasNewlineBefore && !hasNewlineAfter && layoutObj != null && i < textStr.length) {
610
- try {
611
- val line = layoutObj.getLineForOffset(i)
612
- val lineStart = layoutObj.getLineStart(line)
613
- val lineEnd = layoutObj.getLineEnd(line)
614
- // Check if this is the first or last line
615
- isOnFirstLine = line == 0
616
- isOnLastLine = line == layoutObj.lineCount - 1
617
- // Only override if this is an auto-wrapped boundary
618
- if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
619
- isAtLineStart = true
620
- }
621
- if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
622
- isAtLineEnd = true
623
- }
624
- } catch (e: Exception) {
625
- // Layout might not be ready, keep newline-based detection
626
- }
627
- }
628
-
629
- val span = RoundedBackgroundSpan(
630
- characterBackgroundColor,
631
- textColorValue,
632
- charPaddingLeft,
633
- charPaddingRight,
634
- charPaddingTop,
635
- charPaddingBottom,
636
- backgroundInsetTop,
637
- backgroundInsetBottom,
638
- backgroundInsetLeft,
639
- backgroundInsetRight,
640
- radius,
641
- isAtLineStart,
642
- isAtLineEnd,
643
- isOnFirstLine,
644
- isOnLastLine
645
- )
646
- editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
647
- }
648
- }
649
-
650
- isUpdatingText = false
651
-
652
- // Schedule a post-layout update to ensure corner rounding is correct
653
- // This is needed because layout might not be ready when this method is called
654
- post {
655
- if (!isUpdatingText) {
656
- updateAutoWrappedLineBoundaries()
657
- }
658
- }
659
421
  }
660
-
661
- /**
662
- * Update line boundary flags only for auto-wrapped lines.
663
- * This is called after layout completes to handle text wrapping.
664
- * Only updates spans that are at auto-wrapped line boundaries.
665
- * Optimized to skip updates when layout hasn't changed.
666
- */
667
- private fun updateAutoWrappedLineBoundaries() {
668
- if (isUpdatingText) return
669
-
670
- val layout = layout ?: return
671
- val editable = editableText ?: return
672
- val textStr = editable.toString()
673
- if (textStr.isEmpty()) return
674
-
675
- // Validate that layout is ready and has valid dimensions
676
- if (width <= 0 || layout.lineCount == 0) return
677
-
678
- val spans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
679
- if (spans.isEmpty()) return
680
-
681
- isUpdatingText = true
682
- var hasChanges = false
683
-
684
- for (span in spans) {
685
- val spanStart = editable.getSpanStart(span)
686
- val spanEnd = editable.getSpanEnd(span)
687
-
688
- if (spanStart < 0 || spanStart >= textStr.length) continue
689
-
690
- try {
691
- val line = layout.getLineForOffset(spanStart)
692
- val lineStart = layout.getLineStart(line)
693
- val lineEnd = layout.getLineEnd(line)
694
-
695
- // Determine actual line boundaries (includes auto-wrap)
696
- val isAtLineStart = spanStart == lineStart
697
- val isAtLineEnd = spanEnd == lineEnd
698
-
699
- // Check if this is the first or last line
700
- val isOnFirstLine = line == 0
701
- val isOnLastLine = line == layout.lineCount - 1
702
-
703
- // Only update if this is an auto-wrapped line boundary (not a newline boundary)
704
- val isNewlineBoundary = (spanStart > 0 && textStr[spanStart - 1] == '\n') ||
705
- (spanEnd < textStr.length && textStr[spanEnd] == '\n')
706
-
707
- // Only recreate span if it's at an auto-wrapped boundary and flags are wrong
708
- if (!isNewlineBoundary && (isAtLineStart != span.isLineStart || isAtLineEnd != span.isLineEnd ||
709
- isOnFirstLine != span.isFirstLine || isOnLastLine != span.isLastLine)) {
710
- val newSpan = RoundedBackgroundSpan(
711
- span.backgroundColor,
712
- span.textColor,
713
- span.paddingLeft,
714
- span.paddingRight,
715
- span.paddingTop,
716
- span.paddingBottom,
717
- span.backgroundInsetTop,
718
- span.backgroundInsetBottom,
719
- span.backgroundInsetLeft,
720
- span.backgroundInsetRight,
721
- span.cornerRadius,
722
- isAtLineStart,
723
- isAtLineEnd,
724
- isOnFirstLine,
725
- isOnLastLine
726
- )
727
- editable.removeSpan(span)
728
- editable.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
729
- hasChanges = true
730
- }
731
- } catch (e: Exception) {
732
- // Layout state is invalid, skip this update
733
- continue
734
- }
735
- }
736
-
737
- isUpdatingText = false
738
-
739
- // Only invalidate if we actually made changes
740
- if (hasChanges) {
741
- invalidate()
422
+
423
+ private fun applyLetterSpacing() {
424
+ // React Native's letterSpacing is specified in layout points. Convert that to
425
+ // Android's "em" units: pxSpacing / textSizePx.
426
+ val metrics = resources.displayMetrics
427
+ val pxSpacing = letterSpacingPoints * metrics.scaledDensity
428
+ val textPx = textSize
429
+ if (textPx > 0f) {
430
+ super.setLetterSpacing(pxSpacing / textPx)
431
+ } else {
432
+ super.setLetterSpacing(0f)
742
433
  }
743
434
  }
744
435
  }
@@ -134,6 +134,13 @@ class HighlightTextViewManager : SimpleViewManager<HighlightTextView>(),
134
134
  }
135
135
  }
136
136
 
137
+ @ReactProp(name = "letterSpacing")
138
+ override fun setLetterSpacing(view: HighlightTextView?, value: String?) {
139
+ value?.toFloatOrNull()?.let { spacing ->
140
+ view?.setLetterSpacingProp(spacing)
141
+ }
142
+ }
143
+
137
144
  @ReactProp(name = "padding")
138
145
  override fun setPadding(view: HighlightTextView?, value: String?) {
139
146
  value?.toFloatOrNull()?.let { padding ->
@@ -90,6 +90,7 @@ using namespace facebook::react;
90
90
  CGFloat _backgroundInsetRight;
91
91
  CGFloat _lineHeight;
92
92
  CGFloat _fontSize;
93
+ CGFloat _letterSpacing; // in layout points, matches React Native's letterSpacing
93
94
  NSString * _fontFamily;
94
95
  NSString * _fontWeight;
95
96
  BOOL _isUpdatingText;
@@ -122,6 +123,7 @@ using namespace facebook::react;
122
123
  _backgroundInsetRight = 0.0;
123
124
  _lineHeight = 0.0; // 0 means use default line height
124
125
  _fontSize = 32.0; // Default font size
126
+ _letterSpacing = 0.0; // Default: no extra spacing
125
127
  _fontFamily = nil;
126
128
  _fontWeight = @"normal";
127
129
  _currentVerticalAlignment = nil;
@@ -296,6 +298,13 @@ using namespace facebook::react;
296
298
  }
297
299
  }
298
300
 
301
+ if (oldViewProps.letterSpacing != newViewProps.letterSpacing) {
302
+ NSString *spacingStr = [[NSString alloc] initWithUTF8String: newViewProps.letterSpacing.c_str()];
303
+ CGFloat spacing = [spacingStr floatValue];
304
+ _letterSpacing = spacing;
305
+ [self applyCharacterBackgrounds]; // Reapply to update glyph layout
306
+ }
307
+
299
308
  if (oldViewProps.highlightBorderRadius != newViewProps.highlightBorderRadius) {
300
309
  NSString *radiusStr = [[NSString alloc] initWithUTF8String: newViewProps.highlightBorderRadius.c_str()];
301
310
  CGFloat radius = [radiusStr floatValue];
@@ -537,6 +546,13 @@ Class<RCTComponentViewProtocol> HighlightTextViewCls(void)
537
546
  value:_textView.font
538
547
  range:NSMakeRange(0, text.length)];
539
548
 
549
+ // Apply letter spacing (kern) if specified
550
+ if (_letterSpacing != 0) {
551
+ [attributedString addAttribute:NSKernAttributeName
552
+ value:@(_letterSpacing)
553
+ range:NSMakeRange(0, text.length)];
554
+ }
555
+
540
556
  // Apply text color if available
541
557
  if (_textView.textColor) {
542
558
  [attributedString addAttribute:NSForegroundColorAttributeName
@@ -47,6 +47,8 @@ export interface HighlightTextViewProps extends ViewProps {
47
47
  fontFamily?: string;
48
48
  fontSize?: string;
49
49
  fontWeight?: string;
50
+ /** Additional space between characters, in layout points (matches React Native's letterSpacing). */
51
+ letterSpacing?: string;
50
52
  lineHeight?: string;
51
53
  highlightBorderRadius?: string;
52
54
  padding?: string;
@@ -29,6 +29,8 @@ export interface HighlightTextViewProps extends ViewProps {
29
29
  fontFamily?: string;
30
30
  fontSize?: string;
31
31
  fontWeight?: string;
32
+ /** Additional space between characters, in layout points (matches React Native's letterSpacing). */
33
+ letterSpacing?: string;
32
34
  lineHeight?: string;
33
35
  highlightBorderRadius?: string;
34
36
  padding?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"HighlightTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/HighlightTextViewNativeComponent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEtF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,QAAQ,GACR,OAAO,GACP,SAAS,GACT,YAAY,GACZ,UAAU,GACV,KAAK,GACL,QAAQ,GACR,UAAU,GACV,YAAY,GACZ,WAAW,GACX,aAAa,GACb,eAAe,GACf,cAAc,CAAC;AAEnB,MAAM,WAAW,sBAAuB,SAAQ,SAAS;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oFAAoF;IACpF,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iFAAiF;IACjF,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,oBAAoB,CAAC,iBAAiB,CAAC,CAAC;CACpD;;AAED,wBAEE"}
1
+ {"version":3,"file":"HighlightTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/HighlightTextViewNativeComponent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEtF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,QAAQ,GACR,OAAO,GACP,SAAS,GACT,YAAY,GACZ,UAAU,GACV,KAAK,GACL,QAAQ,GACR,UAAU,GACV,YAAY,GACZ,WAAW,GACX,aAAa,GACb,eAAe,GACf,cAAc,CAAC;AAEnB,MAAM,WAAW,sBAAuB,SAAQ,SAAS;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oGAAoG;IACpG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oFAAoF;IACpF,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iFAAiF;IACjF,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,oBAAoB,CAAC,iBAAiB,CAAC,CAAC;CACpD;;AAED,wBAEE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-highlight-text-view",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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",
@@ -47,6 +47,8 @@ export interface HighlightTextViewProps extends ViewProps {
47
47
  fontFamily?: string;
48
48
  fontSize?: string;
49
49
  fontWeight?: string;
50
+ /** Additional space between characters, in layout points (matches React Native's letterSpacing). */
51
+ letterSpacing?: string;
50
52
  lineHeight?: string;
51
53
  highlightBorderRadius?: string;
52
54
  padding?: string;