Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ScrollView,
Alert,
Linking,
Platform,
View,
Text,
} from 'react-native';
Expand All @@ -25,7 +26,8 @@ export default function App() {
const contextMenuItems = useMemo(
() => [
{
text: '✦ Summarize with AI',
text: 'Summarize with AI',
icon: Platform.OS === 'ios' ? 'sparkles' : undefined,
onPress: ({ text }: { text: string }) => {
Alert.alert('✦ Summarize with AI', `"${text}"`, [
{ text: 'Dismiss', style: 'cancel' },
Expand All @@ -34,6 +36,7 @@ export default function App() {
},
{
text: 'Translate',
icon: Platform.OS === 'ios' ? 'globe' : undefined,
onPress: ({ text }: { text: string }) => {
Alert.alert('Translate', `"${text}"`, [
{ text: 'Dismiss', style: 'cancel' },
Expand Down
6 changes: 5 additions & 1 deletion apps/example/src/InputScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export default function InputScreen() {
const bubbleContextMenuItems = useMemo(
() => [
{
text: '✦ Summarize with AI',
text: 'Summarize with AI',
icon: Platform.OS === 'ios' ? 'sparkles' : undefined,
onPress: ({ text }: { text: string }) => {
Alert.alert('✦ Summarize with AI', `"${text}"`, [
{ text: 'Dismiss', style: 'cancel' },
Expand All @@ -116,6 +117,8 @@ export default function InputScreen() {
},
{
text: 'Reply',
icon:
Platform.OS === 'ios' ? 'arrowshape.turn.up.left.fill' : undefined,
onPress: ({ text }: { text: string }) => {
inputRef.current?.setValue(`> ${text}\n\n`);
inputRef.current?.focus();
Expand All @@ -129,6 +132,7 @@ export default function InputScreen() {
() => [
{
text: '✦ Summarize with AI',
icon: Platform.OS === 'ios' ? 'sparkles' : undefined,
onPress: ({
text,
styleState,
Expand Down
4 changes: 3 additions & 1 deletion apps/macos-example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export default function App() {
const contextMenuItems = useMemo(
() => [
{
text: '✦ Summarize with AI',
text: 'Summarize with AI',
icon: 'sparkles',
onPress: ({ text }: { text: string }) => {
Alert.alert('✦ Summarize with AI', `"${text}"`, [
{ text: 'Dismiss', style: 'cancel' },
Expand All @@ -34,6 +35,7 @@ export default function App() {
},
{
text: 'Translate',
icon: 'globe',
onPress: ({ text }: { text: string }) => {
Alert.alert('Translate', `"${text}"`, [
{ text: 'Dismiss', style: 'cancel' },
Expand Down
7 changes: 5 additions & 2 deletions apps/macos-example/src/InputScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export default function InputScreen() {
const bubbleContextMenuItems = useMemo(
() => [
{
text: '✦ Summarize with AI',
text: 'Summarize with AI',
icon: 'sparkles',
onPress: ({ text }: { text: string }) => {
Alert.alert('✦ Summarize with AI', `"${text}"`, [
{ text: 'Dismiss', style: 'cancel' },
Expand All @@ -75,6 +76,7 @@ export default function InputScreen() {
},
{
text: 'Reply',
icon: 'arrowshape.turn.up.left.fill',
onPress: ({ text }: { text: string }) => {
inputRef.current?.setValue(`> ${text}\n\n`);
inputRef.current?.focus();
Expand All @@ -87,7 +89,8 @@ export default function InputScreen() {
const inputContextMenuItems = useMemo(
() => [
{
text: '✦ Summarize with AI',
text: 'Summarize with AI',
icon: 'sparkles',
onPress: ({
text,
styleState,
Expand Down
16 changes: 16 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown table supp

Custom items to add to the text selection context menu. Items appear before the system actions (Copy, etc.). Items with `visible: false` are hidden from the menu.

> **iOS**: Requires iOS 16+. On earlier versions the prop is ignored.

| Type | Default Value | Platform |
| -------------------- | ------------- | -------- |
| `ContextMenuItem[]` | - | Both |
Expand All @@ -182,6 +184,12 @@ Custom items to add to the text selection context menu. Items appear before the
interface ContextMenuItem {
/** Label shown in the context menu. */
text: string;
/**
* SF Symbol name for the icon shown next to the item label.
* Supported on iOS and macOS. Ignored on Android.
* Example: 'sparkles', 'translate', 'doc.text'
*/
icon?: string;
/** Called when the item is tapped. */
onPress: (event: {
/** The selected text at the time of the press. */
Expand Down Expand Up @@ -393,6 +401,8 @@ Fires when the input loses focus.

Custom items to add to the text selection context menu. Items appear before the system actions (Copy, Cut, etc.). Items with `visible: false` are hidden from the menu.

> **iOS**: Requires iOS 16+. On earlier versions the prop is ignored.

| Type | Default Value | Platform |
| -------------------- | ------------- | -------- |
| `ContextMenuItem[]` | - | Both |
Expand All @@ -403,6 +413,12 @@ Custom items to add to the text selection context menu. Items appear before the
interface ContextMenuItem {
/** Label shown in the context menu. */
text: string;
/**
* SF Symbol name for the icon shown next to the item label.
* Supported on iOS and macOS. Ignored on Android.
* Example: 'sparkles', 'translate', 'doc.text'
*/
icon?: string;
/** Called when the item is tapped. */
onPress: (event: {
/** The selected text at the time of the press. */
Expand Down
6 changes: 4 additions & 2 deletions ios/EnrichedMarkdown.mm
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ @implementation EnrichedMarkdown {
BOOL _enableLinkPreview;

NSArray<NSString *> *_contextMenuItemTexts;
NSArray<NSString *> *_contextMenuItemIcons;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
Expand Down Expand Up @@ -545,7 +546,7 @@ - (EnrichedMarkdownInternalText *)createTextViewForRenderedSegment:(EMRenderedTe
}
NSString *segmentMarkdown = extractMarkdownFromAttributedString(textView.textStorage, textView.selectedRange);
NSArray<NSMenuItem *> *customItems = ENRMBuildContextMenuItems(
strongSelf->_contextMenuItemTexts, textView,
strongSelf->_contextMenuItemTexts, strongSelf->_contextMenuItemIcons, textView,
^(NSString *itemText, NSString *selectedText, NSUInteger selectionStart, NSUInteger selectionEnd) {
auto eventEmitter = std::static_pointer_cast<EnrichedMarkdownEventEmitter const>(strongSelf->_eventEmitter);
if (eventEmitter) {
Expand Down Expand Up @@ -683,6 +684,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &

if (ENRMContextMenuItemsChanged(oldViewProps.contextMenuItems, newViewProps.contextMenuItems)) {
_contextMenuItemTexts = ENRMContextMenuTextsFromItems(newViewProps.contextMenuItems);
_contextMenuItemIcons = ENRMContextMenuIconsFromItems(newViewProps.contextMenuItems);
}

if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged) {
Expand Down Expand Up @@ -812,7 +814,7 @@ - (UIMenu *)textView:(UITextView *)textView
}
};
NSMutableArray<UIAction *> *customActions =
ENRMBuildContextMenuActions(_contextMenuItemTexts, textView, range, handler);
ENRMBuildContextMenuActions(_contextMenuItemTexts, _contextMenuItemIcons, textView, range, handler);

NSString *segmentMarkdown = extractMarkdownFromAttributedString(textView.attributedText, range);
return buildEditMenuForSelection(textView.attributedText, range, segmentMarkdown, _config, suggestedActions,
Expand Down
6 changes: 4 additions & 2 deletions ios/EnrichedMarkdownText.mm
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ @implementation EnrichedMarkdownText {
BOOL _accessibilityNeedsRebuild;

NSArray<NSString *> *_contextMenuItemTexts;
NSArray<NSString *> *_contextMenuItemIcons;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
Expand Down Expand Up @@ -210,7 +211,7 @@ - (void)setupTextView
return baseMenu;
}
NSArray<NSMenuItem *> *customItems = ENRMBuildContextMenuItems(
strongSelf->_contextMenuItemTexts, textView,
strongSelf->_contextMenuItemTexts, strongSelf->_contextMenuItemIcons, textView,
^(NSString *itemText, NSString *selectedText, NSUInteger selectionStart, NSUInteger selectionEnd) {
auto eventEmitter =
std::static_pointer_cast<EnrichedMarkdownTextEventEmitter const>(strongSelf->_eventEmitter);
Expand Down Expand Up @@ -495,6 +496,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &

if (ENRMContextMenuItemsChanged(oldViewProps.contextMenuItems, newViewProps.contextMenuItems)) {
_contextMenuItemTexts = ENRMContextMenuTextsFromItems(newViewProps.contextMenuItems);
_contextMenuItemIcons = ENRMContextMenuIconsFromItems(newViewProps.contextMenuItems);
}

if (newViewProps.streamingAnimation != oldViewProps.streamingAnimation) {
Expand Down Expand Up @@ -629,7 +631,7 @@ - (UIMenu *)textView:(ENRMPlatformTextView *)textView
}
};
NSMutableArray<UIAction *> *customActions =
ENRMBuildContextMenuActions(_contextMenuItemTexts, textView, range, handler);
ENRMBuildContextMenuActions(_contextMenuItemTexts, _contextMenuItemIcons, textView, range, handler);

return buildEditMenuForSelection(textView.attributedText, range, _cachedMarkdown, _config, suggestedActions,
customActions);
Expand Down
18 changes: 11 additions & 7 deletions ios/input/EnrichedMarkdownInput+ContextMenu.mm
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,19 @@ - (UIMenu *)textView:(UITextView *)textView
handler:^(__kindof UIAction *action) { [self copySelectedRangeAsMarkdown]; }];

NSArray<NSString *> *customItemTexts = [self contextMenuItemTexts];
NSArray<NSString *> *customItemIcons = [self contextMenuItemIcons];
__weak EnrichedMarkdownInput *weakSelf = self;
NSMutableArray<UIMenuElement *> *allActions = [NSMutableArray arrayWithCapacity:customItemTexts.count];
for (NSString *itemText in customItemTexts) {
[customItemTexts enumerateObjectsUsingBlock:^(NSString *itemText, NSUInteger index, BOOL *_) {
NSString *iconName = index < customItemIcons.count ? customItemIcons[index] : nil;
UIImage *image = iconName.length > 0 ? [UIImage systemImageNamed:iconName] : nil;
UIAction *customAction =
[UIAction actionWithTitle:itemText
image:nil
image:image
identifier:nil
handler:^(__kindof UIAction *_) { [weakSelf emitContextMenuItemPress:itemText]; }];
[allActions addObject:customAction];
}
}];

NSUInteger insertIndex = suggestedActions.count;
NSMutableArray *systemActions = [suggestedActions mutableCopy];
Expand All @@ -70,10 +73,11 @@ - (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu tex
}

__weak EnrichedMarkdownInput *weakSelf = self;
NSArray<NSMenuItem *> *customItems = ENRMBuildContextMenuItems(
[self contextMenuItemTexts], textView, ^(NSString *itemText, NSString *_, NSUInteger __, NSUInteger ___) {
[weakSelf emitContextMenuItemPress:itemText];
});
NSArray<NSMenuItem *> *customItems =
ENRMBuildContextMenuItems([self contextMenuItemTexts], [self contextMenuItemIcons], textView,
^(NSString *itemText, NSString *_, NSUInteger __, NSUInteger ___) {
[weakSelf emitContextMenuItemPress:itemText];
});
ENRMPrependMenuItems(menu, customItems);

[menu addItem:[NSMenuItem separatorItem]];
Expand Down
1 change: 1 addition & 0 deletions ios/input/EnrichedMarkdownInput+Internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN

- (void)emitContextMenuItemPress:(NSString *)itemText;
- (NSArray<NSString *> *)contextMenuItemTexts;
- (NSArray<NSString *> *)contextMenuItemIcons;

#if !TARGET_OS_OSX
- (void)showFormatBar;
Expand Down
7 changes: 7 additions & 0 deletions ios/input/EnrichedMarkdownInput.mm
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ @implementation EnrichedMarkdownInput {
#endif

NSArray<NSString *> *_contextMenuItemTexts;
NSArray<NSString *> *_contextMenuItemIcons;
}

#pragma mark - Fabric lifecycle
Expand Down Expand Up @@ -277,6 +278,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &

if (ENRMContextMenuItemsChanged(oldViewProps.contextMenuItems, newViewProps.contextMenuItems)) {
_contextMenuItemTexts = ENRMContextMenuTextsFromItems(newViewProps.contextMenuItems);
_contextMenuItemIcons = ENRMContextMenuIconsFromItems(newViewProps.contextMenuItems);
}

BOOL styleChanged = applyInputStyleProps(_formatterStyle, newViewProps, oldViewProps);
Expand Down Expand Up @@ -824,6 +826,11 @@ - (void)emitOnChangeState
return _contextMenuItemTexts ?: @[];
}

- (NSArray<NSString *> *)contextMenuItemIcons
{
return _contextMenuItemIcons ?: @[];
}

- (void)emitContextMenuItemPress:(NSString *)itemText
{
auto eventEmitter = [self getEventEmitter];
Expand Down
16 changes: 15 additions & 1 deletion ios/utils/ContextMenuUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ static bool ENRMContextMenuItemsChanged(const std::vector<T> &oldItems, const st
return true;
}
for (size_t i = 0; i < newItems.size(); i++) {
if (newItems[i].text != oldItems[i].text) {
if (newItems[i].text != oldItems[i].text || newItems[i].icon != oldItems[i].icon) {
return true;
}
}
Expand All @@ -30,6 +30,18 @@ template <typename T> static NSArray<NSString *> *ENRMContextMenuTextsFromItems(
return [result copy];
}

template <typename T> static NSArray<NSString *> *_Nullable ENRMContextMenuIconsFromItems(const std::vector<T> &items)
{
NSMutableArray<NSString *> *result = [NSMutableArray arrayWithCapacity:items.size()];
bool hasAnyIcon = false;
for (const auto &item : items) {
NSString *iconName = @(item.icon.c_str());
hasAnyIcon = hasAnyIcon || iconName.length > 0;
[result addObject:iconName];
}
return hasAnyIcon ? [result copy] : nil;
}

#endif

typedef void (^ENRMContextMenuPressHandler)(NSString *_Nonnull itemText, NSString *_Nonnull selectedText,
Expand All @@ -43,13 +55,15 @@ extern "C" {

// TODO: Remove API_AVAILABLE(ios(16.0)) guard when the minimum iOS deployment target in RN is bumped to 16.
NSMutableArray<UIAction *> *_Nullable ENRMBuildContextMenuActions(NSArray<NSString *> *_Nonnull itemTexts,
NSArray<NSString *> *_Nullable iconNames,
UITextView *_Nonnull textView, NSRange selectedRange,
ENRMContextMenuPressHandler _Nonnull handler)
API_AVAILABLE(ios(16.0));

#else

NSArray<NSMenuItem *> *_Nullable ENRMBuildContextMenuItems(NSArray<NSString *> *_Nonnull itemTexts,
NSArray<NSString *> *_Nullable iconNames,
NSTextView *_Nonnull textView,
ENRMContextMenuPressHandler _Nonnull handler);

Expand Down
30 changes: 20 additions & 10 deletions ios/utils/ContextMenuUtils.mm
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
#if !TARGET_OS_OSX

// TODO: Remove API_AVAILABLE(ios(16.0)) guard when the minimum iOS deployment target in RN is bumped to 16.
NSMutableArray<UIAction *> *_Nullable ENRMBuildContextMenuActions(NSArray<NSString *> *itemTexts, UITextView *textView,
NSRange selectedRange,
NSMutableArray<UIAction *> *_Nullable ENRMBuildContextMenuActions(NSArray<NSString *> *itemTexts,
NSArray<NSString *> *_Nullable iconNames,
UITextView *textView, NSRange selectedRange,
ENRMContextMenuPressHandler handler)
API_AVAILABLE(ios(16.0))
{
Expand All @@ -18,21 +19,24 @@
NSUInteger selectionEnd = NSMaxRange(selectedRange);

NSMutableArray<UIAction *> *actions = [NSMutableArray arrayWithCapacity:itemTexts.count];
for (NSString *itemText in itemTexts) {
[itemTexts enumerateObjectsUsingBlock:^(NSString *itemText, NSUInteger index, BOOL *_) {
NSString *iconName = iconNames ? iconNames[index] : nil;
UIImage *image = iconName.length > 0 ? [UIImage systemImageNamed:iconName] : nil;
[actions addObject:[UIAction actionWithTitle:itemText
image:nil
image:image
identifier:nil
handler:^(__kindof UIAction *_) {
handler(itemText, selectedText, selectionStart, selectionEnd);
}]];
}
}];
return actions;
}

#else

NSArray<NSMenuItem *> *_Nullable ENRMBuildContextMenuItems(NSArray<NSString *> *itemTexts, NSTextView *textView,
ENRMContextMenuPressHandler handler)
NSArray<NSMenuItem *> *_Nullable ENRMBuildContextMenuItems(NSArray<NSString *> *itemTexts,
NSArray<NSString *> *_Nullable iconNames,
NSTextView *textView, ENRMContextMenuPressHandler handler)
{
if (itemTexts.count == 0) {
return nil;
Expand All @@ -44,9 +48,15 @@
NSUInteger selectionEnd = NSMaxRange(selectedRange);

NSMutableArray<NSMenuItem *> *items = [NSMutableArray arrayWithCapacity:itemTexts.count];
for (NSString *itemText in itemTexts) {
[items addObject:ENRMCreateMenuItem(itemText, ^{ handler(itemText, selectedText, selectionStart, selectionEnd); })];
}
[itemTexts enumerateObjectsUsingBlock:^(NSString *itemText, NSUInteger index, BOOL *_) {
NSMenuItem *item =
ENRMCreateMenuItem(itemText, ^{ handler(itemText, selectedText, selectionStart, selectionEnd); });
NSString *iconName = iconNames ? iconNames[index] : nil;
if (iconName.length > 0) {
item.image = [NSImage imageWithSystemSymbolName:iconName accessibilityDescription:nil];
}
[items addObject:item];
}];
return items;
}

Expand Down
Loading
Loading