diff --git a/apps/example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/props/FormatMenu.stories.tsx b/apps/example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/props/FormatMenu.stories.tsx new file mode 100644 index 00000000..3b2f3e67 --- /dev/null +++ b/apps/example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/props/FormatMenu.stories.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { EnrichedMarkdownTextInputStory } from '../EnrichedMarkdownTextInputStory'; +import { storyMeta } from '../shared/storyMeta'; +import type { InputStory } from '../shared/storyTypes'; + +type FormatMenuStoryExtra = { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + spoiler: boolean; + link: boolean; +}; + +const MARKDOWN = + 'Select this text and open the Format submenu to see which items are visible.'; + +const argTypes = { + bold: { + control: 'boolean', + description: 'formatMenuConfig.bold — show "Bold" in the Format submenu.', + }, + italic: { + control: 'boolean', + description: + 'formatMenuConfig.italic — show "Italic" in the Format submenu.', + }, + underline: { + control: 'boolean', + description: + 'formatMenuConfig.underline — show "Underline" in the Format submenu.', + }, + strikethrough: { + control: 'boolean', + description: + 'formatMenuConfig.strikethrough — show "Strikethrough" in the Format submenu.', + }, + spoiler: { + control: 'boolean', + description: + 'formatMenuConfig.spoiler — show "Spoiler" in the Format submenu.', + }, + link: { + control: 'boolean', + description: 'formatMenuConfig.link — show "Link" in the Format submenu.', + }, +}; + +export default storyMeta('Props', 'Format Menu'); + +export const Default: InputStory = { + args: { + initialMarkdown: MARKDOWN, + bold: true, + italic: true, + underline: true, + strikethrough: true, + spoiler: true, + link: true, + }, + argTypes, + render: ({ + bold, + italic, + underline, + strikethrough, + spoiler, + link, + ...args + }) => ( + + ), +}; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index bebb312b..1ca0660f 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -722,6 +722,36 @@ interface InputSelectionMenuConfig { /> ``` +### `formatMenuConfig` + +Controls which individual items appear inside the Format submenu. Only effective when `selectionMenuConfig.format` is `true` (the default). Omitting the prop or any field shows all items. + +| Type | Default Value | Platform | +| ------------------ | ------------------------------------------------------------------------------------ | ------------------- | +| `FormatMenuConfig` | `{ bold: true, italic: true, underline: true, strikethrough: true, spoiler: true, link: true }` | iOS, Android, macOS | + +**`FormatMenuConfig` shape:** + +```ts +interface FormatMenuConfig { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + spoiler?: boolean; + link?: boolean; +} +``` + +**Example:** + +```tsx +// Hide Spoiler and Link from the Format submenu + +``` + ### Ref Methods All methods are called imperatively on the ref (`ref.current?.methodName()`). diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt index 391d5061..a52be906 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt @@ -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.FormatMenuConfig import com.swmansion.enriched.markdown.input.toolbar.InputSelectionMenuConfig import com.swmansion.enriched.markdown.utils.input.BorderPropsApplicator import com.swmansion.enriched.markdown.utils.input.MarkdownStyleParser @@ -281,6 +282,27 @@ class EnrichedMarkdownTextInputManager : } } + @ReactProp(name = "formatMenuConfig") + override fun setFormatMenuConfig( + view: EnrichedMarkdownTextInputView?, + value: ReadableMap?, + ) { + if (view == null) return + view.contextMenu.formatMenuConfig = + if (value == null) { + FormatMenuConfig() + } else { + FormatMenuConfig( + bold = value.getBoolean("bold"), + italic = value.getBoolean("italic"), + underline = value.getBoolean("underline"), + strikethrough = value.getBoolean("strikethrough"), + spoiler = value.getBoolean("spoiler"), + link = value.getBoolean("link"), + ) + } + } + @ReactProp(name = "linkRegex") override fun setLinkRegex( view: EnrichedMarkdownTextInputView?, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt index f2ec225d..4dc215fa 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt @@ -21,11 +21,21 @@ data class InputSelectionMenuConfig( val copyAsMarkdown: Boolean = true, ) +data class FormatMenuConfig( + val bold: Boolean = true, + val italic: Boolean = true, + val underline: Boolean = true, + val strikethrough: Boolean = true, + val spoiler: Boolean = true, + val link: Boolean = true, +) + class InputContextMenu( private val view: EnrichedMarkdownTextInputView, ) { private var customItemTexts: List = emptyList() var selectionMenuConfig: InputSelectionMenuConfig = InputSelectionMenuConfig() + var formatMenuConfig: FormatMenuConfig = FormatMenuConfig() fun setContextMenuItems(items: List) { customItemTexts = items @@ -50,8 +60,10 @@ class InputContextMenu( 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) + FORMAT_ITEMS.forEachIndexed { index, (title, styleType) -> + if (isFormatItemVisible(styleType)) { + formatSubMenu.add(Menu.NONE, MENU_FORMAT_ITEM_BASE + index, index, title) + } } } @@ -106,6 +118,16 @@ class InputContextMenu( } } + private fun isFormatItemVisible(styleType: StyleType): Boolean = + when (styleType) { + StyleType.BOLD -> formatMenuConfig.bold + StyleType.ITALIC -> formatMenuConfig.italic + StyleType.UNDERLINE -> formatMenuConfig.underline + StyleType.STRIKETHROUGH -> formatMenuConfig.strikethrough + StyleType.SPOILER -> formatMenuConfig.spoiler + StyleType.LINK -> formatMenuConfig.link + } + private fun applyFormat(styleType: StyleType) { val start = view.selectionStart val end = view.selectionEnd diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm index 9aa70a76..1beb4265 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm @@ -26,6 +26,7 @@ - (UIMenu *)textView:(UITextView *)textView } ENRMInputSelectionMenuConfig menuConfig = [self inputSelectionMenuConfig]; + ENRMFormatMenuConfig fmtConfig = [self formatMenuConfig]; __weak EnrichedMarkdownTextInput *weakSelf = self; // TODO: Localize titles with NSLocalizedString. @@ -42,9 +43,17 @@ - (UIMenu *)textView:(UITextView *)textView {@"Link", @"link", ENRMInputStyleTypeLink}, }; static const NSUInteger kFormatItemCount = sizeof(kFormatItems) / sizeof(kFormatItems[0]); + const BOOL kFormatItemVisible[] = {fmtConfig.bold, fmtConfig.italic, fmtConfig.underline, + fmtConfig.strikethrough, fmtConfig.spoiler, fmtConfig.link}; + _Static_assert(sizeof(kFormatItemVisible) / sizeof(kFormatItemVisible[0]) == + sizeof(kFormatItems) / sizeof(kFormatItems[0]), + "kFormatItemVisible must match kFormatItems length"); NSMutableArray *formatActions = [NSMutableArray arrayWithCapacity:kFormatItemCount]; for (NSUInteger i = 0; i < kFormatItemCount; i++) { + if (!kFormatItemVisible[i]) { + continue; + } ENRMInputStyleType styleType = kFormatItems[i].styleType; UIAction *action = [UIAction actionWithTitle:kFormatItems[i].title image:[UIImage systemImageNamed:kFormatItems[i].icon] @@ -111,6 +120,7 @@ - (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu tex } ENRMInputSelectionMenuConfig menuConfig = [self inputSelectionMenuConfig]; + ENRMFormatMenuConfig fmtConfig = [self formatMenuConfig]; __weak EnrichedMarkdownTextInput *weakSelf = self; NSArray *customItems = ENRMBuildContextMenuItems([self contextMenuItemTexts], [self contextMenuItemIcons], textView, @@ -144,8 +154,15 @@ - (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu tex {@"Spoiler", @selector(toggleSpoiler), @"", 0}, {@"Link", @selector(showLinkPrompt), @"", 0}, }; + const BOOL visible[] = {fmtConfig.bold, fmtConfig.italic, fmtConfig.underline, + fmtConfig.strikethrough, fmtConfig.spoiler, fmtConfig.link}; + _Static_assert(sizeof(visible) / sizeof(visible[0]) == sizeof(items) / sizeof(items[0]), + "visible must match items length"); for (NSUInteger i = 0; i < sizeof(items) / sizeof(items[0]); i++) { + if (!visible[i]) { + continue; + } NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:items[i].title action:items[i].action keyEquivalent:items[i].key]; diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h index 4319880a..25fcd1fc 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h @@ -10,6 +10,15 @@ typedef struct { BOOL copyAsMarkdown; } ENRMInputSelectionMenuConfig; +typedef struct { + BOOL bold; + BOOL italic; + BOOL underline; + BOOL strikethrough; + BOOL spoiler; + BOOL link; +} ENRMFormatMenuConfig; + @interface EnrichedMarkdownTextInput (Internal) - (void)toggleBold; @@ -26,6 +35,7 @@ typedef struct { - (NSArray *)contextMenuItemTexts; - (NSArray *)contextMenuItemIcons; - (ENRMInputSelectionMenuConfig)inputSelectionMenuConfig; +- (ENRMFormatMenuConfig)formatMenuConfig; #if TARGET_OS_OSX - (NSMenu *)enrichedMenuForEvent:(NSEvent *)event defaultMenu:(NSMenu *)menu textView:(NSTextView *)textView; diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index c243edaa..442cfa45 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -94,6 +94,7 @@ @implementation EnrichedMarkdownTextInput { NSWritingDirection _resolvedLayoutDirection; ENRMInputSelectionMenuConfig _inputSelectionMenuConfig; + ENRMFormatMenuConfig _formatMenuConfig; } #pragma mark - Fabric lifecycle @@ -132,6 +133,8 @@ - (instancetype)initWithFrame:(CGRect)frame _resolvedLayoutDirection = [[RCTI18nUtil sharedInstance] isRTL] ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight; _inputSelectionMenuConfig = (ENRMInputSelectionMenuConfig){.format = YES, .copyAsMarkdown = YES}; + _formatMenuConfig = (ENRMFormatMenuConfig){ + .bold = YES, .italic = YES, .underline = YES, .strikethrough = YES, .spoiler = YES, .link = YES}; [self setupTextView]; @@ -361,6 +364,15 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & .copyAsMarkdown = newViewProps.selectionMenuConfig.copyAsMarkdown, }; + _formatMenuConfig = (ENRMFormatMenuConfig){ + .bold = newViewProps.formatMenuConfig.bold, + .italic = newViewProps.formatMenuConfig.italic, + .underline = newViewProps.formatMenuConfig.underline, + .strikethrough = newViewProps.formatMenuConfig.strikethrough, + .spoiler = newViewProps.formatMenuConfig.spoiler, + .link = newViewProps.formatMenuConfig.link, + }; + if (newViewProps.mentionIndicators != oldViewProps.mentionIndicators) { NSMutableArray *indicators = [NSMutableArray array]; for (const auto &indicator : newViewProps.mentionIndicators) { @@ -1247,6 +1259,11 @@ - (ENRMInputSelectionMenuConfig)inputSelectionMenuConfig return _inputSelectionMenuConfig; } +- (ENRMFormatMenuConfig)formatMenuConfig +{ + return _formatMenuConfig; +} + - (void)emitContextMenuItemPress:(NSString *)itemText { auto eventEmitter = [self getEventEmitter]; diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx index 3aaa5f6d..33126514 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx @@ -127,6 +127,21 @@ export interface InputSelectionMenuConfig { copyAsMarkdown?: boolean; } +export interface FormatMenuConfig { + /** @default true */ + bold?: boolean; + /** @default true */ + italic?: boolean; + /** @default true */ + underline?: boolean; + /** @default true */ + strikethrough?: boolean; + /** @default true */ + spoiler?: boolean; + /** @default true */ + link?: boolean; +} + export interface EnrichedMarkdownTextInputProps extends Omit< ViewProps, 'style' | 'children' @@ -165,6 +180,14 @@ export interface EnrichedMarkdownTextInputProps extends Omit< * @platform ios, android, macos */ selectionMenuConfig?: InputSelectionMenuConfig; + /** + * Controls which items appear inside the Format submenu. + * Only effective when `selectionMenuConfig.format` is `true` (the default). + * Omitting the prop or any field shows all items. + * @default { bold: true, italic: true, underline: true, strikethrough: true, spoiler: true, link: true } + * @platform ios, android, macos + */ + formatMenuConfig?: FormatMenuConfig; linkRegex?: RegExp | null; /** * Paragraph writing direction. @@ -227,6 +250,7 @@ export const EnrichedMarkdownTextInput = ({ onBlur, contextMenuItems, selectionMenuConfig, + formatMenuConfig, linkRegex: _linkRegex, writingDirection = 'first-strong', ...rest @@ -283,6 +307,18 @@ export const EnrichedMarkdownTextInput = ({ [selectionMenuConfig] ); + const normalizedFormatMenuConfig = useMemo( + () => ({ + bold: formatMenuConfig?.bold ?? true, + italic: formatMenuConfig?.italic ?? true, + underline: formatMenuConfig?.underline ?? true, + strikethrough: formatMenuConfig?.strikethrough ?? true, + spoiler: formatMenuConfig?.spoiler ?? true, + link: formatMenuConfig?.link ?? true, + }), + [formatMenuConfig] + ); + const linkRegex = useMemo( () => toNativeRegexConfig(_linkRegex), [_linkRegex] @@ -494,6 +530,7 @@ export const EnrichedMarkdownTextInput = ({ } contextMenuItems={nativeContextMenuItems} selectionMenuConfig={normalizedSelectionMenuConfig} + formatMenuConfig={normalizedFormatMenuConfig} mentionIndicators={mentionIndicators} onContextMenuItemPress={ handleContextMenuItemPress as NativeProps['onContextMenuItemPress'] diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts index 46292345..282e5238 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts @@ -118,6 +118,15 @@ export interface InputSelectionMenuConfigInternal { copyAsMarkdown: boolean; } +export interface FormatMenuConfigInternal { + bold: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + spoiler: boolean; + link: boolean; +} + export interface OnContextMenuItemPressEvent { itemText: string; selectedText: string; @@ -209,6 +218,11 @@ export interface NativeProps extends ViewProps { */ selectionMenuConfig: Readonly; + /** + * Controls which items appear inside the Format submenu. + */ + formatMenuConfig: Readonly; + /** * Regex configuration for automatic link detection. * Omit or pass undefined to use platform defaults. diff --git a/packages/react-native-enriched-markdown/src/index.tsx b/packages/react-native-enriched-markdown/src/index.tsx index 4d7ac2b9..e4d315ec 100644 --- a/packages/react-native-enriched-markdown/src/index.tsx +++ b/packages/react-native-enriched-markdown/src/index.tsx @@ -21,6 +21,7 @@ export type { StyleState, ContextMenuItem, InputSelectionMenuConfig, + FormatMenuConfig, OnLinkDetected, OnStartMentionEvent, OnChangeMentionEvent,