@chaitrabhairappa/react-native-rich-text-editor 2.1.2 → 3.1.0

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.
@@ -1,6 +1,7 @@
1
1
  package com.richtext.editor
2
2
 
3
3
  import android.content.Context
4
+ import android.content.ClipboardManager
4
5
  import android.graphics.Canvas
5
6
  import android.graphics.Color
6
7
  import android.graphics.Paint
@@ -20,11 +21,22 @@ import android.view.View
20
21
  import android.view.ViewGroup
21
22
  import android.view.WindowManager
22
23
  import android.view.inputmethod.EditorInfo
24
+ import android.view.inputmethod.InputConnection
23
25
  import android.widget.PopupWindow
24
26
  import android.widget.FrameLayout
25
27
  import android.app.AlertDialog
28
+ import android.net.Uri
26
29
  import android.widget.EditText
27
30
  import android.widget.LinearLayout
31
+ import android.webkit.MimeTypeMap
32
+ import androidx.activity.ComponentActivity
33
+ import androidx.activity.result.ActivityResultLauncher
34
+ import androidx.activity.result.contract.ActivityResultContracts
35
+ import androidx.core.view.ContentInfoCompat
36
+ import androidx.core.view.ViewCompat
37
+ import androidx.core.view.inputmethod.EditorInfoCompat
38
+ import androidx.core.view.inputmethod.InputConnectionCompat
39
+ import androidx.core.view.inputmethod.InputContentInfoCompat
28
40
  import com.facebook.react.bridge.Arguments
29
41
  import com.facebook.react.bridge.ReactContext
30
42
  import com.facebook.react.bridge.WritableArray
@@ -34,6 +46,10 @@ import com.facebook.react.uimanager.events.RCTEventEmitter
34
46
  class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompatEditText(context),
