react-native-advanced-text 0.2.15 → 0.2.17

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
@@ -38,6 +38,11 @@ import { AdvancedText } from "react-native-advanced-text";
38
38
  highlightColor: '#6baeffb5',
39
39
  borderRadius: 6,
40
40
  },
41
+ {
42
+ index: 7,
43
+ highlightColor: '#818CF8',
44
+ underline: true, // a line under the word instead of a background
45
+ },
41
46
  ]}
42
47
  fontSize={24}
43
48
  color={'#FFFFFF'}
@@ -47,6 +52,22 @@ import { AdvancedText } from "react-native-advanced-text";
47
52
  />
48
53
  ```
49
54
 
55
+ ## Word gestures
56
+
57
+ | Prop | Fires on | Notes |
58
+ | --- | --- | --- |
59
+ | `onWordPress` | a tap on a word | `{ word, index }` |
60
+ | `onWordDoublePress` | a double tap on a word | `{ word, index }`. A double tap never selects text. While this is set, a single tap waits out the double-tap delay (~300 ms) before `onWordPress`, so a double tap doesn't also fire it. |
61
+ | `onWordLongPress` | a long press on a word | `{ word, index }`. Text selection still starts as usual. |
62
+ | `onSelection` | the selection menu | `{ selectedText, event, startWordIndex, endWordIndex }`: the menu option, and the first and last word index the selection touches (`-1` if none). |
63
+
64
+ ## Underlined words
65
+
66
+ An entry in `highlightedWords` with `underline: true` draws a line under the
67
+ word in `highlightColor` instead of filling its background (`borderRadius` is
68
+ ignored). A word can be both highlighted and underlined: list it twice, once
69
+ with `underline: true` and once without.
70
+
50
71
  ## Sizing
51
72
 
52
73
  `AdvancedText` measures its own height, like React Native's built‑in `<Text>`.
@@ -24,6 +24,7 @@ import android.view.Menu
24
24
  import android.view.MenuItem
25
25
  import android.view.View
26
26
  import android.view.MotionEvent
27
+ import android.view.ViewConfiguration
27
28
  import android.view.accessibility.AccessibilityNodeInfo
28
29
  import android.widget.TextView
29
30
  import com.facebook.react.bridge.Arguments
@@ -40,6 +41,8 @@ class AdvancedTextView : TextView {
40
41
  private var highlightedWords: List<HighlightedWord> = emptyList()
41
42
  private var menuOptions: List<String> = emptyList()
42
43
  private var lastSelectedText: String = ""
44
+ // First and last word index the selection touches, -1 for none.
45
+ private var lastSelectedWords: Pair<Int, Int> = -1 to -1
43
46
  private var customActionMode: ActionMode? = null
44
47
  private var currentText: String = ""
45
48
  private var textColor: String = "#000000"
@@ -66,6 +69,24 @@ class AdvancedTextView : TextView {
66
69
  private val rangePath = Path()
67
70
  private val lineClipPath = Path()
68
71
  private val rangeBounds = RectF()
72
+ private val underlineBounds = RectF()
73
+
74
+ // Double taps are only told apart while the app handles them
75
+ // (onWordDoublePress); otherwise a tap fires onWordPress at once.
76
+ private var wordDoublePressEnabled = false
77
+ private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
78
+ private var doubleTapSlopSquare = 0
79
+ private var lastTapUpTime = 0L
80
+ private var lastTapUpX = 0f
81
+ private var lastTapUpY = 0f
82
+ // From a double tap's second touch down to its up. The TextView never sees
83
+ // those events, so it can't select the word as it would on a double tap.
84
+ private var handlingDoubleTap = false
85
+ // A tap's onWordPress, held back while a second tap could still come.
86
+ private var pendingWordPress: Runnable? = null
87
+ private var lastDownX = 0f
88
+ private var lastDownY = 0f
89
+ private var longPressed = false
69
90
 
70
91
  constructor(context: Context?) : super(context) { init() }
71
92
  constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { init() }
@@ -92,6 +113,19 @@ class AdvancedTextView : TextView {
92
113
 
93
114
  movementMethod = SmartMovementMethod
94
115
 
116
+ val slop = ViewConfiguration.get(context).scaledDoubleTapSlop
117
+ doubleTapSlopSquare = slop * slop
118
+
119
+ // Reports the word, then returns false so the TextView still starts a
120
+ // selection as usual.
121
+ setOnLongClickListener {
122
+ longPressed = true
123
+ wordAt(lastDownX, lastDownY)?.let {
124
+ sendWordEvent("onWordLongPress", it.word, it.index)
125
+ }
126
+ false
127
+ }
128
+
95
129
  customSelectionActionModeCallback = object : ActionMode.Callback {
96
130
  override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
97
131
  customActionMode = mode
@@ -106,6 +140,7 @@ class AdvancedTextView : TextView {
106
140
 
107
141
  if (selectionStart >= 0 && selectionEnd >= 0 && selectionStart != selectionEnd) {
108
142
  lastSelectedText = text.subSequence(selectionStart, selectionEnd).toString()
143
+ lastSelectedWords = wordIndexRange(selectionStart, selectionEnd)
109
144
 
110
145
  menuOptions.forEachIndexed { index, option ->
111
146
  menu?.add(0, index, index, option)
@@ -394,14 +429,25 @@ class AdvancedTextView : TextView {
394
429
 
395
430
  highlightedWords.forEach { highlightedWord ->
396
431
  val wordPos = wordPositions.find { it.index == highlightedWord.index } ?: return@forEach
397
- drawRangeBackground(
398
- canvas,
399
- currentLayout,
400
- wordPos.start,
401
- wordPos.extendedEnd.coerceAtMost(currentText.length),
402
- parseColor(highlightedWord.highlightColor),
403
- highlightedWord.borderRadius * density
404
- )
432
+ if (highlightedWord.underline) {
433
+ // The word, but not the space after it.
434
+ drawRangeUnderline(
435
+ canvas,
436
+ currentLayout,
437
+ wordPos.start,
438
+ wordPos.end.coerceAtMost(currentText.length),
439
+ parseColor(highlightedWord.highlightColor)
440
+ )
441
+ } else {
442
+ drawRangeBackground(
443
+ canvas,
444
+ currentLayout,
445
+ wordPos.start,
446
+ wordPos.extendedEnd.coerceAtMost(currentText.length),
447
+ parseColor(highlightedWord.highlightColor),
448
+ highlightedWord.borderRadius * density
449
+ )
450
+ }
405
451
  }
406
452
 
407
453
  // Draw indicator background only in Highlight mode
@@ -420,10 +466,7 @@ class AdvancedTextView : TextView {
420
466
  }
421
467
  }
422
468
 
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.
469
+ // Fills the background of text[start, end), one box per line.
427
470
  private fun drawRangeBackground(
428
471
  canvas: Canvas,
429
472
  layout: Layout,
@@ -432,8 +475,44 @@ class AdvancedTextView : TextView {
432
475
  color: Int,
433
476
  radius: Float
434
477
  ) {
435
- if (start >= end) return
436
478
  highlightPaint.color = color
479
+ forEachLineOfRange(layout, start, end) {
480
+ canvas.drawRoundRect(rangeBounds, radius, radius, highlightPaint)
481
+ }
482
+ }
483
+
484
+ // Draws a line under text[start, end), one per line it spans, just below
485
+ // the baseline like a text underline.
486
+ private fun drawRangeUnderline(
487
+ canvas: Canvas,
488
+ layout: Layout,
489
+ start: Int,
490
+ end: Int,
491
+ color: Int
492
+ ) {
493
+ val density = resources.displayMetrics.density
494
+ val thickness = UNDERLINE_THICKNESS_DP * density
495
+ val gap = UNDERLINE_GAP_DP * density
496
+ highlightPaint.color = color
497
+ forEachLineOfRange(layout, start, end) { line ->
498
+ val top = layout.getLineBaseline(line) + totalPaddingTop + gap
499
+ underlineBounds.set(rangeBounds.left, top, rangeBounds.right, top + thickness)
500
+ canvas.drawRoundRect(underlineBounds, thickness / 2, thickness / 2, highlightPaint)
501
+ }
502
+ }
503
+
504
+ // Calls draw for each line text[start, end) spans, with rangeBounds set to
505
+ // that line's part of the range in view coordinates. The edges come from
506
+ // Layout.getSelectionPath, which follows the text's direction: a
507
+ // right-to-left word (Arabic) starts at its right edge, so treating
508
+ // getPrimaryHorizontal(start) as the left edge put boxes in the wrong place.
509
+ private inline fun forEachLineOfRange(
510
+ layout: Layout,
511
+ start: Int,
512
+ end: Int,
513
+ draw: (line: Int) -> Unit
514
+ ) {
515
+ if (start >= end) return
437
516
 
438
517
  val startLine = layout.getLineForOffset(start)
439
518
  val endLine = layout.getLineForOffset(end - 1)
@@ -461,7 +540,7 @@ class AdvancedTextView : TextView {
461
540
  if (rangeBounds.isEmpty) continue
462
541
 
463
542
  rangeBounds.offset(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
464
- canvas.drawRoundRect(rangeBounds, radius, radius, highlightPaint)
543
+ draw(line)
465
544
  }
466
545
  }
467
546
 
@@ -480,6 +559,8 @@ class AdvancedTextView : TextView {
480
559
  val event = Arguments.createMap().apply {
481
560
  putString("selectedText", selectedText)
482
561
  putString("event", eventType)
562
+ putInt("startWordIndex", lastSelectedWords.first)
563
+ putInt("endWordIndex", lastSelectedWords.second)
483
564
  }
484
565
 
485
566
  reactContext.getJSModule(RCTEventEmitter::class.java)
@@ -496,7 +577,18 @@ class AdvancedTextView : TextView {
496
577
 
497
578
  override fun onClick(widget: View) {
498
579
  Log.d(TAG, "Word clicked: '$word' (index=$wordIndex)")
499
- sendWordPressEvent(word, wordIndex)
580
+ if (!wordDoublePressEnabled) {
581
+ sendWordEvent("onWordPress", word, wordIndex)
582
+ return
583
+ }
584
+ // Wait to see whether this is the first tap of a double tap.
585
+ cancelPendingWordPress()
586
+ val press = Runnable {
587
+ pendingWordPress = null
588
+ sendWordEvent("onWordPress", word, wordIndex)
589
+ }
590
+ pendingWordPress = press
591
+ postDelayed(press, doubleTapTimeout)
500
592
  }
501
593
 
502
594
  // Skips super, which would paint the word in the link color and
@@ -506,7 +598,8 @@ class AdvancedTextView : TextView {
506
598
  }
507
599
  }
508
600
 
509
- private fun sendWordPressEvent(word: String, index: Int) {
601
+ // onWordPress, onWordDoublePress or onWordLongPress.
602
+ private fun sendWordEvent(eventName: String, word: String, index: Int) {
510
603
  try {
511
604
  val reactContext = context as? ReactContext ?: return
512
605
  val event = Arguments.createMap().apply {
@@ -515,10 +608,102 @@ class AdvancedTextView : TextView {
515
608
  }
516
609
 
517
610
  reactContext.getJSModule(RCTEventEmitter::class.java)
518
- .receiveEvent(id, "onWordPress", event)
611
+ .receiveEvent(id, eventName, event)
519
612
  } catch (e: Exception) {
520
- Log.e(TAG, "Error sending word press event", e)
613
+ Log.e(TAG, "Error sending $eventName event", e)
614
+ }
615
+ }
616
+
617
+ fun setWordDoublePressEnabled(enabled: Boolean) {
618
+ if (wordDoublePressEnabled == enabled) return
619
+ wordDoublePressEnabled = enabled
620
+ lastTapUpTime = 0L
621
+ // A tap held back for a double tap fires now instead of being lost.
622
+ pendingWordPress?.let {
623
+ removeCallbacks(it)
624
+ it.run()
625
+ }
626
+ }
627
+
628
+ override fun onTouchEvent(event: MotionEvent): Boolean {
629
+ when (event.actionMasked) {
630
+ MotionEvent.ACTION_DOWN -> {
631
+ lastDownX = event.x
632
+ lastDownY = event.y
633
+ longPressed = false
634
+ if (wordDoublePressEnabled && isSecondTap(event)) {
635
+ cancelPendingWordPress()
636
+ handlingDoubleTap = true
637
+ return true
638
+ }
639
+ }
640
+ MotionEvent.ACTION_UP -> if (handlingDoubleTap) {
641
+ handlingDoubleTap = false
642
+ lastTapUpTime = 0L
643
+ wordAt(event.x, event.y)?.let {
644
+ sendWordEvent("onWordDoublePress", it.word, it.index)
645
+ }
646
+ return true
647
+ }
648
+ MotionEvent.ACTION_CANCEL -> if (handlingDoubleTap) {
649
+ handlingDoubleTap = false
650
+ lastTapUpTime = 0L
651
+ return true
652
+ }
521
653
  }
654
+ if (handlingDoubleTap) return true
655
+
656
+ val handled = super.onTouchEvent(event)
657
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
658
+ // A long press isn't the first tap of a double tap.
659
+ if (longPressed) {
660
+ lastTapUpTime = 0L
661
+ } else {
662
+ lastTapUpTime = event.eventTime
663
+ lastTapUpX = event.x
664
+ lastTapUpY = event.y
665
+ }
666
+ }
667
+ return handled
668
+ }
669
+
670
+ override fun onDetachedFromWindow() {
671
+ cancelPendingWordPress()
672
+ super.onDetachedFromWindow()
673
+ }
674
+
675
+ private fun isSecondTap(down: MotionEvent): Boolean {
676
+ if (lastTapUpTime == 0L || down.eventTime - lastTapUpTime > doubleTapTimeout) {
677
+ return false
678
+ }
679
+ val dx = down.x - lastTapUpX
680
+ val dy = down.y - lastTapUpY
681
+ return dx * dx + dy * dy < doubleTapSlopSquare
682
+ }
683
+
684
+ private fun cancelPendingWordPress() {
685
+ pendingWordPress?.let { removeCallbacks(it) }
686
+ pendingWordPress = null
687
+ }
688
+
689
+ // The word under a point in view coordinates, or null for blank space,
690
+ // including the space past the end of a line.
691
+ private fun wordAt(x: Float, y: Float): WordPosition? {
692
+ val currentLayout = layout ?: return null
693
+ val line = currentLayout.getLineForVertical((y - totalPaddingTop + scrollY).toInt())
694
+ val lineX = x - totalPaddingLeft + scrollX
695
+ if (lineX < currentLayout.getLineLeft(line) || lineX > currentLayout.getLineRight(line)) {
696
+ return null
697
+ }
698
+ val offset = getOffsetForPosition(x, y)
699
+ return wordPositions.find { offset >= it.start && offset <= it.end }
700
+ }
701
+
702
+ // First and last word text[start, end) touches, or -1 to -1 for none.
703
+ private fun wordIndexRange(start: Int, end: Int): Pair<Int, Int> {
704
+ val touched = wordPositions.filter { it.start < end && it.end > start }
705
+ if (touched.isEmpty()) return -1 to -1
706
+ return touched.first().index to touched.last().index
522
707
  }
523
708
 
524
709
  fun clearSelection() {
@@ -589,5 +774,10 @@ class AdvancedTextView : TextView {
589
774
  data class HighlightedWord(
590
775
  val index: Int,
591
776
  val highlightColor: String,
592
- val borderRadius: Float = 0f
777
+ val borderRadius: Float = 0f,
778
+ // Draws a line under the word instead of filling its background.
779
+ val underline: Boolean = false
593
780
  )
781
+
782
+ private const val UNDERLINE_THICKNESS_DP = 1.5f
783
+ private const val UNDERLINE_GAP_DP = 2.5f
@@ -54,7 +54,10 @@ class AdvancedTextViewManager : SimpleViewManager<AdvancedTextView>() {
54
54
  wordMap.getDouble("borderRadius").toFloat()
55
55
  } else {
56
56
  0f
57
- }
57
+ },
58
+ underline = wordMap.hasKey("underline") &&
59
+ !wordMap.isNull("underline") &&
60
+ wordMap.getBoolean("underline")
58
61
  )
59
62
  )
60
63
  }
@@ -158,9 +161,16 @@ class AdvancedTextViewManager : SimpleViewManager<AdvancedTextView>() {
158
161
  view?.setAdvancedLetterSpacing(letterSpacing)
159
162
  }
160
163
 
164
+ @ReactProp(name = "wordDoublePressEnabled")
165
+ fun setWordDoublePressEnabled(view: AdvancedTextView?, enabled: Boolean) {
166
+ view?.setWordDoublePressEnabled(enabled)
167
+ }
168
+
161
169
  override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> {
162
170
  return mapOf(
163
171
  "onWordPress" to mapOf("registrationName" to "onWordPress"),
172
+ "onWordDoublePress" to mapOf("registrationName" to "onWordDoublePress"),
173
+ "onWordLongPress" to mapOf("registrationName" to "onWordLongPress"),
164
174
  "onSelection" to mapOf("registrationName" to "onSelection")
165
175
  )
166
176
  }
@@ -31,6 +31,9 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
31
31
 
32
32
  @property (nonatomic, strong) NSMutableArray<NSDictionary *> *wordRanges;
33
33
  @property (nonatomic, strong) NSMutableDictionary<NSNumber *, NSDictionary *> *highlightStyles;
34
+ // Underline colors by word index, kept apart from highlightStyles so a word
35
+ // can be both highlighted and underlined.
36
+ @property (nonatomic, strong) NSMutableDictionary<NSNumber *, UIColor *> *underlineStyles;
34
37
  @property (nonatomic, strong) NSArray<NSString *> *menuOptions;
35
38
  @property (nonatomic, assign) CGFloat fontSize;
36
39
  @property (nonatomic, strong) NSString *fontWeight;
@@ -88,6 +91,11 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
88
91
  @interface AdvancedTextView () <RCTAdvancedTextViewViewProtocol, UIGestureRecognizerDelegate, UITextViewDelegate>
89
92
 
90
93
  @property (nonatomic, strong) CustomTextView *textView;
94
+ @property (nonatomic, strong) UITapGestureRecognizer *singleTapGesture;
95
+ @property (nonatomic, strong) UITapGestureRecognizer *doubleTapGesture;
96
+ @property (nonatomic, strong) UILongPressGestureRecognizer *longPressGesture;
97
+ // On while the app handles double taps (onWordDoublePress).
98
+ @property (nonatomic, assign) BOOL wordDoublePressEnabled;
91
99
 
92
100
  @end
93
101
 
@@ -109,6 +117,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
109
117
 
110
118
  _wordRanges = [NSMutableArray array];
111
119
  _highlightStyles = [NSMutableDictionary dictionary];
120
+ _underlineStyles = [NSMutableDictionary dictionary];
112
121
  _fontSize = 16.0;
113
122
  _fontWeight = @"normal";
114
123
  _textColor = [UIColor labelColor];
@@ -173,6 +182,24 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
173
182
  action:@selector(handleTap:)];
174
183
  tapGesture.delegate = self;
175
184
  [_textView addGestureRecognizer:tapGesture];
185
+ _singleTapGesture = tapGesture;
186
+
187
+ // Off until the app handles double taps, so taps aren't delayed.
188
+ UITapGestureRecognizer *doubleTapGesture = [[UITapGestureRecognizer alloc]
189
+ initWithTarget:self
190
+ action:@selector(handleDoubleTap:)];
191
+ doubleTapGesture.numberOfTapsRequired = 2;
192
+ doubleTapGesture.delegate = self;
193
+ doubleTapGesture.enabled = NO;
194
+ [_textView addGestureRecognizer:doubleTapGesture];
195
+ _doubleTapGesture = doubleTapGesture;
196
+
197
+ UILongPressGestureRecognizer *longPressGesture = [[UILongPressGestureRecognizer alloc]
198
+ initWithTarget:self
199
+ action:@selector(handleLongPress:)];
200
+ longPressGesture.delegate = self;
201
+ [_textView addGestureRecognizer:longPressGesture];
202
+ _longPressGesture = longPressGesture;
176
203
 
177
204
  NSLog(@"[AdvancedTextView] Gesture recognizers setup successful");
178
205
  } @catch (NSException *exception) {
@@ -224,6 +251,11 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
224
251
  styleChanged = YES;
225
252
  }
226
253
 
254
+ if (oldViewProps.wordDoublePressEnabled != newViewProps.wordDoublePressEnabled) {
255
+ _wordDoublePressEnabled = newViewProps.wordDoublePressEnabled;
256
+ _doubleTapGesture.enabled = _wordDoublePressEnabled;
257
+ }
258
+
227
259
  if (oldViewProps.text != newViewProps.text) {
228
260
  textChanged = YES;
229
261
  NSLog(@"[AdvancedTextView] Text changed");
@@ -237,7 +269,8 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
237
269
  const auto &newHW = newViewProps.highlightedWords[i];
238
270
  if (oldHW.index != newHW.index ||
239
271
  oldHW.highlightColor != newHW.highlightColor ||
240
- oldHW.borderRadius != newHW.borderRadius) {
272
+ oldHW.borderRadius != newHW.borderRadius ||
273
+ oldHW.underline != newHW.underline) {
241
274
  highlightsChanged = YES;
242
275
  break;
243
276
  }
@@ -410,6 +443,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
410
443
  highlightedWords.size());
411
444
  @try {
412
445
  [_highlightStyles removeAllObjects];
446
+ [_underlineStyles removeAllObjects];
413
447
 
414
448
  for (const auto &hw : highlightedWords) {
415
449
  NSInteger index = hw.index;
@@ -417,7 +451,9 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
417
451
  UIColor *color = [self hexStringToColor:colorString];
418
452
  CGFloat borderRadius = static_cast<CGFloat>(hw.borderRadius);
419
453
 
420
- if (color) {
454
+ if (color && hw.underline) {
455
+ _underlineStyles[@(index)] = color;
456
+ } else if (color) {
421
457
  _highlightStyles[@(index)] = @{
422
458
  @"color": color,
423
459
  @"borderRadius": @(MAX(borderRadius, 0.0))
@@ -499,6 +535,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
499
535
 
500
536
 
501
537
  [self applyWordColorsToString:attributedString];
538
+ [self applyUnderlinesToString:attributedString];
502
539
 
503
540
  _textView.attributedText = attributedString;
504
541
  [_textView setNeedsDisplay];
@@ -545,6 +582,29 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
545
582
  }
546
583
  }
547
584
 
585
+ // A text underline under each underlined word, but not the space after it.
586
+ - (void)applyUnderlinesToString:(NSMutableAttributedString *)string
587
+ {
588
+ if (_underlineStyles.count == 0) {
589
+ return;
590
+ }
591
+
592
+ for (NSDictionary *wordInfo in _wordRanges) {
593
+ UIColor *color = _underlineStyles[wordInfo[@"index"]];
594
+ if (!color) {
595
+ continue;
596
+ }
597
+ NSRange wordRange = [wordInfo[@"range"] rangeValue];
598
+ if (NSMaxRange(wordRange) > string.length) {
599
+ break;
600
+ }
601
+ [string addAttributes:@{
602
+ NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle),
603
+ NSUnderlineColorAttributeName: color
604
+ } range:wordRange];
605
+ }
606
+ }
607
+
548
608
  // Recolors the live textStorage instead of reassigning attributedText, so the
549
609
  // indicator can advance while the user is selecting text: attribute-only
550
610
  // edits keep selectedRange and the edit menu, assigning attributedText resets them.
@@ -661,6 +721,89 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
661
721
  }
662
722
  }
663
723
 
724
+ - (void)handleDoubleTap:(UITapGestureRecognizer *)gesture
725
+ {
726
+ @try {
727
+ if (gesture.state != UIGestureRecognizerStateEnded) return;
728
+
729
+ // In case the text view selected anything before giving way.
730
+ _textView.selectedTextRange = nil;
731
+
732
+ NSInteger wordIndex = [self wordIndexAtPoint:[gesture locationInView:_textView]];
733
+ if (wordIndex >= 0 && wordIndex < _wordRanges.count) {
734
+ [self emitWordEvent:@"doublePress" word:_wordRanges[wordIndex][@"word"] index:wordIndex];
735
+ }
736
+ } @catch (NSException *exception) {
737
+ NSLog(@"[AdvancedTextView] Exception in handleDoubleTap: %@", exception);
738
+ }
739
+ }
740
+
741
+ // Only reports the word: the text view's own long press still starts a selection.
742
+ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture
743
+ {
744
+ @try {
745
+ if (gesture.state != UIGestureRecognizerStateBegan) return;
746
+
747
+ NSInteger wordIndex = [self wordIndexAtPoint:[gesture locationInView:_textView]];
748
+ if (wordIndex >= 0 && wordIndex < _wordRanges.count) {
749
+ [self emitWordEvent:@"longPress" word:_wordRanges[wordIndex][@"word"] index:wordIndex];
750
+ }
751
+ } @catch (NSException *exception) {
752
+ NSLog(@"[AdvancedTextView] Exception in handleLongPress: %@", exception);
753
+ }
754
+ }
755
+
756
+ #pragma mark - UIGestureRecognizerDelegate
757
+
758
+ // While double taps are handled, a single tap waits to see whether a second
759
+ // one follows, so a double tap doesn't also fire onWordPress.
760
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
761
+ shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
762
+ {
763
+ return _wordDoublePressEnabled &&
764
+ gestureRecognizer == _singleTapGesture &&
765
+ otherGestureRecognizer == _doubleTapGesture;
766
+ }
767
+
768
+ // The text view's own double tap selects the word. It waits for ours, which
769
+ // wins on every double tap, so a double tap never selects text.
770
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
771
+ shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
772
+ {
773
+ if (!_wordDoublePressEnabled || gestureRecognizer != _doubleTapGesture) {
774
+ return NO;
775
+ }
776
+ if (![otherGestureRecognizer isKindOfClass:[UITapGestureRecognizer class]]) {
777
+ return NO;
778
+ }
779
+ return ((UITapGestureRecognizer *)otherGestureRecognizer).numberOfTapsRequired >= 2;
780
+ }
781
+
782
+ // The long press runs alongside the text view's own, which starts the selection.
783
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
784
+ shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
785
+ {
786
+ return gestureRecognizer == _longPressGesture || otherGestureRecognizer == _longPressGesture;
787
+ }
788
+
789
+ // The first and last word a character range touches, or -1 for none.
790
+ - (void)wordIndexesForRange:(NSRange)range first:(NSInteger *)first last:(NSInteger *)last
791
+ {
792
+ *first = -1;
793
+ *last = -1;
794
+ for (NSDictionary *wordInfo in _wordRanges) {
795
+ NSRange wordRange = [wordInfo[@"range"] rangeValue];
796
+ if (NSIntersectionRange(wordRange, range).length == 0) {
797
+ continue;
798
+ }
799
+ NSInteger index = [wordInfo[@"index"] integerValue];
800
+ if (*first < 0) {
801
+ *first = index;
802
+ }
803
+ *last = index;
804
+ }
805
+ }
806
+
664
807
  #pragma mark - UITextViewDelegate
665
808
 
666
809
  - (void)textViewDidChangeSelection:(UITextView *)textView
@@ -783,6 +926,29 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
783
926
  }
784
927
  }
785
928
 
929
+ // onWordDoublePress ("doublePress") or onWordLongPress ("longPress").
930
+ - (void)emitWordEvent:(NSString *)type word:(NSString *)word index:(NSInteger)index
931
+ {
932
+ @try {
933
+ if (!_eventEmitter) return;
934
+ auto emitter = std::static_pointer_cast<const AdvancedTextViewEventEmitter>(_eventEmitter);
935
+
936
+ if ([type isEqualToString:@"doublePress"]) {
937
+ AdvancedTextViewEventEmitter::OnWordDoublePress event;
938
+ event.word = [word UTF8String];
939
+ event.index = static_cast<int>(index);
940
+ emitter->onWordDoublePress(event);
941
+ } else {
942
+ AdvancedTextViewEventEmitter::OnWordLongPress event;
943
+ event.word = [word UTF8String];
944
+ event.index = static_cast<int>(index);
945
+ emitter->onWordLongPress(event);
946
+ }
947
+ } @catch (NSException *exception) {
948
+ NSLog(@"[AdvancedTextView] Exception in emitWordEvent: %@", exception);
949
+ }
950
+ }
951
+
786
952
  - (void)emitSelectionEvent:(NSString *)selectedText menuOption:(NSString *)option
787
953
  {
788
954
  NSLog(@"[AdvancedTextView] emitSelectionEvent: %@ with option: %@", selectedText, option);
@@ -790,9 +956,15 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
790
956
  if (_eventEmitter) {
791
957
  auto emitter = std::static_pointer_cast<const AdvancedTextViewEventEmitter>(_eventEmitter);
792
958
 
959
+ NSInteger firstWord = -1;
960
+ NSInteger lastWord = -1;
961
+ [self wordIndexesForRange:_textView.selectedRange first:&firstWord last:&lastWord];
962
+
793
963
  AdvancedTextViewEventEmitter::OnSelection event;
794
964
  event.selectedText = [selectedText UTF8String];
795
965
  event.event = [option UTF8String];
966
+ event.startWordIndex = static_cast<int>(firstWord);
967
+ event.endWordIndex = static_cast<int>(lastWord);
796
968
 
797
969
  emitter->onSelection(event);
798
970
  }
@@ -9,6 +9,8 @@ export const AdvancedText = ({
9
9
  highlightedWords,
10
10
  menuOptions,
11
11
  onWordPress,
12
+ onWordDoublePress,
13
+ onWordLongPress,
12
14
  onSelection,
13
15
  ...restProps
14
16
  }) => {
@@ -19,7 +21,12 @@ export const AdvancedText = ({
19
21
  highlightedWords: highlightedWords,
20
22
  menuOptions: menuOptions,
21
23
  onWordPress: onWordPress,
24
+ onWordDoublePress: onWordDoublePress,
25
+ onWordLongPress: onWordLongPress,
22
26
  onSelection: onSelection
27
+ // Only apps that handle double taps pay the single-tap delay.
28
+ ,
29
+ wordDoublePressEnabled: !!onWordDoublePress
23
30
  });
24
31
  };
25
32
  //# sourceMappingURL=AdvancedText.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["React","AdvancedTextViewNativeComponent","jsx","_jsx","AdvancedText","text","style","highlightedWords","menuOptions","onWordPress","onSelection","restProps"],"sourceRoot":"../../src","sources":["AdvancedText.tsx"],"mappings":";;AAAA,OAAOA,KAAK,MAAM,OAAO;AACzB,OAAOC,+BAA+B,MAE/B,mCAAmC;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAE3C,OAAO,MAAMC,YAAmC,GAAGA,CAAC;EAClDC,IAAI;EACJC,KAAK;EACLC,gBAAgB;EAChBC,WAAW;EACXC,WAAW;EACXC,WAAW;EACX,GAAGC;AACL,CAAC,KAAK;EACJ,oBACER,IAAA,CAACF,+BAA+B;IAAA,GAC1BU,SAAS;IACbL,KAAK,EAAEA,KAAM;IACbD,IAAI,EAAEA,IAAK;IACXE,gBAAgB,EAAEA,gBAAiB;IACnCC,WAAW,EAAEA,WAAY;IACzBC,WAAW,EAAEA,WAAY;IACzBC,WAAW,EAAEA;EAAY,CAC1B,CAAC;AAEN,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["React","AdvancedTextViewNativeComponent","jsx","_jsx","AdvancedText","text","style","highlightedWords","menuOptions","onWordPress","onWordDoublePress","onWordLongPress","onSelection","restProps","wordDoublePressEnabled"],"sourceRoot":"../../src","sources":["AdvancedText.tsx"],"mappings":";;AAAA,OAAOA,KAAK,MAAM,OAAO;AACzB,OAAOC,+BAA+B,MAE/B,mCAAmC;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAE3C,OAAO,MAAMC,YAAmC,GAAGA,CAAC;EAClDC,IAAI;EACJC,KAAK;EACLC,gBAAgB;EAChBC,WAAW;EACXC,WAAW;EACXC,iBAAiB;EACjBC,eAAe;EACfC,WAAW;EACX,GAAGC;AACL,CAAC,KAAK;EACJ,oBACEV,IAAA,CAACF,+BAA+B;IAAA,GAC1BY,SAAS;IACbP,KAAK,EAAEA,KAAM;IACbD,IAAI,EAAEA,IAAK;IACXE,gBAAgB,EAAEA,gBAAiB;IACnCC,WAAW,EAAEA,WAAY;IACzBC,WAAW,EAAEA,WAAY;IACzBC,iBAAiB,EAAEA,iBAAkB;IACrCC,eAAe,EAAEA,eAAgB;IACjCC,WAAW,EAAEA;IACb;IAAA;IACAE,sBAAsB,EAAE,CAAC,CAACJ;EAAkB,CAC7C,CAAC;AAEN,CAAC","ignoreList":[]}
@@ -8,6 +8,9 @@ interface HighlightedWord {
8
8
  index: Int32;
9
9
  highlightColor: string;
10
10
  borderRadius?: Float;
11
+ // Underlines the word in highlightColor instead of filling its background.
12
+ // A word can have both: list it once with and once without.
13
+ underline?: boolean;
11
14
  }
12
15
 
13
16
  export interface NativeProps extends ViewProps {
@@ -15,7 +18,21 @@ export interface NativeProps extends ViewProps {
15
18
  highlightedWords?: ReadonlyArray<HighlightedWord>;
16
19
  menuOptions?: ReadonlyArray<string>;
17
20
  onWordPress?: DirectEventHandler<{ word: string; index: Int32 }>;
18
- onSelection?: DirectEventHandler<{ selectedText: string; event: string }>;
21
+ // A double tap on a word. It never selects text.
22
+ onWordDoublePress?: DirectEventHandler<{ word: string; index: Int32 }>;
23
+ // A long press on a word. Text selection still starts as usual.
24
+ onWordLongPress?: DirectEventHandler<{ word: string; index: Int32 }>;
25
+ // startWordIndex/endWordIndex: the first and last word the selection
26
+ // touches, or -1 when it touches none.
27
+ onSelection?: DirectEventHandler<{
28
+ selectedText: string;
29
+ event: string;
30
+ startWordIndex: Int32;
31
+ endWordIndex: Int32;
32
+ }>;
33
+ // Set by AdvancedText when onWordDoublePress is given. While on, a single
34
+ // tap waits out the double-tap delay before firing onWordPress.
35
+ wordDoublePressEnabled?: boolean;
19
36
  fontSize?: Int32;
20
37
  fontWeight?: string;
21
38
  color?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"AdvancedText.d.ts","sourceRoot":"","sources":["../../../src/AdvancedText.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAwC,EACtC,KAAK,WAAW,EACjB,MAAM,mCAAmC,CAAC;AAE3C,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CAoB9C,CAAC"}
1
+ {"version":3,"file":"AdvancedText.d.ts","sourceRoot":"","sources":["../../../src/AdvancedText.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAwC,EACtC,KAAK,WAAW,EACjB,MAAM,mCAAmC,CAAC;AAE3C,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CA0B9C,CAAC"}
@@ -4,6 +4,7 @@ interface HighlightedWord {
4
4
  index: Int32;
5
5
  highlightColor: string;
6
6
  borderRadius?: Float;
7
+ underline?: boolean;
7
8
  }
8
9
  export interface NativeProps extends ViewProps {
9
10
  text: string;
@@ -13,10 +14,21 @@ export interface NativeProps extends ViewProps {
13
14
  word: string;
14
15
  index: Int32;
15
16
  }>;
17
+ onWordDoublePress?: DirectEventHandler<{
18
+ word: string;
19
+ index: Int32;
20
+ }>;
21
+ onWordLongPress?: DirectEventHandler<{
22
+ word: string;
23
+ index: Int32;
24
+ }>;
16
25
  onSelection?: DirectEventHandler<{
17
26
  selectedText: string;
18
27
  event: string;
28
+ startWordIndex: Int32;
29
+ endWordIndex: Int32;
19
30
  }>;
31
+ wordDoublePressEnabled?: boolean;
20
32
  fontSize?: Int32;
21
33
  fontWeight?: string;
22
34
  color?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"AdvancedTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/AdvancedTextViewNativeComponent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAG,KAAK,EAAG,MAAM,2CAA2C,CAAC;AAEpG,UAAU,eAAe;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAClD,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IACjE,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,EAAE,KAAK,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,KAAK,CAAC;IACnB,aAAa,CAAC,EAAE,KAAK,CAAC;IACtB,kBAAkB,CAAC,EAAE,KAAK,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;;AAED,wBAAuE"}
1
+ {"version":3,"file":"AdvancedTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/AdvancedTextViewNativeComponent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAG,KAAK,EAAG,MAAM,2CAA2C,CAAC;AAEpG,UAAU,eAAe;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,KAAK,CAAC;IAGrB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAClD,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IAEjE,iBAAiB,CAAC,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IAEvE,eAAe,CAAC,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IAGrE,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAC/B,YAAY,EAAE,MAAM,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,cAAc,EAAE,KAAK,CAAC;QACtB,YAAY,EAAE,KAAK,CAAC;KACrB,CAAC,CAAC;IAGH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,EAAE,KAAK,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,KAAK,CAAC;IACnB,aAAa,CAAC,EAAE,KAAK,CAAC;IACtB,kBAAkB,CAAC,EAAE,KAAK,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;;AAED,wBAAuE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-advanced-text",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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",
@@ -9,6 +9,8 @@ export const AdvancedText: React.FC<NativeProps> = ({
9
9
  highlightedWords,
10
10
  menuOptions,
11
11
  onWordPress,
12
+ onWordDoublePress,
13
+ onWordLongPress,
12
14
  onSelection,
13
15
  ...restProps
14
16
  }) => {
@@ -20,7 +22,11 @@ export const AdvancedText: React.FC<NativeProps> = ({
20
22
  highlightedWords={highlightedWords}
21
23
  menuOptions={menuOptions}
22
24
  onWordPress={onWordPress}
25
+ onWordDoublePress={onWordDoublePress}
26
+ onWordLongPress={onWordLongPress}
23
27
  onSelection={onSelection}
28
+ // Only apps that handle double taps pay the single-tap delay.
29
+ wordDoublePressEnabled={!!onWordDoublePress}
24
30
  />
25
31
  );
26
32
  };
@@ -8,6 +8,9 @@ interface HighlightedWord {
8
8
  index: Int32;
9
9
  highlightColor: string;
10
10
  borderRadius?: Float;
11
+ // Underlines the word in highlightColor instead of filling its background.
12
+ // A word can have both: list it once with and once without.
13
+ underline?: boolean;
11
14
  }
12
15
 
13
16
  export interface NativeProps extends ViewProps {
@@ -15,7 +18,21 @@ export interface NativeProps extends ViewProps {
15
18
  highlightedWords?: ReadonlyArray<HighlightedWord>;
16
19
  menuOptions?: ReadonlyArray<string>;
17
20
  onWordPress?: DirectEventHandler<{ word: string; index: Int32 }>;
18
- onSelection?: DirectEventHandler<{ selectedText: string; event: string }>;
21
+ // A double tap on a word. It never selects text.
22
+ onWordDoublePress?: DirectEventHandler<{ word: string; index: Int32 }>;
23
+ // A long press on a word. Text selection still starts as usual.
24
+ onWordLongPress?: DirectEventHandler<{ word: string; index: Int32 }>;
25
+ // startWordIndex/endWordIndex: the first and last word the selection
26
+ // touches, or -1 when it touches none.
27
+ onSelection?: DirectEventHandler<{
28
+ selectedText: string;
29
+ event: string;
30
+ startWordIndex: Int32;
31
+ endWordIndex: Int32;
32
+ }>;
33
+ // Set by AdvancedText when onWordDoublePress is given. While on, a single
34
+ // tap waits out the double-tap delay before firing onWordPress.
35
+ wordDoublePressEnabled?: boolean;
19
36
  fontSize?: Int32;
20
37
  fontWeight?: string;
21
38
  color?: string;