Skip to content

Commit e420a00

Browse files
feat(iOS): add support for adding custom context menu items (#422)
# Summary Add support for `contextMenuItems` prop that allows to add custom buttons in the context native menu. ## Test Plan 1. Run example app 2. Add custom button/buttons through `contextMenuItems` prop 3. Check if they are visible properly and they react on press action properly ## Screenshots / Videos https://github.com/user-attachments/assets/6064afe8-3cd5-4eb3-be25-a7076405d4ef ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ✅ | | Android | ❌ | ---------
1 parent c9828c5 commit e420a00

File tree

6 files changed

+330
-0
lines changed

6 files changed

+330
-0
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ We can help you build your next dream product –
3030
- [Inline Images](#inline-images)
3131
- [Style Detection](#style-detection)
3232
- [Other Events](#other-events)
33+
- [Context Menu Items](#context-menu-items)
3334
- [Customizing \<EnrichedTextInput /> styles](#customizing-enrichedtextinput--styles)
3435
- [API Reference](#api-reference)
3536
- [Known limitations](#known-limitations)
@@ -232,6 +233,29 @@ You can find some examples in the [usage section](#usage) or in the example app.
232233
- [onKeyPress](docs/API_REFERENCE.md#onkeypress) - emits whenever a key is pressed. Follows react-native TextInput's onKeyPress event [spec](https://reactnative.dev/docs/textinput#onkeypress).
233234
- [onPasteImages](docs/API_REFERENCE.md#onpasteimages) - returns an array of images details whenever an image/GIF is pasted into the input.
234235

236+
## Context Menu Items
237+
238+
> **Note:** This feature is currently supported on iOS only (iOS 16+).
239+
240+
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.
241+
242+
```tsx
243+
<EnrichedTextInput
244+
ref={ref}
245+
contextMenuItems={[
246+
{
247+
text: 'Paste Link',
248+
onPress: ({ text, selection, styleState }) => {
249+
if (!styleState.link.isBlocking) {
250+
ref.current?.setLink(selection.start, selection.end, text, url);
251+
}
252+
},
253+
visible: true,
254+
},
255+
]}
256+
/>
257+
```
258+
235259
## Customizing \<EnrichedTextInput /> styles
236260

237261
`react-native-enriched` allows customizing styles of the `<EnrichedTextInput />` component. See [htmlStyle](docs/API_REFERENCE.md#htmlstyle) prop.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ class EnrichedTextInputViewManager :
265265
view?.experimentalSynchronousEvents = value
266266
}
267267

268+
override fun setContextMenuItems(
269+
view: EnrichedTextInputView?,
270+
value: ReadableArray?,
271+
) {
272+
// no-op
273+
}
274+
268275
override fun focus(view: EnrichedTextInputView?) {
269276
view?.requestFocusProgrammatically()
270277
}

docs/API_REFERENCE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,41 @@ Tells input to automatically capitalize certain characters.
2323
| -------------------------------------------------- | ------------- | -------- |
2424
| `'none' \| 'sentences' \| 'words' \| 'characters'` | `'sentences'` | Both |
2525

26+
### `contextMenuItems`
27+
28+
An array of custom items to display in the native text editing menu. Items appear in array order, before the system items (Copy/Paste/Cut). Each item specifies a title, visibility flag, and a callback that fires when the item is tapped.
29+
30+
The `onPress` callback receives a single object argument with the following properties:
31+
32+
- `text` - the currently selected text.
33+
- `selection` - an object with `start` and `end` indices of the current selection.
34+
- `styleState` - the latest `OnChangeStateEvent` payload reflecting active styles at the time of the tap.
35+
36+
Item type:
37+
38+
```ts
39+
interface ContextMenuItem {
40+
text: string;
41+
onPress: (args: {
42+
text: string;
43+
selection: { start: number; end: number };
44+
styleState: OnChangeStateEvent;
45+
}) => void;
46+
visible?: boolean;
47+
}
48+
```
49+
50+
- `text` is the title displayed in the menu.
51+
- `onPress` is the callback invoked when the item is tapped.
52+
- `visible` controls whether the item is shown. Defaults to `true`.
53+
54+
| Type | Default Value | Platform |
55+
| ------------------- | ------------- | -------- |
56+
| `ContextMenuItem[]` | [] | iOS |
57+
58+
> [!NOTE]
59+
> This prop is currently supported on iOS only (iOS 16+).
60+
2661
### `cursorColor`
2762

2863
When provided it will set the color of the cursor (or "caret") in the component.

ios/EnrichedTextInputView.mm

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ @implementation EnrichedTextInputView {
5050
BOOL _emitFocusBlur;
5151
BOOL _emitTextChange;
5252
NSMutableDictionary<NSValue *, UIImageView *> *_attachmentViews;
53+
NSArray<NSDictionary *> *_contextMenuItems;
5354
}
5455

5556
// MARK: - Component utils
@@ -898,6 +899,26 @@ - (void)updateProps:(Props::Shared const &)props
898899
// isOnChangeTextSet
899900
_emitTextChange = newViewProps.isOnChangeTextSet;
900901

902+
// contextMenuItems
903+
bool contextMenuChanged = newViewProps.contextMenuItems.size() !=
904+
oldViewProps.contextMenuItems.size();
905+
if (!contextMenuChanged) {
906+
for (size_t i = 0; i < newViewProps.contextMenuItems.size(); i++) {
907+
if (newViewProps.contextMenuItems[i].text !=
908+
oldViewProps.contextMenuItems[i].text) {
909+
contextMenuChanged = true;
910+
break;
911+
}
912+
}
913+
}
914+
if (contextMenuChanged) {
915+
NSMutableArray<NSString *> *items = [NSMutableArray new];
916+
for (const auto &item : newViewProps.contextMenuItems) {
917+
[items addObject:[NSString fromCppString:item.text]];
918+
}
919+
_contextMenuItems = [items copy];
920+
}
921+
901922
[super updateProps:props oldProps:oldProps];
902923
// run the changes callback
903924
[self anyTextMayHaveBeenModified];
@@ -1846,6 +1867,75 @@ - (void)textViewDidEndEditing:(UITextView *)textView {
18461867
}
18471868
}
18481869

1870+
- (UIMenu *)textView:(UITextView *)tv
1871+
editMenuForTextInRange:(NSRange)range
1872+
suggestedActions:(NSArray<UIMenuElement *> *)suggestedActions
1873+
API_AVAILABLE(ios(16.0)) {
1874+
if (_contextMenuItems == nil || _contextMenuItems.count == 0) {
1875+
return [UIMenu menuWithChildren:suggestedActions];
1876+
}
1877+
1878+
NSMutableArray<UIMenuElement *> *customActions = [NSMutableArray new];
1879+
1880+
for (NSString *title in _contextMenuItems) {
1881+
__weak EnrichedTextInputView *weakSelf = self;
1882+
1883+
UIAction *action =
1884+
[UIAction actionWithTitle:title
1885+
image:nil
1886+
identifier:nil
1887+
handler:^(__kindof UIAction *_Nonnull action) {
1888+
[weakSelf emitOnContextMenuItemPressEvent:title];
1889+
}];
1890+
[customActions addObject:action];
1891+
}
1892+
1893+
[customActions addObjectsFromArray:suggestedActions];
1894+
return [UIMenu menuWithChildren:customActions];
1895+
}
1896+
1897+
- (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
1898+
auto emitter = [self getEventEmitter];
1899+
if (emitter != nullptr) {
1900+
NSRange selectedRange = textView.selectedRange;
1901+
NSString *selectedText = @"";
1902+
if (selectedRange.length > 0) {
1903+
selectedText =
1904+
[textView.textStorage.string substringWithRange:selectedRange];
1905+
}
1906+
1907+
emitter->onContextMenuItemPress(
1908+
{.itemText = [itemText toCppString],
1909+
.selectedText = [selectedText toCppString],
1910+
.selectionStart = static_cast<int>(selectedRange.location),
1911+
.selectionEnd =
1912+
static_cast<int>(selectedRange.location + selectedRange.length),
1913+
.styleState = {
1914+
.bold = GET_STYLE_STATE([BoldStyle getStyleType]),
1915+
.italic = GET_STYLE_STATE([ItalicStyle getStyleType]),
1916+
.underline = GET_STYLE_STATE([UnderlineStyle getStyleType]),
1917+
.strikeThrough =
1918+
GET_STYLE_STATE([StrikethroughStyle getStyleType]),
1919+
.inlineCode = GET_STYLE_STATE([InlineCodeStyle getStyleType]),
1920+
.h1 = GET_STYLE_STATE([H1Style getStyleType]),
1921+
.h2 = GET_STYLE_STATE([H2Style getStyleType]),
1922+
.h3 = GET_STYLE_STATE([H3Style getStyleType]),
1923+
.h4 = GET_STYLE_STATE([H4Style getStyleType]),
1924+
.h5 = GET_STYLE_STATE([H5Style getStyleType]),
1925+
.h6 = GET_STYLE_STATE([H6Style getStyleType]),
1926+
.codeBlock = GET_STYLE_STATE([CodeBlockStyle getStyleType]),
1927+
.blockQuote = GET_STYLE_STATE([BlockQuoteStyle getStyleType]),
1928+
.orderedList = GET_STYLE_STATE([OrderedListStyle getStyleType]),
1929+
.unorderedList =
1930+
GET_STYLE_STATE([UnorderedListStyle getStyleType]),
1931+
.link = GET_STYLE_STATE([LinkStyle getStyleType]),
1932+
.image = GET_STYLE_STATE([ImageStyle getStyleType]),
1933+
.mention = GET_STYLE_STATE([MentionStyle getStyleType]),
1934+
.checkboxList =
1935+
GET_STYLE_STATE([CheckboxListStyle getStyleType])}});
1936+
}
1937+
}
1938+
18491939
- (void)handleKeyPressInRange:(NSString *)text range:(NSRange)range {
18501940
NSString *key = nil;
18511941

src/EnrichedTextInput.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
useMemo,
77
useRef,
88
} from 'react';
9+
import { useCallback } from 'react';
910
import EnrichedTextInputNativeComponent, {
1011
Commands,
1112
type NativeProps,
1213
type OnChangeHtmlEvent,
1314
type OnChangeSelectionEvent,
1415
type OnChangeStateEvent,
1516
type OnChangeTextEvent,
17+
type OnContextMenuItemPressEvent,
1618
type OnLinkDetected,
1719
type OnMentionEvent,
1820
type OnMentionDetected,
@@ -78,6 +80,20 @@ export interface EnrichedTextInputInstance extends NativeMethods {
7880
) => void;
7981
}
8082

83+
export interface ContextMenuItem {
84+
text: string;
85+
onPress: ({
86+
text,
87+
selection,
88+
styleState,
89+
}: {
90+
text: string;
91+
selection: { start: number; end: number };
92+
styleState: OnChangeStateEvent;
93+
}) => void;
94+
visible?: boolean;
95+
}
96+
8197
export interface OnChangeMentionEvent {
8298
indicator: string;
8399
text: string;
@@ -117,6 +133,7 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
117133
onChangeSelection?: (e: NativeSyntheticEvent<OnChangeSelectionEvent>) => void;
118134
onKeyPress?: (e: NativeSyntheticEvent<OnKeyPressEvent>) => void;
119135
onPasteImages?: (e: NativeSyntheticEvent<OnPasteImagesEvent>) => void;
136+
contextMenuItems?: ContextMenuItem[];
120137
/**
121138
* If true, Android will use experimental synchronous events.
122139
* This will prevent from input flickering when updating component size.
@@ -167,6 +184,7 @@ export const EnrichedTextInput = ({
167184
onEndMention,
168185
onChangeSelection,
169186
onKeyPress,
187+
contextMenuItems,
170188
androidExperimentalSynchronousEvents = false,
171189
scrollEnabled = true,
172190
...rest
@@ -176,6 +194,50 @@ export const EnrichedTextInput = ({
176194
const nextHtmlRequestId = useRef(1);
177195
const pendingHtmlRequests = useRef(new Map<number, HtmlRequest>());
178196

197+
// Store onPress callbacks in a ref so native only receives serializable data
198+
const contextMenuCallbacksRef = useRef<
199+
Map<string, ContextMenuItem['onPress']>
200+
>(new Map());
201+
202+
useEffect(() => {
203+
const callbacksMap = new Map<string, ContextMenuItem['onPress']>();
204+
if (contextMenuItems) {
205+
for (const item of contextMenuItems) {
206+
callbacksMap.set(item.text, item.onPress);
207+
}
208+
}
209+
contextMenuCallbacksRef.current = callbacksMap;
210+
}, [contextMenuItems]);
211+
212+
const nativeContextMenuItems = useMemo(
213+
() =>
214+
contextMenuItems
215+
?.filter((item) => item.visible !== false)
216+
.map((item) => ({
217+
text: item.text,
218+
})),
219+
[contextMenuItems]
220+
);
221+
222+
const handleContextMenuItemPress = useCallback(
223+
(e: NativeSyntheticEvent<OnContextMenuItemPressEvent>) => {
224+
const {
225+
itemText,
226+
selectedText,
227+
selectionStart,
228+
selectionEnd,
229+
styleState,
230+
} = e.nativeEvent;
231+
const callback = contextMenuCallbacksRef.current.get(itemText);
232+
callback?.({
233+
text: selectedText,
234+
selection: { start: selectionStart, end: selectionEnd },
235+
styleState,
236+
});
237+
},
238+
[]
239+
);
240+
179241
useEffect(() => {
180242
const pendingRequests = pendingHtmlRequests.current;
181243
return () => {
@@ -417,6 +479,8 @@ export const EnrichedTextInput = ({
417479
onChangeSelection={onChangeSelection}
418480
onRequestHtmlResult={handleRequestHtmlResult}
419481
onInputKeyPress={onKeyPress}
482+
contextMenuItems={nativeContextMenuItems}
483+
onContextMenuItemPress={handleContextMenuItemPress}
420484
androidExperimentalSynchronousEvents={
421485
androidExperimentalSynchronousEvents
422486
}

0 commit comments

Comments
 (0)