Skip to content

Commit c13d5c9

Browse files
feat(Android): context menu items (#433)
# Summary Adds support for `contextMenuItems` prop on Android. ## Test Plan - specify `contextMenuItems` - ensure items are rendered on Android - ensure onPress and visible options work properly ## Screenshots / Videos https://github.com/user-attachments/assets/27cae922-71d9-49d0-874e-53717209ab48 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ✅ | --------- Co-authored-by: Kacper Żółkiewski <kacper.zolkiewski@swmansion.com> Co-authored-by: Kacper Żółkiewski <74975508+kacperzolkiewski@users.noreply.github.com>
1 parent 013785e commit c13d5c9

8 files changed

Lines changed: 512 additions & 137 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ You can find some examples in the [usage section](#usage) or in the example app.
236236

237237
## Context Menu Items
238238

239-
> **Note:** This feature is currently supported on iOS only (iOS 16+).
239+
> **Note:** This feature is currently supported on Android and iOS 16+.
240240
241241
You can extend the native text editing menu with custom items using the [contextMenuItems](docs/API_REFERENCE.md#contextmenuitems) prop. Each item has a `text` (title), `visible` flag and an `onPress` callback. Items appear in the specified order, before the system actions.
242242

android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ import android.util.AttributeSet
1515
import android.util.Log
1616
import android.util.Patterns
1717
import android.util.TypedValue
18+
import android.view.ActionMode
1819
import android.view.Gravity
20+
import android.view.Menu
21+
import android.view.MenuItem
1922
import android.view.MotionEvent
2023
import android.view.inputmethod.EditorInfo
2124
import android.view.inputmethod.InputConnection
2225
import android.view.inputmethod.InputMethodManager
2326
import androidx.appcompat.widget.AppCompatEditText
2427
import androidx.core.view.ViewCompat
2528
import com.facebook.react.bridge.ReactContext
29+
import com.facebook.react.bridge.ReadableArray
2630
import com.facebook.react.bridge.ReadableMap
2731
import com.facebook.react.common.ReactConstants
2832
import com.facebook.react.uimanager.PixelUtil
@@ -34,6 +38,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
3438
import com.swmansion.enriched.common.EnrichedConstants
3539
import com.swmansion.enriched.common.parser.EnrichedParser
3640
import com.swmansion.enriched.textinput.events.MentionHandler
41+
import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent
3742
import com.swmansion.enriched.textinput.events.OnInputBlurEvent
3843
import com.swmansion.enriched.textinput.events.OnInputFocusEvent
3944
import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent
@@ -108,6 +113,7 @@ class EnrichedTextInputView : AppCompatEditText {
108113

109114
private var inputMethodManager: InputMethodManager? = null
110115
private val spannableFactory = EnrichedTextInputSpannableFactory()
116+
private var contextMenuItems: List<Pair<Int, String>> = emptyList()
111117

112118
constructor(context: Context) : super(context) {
113119
prepareComponent()
@@ -390,8 +396,7 @@ class EnrichedTextInputView : AppCompatEditText {
390396

391397
fun setCursorColor(colorInt: Int?) {
392398
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
393-
val cursorDrawable = textCursorDrawable
394-
if (cursorDrawable == null) return
399+
val cursorDrawable = textCursorDrawable ?: return
395400

396401
if (colorInt != null) {
397402
cursorDrawable.colorFilter = BlendModeColorFilter(colorInt, BlendMode.SRC_IN)
@@ -513,12 +518,100 @@ class EnrichedTextInputView : AppCompatEditText {
513518

514519
try {
515520
linkRegex = Pattern.compile("(?s).*?($patternStr).*", flags)
516-
} catch (e: PatternSyntaxException) {
521+
} catch (_: PatternSyntaxException) {
517522
Log.w("EnrichedTextInputView", "Invalid link regex pattern: $patternStr")
518523
linkRegex = Patterns.WEB_URL
519524
}
520525
}
521526

527+
fun setContextMenuItems(items: ReadableArray?) {
528+
if (items == null) {
529+
contextMenuItems = emptyList()
530+
return
531+
}
532+
533+
val result = mutableListOf<Pair<Int, String>>()
534+
for (i in 0 until items.size()) {
535+
val item = items.getMap(i) ?: continue
536+
val text = item.getString("text") ?: continue
537+
result.add(Pair(i, text))
538+
}
539+
540+
contextMenuItems = result
541+
}
542+
543+
override fun startActionMode(
544+
callback: ActionMode.Callback?,
545+
type: Int,
546+
): ActionMode? {
547+
if (contextMenuItems.isEmpty()) {
548+
return super.startActionMode(callback, type)
549+
}
550+
551+
val wrappedCallback =
552+
object : ActionMode.Callback2() {
553+
override fun onCreateActionMode(
554+
mode: ActionMode,
555+
menu: Menu,
556+
): Boolean {
557+
val result = callback?.onCreateActionMode(mode, menu) ?: false
558+
for ((index, text) in contextMenuItems) {
559+
menu.add(Menu.NONE, CONTEXT_MENU_ITEM_ID + index, Menu.NONE, text)
560+
}
561+
562+
return result
563+
}
564+
565+
override fun onPrepareActionMode(
566+
mode: ActionMode,
567+
menu: Menu,
568+
) = callback?.onPrepareActionMode(mode, menu) ?: false
569+
570+
override fun onActionItemClicked(
571+
mode: ActionMode,
572+
menuItem: MenuItem,
573+
): Boolean {
574+
val itemId = menuItem.itemId
575+
if (itemId < CONTEXT_MENU_ITEM_ID) {
576+
return callback?.onActionItemClicked(mode, menuItem) ?: false
577+
}
578+
579+
val selStart = selection?.start ?: 0
580+
val selEnd = selection?.end ?: 0
581+
val itemText = contextMenuItems.getOrNull(itemId - CONTEXT_MENU_ITEM_ID)?.second ?: return false
582+
emitContextMenuItemPressEvent(itemText)
583+
mode.finish()
584+
post {
585+
// Ensures selection is not lost after the action mode is finished
586+
if (selStart in 0..selEnd) {
587+
setSelection(selStart, selEnd)
588+
}
589+
}
590+
return true
591+
}
592+
593+
override fun onDestroyActionMode(mode: ActionMode) {
594+
callback?.onDestroyActionMode(mode)
595+
}
596+
}
597+
598+
return super.startActionMode(wrappedCallback, type)
599+
}
600+
601+
private fun emitContextMenuItemPressEvent(itemText: String) {
602+
val start = selection?.start ?: return
603+
val end = selection.end
604+
val styleState = spanState?.getStyleStatePayload() ?: return
605+
val selectedText = text?.subSequence(start, end)?.toString() ?: ""
606+
607+
val reactContext = context as ReactContext
608+
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
609+
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
610+
dispatcher?.dispatchEvent(
611+
OnContextMenuItemPressEvent(surfaceId, id, itemText, selectedText, start, end, styleState, experimentalSynchronousEvents),
612+
)
613+
}
614+
522615
// https://github.com/facebook/react-native/blob/36df97f500aa0aa8031098caf7526db358b6ddc1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L283C2-L284C1
523616
// After the text changes inside an EditText, TextView checks if a layout() has been requested.
524617
// If it has, it will not scroll the text to the end of the new text inserted, but wait for the
@@ -742,7 +835,7 @@ class EnrichedTextInputView : AppCompatEditText {
742835
val html =
743836
try {
744837
EnrichedParser.toHtmlWithDefault(text)
745-
} catch (e: Exception) {
838+
} catch (_: Exception) {
746839
null
747840
}
748841

@@ -870,5 +963,6 @@ class EnrichedTextInputView : AppCompatEditText {
870963

871964
companion object {
872965
const val CLIPBOARD_TAG = "react-native-enriched-clipboard"
966+
private const val CONTEXT_MENU_ITEM_ID = 10000
873967
}
874968
}

android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.swmansion.enriched.textinput.events.OnChangeHtmlEvent
1919
import com.swmansion.enriched.textinput.events.OnChangeSelectionEvent
2020
import com.swmansion.enriched.textinput.events.OnChangeStateEvent
2121
import com.swmansion.enriched.textinput.events.OnChangeTextEvent
22+
import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent
2223
import com.swmansion.enriched.textinput.events.OnInputBlurEvent
2324
import com.swmansion.enriched.textinput.events.OnInputFocusEvent
2425
import com.swmansion.enriched.textinput.events.OnInputKeyPressEvent
@@ -72,6 +73,7 @@ class EnrichedTextInputViewManager :
7273
map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME))
7374
map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME))
7475
map.put(OnPasteImagesEvent.EVENT_NAME, mapOf("registrationName" to OnPasteImagesEvent.EVENT_NAME))
76+
map.put(OnContextMenuItemPressEvent.EVENT_NAME, mapOf("registrationName" to OnContextMenuItemPressEvent.EVENT_NAME))
7577

7678
return map
7779
}
@@ -269,7 +271,7 @@ class EnrichedTextInputViewManager :
269271
view: EnrichedTextInputView?,
270272
value: ReadableArray?,
271273
) {
272-
// no-op
274+
view?.setContextMenuItems(value)
273275
}
274276

275277
override fun setUseHtmlNormalizer(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.swmansion.enriched.textinput.events
2+
3+
import android.util.Log
4+
import com.facebook.react.bridge.Arguments
5+
import com.facebook.react.bridge.WritableMap
6+
import com.facebook.react.uimanager.events.Event
7+
8+
class OnContextMenuItemPressEvent(
9+
surfaceId: Int,
10+
viewId: Int,
11+
private val itemText: String,
12+
private val selectedText: String,
13+
private val selectionStart: Int,
14+
private val selectionEnd: Int,
15+
private val styleState: WritableMap,
16+
private val experimentalSynchronousEvents: Boolean,
17+
) : Event<OnContextMenuItemPressEvent>(surfaceId, viewId) {
18+
override fun getEventName(): String = EVENT_NAME
19+
20+
override fun getEventData(): WritableMap {
21+
val eventData: WritableMap = Arguments.createMap()
22+
eventData.putString("itemText", itemText)
23+
eventData.putString("selectedText", selectedText)
24+
eventData.putInt("selectionStart", selectionStart)
25+
eventData.putInt("selectionEnd", selectionEnd)
26+
eventData.putMap("styleState", styleState)
27+
return eventData
28+
}
29+
30+
override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents
31+
32+
companion object {
33+
const val EVENT_NAME: String = "onContextMenuItemPress"
34+
}
35+
}

android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,18 +203,7 @@ class EnrichedSpanState(
203203
}
204204
}
205205

206-
private fun emitStateChangeEvent() {
207-
val context = view.context as ReactContext
208-
val surfaceId = UIManagerHelper.getSurfaceId(context)
209-
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
210-
211-
dispatchPayload(dispatcher, surfaceId)
212-
}
213-
214-
private fun dispatchPayload(
215-
dispatcher: EventDispatcher?,
216-
surfaceId: Int,
217-
) {
206+
fun getStyleStatePayload(): WritableMap {
218207
val activeStyles =
219208
listOfNotNull(
220209
if (boldStart != null) EnrichedSpans.BOLD else null,
@@ -258,6 +247,23 @@ class EnrichedSpanState(
258247
payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION))
259248
payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST))
260249

250+
return payload
251+
}
252+
253+
private fun emitStateChangeEvent() {
254+
val context = view.context as ReactContext
255+
val surfaceId = UIManagerHelper.getSurfaceId(context)
256+
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
257+
258+
dispatchPayload(dispatcher, surfaceId)
259+
}
260+
261+
private fun dispatchPayload(
262+
dispatcher: EventDispatcher?,
263+
surfaceId: Int,
264+
) {
265+
val payload = getStyleStatePayload()
266+
261267
// Do not emit event if payload is the same
262268
if (previousPayload == payload) {
263269
return

cpp/GumboParser/GumboParser.h

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25382,8 +25382,10 @@ void gumbo_token_destroy(struct GumboInternalParser *parser, GumboToken *token);
2538225382

2538325383
#define AVOID_UNUSED_VARIABLE_WARNING(i) (void)(i)
2538425384

25385-
#define GUMBO_STRING(literal) {literal, sizeof(literal) - 1}
25386-
#define TERMINATOR {"", 0}
25385+
#define GUMBO_STRING(literal) \
25386+
{ literal, sizeof(literal) - 1 }
25387+
#define TERMINATOR \
25388+
{ "", 0 }
2538725389

2538825390
typedef char gumbo_tagset[GUMBO_TAG_LAST];
2538925391
#define TAG(tag) [GUMBO_TAG_##tag] = (1 << GUMBO_NAMESPACE_HTML)
@@ -25527,7 +25529,8 @@ typedef struct _ReplacementEntry {
2552725529
const GumboStringPiece to;
2552825530
} ReplacementEntry;
2552925531

25530-
#define REPLACEMENT_ENTRY(from, to) {GUMBO_STRING(from), GUMBO_STRING(to)}
25532+
#define REPLACEMENT_ENTRY(from, to) \
25533+
{ GUMBO_STRING(from), GUMBO_STRING(to) }
2553125534

2553225535
// Static data for SVG attribute replacements.
2553325536
// https://html.spec.whatwg.org/multipage/syntax.html#creating-and-inserting-nodes

0 commit comments

Comments
 (0)