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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
|
package/ios/AdvancedTextView.mm
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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