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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import { EnrichedMarkdownTextInputStory } from '../EnrichedMarkdownTextInputStory';
import { storyMeta } from '../shared/storyMeta';
import type { InputStory } from '../shared/storyTypes';

type SelectionMenuStoryExtra = {
format: boolean;
copyAsMarkdown: boolean;
};

const MARKDOWN =
'Select this text and open the context menu to see the built-in actions.';

const argTypes = {
format: {
control: 'boolean',
description:
'selectionMenuConfig.format — show the "Format" submenu (Bold, Italic, etc.) in the selection menu.',
},
copyAsMarkdown: {
control: 'boolean',
description:
'selectionMenuConfig.copyAsMarkdown — show "Copy as Markdown" in the selection menu.',
},
};

export default storyMeta('Props', 'Selection Menu');

export const Default: InputStory<SelectionMenuStoryExtra> = {
args: {
initialMarkdown: MARKDOWN,
format: true,
copyAsMarkdown: true,
},
argTypes,
render: ({ format, copyAsMarkdown, ...args }) => (
<EnrichedMarkdownTextInputStory
title="Selection Menu"
description="selectionMenuConfig controls built-in items in the native selection menu. Toggle the controls to show/hide the Format submenu and Copy as Markdown action."
{...args}
selectionMenuConfig={{ format, copyAsMarkdown }}
/>
),
};
33 changes: 33 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,39 @@ interface ContextMenuItem {
/>
```

### `selectionMenuConfig`

Controls built-in items in the text selection context menu. The Format submenu and the Copy as Markdown action can each be hidden independently. Custom app-provided actions are controlled separately with `contextMenuItems`.

| Type | Default Value | Platform |
| -------------------------- | -------------------------------------- | ------------------- |
| `InputSelectionMenuConfig` | `{ format: true, copyAsMarkdown: true }` | iOS, Android, macOS |

**`InputSelectionMenuConfig` shape:**

```ts
interface InputSelectionMenuConfig {
/** Shows the built-in "Format" submenu (Bold, Italic, Underline, etc.). */
format?: boolean;
/** Shows the built-in "Copy as Markdown" action. */
copyAsMarkdown?: boolean;
}
```

**Example:**

```tsx
// Hide both the Format submenu and the Copy as Markdown action
<EnrichedMarkdownTextInput
selectionMenuConfig={{ format: false, copyAsMarkdown: false }}
/>

// Keep Format but hide Copy as Markdown
<EnrichedMarkdownTextInput
selectionMenuConfig={{ copyAsMarkdown: false }}
/>
```

### Ref Methods

All methods are called imperatively on the ref (`ref.current?.methodName()`).
Expand Down
11 changes: 11 additions & 0 deletions docs/COPY_OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,14 @@ Use `selectionMenuConfig` to hide built-in selection menu actions while keeping
}}
/>
```

`EnrichedMarkdownTextInput` supports the same prop. In addition to `copyAsMarkdown`, the input's `selectionMenuConfig` can hide the built-in **Format** submenu:

```tsx
<EnrichedMarkdownTextInput
selectionMenuConfig={{
format: false,
copyAsMarkdown: false,
}}
/>
```
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
import com.swmansion.enriched.markdown.input.layout.InputMeasurementStore
import com.swmansion.enriched.markdown.input.model.StyleType
import com.swmansion.enriched.markdown.input.toolbar.InputSelectionMenuConfig
import com.swmansion.enriched.markdown.utils.input.BorderPropsApplicator
import com.swmansion.enriched.markdown.utils.input.MarkdownStyleParser

Expand Down Expand Up @@ -263,6 +264,23 @@ class EnrichedMarkdownTextInputManager :
view.setContextMenuItems(items)
}

@ReactProp(name = "selectionMenuConfig")
override fun setSelectionMenuConfig(
view: EnrichedMarkdownTextInputView?,
value: ReadableMap?,
) {
if (view == null) return
view.contextMenu.selectionMenuConfig =
if (value == null) {
InputSelectionMenuConfig()
} else {
InputSelectionMenuConfig(
format = value.getBoolean("format"),
copyAsMarkdown = value.getBoolean("copyAsMarkdown"),
)
}
}

