Skip to content

Commit 274ed44

Browse files
authored
feat: add custom context menu items for EnrichedMarkdownText and EnrichedMarkdownInput (#190)
* feat: add custom context menu items for EnrichedMarkdownText and EnrichedMarkdownInput * chore: add TODOs for API_AVAILABLE check
1 parent 9820818 commit 274ed44

37 files changed

Lines changed: 1093 additions & 36 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- 🔀 [Markdown Streaming](docs/MARKDOWN_STREAMING.md) support (via [react-native-streamdown](https://github.com/software-mansion-labs/react-native-streamdown))
1515
- 🎨 Fully customizable styles for all elements
1616
- ✨ Text selection and copy support
17+
- 📌 Custom text selection context menu items
1718
- 🔗 Interactive link handling
1819
- 🖼️ Native image interactions (iOS: Copy, Save to Camera Roll)
1920
- 🌐 Native platform features (Translate, Look Up, Search Web, Share)

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class EnrichedMarkdown
7575
private var onLinkPressCallback: ((String) -> Unit)? = null
7676
private var onLinkLongPressCallback: ((String) -> Unit)? = null
7777
private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null
78+
private var contextMenuItemTexts: List<String> = emptyList()
79+
var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
7880

7981
fun setMarkdownContent(markdown: String) {
8082
if (currentMarkdown == markdown) return
@@ -147,6 +149,22 @@ class EnrichedMarkdown
147149
onTaskListItemPressCallback = callback
148150
}
149151

152+
fun setContextMenuItems(items: List<String>) {
153+
contextMenuItemTexts = items
154+
segmentViews.filterIsInstance<EnrichedMarkdownInternalText>().forEach {
155+
it.setContextMenuItems(items, ::forwardContextMenuItemPress)
156+
}
157+
}
158+
159+
private fun forwardContextMenuItemPress(
160+
itemText: String,
161+
selectedText: String,
162+
selectionStart: Int,
163+
selectionEnd: Int,
164+
) {
165+
onContextMenuItemPressCallback?.invoke(itemText, selectedText, selectionStart, selectionEnd)
166+
}
167+
150168
private fun recreateStyleConfig() {
151169
markdownStyleMap?.let {
152170
markdownStyle = StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier)
@@ -261,6 +279,10 @@ class EnrichedMarkdown
261279
onTaskListItemPressCallback = { taskIndex, checked, itemText ->
262280
this@EnrichedMarkdown.onTaskListItemPressCallback?.invoke(taskIndex, checked, itemText)
263281
}
282+
283+
if (contextMenuItemTexts.isNotEmpty()) {
284+
setContextMenuItems(contextMenuItemTexts, ::forwardContextMenuItemPress)
285+
}
264286
}
265287

266288
private fun createTableView(

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMeth
1111
import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
1212
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
1313
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
14+
import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
1415
import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView
1516
import com.swmansion.enriched.markdown.views.BlockSegmentView
1617

@@ -36,8 +37,19 @@ class EnrichedMarkdownInternalText
3637

3738
override val segmentMarginBottom: Int get() = lastElementMarginBottom.toInt()
3839

40+
private var contextMenuItemTexts: List<String> = emptyList()
41+
private var onContextMenuItemPress: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
42+
3943
init {
4044
setupAsMarkdownTextView(accessibilityHelper)
45+
customSelectionActionModeCallback =
46+
createSelectionActionModeCallback(
47+
this,
48+
getCustomItemTexts = { contextMenuItemTexts },
49+
onCustomItemPress = { itemText, selectedText, start, end ->
50+
onContextMenuItemPress?.invoke(itemText, selectedText, start, end)
51+
},
52+
)
4153
}
4254

4355
fun applyStyledText(styledText: CharSequence) {
@@ -54,6 +66,14 @@ class EnrichedMarkdownInternalText
5466
applySelectableState(selectable)
5567
}
5668

69+
fun setContextMenuItems(
70+
items: List<String>,
71+
onPress: (itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit,
72+
) {
73+
contextMenuItemTexts = items
74+
onContextMenuItemPress = onPress
75+
}
76+
5777
override fun onTouchEvent(event: MotionEvent): Boolean {
5878
if (checkboxTouchHelper.onTouchEvent(event)) {
5979
if (event.action == MotionEvent.ACTION_DOWN) {

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.swmansion.enriched.markdown
22

33
import android.content.Context
4+
import com.facebook.react.bridge.ReadableArray
45
import com.facebook.react.bridge.ReadableMap
56
import com.facebook.react.module.annotations.ReactModule
67
import com.facebook.react.uimanager.SimpleViewManager
@@ -11,6 +12,7 @@ import com.facebook.react.uimanager.annotations.ReactProp
1112
import com.facebook.react.viewmanagers.EnrichedMarkdownManagerDelegate
1213
import com.facebook.react.viewmanagers.EnrichedMarkdownManagerInterface
1314
import com.facebook.yoga.YogaMeasureMode
15+
import com.swmansion.enriched.markdown.events.ContextMenuItemPressEvent
1416
import com.swmansion.enriched.markdown.events.LinkLongPressEvent
1517
import com.swmansion.enriched.markdown.events.LinkPressEvent
1618
import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
@@ -28,14 +30,22 @@ class EnrichedMarkdownManager :
2830

2931
override fun getName(): String = NAME
3032

31-
override fun createViewInstance(reactContext: ThemedReactContext): EnrichedMarkdown = EnrichedMarkdown(reactContext)
33+
override fun createViewInstance(reactContext: ThemedReactContext): EnrichedMarkdown {
34+
val view = EnrichedMarkdown(reactContext)
35+
view.onContextMenuItemPressCallback = { itemText, selectedText, selectionStart, selectionEnd ->
36+
emitContextMenuItemPress(view, itemText, selectedText, selectionStart, selectionEnd)
37+
}
38+
return view
39+
}
3240

3341
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
3442
val map = mutableMapOf<String, Any>()
3543
map[LinkPressEvent.EVENT_NAME] = mapOf("registrationName" to LinkPressEvent.EVENT_NAME)
3644
map[LinkLongPressEvent.EVENT_NAME] = mapOf("registrationName" to LinkLongPressEvent.EVENT_NAME)
3745
map[TaskListItemPressEvent.EVENT_NAME] =
3846
mapOf("registrationName" to TaskListItemPressEvent.EVENT_NAME)
47+
map[ContextMenuItemPressEvent.EVENT_NAME] =
48+
mapOf("registrationName" to ContextMenuItemPressEvent.EVENT_NAME)
3949
return map
4050
}
4151

@@ -132,6 +142,16 @@ class EnrichedMarkdownManager :
132142
// Currently only supported with flavor="commonmark" (single TextView).
133143
}
134144

145+
@ReactProp(name = "contextMenuItems")
146+
override fun setContextMenuItems(
147+
view: EnrichedMarkdown?,
148+
value: ReadableArray?,
149+
) {
150+
if (view == null) return
151+
val items = (0 until (value?.size() ?: 0)).mapNotNull { value?.getMap(it)?.getString("text") }
152+
view.setContextMenuItems(items)
153+
}
154+
135155
override fun setPadding(
136156
view: EnrichedMarkdown,
137157
left: Int,
@@ -143,6 +163,19 @@ class EnrichedMarkdownManager :
143163
view.setPadding(left, top, right, bottom)
144164
}
145165

166+
private fun emitContextMenuItemPress(
167+
view: EnrichedMarkdown,
168+
itemText: String,
169+
selectedText: String,
170+
selectionStart: Int,
171+
selectionEnd: Int,
172+
) {
173+
val context = view.context as com.facebook.react.bridge.ReactContext
174+
val surfaceId = UIManagerHelper.getSurfaceId(context)
175+
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
176+
eventDispatcher?.dispatchEvent(ContextMenuItemPressEvent(surfaceId, view.id, itemText, selectedText, selectionStart, selectionEnd))
177+
}
178+
146179
private fun emitOnLinkPress(
147180
view: EnrichedMarkdown,
148181
url: String,

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMeth
2222
import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
2323
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
2424
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
25+
import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
2526
import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
2627
import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
2728
import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView
@@ -53,6 +54,9 @@ class EnrichedMarkdownText
5354
// Accessibility helper for TalkBack support
5455
private val accessibilityHelper = MarkdownAccessibilityHelper(this)
5556

57+
private var contextMenuItemTexts: List<String> = emptyList()
58+
var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
59+
5660
var markdownStyle: StyleConfig? = null
5761
private set
5862

@@ -75,6 +79,14 @@ class EnrichedMarkdownText
7579

7680
init {
7781
setupAsMarkdownTextView(accessibilityHelper)
82+
customSelectionActionModeCallback =
83+
createSelectionActionModeCallback(
84+
this,
85+
getCustomItemTexts = { contextMenuItemTexts },
86+
onCustomItemPress = { itemText, selectedText, start, end ->
87+
onContextMenuItemPressCallback?.invoke(itemText, selectedText, start, end)
88+
},
89+
)
7890
}
7991

8092
fun setMarkdownContent(markdown: String) {
@@ -232,6 +244,10 @@ class EnrichedMarkdownText
232244
}
233245
}
234246

247+
fun setContextMenuItems(items: List<String>) {
248+
contextMenuItemTexts = items
249+
}
250+
235251
fun setIsSelectable(selectable: Boolean) {
236252
applySelectableState(selectable)
237253
}

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.swmansion.enriched.markdown
22

33
import android.content.Context
4+
import com.facebook.react.bridge.ReadableArray
45
import com.facebook.react.bridge.ReadableMap
56
import com.facebook.react.module.annotations.ReactModule
67
import com.facebook.react.uimanager.SimpleViewManager
@@ -11,6 +12,7 @@ import com.facebook.react.uimanager.annotations.ReactProp
1112
import com.facebook.react.viewmanagers.EnrichedMarkdownTextManagerDelegate
1213
import com.facebook.react.viewmanagers.EnrichedMarkdownTextManagerInterface
1314
import com.facebook.yoga.YogaMeasureMode
15+
import com.swmansion.enriched.markdown.events.ContextMenuItemPressEvent
1416
import com.swmansion.enriched.markdown.events.LinkLongPressEvent
1517
import com.swmansion.enriched.markdown.events.LinkPressEvent
1618
import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
@@ -29,7 +31,13 @@ class EnrichedMarkdownTextManager :
2931

3032
override fun getName(): String = NAME
3133

32-
override fun createViewInstance(reactContext: ThemedReactContext): EnrichedMarkdownText = EnrichedMarkdownText(reactContext)
34+
override fun createViewInstance(reactContext: ThemedReactContext): EnrichedMarkdownText {
35+
val view = EnrichedMarkdownText(reactContext)
36+
view.onContextMenuItemPressCallback = { itemText, selectedText, selectionStart, selectionEnd ->
37+
emitContextMenuItemPress(view, itemText, selectedText, selectionStart, selectionEnd)
38+
}
39+
return view
40+
}
3341

3442
override fun onDropViewInstance(view: EnrichedMarkdownText) {
3543
super.onDropViewInstance(view)
@@ -44,6 +52,8 @@ class EnrichedMarkdownTextManager :
4452
map[LinkLongPressEvent.EVENT_NAME] = mapOf("registrationName" to LinkLongPressEvent.EVENT_NAME)
4553
map[TaskListItemPressEvent.EVENT_NAME] =
4654
mapOf("registrationName" to TaskListItemPressEvent.EVENT_NAME)
55+
map[ContextMenuItemPressEvent.EVENT_NAME] =
56+
mapOf("registrationName" to ContextMenuItemPressEvent.EVENT_NAME)
4757
return map
4858
}
4959

@@ -152,6 +162,16 @@ class EnrichedMarkdownTextManager :
152162
view?.setStreamingAnimation(streamingAnimation)
153163
}
154164

165+
@ReactProp(name = "contextMenuItems")
166+
override fun setContextMenuItems(
167+
view: EnrichedMarkdownText?,
168+
value: ReadableArray?,
169+
) {
170+
if (view == null) return
171+
val items = (0 until (value?.size() ?: 0)).mapNotNull { value?.getMap(it)?.getString("text") }
172+
view.setContextMenuItems(items)
173+
}
174+
155175
override fun setPadding(
156176
view: EnrichedMarkdownText,
157177
left: Int,
@@ -187,6 +207,19 @@ class EnrichedMarkdownTextManager :
187207
eventDispatcher?.dispatchEvent(event)
188208
}
189209

210+
private fun emitContextMenuItemPress(
211+
view: EnrichedMarkdownText,
212+
itemText: String,
213+
selectedText: String,
214+
selectionStart: Int,
215+
selectionEnd: Int,
216+
) {
217+
val context = view.context as com.facebook.react.bridge.ReactContext
218+
val surfaceId = UIManagerHelper.getSurfaceId(context)
219+
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
220+
eventDispatcher?.dispatchEvent(ContextMenuItemPressEvent(surfaceId, view.id, itemText, selectedText, selectionStart, selectionEnd))
221+
}
222+
190223
private fun emitOnTaskListItemPress(
191224
view: EnrichedMarkdownText,
192225
taskIndex: Int,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.swmansion.enriched.markdown.events
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
7+
class ContextMenuItemPressEvent(
8+
surfaceId: Int,
9+
viewId: Int,
10+
private val itemText: String,
11+
private val selectedText: String,
12+
private val selectionStart: Int,
13+
private val selectionEnd: Int,
14+
) : Event<ContextMenuItemPressEvent>(surfaceId, viewId) {
15+
override fun getEventName(): String = EVENT_NAME
16+
17+
override fun getEventData(): WritableMap =
18+
Arguments.createMap().apply {
19+
putString("itemText", itemText)
20+
putString("selectedText", selectedText)
21+
putInt("selectionStart", selectionStart)
22+
putInt("selectionEnd", selectionEnd)
23+
}
24+
25+
companion object {
26+
const val EVENT_NAME = "onContextMenuItemPress"
27+
}
28+
}

android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownInputManager.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.swmansion.enriched.markdown.input
22

33
import android.content.Context
44
import android.graphics.Color
5+
import com.facebook.react.bridge.ReadableArray
56
import com.facebook.react.bridge.ReadableMap
67
import com.facebook.react.module.annotations.ReactModule
78
import com.facebook.react.uimanager.ReactStylesDiffMap
@@ -17,6 +18,7 @@ import com.swmansion.enriched.markdown.input.events.OnChangeMarkdownEvent
1718
import com.swmansion.enriched.markdown.input.events.OnChangeSelectionEvent
1819
import com.swmansion.enriched.markdown.input.events.OnChangeStateEvent
1920
import com.swmansion.enriched.markdown.input.events.OnChangeTextEvent
21+
import com.swmansion.enriched.markdown.input.events.OnContextMenuItemPressEvent
2022
import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
2123
import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
2224
import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
@@ -81,6 +83,7 @@ class EnrichedMarkdownInputManager :
8183
OnRequestMarkdownResultEvent.EVENT_NAME,
8284
OnInputFocusEvent.EVENT_NAME,
8385
OnInputBlurEvent.EVENT_NAME,
86+
OnContextMenuItemPressEvent.EVENT_NAME,
8487
).associateWithTo(mutableMapOf()) { name -> mapOf("registrationName" to name) }
8588

8689
// Props
@@ -234,6 +237,16 @@ class EnrichedMarkdownInputManager :
234237
view?.emitMarkdown = value
235238
}
236239

240+
@ReactProp(name = "contextMenuItems")
241+
override fun setContextMenuItems(
242+
view: EnrichedMarkdownInputView?,
243+
value: ReadableArray?,
244+
) {
245+
if (view == null) return
246+
val items = (0 until (value?.size() ?: 0)).mapNotNull { value?.getMap(it)?.getString("text") }
247+
view.setContextMenuItems(items)
248+
}
249+
237250
override fun updateProperties(
238251
view: EnrichedMarkdownInputView,
239252
props: ReactStylesDiffMap,

android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownInputView.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,10 @@ class EnrichedMarkdownInputView(
378378
applyFormattingAndEmit()
379379
}
380380

381+
fun setContextMenuItems(items: List<String>) {
382+
contextMenu.setContextMenuItems(items)
383+
}
384+
381385
fun setValueFromJS(markdown: String) {
382386
val parsed = InputParser.parseToPlainTextAndRanges(markdown)
383387
blockEmitting = true

0 commit comments

Comments
 (0)