35
47
  FloatingToolbar.ToolbarActionListener {
36
48
 
49
+ companion object {
50
+ private const val MEDIA_PLACEHOLDER_CHAR = '\uFFFC'
51
+ }
52
+
37
53
  private var placeholder: String = ""
38
54
  private var maxHeightValue: Int = 0
39
55
  private var numberOfLinesValue: Int = 0
@@ -49,6 +65,32 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
49
65
  private var previousText: String = ""
50
66
  private var pendingDelta: Map<String, Any>? = null
51
67
  private var pendingPrefixDeletion: Pair<Int, Int>? = null // (lineStart, prefixLength) for backspace-in-prefix
68
+ private var imagePickerLauncher: ActivityResultLauncher<String>? = null
69
+ private val imagePickerLauncherKey = "richtext_image_picker_${hashCode()}"
70
+ private val mediaAttachmentSupport by lazy {
71
+ MediaAttachmentSupport(
72
+ context = context,
73
+ density = density,
74
+ placeholderChar = MEDIA_PLACEHOLDER_CHAR,
75
+ getLineSpacingMultiplier = { lineSpacingMultiplier },
76
+ getTargetWidthPx = {
77
+ val contentWidth = width - totalPaddingLeft - totalPaddingRight
78
+ if (contentWidth > 0) {
79
+ contentWidth
80
+ } else {
81
+ (context.resources.displayMetrics.widthPixels - totalPaddingLeft - totalPaddingRight)
82
+ .coerceAtLeast((120 * density).toInt())
83
+ }
84
+ },
85
+ editableProvider = { text },
86
+ runOnUiThread = { action -> post(action) },
87
+ onMediaSpansUpdated = {
88
+ invalidate()
89
+ requestLayout()
90
+ post { updateContentSize() }
91
+ }
92
+ )
93
+ }
52
94
 
53
95
  // For flat variant bottom border
54
96
  private val bottomBorderPaint = Paint().apply {
@@ -103,6 +145,7 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
103
145
  isFocusable = true
104
146
  isFocusableInTouchMode = true
105
147
  inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE
148
+ setupImageReceiveContentHandler()
106
149
 
107
150
  // Disable vertical scrolling by default
108
151
  isVerticalScrollBarEnabled = false
@@ -168,6 +211,9 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
168
211
  if (!handled) {
169
212
  handled = autoContinueListOnEnter(s)
170
213
  }
214
+ if (!handled) {
215
+ handled = applyInlineStyleShortcut(s)
216
+ }
171
217
  if (!handled) {
172
218
  isInternalChange = true
173
219
  renumberNumberedLists()
@@ -194,6 +240,51 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
194
240
  isInitialized = true
195
241
  }
196
242
 
243
+ // Handle image pasting from clipboard and input method
244
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
245
+ val inputConnection = super.onCreateInputConnection(outAttrs) ?: return null
246
+ EditorInfoCompat.setContentMimeTypes(outAttrs, arrayOf("image/*"))
247
+
248
+ return InputConnectionCompat.createWrapper(
249
+ inputConnection,
250
+ outAttrs
251
+ ) { inputContentInfo: InputContentInfoCompat, flags: Int, _ ->
252
+ if ((flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
253
+ runCatching { inputContentInfo.requestPermission() }
254
+ }
255
+
256
+ val uri = inputContentInfo.contentUri
257
+ if (isImageUri(uri)) {
258
+ post { insertMediaAttachmentBlock(uri.toString()) }
259
+ true
260
+ } else {
261
+ false
262
+ }
263
+ }
264
+ }
265
+
266
+ // Handle receiving images via drag-and-drop or clipboard paste (Android 13+)
267
+ private fun setupImageReceiveContentHandler() {
268
+ ViewCompat.setOnReceiveContentListener(this, arrayOf("image/*")) { _, payload: ContentInfoCompat ->
269
+ val clip = payload.clip
270
+ if (clip == null || clip.itemCount == 0) {
271
+ return@setOnReceiveContentListener payload
272
+ }
273
+
274
+ var handled = false
275
+ for (index in 0 until clip.itemCount) {
276
+ val item = clip.getItemAt(index)
277
+ val uri = item.uri
278
+ if (uri != null && isImageUri(uri)) {
279
+ insertMediaAttachmentBlock(uri.toString())
280
+ handled = true
281
+ }
282
+ }
283
+
284
+ if (handled) null else payload
285
+ }
286
+ }
287
+
197
288
  private fun setupToolbar() {
198
289
  floatingToolbar = FloatingToolbar(context).apply {
199
290
  listener = this@RichTextEditorView
@@ -571,6 +662,49 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
571
662
  return super.onTouchEvent(event)
572
663
  }
573
664
 
665
+ // Handle image pasting from clipboard (Android 13+ also sends via onReceiveContent)
666
+ override fun onTextContextMenuItem(id: Int): Boolean {
667
+ val isPasteAction = id == android.R.id.paste || id == android.R.id.pasteAsPlainText
668
+ if (!isPasteAction) {
669
+ return super.onTextContextMenuItem(id)
670
+ }
671
+
672
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
673
+ ?: return super.onTextContextMenuItem(id)
674
+ val primaryClip = clipboard.primaryClip ?: return super.onTextContextMenuItem(id)
675
+
676
+ val imageUris = mutableListOf<Uri>()
677
+ for (index in 0 until primaryClip.itemCount) {
678
+ val item = primaryClip.getItemAt(index)
679
+ item.uri?.let { uri ->
680
+ if (isImageUri(uri)) {
681
+ imageUris.add(uri)
682
+ }
683
+ }
684
+
685
+ val maybeUriText = item.text?.toString()?.trim().orEmpty()
686
+ if (maybeUriText.isNotEmpty()) {
687
+ runCatching { Uri.parse(maybeUriText) }
688
+ .getOrNull()
689
+ ?.let { parsed ->
690
+ if (parsed.scheme != null && isImageUri(parsed)) {
691
+ imageUris.add(parsed)
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ if (imageUris.isEmpty()) {
698
+ return super.onTextContextMenuItem(id)
699
+ }
700
+
701
+ imageUris
702
+ .distinctBy { it.toString() }
703
+ .forEach { insertMediaAttachmentBlock(it.toString()) }
704
+
705
+ return true
706
+ }
707
+
574
708
  private fun selectWordAtPosition(x: Float, y: Float) {
575
709
  val layout = layout ?: return
576
710
  val textContent = text?.toString() ?: return
@@ -601,6 +735,38 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
601
735
  }
602
736
  }
603
737
 
738
+ private fun isImageUri(uri: Uri): Boolean {
739
+ val scheme = uri.scheme?.lowercase()
740
+
741
+ if (scheme == "http" || scheme == "https") {
742
+ val path = uri.path.orEmpty()
743
+ return path.endsWith(".png", true) ||
744
+ path.endsWith(".jpg", true) ||
745
+ path.endsWith(".jpeg", true) ||
746
+ path.endsWith(".webp", true) ||
747
+ path.endsWith(".gif", true) ||
748
+ path.endsWith(".bmp", true)
749
+ }
750
+
751
+ val mimeType = runCatching {
752
+ context.contentResolver.getType(uri)
753
+ }.getOrNull()
754
+
755
+ if (mimeType?.startsWith("image/") == true) {
756
+ return true
757
+ }
758
+
759
+ val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
760
+ if (!extension.isNullOrEmpty()) {
761
+ val guessedMime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())
762
+ if (guessedMime?.startsWith("image/") == true) {
763
+ return true
764
+ }
765
+ }
766
+
767
+ return false
768
+ }
769
+
604
770
  override fun onDraw(canvas: Canvas) {
605
771
  super.onDraw(canvas)
606
772
 
@@ -878,6 +1044,18 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
878
1044
  val textContent = block["text"] as? String ?: ""
879
1045
  val blockType = block["type"] as? String ?: "paragraph"
880
1046
 
1047
+ if (blockType == "mediaAttachment") {
1048
+ numberedListCounter = 1
1049
+ val mediaData = mediaAttachmentSupport.parseMediaData(block) ?: return@forEachIndexed
1050
+ currentOffset = mediaAttachmentSupport.appendMediaBlock(
1051
+ spannable = spannable,
1052
+ currentOffset = currentOffset,
1053
+ mediaData = mediaData,
1054
+ appendTrailingNewline = index < blocks.size - 1
1055
+ )
1056
+ return@forEachIndexed
1057
+ }
1058
+
881
1059
  // Add list prefix based on block type
882
1060
  val prefix = when (blockType) {
883
1061
  "bullet", "bulletList" -> "• "
@@ -1042,6 +1220,19 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1042
1220
  val lines = textContent.split("\n")
1043
1221
  var currentIndex = 0
1044
1222
  lines.forEach { line ->
1223
+ val lineStart = currentIndex
1224
+ val lineEnd = currentIndex + line.length
1225
+
1226
+ val mediaSpan = mediaAttachmentSupport.findMediaAttachmentSpan(spannable, lineStart, lineEnd)
1227
+ if (mediaSpan != null && mediaAttachmentSupport.isMediaLine(line)) {
1228
+ val mediaData = mediaSpan.toMediaAttachmentData()
1229
+ val block = mediaAttachmentSupport.createWritableMediaBlock(mediaData)
1230
+
1231
+ blocks.pushMap(block)
1232
+ currentIndex += line.length + 1
1233
+ return@forEach
1234
+ }
1235
+
1045
1236
  val (blockType, displayText) = detectBlockType(line)
1046
1237
  val prefixLen = line.length - displayText.length
1047
1238
 
@@ -1050,9 +1241,8 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1050
1241
  block.putString("text", displayText)
1051
1242
 
1052
1243
  val stylesArray = Arguments.createArray()
1053
- val lineStart = currentIndex + prefixLen
1054
- val lineEnd = currentIndex + line.length
1055
- extractStylesForRange(spannable, lineStart, lineEnd).forEach { style ->
1244
+ val styleStart = currentIndex + prefixLen
1245
+ extractStylesForRange(spannable, styleStart, lineEnd).forEach { style ->
1056
1246
  val styleMap = Arguments.createMap()
1057
1247
  styleMap.putString("style", style["style"] as String)
1058
1248
  styleMap.putInt("start", style["start"] as Int)
@@ -1076,6 +1266,19 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1076
1266
  val lines = textContent.split("\n")
1077
1267
  var currentIndex = 0
1078
1268
  lines.forEach { line ->
1269
+ val lineStart = currentIndex
1270
+ val lineEnd = currentIndex + line.length
1271
+
1272
+ val mediaSpan = mediaAttachmentSupport.findMediaAttachmentSpan(spannable, lineStart, lineEnd)
1273
+ if (mediaSpan != null && mediaAttachmentSupport.isMediaLine(line)) {
1274
+ val mediaData = mediaSpan.toMediaAttachmentData()
1275
+ val block = mediaAttachmentSupport.createJsonMediaBlock(mediaData)
1276
+
1277
+ jsonArray.put(block)
1278
+ currentIndex += line.length + 1
1279
+ return@forEach
1280
+ }
1281
+
1079
1282
  val (blockType, displayText) = detectBlockType(line)
1080
1283
  val prefixLen = line.length - displayText.length
1081
1284
 
@@ -1084,9 +1287,8 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1084
1287
  block.put("text", displayText)
1085
1288
 
1086
1289
  val stylesJson = org.json.JSONArray()
1087
- val lineStart = currentIndex + prefixLen
1088
- val lineEnd = currentIndex + line.length
1089
- extractStylesForRange(spannable, lineStart, lineEnd).forEach { style ->
1290
+ val styleStart = currentIndex + prefixLen
1291
+ extractStylesForRange(spannable, styleStart, lineEnd).forEach { style ->
1090
1292
  val styleObj = org.json.JSONObject()
1091
1293
  styleObj.put("style", style["style"])
1092
1294
  styleObj.put("start", style["start"])
@@ -1244,6 +1446,10 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1244
1446
  toggleChecklistPrefix()
1245
1447
  }
1246
1448
 
1449
+ override fun onMediaAttachmentClick() {
1450
+ openImagePicker()
1451
+ }
1452
+
1247
1453
  override fun onLinkClick() {
1248
1454
  promptInsertLink()
1249
1455
  }
@@ -1533,6 +1739,64 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1533
1739
  return false
1534
1740
  }
1535
1741
 
1742
+ private fun applyInlineStyleShortcut(s: Editable?): Boolean {
1743
+ if (s == null) return false
1744
+
1745
+ val start = selectionStart
1746
+ val end = selectionEnd
1747
+ if (start != end || start <= 0 || start > s.length) return false
1748
+
1749
+ var lineStart = start - 1
1750
+ while (lineStart > 0 && s[lineStart - 1] != '\n') {
1751
+ lineStart--
1752
+ }
1753
+
1754
+ val textBeforeCursor = s.subSequence(lineStart, start).toString()
1755
+ if (textBeforeCursor.length < 3) return false
1756
+
1757
+ data class ShortcutPattern(
1758
+ val regex: Regex,
1759
+ val apply: (Editable, Int, Int) -> Unit,
1760
+ )
1761
+
1762
+ val patterns = listOf(
1763
+ ShortcutPattern(Regex("(^|\\s)\\*([^*\\n]+)\\*$")) { editable, spanStart, spanEnd ->
1764
+ editable.setSpan(StyleSpan(Typeface.BOLD), spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
1765
+ },
1766
+ ShortcutPattern(Regex("(^|\\s)_([^_\\n]+)_$")) { editable, spanStart, spanEnd ->
1767
+ editable.setSpan(StyleSpan(Typeface.ITALIC), spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
1768
+ },
1769
+ ShortcutPattern(Regex("(^|\\s)~([^~\\n]+)~$")) { editable, spanStart, spanEnd ->
1770
+ editable.setSpan(StrikethroughSpan(), spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
1771
+ }
1772
+ )
1773
+
1774
+ for (pattern in patterns) {
1775
+ val match = pattern.regex.find(textBeforeCursor) ?: continue
1776
+ val prefixWhitespaceLen = match.groupValues[1].length
1777
+ val styledText = match.groupValues[2]
1778
+ if (styledText.isEmpty()) continue
1779
+
1780
+ val markerStartInLine = match.range.first + prefixWhitespaceLen
1781
+ val markerStart = lineStart + markerStartInLine
1782
+ if (markerStart < 0 || markerStart > start) continue
1783
+
1784
+ isInternalChange = true
1785
+ s.replace(markerStart, start, styledText)
1786
+
1787
+ val styleStart = markerStart
1788
+ val styleEnd = markerStart + styledText.length
1789
+ if (styleStart < styleEnd && styleEnd <= s.length) {
1790
+ pattern.apply(s, styleStart, styleEnd)
1791
+ setSelection(styleEnd)
1792
+ }
1793
+ isInternalChange = false
1794
+ return true
1795
+ }
1796
+
1797
+ return false
1798
+ }
1799
+
1536
1800
  private fun renumberNumberedLists() {
1537
1801
  val editable = text ?: return
1538
1802
  val fullText = editable.toString()
@@ -1621,6 +1885,47 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1621
1885
  }
1622
1886
  }
1623
1887
 
1888
+ private fun openImagePicker() {
1889
+ val reactContext = context as? ReactContext ?: return
1890
+ val activity = reactContext.currentActivity ?: return
1891
+
1892
+ if (activity !is ComponentActivity) {
1893
+ return
1894
+ }
1895
+
1896
+ if (imagePickerLauncher == null) {
1897
+ imagePickerLauncher = activity.activityResultRegistry.register(
1898
+ imagePickerLauncherKey,
1899
+ ActivityResultContracts.GetContent()
1900
+ ) { uri: Uri? ->
1901
+ if (uri == null) return@register
1902
+ insertMediaAttachmentBlock(uri.toString())
1903
+ }
1904
+ }
1905
+
1906
+ imagePickerLauncher?.launch("image/*")
1907
+ }
1908
+
1909
+ private fun insertMediaAttachmentBlock(uri: String) {
1910
+ val editable = text ?: return
1911
+ var insertPos = selectionStart.coerceIn(0, editable.length)
1912
+
1913
+ isInternalChange = true
1914
+ val nextPos = mediaAttachmentSupport.insertMediaAttachmentBlock(editable, insertPos, uri)
1915
+ setSelection(nextPos.coerceAtMost(editable.length))
1916
+
1917
+ isInternalChange = false
1918
+ sendContentChange()
1919
+ saveToUndoStack()
1920
+ post { updateContentSize() }
1921
+ }
1922
+
1923
+ fun insertMediaAttachment(uri: String?) {
1924
+ val safeUri = uri?.trim().orEmpty()
1925
+ if (safeUri.isEmpty()) return
1926
+ insertMediaAttachmentBlock(safeUri)
1927
+ }
1928
+
1624
1929
  private fun promptInsertLink() {
1625
1930
  val context = context
1626
1931
  val builder = AlertDialog.Builder(context)
@@ -1828,6 +2133,8 @@ class RichTextEditorView(context: Context) : androidx.appcompat.widget.AppCompat
1828
2133
 
1829
2134
  override fun onDetachedFromWindow() {
1830
2135
  super.onDetachedFromWindow()
2136
+ imagePickerLauncher?.unregister()
2137
+ imagePickerLauncher = null
1831
2138
  hideToolbar()
1832
2139
  toolbarPopup = null
1833
2140
  floatingToolbar = null
@@ -11,14 +11,39 @@ class RichTextEditorViewManager : SimpleViewManager<RichTextEditorView>() {
11
11
 
12
12
  companion object {
13
13
  const val NAME = "RichTextEditorView"
14
+ private const val COMMAND_INSERT_MEDIA_ATTACHMENT = 1
14
15
  }
15
16
 
16
17
  override fun getName(): String = NAME
17
18
 
19
+ override fun getCommandsMap(): MutableMap<String, Int> {
20
+ return mutableMapOf(
21
+ "insertMediaAttachment" to COMMAND_INSERT_MEDIA_ATTACHMENT
22
+ )
23
+ }
24
+
18
25
  override fun createViewInstance(reactContext: ThemedReactContext): RichTextEditorView {
19
26
  return RichTextEditorView(reactContext)
20
27
  }
21
28
 
29
+ override fun receiveCommand(view: RichTextEditorView, commandId: String?, args: ReadableArray?) {
30
+ when (commandId) {
31
+ "insertMediaAttachment" -> {
32
+ val uri = args?.getString(0)
33
+ view.insertMediaAttachment(uri)
34
+ }
35
+ }
36
+ }
37
+
38
+ override fun receiveCommand(view: RichTextEditorView, commandId: Int, args: ReadableArray?) {
39
+ when (commandId) {
40
+ COMMAND_INSERT_MEDIA_ATTACHMENT -> {
41
+ val uri = args?.getString(0)
42
+ view.insertMediaAttachment(uri)
43
+ }
44
+ }
45
+ }
46
+
22
47
  @ReactProp(name = "placeholder")
23
48
  fun setPlaceholder(view: RichTextEditorView, placeholder: String?) {
24
49
  try {
@@ -116,6 +141,18 @@ class RichTextEditorViewManager : SimpleViewManager<RichTextEditorView>() {
116
141
  }
117
142
  }
118
143
  blockMap["styles"] = stylesList
144
+
145
+ val mediaAttachment = block.optJSONObject("mediaAttachment")
146
+ if (mediaAttachment != null) {
147
+ val mediaMap = mutableMapOf<String, Any>()
148
+ mediaMap["kind"] = mediaAttachment.optString("kind", "image")
149
+ mediaMap["uri"] = mediaAttachment.optString("uri", "")
150
+ mediaMap["width"] = mediaAttachment.optInt("width", 100)
151
+ mediaMap["height"] = mediaAttachment.optInt("height", 100)
152
+ mediaMap["alt"] = mediaAttachment.optString("alt", "")
153
+ blockMap["mediaAttachment"] = mediaMap
154
+ }
155
+
119
156
  blocksList.add(blockMap)
120
157
  }
121
158
  view.post {
@@ -0,0 +1,9 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path
7
+ android:fillColor="#FFFFFF"
8
+ android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V5h14v14zM8.5,11A1.5,1.5 0 1,0 8.5,8A1.5,1.5 0 1,0 8.5,11zM6,17h12l-3.75,-5 -2.75,3.54 -1.75,-2.04L6,17z" />
9
+ </vector>