@ReactProp(name = "linkRegex")
override fun setLinkRegex(
view: EnrichedMarkdownTextInputView?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ import com.swmansion.enriched.markdown.input.model.StyleType

// TODO: Wrap all user-facing strings for localization support.

data class InputSelectionMenuConfig(
val format: Boolean = true,
val copyAsMarkdown: Boolean = true,
)

class InputContextMenu(
private val view: EnrichedMarkdownTextInputView,
) {
private var customItemTexts: List<String> = emptyList()
var selectionMenuConfig: InputSelectionMenuConfig = InputSelectionMenuConfig()

fun setContextMenuItems(items: List<String>) {
customItemTexts = items
Expand All @@ -42,13 +48,17 @@ class InputContextMenu(
menu.removeGroup(FORMAT_MENU_GROUP_ID)
menu.removeGroup(CUSTOM_MENU_GROUP_ID)

val formatSubMenu = menu.addSubMenu(FORMAT_MENU_GROUP_ID, MENU_FORMAT_ID, 100, "Format")
FORMAT_ITEMS.forEachIndexed { index, (title, _) ->
formatSubMenu.add(Menu.NONE, MENU_FORMAT_ITEM_BASE + index, index, title)
if (selectionMenuConfig.format) {
val formatSubMenu = menu.addSubMenu(FORMAT_MENU_GROUP_ID, MENU_FORMAT_ID, 100, "Format")
FORMAT_ITEMS.forEachIndexed { index, (title, _) ->
formatSubMenu.add(Menu.NONE, MENU_FORMAT_ITEM_BASE + index, index, title)
}
}

if (view.selectionStart < view.selectionEnd) {
menu.add(FORMAT_MENU_GROUP_ID, MENU_COPY_MARKDOWN_ID, 101, "Copy as Markdown")
if (selectionMenuConfig.copyAsMarkdown) {
menu.add(FORMAT_MENU_GROUP_ID, MENU_COPY_MARKDOWN_ID, 101, "Copy as Markdown")
}

customItemTexts.forEachIndexed { index, text ->
menu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ - (UIMenu *)textView:(UITextView *)textView
return nil;
}

ENRMInputSelectionMenuConfig menuConfig = [self inputSelectionMenuConfig];
__weak EnrichedMarkdownTextInput *weakSelf = self;

// TODO: Localize titles with NSLocalizedString.
static const struct {
NSString *title;
NSString *icon;
ENRMInputStyleType styleType;
} kFormatItems[] = {
// TODO: Localize titles with NSLocalizedString.
{@"Bold", @"bold", ENRMInputStyleTypeStrong},
{@"Italic", @"italic", ENRMInputStyleTypeEmphasis},
{@"Underline", @"underline", ENRMInputStyleTypeUnderline},
Expand Down Expand Up @@ -91,8 +92,13 @@ - (UIMenu *)textView:(UITextView *)textView
break;
}
}
[systemActions insertObject:formatMenu atIndex:insertIndex];
[systemActions insertObject:copyMarkdownAction atIndex:insertIndex + 1];
if (menuConfig.format) {
[systemActions insertObject:formatMenu atIndex:insertIndex];
insertIndex++;
}
if (menuConfig.copyAsMarkdown) {
[systemActions insertObject:copyMarkdownAction atIndex:insertIndex];
}
[allActions addObjectsFromArray:systemActions];

return [UIMenu menuWithChildren:allActions];
Expand All @@ -104,6 +110,7 @@ - (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu tex
return menu;
}

ENRMInputSelectionMenuConfig menuConfig = [self inputSelectionMenuConfig];
__weak EnrichedMarkdownTextInput *weakSelf = self;
NSArray<NSMenuItem *> *customItems =
ENRMBuildContextMenuItems([self contextMenuItemTexts], [self contextMenuItemIcons], textView,
Expand All @@ -114,41 +121,45 @@ - (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu tex

[menu addItem:[NSMenuItem separatorItem]];

NSMenuItem *copyMarkdownItem = [[NSMenuItem alloc] initWithTitle:@"Copy as Markdown"
action:@selector(copySelectedRangeAsMarkdown)
keyEquivalent:@""];
copyMarkdownItem.target = self;
[menu addItem:copyMarkdownItem];

NSMenu *formatSubmenu = [[NSMenu alloc] initWithTitle:@"Format"];
struct {
NSString *title;
SEL action;
NSString *key;
NSEventModifierFlags modifiers;
} const items[] = {
{@"Bold", @selector(toggleBold), @"b", NSEventModifierFlagCommand},
{@"Italic", @selector(toggleItalic), @"i", NSEventModifierFlagCommand},
{@"Underline", @selector(toggleUnderline), @"u", NSEventModifierFlagCommand},
{@"Strikethrough", @selector(toggleStrikethrough), @"", 0},
{@"Spoiler", @selector(toggleSpoiler), @"", 0},
{@"Link", @selector(showLinkPrompt), @"", 0},
};
if (menuConfig.copyAsMarkdown) {
NSMenuItem *copyMarkdownItem = [[NSMenuItem alloc] initWithTitle:@"Copy as Markdown"
action:@selector(copySelectedRangeAsMarkdown)
keyEquivalent:@""];
copyMarkdownItem.target = self;
[menu addItem:copyMarkdownItem];
}

for (NSUInteger i = 0; i < sizeof(items) / sizeof(items[0]); i++) {
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:items[i].title
action:items[i].action
keyEquivalent:items[i].key];
if (items[i].modifiers) {
item.keyEquivalentModifierMask = items[i].modifiers;
if (menuConfig.format) {
NSMenu *formatSubmenu = [[NSMenu alloc] initWithTitle:@"Format"];
struct {
NSString *title;
SEL action;
NSString *key;
NSEventModifierFlags modifiers;
} const items[] = {
{@"Bold", @selector(toggleBold), @"b", NSEventModifierFlagCommand},
{@"Italic", @selector(toggleItalic), @"i", NSEventModifierFlagCommand},
{@"Underline", @selector(toggleUnderline), @"u", NSEventModifierFlagCommand},
{@"Strikethrough", @selector(toggleStrikethrough), @"", 0},
{@"Spoiler", @selector(toggleSpoiler), @"", 0},
{@"Link", @selector(showLinkPrompt), @"", 0},
};

for (NSUInteger i = 0; i < sizeof(items) / sizeof(items[0]); i++) {
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:items[i].title
action:items[i].action
keyEquivalent:items[i].key];
if (items[i].modifiers) {
item.keyEquivalentModifierMask = items[i].modifiers;
}
item.target = self;
[formatSubmenu addItem:item];
}
item.target = self;
[formatSubmenu addItem:item];
}

NSMenuItem *formatItem = [[NSMenuItem alloc] initWithTitle:@"Format" action:nil keyEquivalent:@""];
formatItem.submenu = formatSubmenu;
[menu addItem:formatItem];
NSMenuItem *formatItem = [[NSMenuItem alloc] initWithTitle:@"Format" action:nil keyEquivalent:@""];
formatItem.submenu = formatSubmenu;
[menu addItem:formatItem];
}

return menu;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

NS_ASSUME_NONNULL_BEGIN

typedef struct {
BOOL format;
BOOL copyAsMarkdown;
} ENRMInputSelectionMenuConfig;

@interface EnrichedMarkdownTextInput (Internal)

- (void)toggleBold;
Expand All @@ -20,6 +25,7 @@ NS_ASSUME_NONNULL_BEGIN
- (void)emitContextMenuItemPress:(NSString *)itemText;
- (NSArray<NSString *> *)contextMenuItemTexts;
- (NSArray<NSString *> *)contextMenuItemIcons;
- (ENRMInputSelectionMenuConfig)inputSelectionMenuConfig;

#if TARGET_OS_OSX
- (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu textView:(NSTextView *)textView;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#import "EnrichedMarkdownTextInput.h"
#import <QuartzCore/CABase.h>
#import "ContextMenuUtils.h"
#import "ENRMAutoLinkDetector.h"
#import "ENRMDetectorPipeline.h"
Expand All @@ -17,9 +16,11 @@
#import "ENRMStyleMergingConfig.h"
#import "ENRMUIKit.h"
#import "ENRMWordsUtils.h"
#import "EnrichedMarkdownTextInput+Internal.h"
#import "InputStylePropsUtils.h"
#import "ParagraphStyleUtils.h"
#import "SelectionColorUtils.h"
#import <QuartzCore/CABase.h>
#import <React/RCTI18nUtil.h>
#if TARGET_OS_OSX
#import <React/RCTBackedTextInputDelegate.h>
Expand Down Expand Up @@ -91,6 +92,8 @@ @implementation EnrichedMarkdownTextInput {

ENRMWritingDirectionMode _writingDirectionMode;
NSWritingDirection _resolvedLayoutDirection;

ENRMInputSelectionMenuConfig _inputSelectionMenuConfig;
}

#pragma mark - Fabric lifecycle
Expand Down Expand Up @@ -128,6 +131,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_writingDirectionMode = ENRMWritingDirectionModeFirstStrong;
_resolvedLayoutDirection =
[[RCTI18nUtil sharedInstance] isRTL] ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight;
_inputSelectionMenuConfig = (ENRMInputSelectionMenuConfig){.format = YES, .copyAsMarkdown = YES};

[self setupTextView];

Expand Down Expand Up @@ -352,6 +356,11 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
_contextMenuItemIcons = ENRMContextMenuIconsFromItems(newViewProps.contextMenuItems);
}

_inputSelectionMenuConfig = (ENRMInputSelectionMenuConfig){
.format = newViewProps.selectionMenuConfig.format,
.copyAsMarkdown = newViewProps.selectionMenuConfig.copyAsMarkdown,
};

if (newViewProps.mentionIndicators != oldViewProps.mentionIndicators) {
NSMutableArray<NSString *> *indicators = [NSMutableArray array];
for (const auto &indicator : newViewProps.mentionIndicators) {
Expand Down Expand Up @@ -964,8 +973,8 @@ - (void)resetPendingStylesForSelectionChange
// Skip system-driven selection adjustments (e.g., predictive text) that fire
// immediately after a text edit.
static const CFTimeInterval kPostEditGracePeriod = 0.1;
BOOL isPostEditAdjustment = (_lastTextChangeTime > 0 &&
(CACurrentMediaTime() - _lastTextChangeTime) < kPostEditGracePeriod);
BOOL isPostEditAdjustment =
(_lastTextChangeTime > 0 && (CACurrentMediaTime() - _lastTextChangeTime) < kPostEditGracePeriod);
if (isPostEditAdjustment) {
return;
}
Expand All @@ -983,11 +992,8 @@ - (void)rebuildPendingStylesFromContext
}

static const ENRMInputStyleType inlineStyles[] = {
ENRMInputStyleTypeStrong,
ENRMInputStyleTypeEmphasis,
ENRMInputStyleTypeUnderline,
ENRMInputStyleTypeStrikethrough,
ENRMInputStyleTypeSpoiler,
ENRMInputStyleTypeStrong, ENRMInputStyleTypeEmphasis, ENRMInputStyleTypeUnderline,
ENRMInputStyleTypeStrikethrough, ENRMInputStyleTypeSpoiler,
};

for (NSUInteger i = 0; i < sizeof(inlineStyles) / sizeof(inlineStyles[0]); i++) {
Expand Down Expand Up @@ -1236,6 +1242,11 @@ - (void)emitCaretRectChangeIfNeeded
return _contextMenuItemIcons ?: @[];
}

- (ENRMInputSelectionMenuConfig)inputSelectionMenuConfig
{
return _inputSelectionMenuConfig;
}

- (void)emitContextMenuItemPress:(NSString *)itemText
{
auto eventEmitter = [self getEventEmitter];
Expand Down
Loading
Loading