react-native-advanced-text 0.2.13 → 0.2.15

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.
@@ -3,15 +3,19 @@ package com.advancedtext
3
3
  import android.content.Context
4
4
  import android.graphics.Canvas
5
5
  import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Path
6
8
  import android.graphics.Point
7
9
  import android.graphics.RectF
8
10
  import android.os.Build
11
+ import android.text.Layout
9
12
  import android.text.SpannableString
10
13
  import android.text.Spannable
11
14
  import android.text.Spanned
12
15
  import android.text.TextPaint
13
16
  import android.text.method.ArrowKeyMovementMethod
14
17
  import android.text.style.ClickableSpan
18
+ import android.text.style.ForegroundColorSpan
15
19
  import android.util.AttributeSet
16
20
  import android.util.Log
17
21
  import android.view.ActionMode
@@ -53,6 +57,15 @@ class AdvancedTextView : TextView {
53
57
  private var indicatorMode: String = "Highlight"
54
58
 
55
59
  private var wordPositions: List<WordPosition> = emptyList()
60
+ // Each word's color span, kept so color changes can be applied to the live
61
+ // text. setText() would clear any selection the user is making.
62
+ private var wordColorSpans: Array<ForegroundColorSpan?> = emptyArray()
63
+
64
+ // Reused on every draw for the highlight backgrounds.
65
+ private val highlightPaint = Paint(Paint.ANTI_ALIAS_FLAG)
66
+ private val rangePath = Path()
67
+ private val lineClipPath = Path()
68
+ private val rangeBounds = RectF()
56
69
 
57
70
  constructor(context: Context?) : super(context) { init() }
58
71
  constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { init() }
@@ -133,8 +146,10 @@ class AdvancedTextView : TextView {
133
146
  }
134
147
 
135
148
  fun setAdvancedTextColor(colorInt: Int) {
136
- textColor = String.format("#%06X", 0xFFFFFF and colorInt)
137
- updateTextWithHighlights()
149
+ val color = String.format("#%06X", 0xFFFFFF and colorInt)
150
+ if (textColor == color) return
151
+ textColor = color
152
+ updateWordColors()
138
153
  }
139
154
 
140
155
  fun setAdvancedTextSize(size: Float) {
@@ -202,7 +217,7 @@ class AdvancedTextView : TextView {
202
217
  if (indicatorWordIndex == index) return
203
218
  indicatorWordIndex = index
204
219
  if (indicatorMode == "fill") {
205
- updateTextWithHighlights()
220
+ updateWordColors()
206
221
  } else {
207
222
  invalidate()
208
223
  }
@@ -212,7 +227,7 @@ class AdvancedTextView : TextView {
212
227
  if (indicatorColor == color) return
213
228
  indicatorColor = color
214
229
  if (indicatorMode == "fill") {
215
- updateTextWithHighlights()
230
+ updateWordColors()
216
231
  } else {
217
232
  invalidate()
218
233
  }
@@ -221,7 +236,7 @@ class AdvancedTextView : TextView {
221
236
  fun setIndicatorMode(mode: String) {
222
237
  if (indicatorMode == mode) return
223
238
  indicatorMode = mode
224
- updateTextWithHighlights()
239
+ updateWordColors()
225
240
  invalidate()
226
241
  }
227
242
 
@@ -268,25 +283,18 @@ class AdvancedTextView : TextView {
268
283
  val spannableString = SpannableString(currentText)
269
284
 
270
285
  wordPositions.forEach { wordPos ->
271
- val wordColor = if (indicatorMode == "fill" &&
272
- indicatorWordIndex >= 0 &&
273
- wordPos.index <= indicatorWordIndex &&
274
- indicatorColor.isNotEmpty()) {
275
- parseColor(indicatorColor)
276
- } else {
277
- parseColor(textColor)
278
- }
279
286
  spannableString.setSpan(
280
287
  WordClickableSpan(
281
288
  wordIndex = wordPos.index,
282
- word = wordPos.word,
283
- wordColor = wordColor
289
+ word = wordPos.word
284
290
  ),
285
291
  wordPos.start,
286
292
  wordPos.end,
287
293
  Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
288
294
  )
289
295
  }
296
+ wordColorSpans = arrayOfNulls(wordPositions.size)
297
+ applyWordColors(spannableString)
290
298
 
291
299
  applyTextAlign()
292
300
 
@@ -322,6 +330,50 @@ class AdvancedTextView : TextView {
322
330
  Log.d(TAG, "Text updated with ${wordPositions.size} spans")
323
331
  }
324
332
 
333
+ // Recolors words on the live text instead of rebuilding it, so the
334
+ // indicator can advance while the user is selecting text. Span changes go
335
+ // through TextView's ChangeWatcher, which redraws just the changed region
336
+ // and keeps the selection and its action mode.
337
+ private fun updateWordColors() {
338
+ if (currentText.isEmpty()) return
339
+ val spannable = text as? Spannable
340
+ if (spannable == null ||
341
+ spannable.length != currentText.length ||
342
+ wordColorSpans.size != wordPositions.size) {
343
+ updateTextWithHighlights()
344
+ return
345
+ }
346
+ applyWordColors(spannable)
347
+ }
348
+
349
+ // Only words whose color changed get a new span, so an indicator step
350
+ // touches one or two words rather than the whole paragraph.
351
+ private fun applyWordColors(spannable: Spannable) {
352
+ val baseColor = parseColor(textColor)
353
+ val fillColor = if (indicatorMode == "fill" &&
354
+ indicatorWordIndex >= 0 &&
355
+ indicatorColor.isNotEmpty()) {
356
+ parseColor(indicatorColor)
357
+ } else {
358
+ null
359
+ }
360
+
361
+ wordPositions.forEach { wordPos ->
362
+ val color = if (fillColor != null && wordPos.index <= indicatorWordIndex) {
363
+ fillColor
364
+ } else {
365
+ baseColor
366
+ }
367
+ val current = wordColorSpans[wordPos.index]
368
+ if (current?.foregroundColor == color) return@forEach
369
+
370
+ current?.let { spannable.removeSpan(it) }
371
+ val span = ForegroundColorSpan(color)
372
+ spannable.setSpan(span, wordPos.start, wordPos.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
373
+ wordColorSpans[wordPos.index] = span
374
+ }
375
+ }
376
+
325
377
  // Every word is a ClickableSpan so a tap can report it, but TalkBack treats
326
378
  // each span as a link: it reads the text one word at a time and lists every
327
379
  // word under Actions. Plain text keeps it reading as prose; granularity
@@ -339,87 +391,78 @@ class AdvancedTextView : TextView {
339
391
  private fun drawHighlightedWordBackgrounds(canvas: Canvas) {
340
392
  val currentLayout = layout ?: return
341
393
  val density = resources.displayMetrics.density
342
- val originalColor = paint.color
343
394
 
344
395
  highlightedWords.forEach { highlightedWord ->
345
396
  val wordPos = wordPositions.find { it.index == highlightedWord.index } ?: return@forEach
346
- val start = wordPos.start
347
- val end = wordPos.extendedEnd.coerceAtMost(currentText.length)
348
-
349
- if (start >= end) return@forEach
350
-
351
- val startLine = currentLayout.getLineForOffset(start)
352
- val safeEndOffset = (end - 1).coerceAtLeast(start)
353
- val endLine = currentLayout.getLineForOffset(safeEndOffset)
354
-
355
- paint.color = parseColor(highlightedWord.highlightColor)
356
-
357
- for (line in startLine..endLine) {
358
- val lineStartOffset = maxOf(start, currentLayout.getLineStart(line))
359
- val lineEndOffset = minOf(end, currentLayout.getLineEnd(line))
360
- if (lineStartOffset >= lineEndOffset) continue
361
-
362
- val left = currentLayout.getPrimaryHorizontal(lineStartOffset)
363
- val isLastWordOnLine = lineEndOffset >= currentLayout.getLineEnd(line)
364
- val right = if (isLastWordOnLine) {
365
- currentLayout.getLineRight(line)
366
- } else {
367
- currentLayout.getPrimaryHorizontal(lineEndOffset)
368
- }
369
- val top = currentLayout.getLineTop(line).toFloat()
370
- val bottom = currentLayout.getLineBottom(line).toFloat()
371
- val rect = RectF(
372
- totalPaddingLeft + left,
373
- totalPaddingTop + top,
374
- totalPaddingLeft + right,
375
- totalPaddingTop + bottom
376
- )
377
- val radius = highlightedWord.borderRadius * density
378
- canvas.drawRoundRect(rect, radius, radius, paint)
379
- }
397
+ drawRangeBackground(
398
+ canvas,
399
+ currentLayout,
400
+ wordPos.start,
401
+ wordPos.extendedEnd.coerceAtMost(currentText.length),
402
+ parseColor(highlightedWord.highlightColor),
403
+ highlightedWord.borderRadius * density
404
+ )
380
405
  }
381
406
 
382
407
  // Draw indicator background only in Highlight mode
383
408
  if (indicatorMode != "fill" && indicatorWordIndex >= 0 && indicatorColor.isNotEmpty()) {
384
409
  val wordPos = wordPositions.find { it.index == indicatorWordIndex }
385
410
  if (wordPos != null) {
386
- val start = wordPos.start
387
- val end = wordPos.end.coerceAtMost(currentText.length)
388
-
389
- if (start < end) {
390
- val startLine = currentLayout.getLineForOffset(start)
391
- val safeEndOffset = (end - 1).coerceAtLeast(start)
392
- val endLine = currentLayout.getLineForOffset(safeEndOffset)
393
-
394
- paint.color = parseColor(indicatorColor)
395
-
396
- for (line in startLine..endLine) {
397
- val lineStartOffset = maxOf(start, currentLayout.getLineStart(line))
398
- val lineEndOffset = minOf(end, currentLayout.getLineEnd(line))
399
- if (lineStartOffset >= lineEndOffset) continue
400
-
401
- val left = currentLayout.getPrimaryHorizontal(lineStartOffset)
402
- val isLastWordOnLine = lineEndOffset >= currentLayout.getLineEnd(line)
403
- val right = if (isLastWordOnLine) {
404
- currentLayout.getLineRight(line)
405
- } else {
406
- currentLayout.getPrimaryHorizontal(lineEndOffset)
407
- }
408
- val top = currentLayout.getLineTop(line).toFloat()
409
- val bottom = currentLayout.getLineBottom(line).toFloat()
410
- val rect = RectF(
411
- totalPaddingLeft + left,
412
- totalPaddingTop + top,
413
- totalPaddingLeft + right,
414
- totalPaddingTop + bottom
415
- )
416
- canvas.drawRoundRect(rect, 0f, 0f, paint)
417
- }
418
- }
411
+ drawRangeBackground(
412
+ canvas,
413
+ currentLayout,
414
+ wordPos.start,
415
+ wordPos.end.coerceAtMost(currentText.length),
416
+ parseColor(indicatorColor),
417
+ 0f
418
+ )
419
419
  }
420
420
  }
421
+ }
422
+
423
+ // Fills the background of text[start, end), one box per line. The edges
424
+ // come from Layout.getSelectionPath, which follows the text's direction:
425
+ // a right-to-left word (Arabic) starts at its right edge, so treating
426
+ // getPrimaryHorizontal(start) as the left edge drew boxes in the wrong place.
427
+ private fun drawRangeBackground(
428
+ canvas: Canvas,
429
+ layout: Layout,
430
+ start: Int,
431
+ end: Int,
432
+ color: Int,
433
+ radius: Float
434
+ ) {
435
+ if (start >= end) return
436
+ highlightPaint.color = color
437
+
438
+ val startLine = layout.getLineForOffset(start)
439
+ val endLine = layout.getLineForOffset(end - 1)
440
+ for (line in startLine..endLine) {
441
+ val segmentStart = maxOf(start, layout.getLineStart(line))
442
+ val segmentEnd = minOf(end, layout.getLineEnd(line))
443
+ if (segmentStart >= segmentEnd) continue
444
+
445
+ rangePath.reset()
446
+ layout.getSelectionPath(segmentStart, segmentEnd, rangePath)
447
+
448
+ // A range reaching the end of a line counts as running onto the
449
+ // next one, so the selection path fills out to the view's edge.
450
+ // Clip to this line's text to stop the box at the last word.
451
+ lineClipPath.reset()
452
+ lineClipPath.addRect(
453
+ layout.getLineLeft(line),
454
+ layout.getLineTop(line).toFloat(),
455
+ layout.getLineRight(line),
456
+ layout.getLineBottom(line).toFloat(),
457
+ Path.Direction.CW
458
+ )
459
+ rangePath.op(lineClipPath, Path.Op.INTERSECT)
460
+ rangePath.computeBounds(rangeBounds, true)
461
+ if (rangeBounds.isEmpty) continue
421
462
 
422
- paint.color = originalColor
463
+ rangeBounds.offset(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
464
+ canvas.drawRoundRect(rangeBounds, radius, radius, highlightPaint)
465
+ }
423
466
  }
424
467
 
425
468
  private fun parseColor(colorString: String): Int {
@@ -448,8 +491,7 @@ class AdvancedTextView : TextView {
448
491
 
449
492
  private inner class WordClickableSpan(
450
493
  private val wordIndex: Int,
451
- private val word: String,
452
- private val wordColor: Int
494
+ private val word: String
453
495
  ) : ClickableSpan() {
454
496
 
455
497
  override fun onClick(widget: View) {
@@ -457,10 +499,10 @@ class AdvancedTextView : TextView {
457
499
  sendWordPressEvent(word, wordIndex)
458
500
  }
459
501
 
502
+ // Skips super, which would paint the word in the link color and
503
+ // underline it. The word's color comes from its ForegroundColorSpan.
460
504
  override fun updateDrawState(ds: TextPaint) {
461
- super.updateDrawState(ds)
462
505
  ds.isUnderlineText = false
463
- ds.color = wordColor
464
506
  }
465
507
  }
466
508
 
@@ -268,6 +268,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
268
268
  }
269
269
 
270
270
  BOOL indicatorChanged = NO;
271
+ BOOL indicatorModeChanged = NO;
271
272
 
272
273
  if (oldViewProps.indicatorMode != newViewProps.indicatorMode) {
273
274
  NSLog(@"[AdvancedTextView] Updating indicatorMode to: %s", newViewProps.indicatorMode.c_str());
@@ -275,6 +276,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
275
276
  ? @"Highlight"
276
277
  : [NSString stringWithUTF8String:newViewProps.indicatorMode.c_str()];
277
278
  indicatorChanged = YES;
279
+ indicatorModeChanged = YES;
278
280
  }
279
281
 
280
282
  if (oldViewProps.indicatorWordIndex != newViewProps.indicatorWordIndex) {
@@ -295,11 +297,10 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
295
297
  }
296
298
 
297
299
  if (indicatorChanged) {
298
- if ([_indicatorMode isEqualToString:@"fill"]) {
299
- [self updateTextAppearance]; // rebuilds attributed text with fill colors; calls setNeedsDisplay
300
- } else {
301
- [_textView setNeedsDisplay];
300
+ if ([_indicatorMode isEqualToString:@"fill"] || indicatorModeChanged) {
301
+ [self updateWordColors];
302
302
  }
303
+ [_textView setNeedsDisplay];
303
304
  }
304
305
 
305
306
  if (textChanged) {
@@ -497,22 +498,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
497
498
  }
498
499
 
499
500
 
500
- UIColor *color = _textColor ?: [UIColor labelColor];
501
- [attributedString addAttribute:NSForegroundColorAttributeName
502
- value:color
503
- range:NSMakeRange(0, attributedString.length)];
504
-
505
- if ([_indicatorMode isEqualToString:@"fill"] && _indicatorWordIndex >= 0 && _indicatorColor) {
506
- for (NSDictionary *wordInfo in _wordRanges) {
507
- NSInteger idx = [wordInfo[@"index"] integerValue];
508
- if (idx <= _indicatorWordIndex) {
509
- NSRange wordRange = [wordInfo[@"range"] rangeValue];
510
- [attributedString addAttribute:NSForegroundColorAttributeName
511
- value:_indicatorColor
512
- range:wordRange];
513
- }
514
- }
515
- }
501
+ [self applyWordColorsToString:attributedString];
516
502
 
517
503
  _textView.attributedText = attributedString;
518
504
  [_textView setNeedsDisplay];
@@ -534,6 +520,50 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
534
520
  }
535
521
  }
536
522
 
523
+ - (void)applyWordColorsToString:(NSMutableAttributedString *)string
524
+ {
525
+ UIColor *color = _textColor ?: [UIColor labelColor];
526
+ [string addAttribute:NSForegroundColorAttributeName
527
+ value:color
528
+ range:NSMakeRange(0, string.length)];
529
+
530
+ if (![_indicatorMode isEqualToString:@"fill"] || _indicatorWordIndex < 0 || !_indicatorColor) {
531
+ return;
532
+ }
533
+
534
+ for (NSDictionary *wordInfo in _wordRanges) {
535
+ if ([wordInfo[@"index"] integerValue] > _indicatorWordIndex) {
536
+ break;
537
+ }
538
+ NSRange wordRange = [wordInfo[@"range"] rangeValue];
539
+ if (NSMaxRange(wordRange) > string.length) {
540
+ break;
541
+ }
542
+ [string addAttribute:NSForegroundColorAttributeName
543
+ value:_indicatorColor
544
+ range:wordRange];
545
+ }
546
+ }
547
+
548
+ // Recolors the live textStorage instead of reassigning attributedText, so the
549
+ // indicator can advance while the user is selecting text: attribute-only
550
+ // edits keep selectedRange and the edit menu, assigning attributedText resets them.
551
+ - (void)updateWordColors
552
+ {
553
+ @try {
554
+ NSTextStorage *storage = _textView.textStorage;
555
+ if (storage.length == 0) {
556
+ return;
557
+ }
558
+
559
+ [storage beginEditing];
560
+ [self applyWordColorsToString:storage];
561
+ [storage endEditing];
562
+ } @catch (NSException *exception) {
563
+ NSLog(@"[AdvancedTextView] Exception in updateWordColors: %@", exception.reason);
564
+ }
565
+ }
566
+
537
567
  - (void)drawHighlightedBackgroundsInTextView:(UITextView *)textView
538
568
  {
539
569
  if (!textView.layoutManager || !textView.textContainer) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-advanced-text",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": " Advanced text component for React Native with custom select options.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",