react-native-highlight-text-view 0.1.3 → 0.1.4

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
@@ -61,7 +61,7 @@ export default function App() {
61
61
 
62
62
 
63
63
  **Note:** Vertical alignment is currently supported on iOS only. On Android, text will use default vertical positioning.
64
- ```
64
+
65
65
 
66
66
 
67
67
  ## Contributing
@@ -70,10 +70,7 @@ export default function App() {
70
70
  - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
71
71
  - [Code of conduct](CODE_OF_CONDUCT.md)
72
72
 
73
- ## License
74
-
75
- MIT
76
73
 
77
- ---
74
+ ## License MIT
78
75
 
79
76
  Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
@@ -1,15 +1,299 @@
1
1
  package com.highlighttext
2
2
 
3
3
  import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.RectF
8
+ import android.text.Editable
9
+ import android.text.Spannable
10
+ import android.text.SpannableString
11
+ import android.text.TextWatcher
12
+ import android.text.style.ReplacementSpan
4
13
  import android.util.AttributeSet
5
- import android.view.View
14
+ import android.util.TypedValue
15
+ import android.view.Gravity
16
+ import androidx.appcompat.widget.AppCompatEditText
6
17
 
7
- class HighlightTextView : View {
8
- constructor(context: Context?) : super(context)
9
- constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
18
+ class RoundedBackgroundSpan(
19
+ private val backgroundColor: Int,
20
+ private val textColor: Int,
21
+ private val paddingLeft: Float,
22
+ private val paddingRight: Float,
23
+ private val paddingTop: Float,
24
+ private val paddingBottom: Float,
25
+ private val cornerRadius: Float,
26
+ private val isFirstInGroup: Boolean = false,
27
+ private val isLastInGroup: Boolean = false
28
+ ) : ReplacementSpan() {
29
+
30
+ override fun getSize(
31
+ paint: Paint,
32
+ text: CharSequence?,
33
+ start: Int,
34
+ end: Int,
35
+ fm: Paint.FontMetricsInt?
36
+ ): Int {
37
+ val width = paint.measureText(text, start, end)
38
+ // Only add padding for first and last characters in a group
39
+ val leftPad = if (isFirstInGroup) paddingLeft else 0f
40
+ val rightPad = if (isLastInGroup) paddingRight else 0f
41
+ return (width + leftPad + rightPad).toInt()
42
+ }
43
+
44
+ override fun draw(
45
+ canvas: Canvas,
46
+ text: CharSequence?,
47
+ start: Int,
48
+ end: Int,
49
+ x: Float,
50
+ top: Int,
51
+ y: Int,
52
+ bottom: Int,
53
+ paint: Paint
54
+ ) {
55
+ // Draw background with padding
56
+ val bgPaint = Paint().apply {
57
+ color = backgroundColor
58
+ style = Paint.Style.FILL
59
+ isAntiAlias = true
60
+ }
61
+
62
+ val width = paint.measureText(text, start, end)
63
+
64
+ // Use font metrics for consistent height instead of character bounds
65
+ val fontMetrics = paint.fontMetrics
66
+ val textHeight = fontMetrics.descent - fontMetrics.ascent
67
+ val textTop = y + fontMetrics.ascent
68
+
69
+ // Only add padding for first and last characters in a group
70
+ val leftPad = if (isFirstInGroup) paddingLeft else 0f
71
+ val rightPad = if (isLastInGroup) paddingRight else 0f
72
+
73
+ // Extend background slightly to overlap and eliminate gaps
74
+ val overlapExtension = 1f
75
+ val leftExtension = if (!isFirstInGroup) overlapExtension else 0f
76
+ val rightExtension = if (!isLastInGroup) overlapExtension else 0f
77
+
78
+ // Calculate proper bounds with padding using consistent font metrics
79
+ val rect = RectF(
80
+ x - leftExtension,
81
+ textTop - paddingTop,
82
+ x + width + leftPad + rightPad + rightExtension,
83
+ textTop + textHeight + paddingBottom
84
+ )
85
+
86
+ // Draw background with selective corner rounding
87
+ when {
88
+ isFirstInGroup && isLastInGroup -> {
89
+ // Single character or isolated group - round all corners
90
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
91
+ }
92
+ isFirstInGroup -> {
93
+ // First character - round left corners only
94
+ val path = android.graphics.Path()
95
+ path.addRoundRect(
96
+ rect,
97
+ floatArrayOf(
98
+ cornerRadius, cornerRadius, // top-left
99
+ 0f, 0f, // top-right
100
+ 0f, 0f, // bottom-right
101
+ cornerRadius, cornerRadius // bottom-left
102
+ ),
103
+ android.graphics.Path.Direction.CW
104
+ )
105
+ canvas.drawPath(path, bgPaint)
106
+ }
107
+ isLastInGroup -> {
108
+ // Last character - round right corners only
109
+ val path = android.graphics.Path()
110
+ path.addRoundRect(
111
+ rect,
112
+ floatArrayOf(
113
+ 0f, 0f, // top-left
114
+ cornerRadius, cornerRadius, // top-right
115
+ cornerRadius, cornerRadius, // bottom-right
116
+ 0f, 0f // bottom-left
117
+ ),
118
+ android.graphics.Path.Direction.CW
119
+ )
120
+ canvas.drawPath(path, bgPaint)
121
+ }
122
+ else -> {
123
+ // Middle character - no rounded corners, just rectangle
124
+ canvas.drawRect(rect, bgPaint)
125
+ }
126
+ }
127
+
128
+ // Draw text with left padding offset only if first in group
129
+ val textPaint = Paint(paint).apply {
130
+ color = textColor
131
+ isAntiAlias = true
132
+ }
133
+ canvas.drawText(text!!, start, end, x + leftPad, y.toFloat(), textPaint)
134
+ }
135
+ }
136
+
137
+ class HighlightTextView : AppCompatEditText {
138
+ private var characterBackgroundColor: Int = Color.parseColor("#FFFF00")
139
+ private var textColorValue: Int = Color.BLACK
140
+ private var cornerRadius: Float = 4f
141
+ private var charPaddingLeft: Float = 8f
142
+ private var charPaddingRight: Float = 8f
143
+ private var charPaddingTop: Float = 4f
144
+ private var charPaddingBottom: Float = 4f
145
+ private var isUpdatingText: Boolean = false
146
+
147
+ var onTextChangeListener: ((String) -> Unit)? = null
148
+
149
+ constructor(context: Context?) : super(context!!) {
150
+ init()
151
+ }
152
+
153
+ constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) {
154
+ init()
155
+ }
156
+
10
157
  constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
11
- context,
158
+ context!!,
12
159
  attrs,
13
160
  defStyleAttr
14
- )
161
+ ) {
162
+ init()
163
+ }
164
+
165
+ private fun init() {
166
+ setBackgroundColor(Color.TRANSPARENT)
167
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 32f)
168
+ gravity = Gravity.CENTER
169
+ setPadding(20, 20, 20, 20)
170
+ textColorValue = currentTextColor
171
+
172
+ // Enable text wrapping
173
+ maxLines = Int.MAX_VALUE
174
+ isSingleLine = false
175
+ setHorizontallyScrolling(false)
176
+
177
+ addTextChangedListener(object : TextWatcher {
178
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
179
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
180
+ override fun afterTextChanged(s: Editable?) {
181
+ if (!isUpdatingText) {
182
+ onTextChangeListener?.invoke(s?.toString() ?: "")
183
+ applyCharacterBackgrounds()
184
+ }
185
+ }
186
+ })
187
+ }
188
+
189
+ fun setCharacterBackgroundColor(color: Int) {
190
+ characterBackgroundColor = color
191
+ applyCharacterBackgrounds()
192
+ }
193
+
194
+ override fun setTextColor(color: Int) {
195
+ super.setTextColor(color)
196
+ textColorValue = color
197
+ applyCharacterBackgrounds()
198
+ }
199
+
200
+ fun setCharPadding(left: Float, top: Float, right: Float, bottom: Float) {
201
+ charPaddingLeft = left
202
+ charPaddingTop = top
203
+ charPaddingRight = right
204
+ charPaddingBottom = bottom
205
+ applyCharacterBackgrounds()
206
+ }
207
+
208
+ fun setCharPaddingLeft(padding: Float) {
209
+ charPaddingLeft = padding
210
+ applyCharacterBackgrounds()
211
+ }
212
+
213
+ fun setCharPaddingRight(padding: Float) {
214
+ charPaddingRight = padding
215
+ applyCharacterBackgrounds()
216
+ }
217
+
218
+ fun setCharPaddingTop(padding: Float) {
219
+ charPaddingTop = padding
220
+ applyCharacterBackgrounds()
221
+ }
222
+
223
+ fun setCharPaddingBottom(padding: Float) {
224
+ charPaddingBottom = padding
225
+ applyCharacterBackgrounds()
226
+ }
227
+
228
+ fun setTextProp(text: String) {
229
+ if (this.text?.toString() != text) {
230
+ isUpdatingText = true
231
+ setText(text)
232
+ applyCharacterBackgrounds()
233
+ isUpdatingText = false
234
+ }
235
+ }
236
+
237
+ private fun applyCharacterBackgrounds() {
238
+ val text = text?.toString() ?: return
239
+ if (text.isEmpty()) return
240
+
241
+ val spannable = SpannableString(text)
242
+
243
+ // Apply character-by-character for proper line wrapping
244
+ for (i in text.indices) {
245
+ val char = text[i]
246
+
247
+ // Check if this is a space that should be highlighted
248
+ val shouldHighlight = when {
249
+ char == '\n' || char == '\t' -> false // Never highlight newlines or tabs
250
+ char == ' ' -> {
251
+ // Highlight space only if it's a single space (not multiple consecutive)
252
+ val hasSpaceBefore = i > 0 && text[i - 1] == ' '
253
+ val hasSpaceAfter = i < text.length - 1 && text[i + 1] == ' '
254
+ !hasSpaceBefore && !hasSpaceAfter
255
+ }
256
+ else -> true // Highlight all other characters
257
+ }
258
+
259
+ if (shouldHighlight) {
260
+ // Determine if this is the first or last character in a word group
261
+ val isFirst = i == 0 || !shouldHighlightChar(text, i - 1)
262
+ val isLast = i == text.length - 1 || !shouldHighlightChar(text, i + 1)
263
+
264
+ val span = RoundedBackgroundSpan(
265
+ characterBackgroundColor,
266
+ textColorValue,
267
+ charPaddingLeft,
268
+ charPaddingRight,
269
+ charPaddingTop,
270
+ charPaddingBottom,
271
+ cornerRadius,
272
+ isFirst,
273
+ isLast
274
+ )
275
+ spannable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
276
+ }
277
+ }
278
+
279
+ isUpdatingText = true
280
+ setText(spannable)
281
+ setSelection(text.length) // Keep cursor at end
282
+ isUpdatingText = false
283
+ }
284
+
285
+ private fun shouldHighlightChar(text: String, index: Int): Boolean {
286
+ if (index < 0 || index >= text.length) return false
287
+ val char = text[index]
288
+
289
+ return when {
290
+ char == '\n' || char == '\t' -> false
291
+ char == ' ' -> {
292
+ val hasSpaceBefore = index > 0 && text[index - 1] == ' '
293
+ val hasSpaceAfter = index < text.length - 1 && text[index + 1] == ' '
294
+ !hasSpaceBefore && !hasSpaceAfter
295
+ }
296
+ else -> true
297
+ }
298
+ }
15
299
  }
@@ -1,11 +1,17 @@
1
1
  package com.highlighttext
2
2
 
3
3
  import android.graphics.Color
4
+ import android.text.InputType
5
+ import android.view.Gravity
6
+ import com.facebook.react.bridge.Arguments
7
+ import com.facebook.react.bridge.ReactContext
8
+ import com.facebook.react.bridge.WritableMap
4
9
  import com.facebook.react.module.annotations.ReactModule
5
10
  import com.facebook.react.uimanager.SimpleViewManager
6
11
  import com.facebook.react.uimanager.ThemedReactContext
7
12
  import com.facebook.react.uimanager.ViewManagerDelegate
8
13
  import com.facebook.react.uimanager.annotations.ReactProp
14
+ import com.facebook.react.uimanager.events.RCTEventEmitter
9
15
  import com.facebook.react.viewmanagers.HighlightTextViewManagerInterface
10
16
  import com.facebook.react.viewmanagers.HighlightTextViewManagerDelegate
11
17
 
@@ -27,12 +33,135 @@ class HighlightTextViewManager : SimpleViewManager<HighlightTextView>(),
27
33
  }
28
34
 
29
35
  public override fun createViewInstance(context: ThemedReactContext): HighlightTextView {
30
- return HighlightTextView(context)
36
+ val view = HighlightTextView(context)
37
+ view.onTextChangeListener = { text ->
38
+ val event: WritableMap = Arguments.createMap()
39
+ event.putString("text", text)
40
+
41
+ val reactContext = context as ReactContext
42
+ reactContext
43
+ .getJSModule(RCTEventEmitter::class.java)
44
+ .receiveEvent(view.id, "onChange", event)
45
+ }
46
+ return view
31
47
  }
32
48
 
33
49
  @ReactProp(name = "color")
34
50
  override fun setColor(view: HighlightTextView?, color: String?) {
35
- view?.setBackgroundColor(Color.parseColor(color))
51
+ color?.let {
52
+ try {
53
+ view?.setCharacterBackgroundColor(Color.parseColor(it))
54
+ } catch (e: IllegalArgumentException) {
55
+ // Invalid color format, use default
56
+ view?.setCharacterBackgroundColor(Color.parseColor("#FFFF00"))
57
+ }
58
+ }
59
+ }
60
+
61
+ @ReactProp(name = "textColor")
62
+ override fun setTextColor(view: HighlightTextView?, value: String?) {
63
+ value?.let {
64
+ try {
65
+ view?.setTextColor(Color.parseColor(it))
66
+ } catch (e: IllegalArgumentException) {
67
+ // Invalid color format, use default
68
+ view?.setTextColor(Color.BLACK)
69
+ }
70
+ }
71
+ }
72
+
73
+ @ReactProp(name = "textAlign")
74
+ override fun setTextAlign(view: HighlightTextView?, value: String?) {
75
+ // Parse combined alignment (e.g., "top-left", "bottom-center")
76
+ val parts = value?.split("-") ?: emptyList()
77
+ var horizontalAlign = value
78
+
79
+ if (parts.size == 2) {
80
+ // Combined format: use the second part for horizontal alignment
81
+ horizontalAlign = parts[1]
82
+ }
83
+
84
+ // Apply horizontal alignment
85
+ view?.gravity = when (horizontalAlign) {
86
+ "left", "flex-start" -> Gravity.START or Gravity.CENTER_VERTICAL
87
+ "right", "flex-end" -> Gravity.END or Gravity.CENTER_VERTICAL
88
+ "center" -> Gravity.CENTER
89
+ "justify" -> Gravity.START or Gravity.CENTER_VERTICAL // Android doesn't support justify natively
90
+ else -> Gravity.CENTER
91
+ }
92
+ }
93
+
94
+ @ReactProp(name = "verticalAlign")
95
+ override fun setVerticalAlign(view: HighlightTextView?, value: String?) {
96
+ // Android EditText doesn't support vertical alignment as easily as iOS
97
+ // This would require custom implementation with layout adjustments
98
+ // For now, we'll keep the default behavior
99
+ }
100
+
101
+ @ReactProp(name = "fontFamily")
102
+ override fun setFontFamily(view: HighlightTextView?, value: String?) {
103
+ // Font family handling can be added if needed
104
+ }
105
+
106
+ @ReactProp(name = "fontSize")
107
+ override fun setFontSize(view: HighlightTextView?, value: String?) {
108
+ value?.toFloatOrNull()?.let { size ->
109
+ view?.textSize = size
110
+ }
111
+ }
112
+
113
+ @ReactProp(name = "padding")
114
+ override fun setPadding(view: HighlightTextView?, value: String?) {
115
+ value?.toFloatOrNull()?.let { padding ->
116
+ view?.setCharPadding(padding, padding, padding, padding)
117
+ }
118
+ }
119
+
120
+ @ReactProp(name = "paddingLeft")
121
+ override fun setPaddingLeft(view: HighlightTextView?, value: String?) {
122
+ value?.toFloatOrNull()?.let { padding ->
123
+ view?.setCharPaddingLeft(padding)
124
+ }
125
+ }
126
+
127
+ @ReactProp(name = "paddingRight")
128
+ override fun setPaddingRight(view: HighlightTextView?, value: String?) {
129
+ value?.toFloatOrNull()?.let { padding ->
130
+ view?.setCharPaddingRight(padding)
131
+ }
132
+ }
133
+
134
+ @ReactProp(name = "paddingTop")
135
+ override fun setPaddingTop(view: HighlightTextView?, value: String?) {
136
+ value?.toFloatOrNull()?.let { padding ->
137
+ view?.setCharPaddingTop(padding)
138
+ }
139
+ }
140
+
141
+ @ReactProp(name = "paddingBottom")
142
+ override fun setPaddingBottom(view: HighlightTextView?, value: String?) {
143
+ value?.toFloatOrNull()?.let { padding ->
144
+ view?.setCharPaddingBottom(padding)
145
+ }
146
+ }
147
+
148
+ @ReactProp(name = "text")
149
+ override fun setText(view: HighlightTextView?, value: String?) {
150
+ view?.setTextProp(value ?: "")
151
+ }
152
+
153
+ @ReactProp(name = "isEditable")
154
+ override fun setIsEditable(view: HighlightTextView?, value: Boolean) {
155
+ view?.apply {
156
+ isFocusable = value
157
+ isFocusableInTouchMode = value
158
+ isEnabled = value
159
+ inputType = if (value) {
160
+ InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
161
+ } else {
162
+ InputType.TYPE_NULL
163
+ }
164
+ }
36
165
  }
37
166
 
38
167
  companion object {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-highlight-text-view",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",