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
Expand Up @@ -22,6 +22,7 @@ import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter
import com.swmansion.enriched.markdown.utils.common.TableStreamingMode
import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
import com.swmansion.enriched.markdown.views.BlockSegmentView
import com.swmansion.enriched.markdown.views.TableContainerView
Expand Down Expand Up @@ -78,6 +79,7 @@ class EnrichedMarkdown
private var selectable: Boolean = true
private var selectionColor: Int? = null
private var selectionHandleColor: Int? = null
private var selectionMenuConfig = SelectionMenuConfig()

private var onLinkPressCallback: ((String) -> Unit)? = null
private var onLinkLongPressCallback: ((String) -> Unit)? = null
Expand Down Expand Up @@ -207,6 +209,14 @@ class EnrichedMarkdown
}
}

fun setSelectionMenuConfig(config: SelectionMenuConfig) {
if (selectionMenuConfig == config) return
selectionMenuConfig = config
segmentViews.filterIsInstance<EnrichedMarkdownInternalText>().forEach {
it.selectionMenuConfig = config
}
}

private fun forwardContextMenuItemPress(
itemText: String,
selectedText: String,
Expand Down Expand Up @@ -405,6 +415,7 @@ class EnrichedMarkdown
private fun createTextView(segment: RenderedSegment.Text) =
EnrichedMarkdownInternalText(context).apply {
spoilerOverlay = this@EnrichedMarkdown.spoilerOverlay
selectionMenuConfig = this@EnrichedMarkdown.selectionMenuConfig
setIsSelectable(selectable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && segment.needsJustify) {
justificationMode = android.text.Layout.JUSTIFICATION_MODE_INTER_WORD
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
Expand Down Expand Up @@ -45,13 +46,15 @@ class EnrichedMarkdownInternalText
var spoilerOverlay: SpoilerOverlay = SpoilerOverlay.PARTICLES
private var contextMenuItemTexts: List<String> = emptyList()
private var onContextMenuItemPress: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
var selectionMenuConfig: SelectionMenuConfig = SelectionMenuConfig()

init {
setupAsMarkdownTextView()
customSelectionActionModeCallback =
createSelectionActionModeCallback(
this,
getCustomItemTexts = { contextMenuItemTexts },
getSelectionMenuConfig = { selectionMenuConfig },
onCustomItemPress = { itemText, selectedText, start, end ->
onContextMenuItemPress?.invoke(itemText, selectedText, start, end)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.swmansion.enriched.markdown.utils.common.emitTaskListItemPress
import com.swmansion.enriched.markdown.utils.common.markdownEventTypeConstants
import com.swmansion.enriched.markdown.utils.common.parseContextMenuItems
import com.swmansion.enriched.markdown.utils.common.parseMd4cFlags
import com.swmansion.enriched.markdown.utils.common.parseSelectionMenuConfig
import com.swmansion.enriched.markdown.utils.text.interaction.TaskListToggleUtils

@ReactModule(name = EnrichedMarkdownManager.NAME)
Expand Down Expand Up @@ -186,6 +187,15 @@ class EnrichedMarkdownManager :
view.setContextMenuItems(parseContextMenuItems(value))
}

@ReactProp(name = "selectionMenuConfig")
override fun setSelectionMenuConfig(
view: EnrichedMarkdown?,
value: ReadableMap?,
) {
if (view == null) return
view.setSelectionMenuConfig(parseSelectionMenuConfig(value))
}

override fun setPadding(
view: EnrichedMarkdown,
left: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
Expand Down Expand Up @@ -84,13 +85,15 @@ class EnrichedMarkdownText

private var selectionColor: Int? = null
private var selectionHandleColor: Int? = null
private var selectionMenuConfig = SelectionMenuConfig()

init {
setupAsMarkdownTextView()
customSelectionActionModeCallback =
createSelectionActionModeCallback(
this,
getCustomItemTexts = { contextMenuItemTexts },
getSelectionMenuConfig = { selectionMenuConfig },
onCustomItemPress = { itemText, selectedText, start, end ->
onContextMenuItemPressCallback?.invoke(itemText, selectedText, start, end)
},
Expand Down Expand Up @@ -260,6 +263,11 @@ class EnrichedMarkdownText
contextMenuItemTexts = items
}

fun setSelectionMenuConfig(config: SelectionMenuConfig) {
if (selectionMenuConfig == config) return
selectionMenuConfig = config
}

fun setIsSelectable(selectable: Boolean) {
applySelectableState(selectable)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.swmansion.enriched.markdown.utils.common.emitTaskListItemPress
import com.swmansion.enriched.markdown.utils.common.markdownEventTypeConstants
import com.swmansion.enriched.markdown.utils.common.parseContextMenuItems
import com.swmansion.enriched.markdown.utils.common.parseMd4cFlags
import com.swmansion.enriched.markdown.utils.common.parseSelectionMenuConfig
import com.swmansion.enriched.markdown.utils.text.interaction.TaskListTapUtils
import com.swmansion.enriched.markdown.utils.text.interaction.TaskListToggleUtils

Expand Down Expand Up @@ -187,6 +188,15 @@ class EnrichedMarkdownTextManager :
view.setContextMenuItems(parseContextMenuItems(value))
}

@ReactProp(name = "selectionMenuConfig")
override fun setSelectionMenuConfig(
view: EnrichedMarkdownText?,
value: ReadableMap?,
) {
if (view == null) return
view.setSelectionMenuConfig(parseSelectionMenuConfig(value))
}

override fun setPadding(
view: EnrichedMarkdownText,
left: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.swmansion.enriched.markdown.events.LinkLongPressEvent
import com.swmansion.enriched.markdown.events.LinkPressEvent
import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
import com.swmansion.enriched.markdown.parser.Md4cFlags
import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig

fun markdownEventTypeConstants(): MutableMap<String, Any> {
val map = mutableMapOf<String, Any>()
Expand Down Expand Up @@ -86,3 +87,11 @@ fun parseMd4cFlags(flags: ReadableMap?): Md4cFlags =

fun parseContextMenuItems(value: ReadableArray?): List<String> =
(0 until (value?.size() ?: 0)).mapNotNull { value?.getMap(it)?.getString("text") }

fun parseSelectionMenuConfig(value: ReadableMap?): SelectionMenuConfig {
if (value == null) return SelectionMenuConfig()
return SelectionMenuConfig(
copyAsMarkdown = value.getBoolean("copyAsMarkdown"),
copyImageUrl = value.getBoolean("copyImageUrl"),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,21 @@ private const val MENU_ITEM_COPY_IMAGE_URL = 1001
private const val MENU_ITEM_CUSTOM_BASE = 2000
private const val MENU_ITEM_CUSTOM_GROUP = 2001

data class SelectionMenuConfig(
val copyAsMarkdown: Boolean = true,
val copyImageUrl: Boolean = true,
)

/**
* Creates an ActionMode.Callback that adds custom copy options and
* overrides the default "Copy" action to include HTML for rich text support.
*/
fun createSelectionActionModeCallback(
textView: TextView,
getCustomItemTexts: () -> List<String> = { emptyList() },
onCustomItemPress: (itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit = { _, _, _, _ -> },
getSelectionMenuConfig: () -> SelectionMenuConfig = { SelectionMenuConfig() },
onCustomItemPress: (itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit =
{ _, _, _, _ -> },
): ActionMode.Callback =
object : ActionMode.Callback {
override fun onCreateActionMode(
Expand All @@ -47,9 +54,17 @@ fun createSelectionActionModeCallback(
menu.removeItem(MENU_ITEM_COPY_IMAGE_URL)
menu.removeGroup(MENU_ITEM_CUSTOM_GROUP)

if (textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart) {
val selectionMenuConfig = getSelectionMenuConfig()

if (
selectionMenuConfig.copyAsMarkdown &&
textView.selectionStart >= 0 &&
textView.selectionEnd > textView.selectionStart
) {
menu.add(Menu.NONE, MENU_ITEM_COPY_MARKDOWN, Menu.NONE, "Copy as Markdown")
}

if (textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart) {
val customItems = getCustomItemTexts()
customItems.forEachIndexed { index, text ->
menu
Expand All @@ -58,7 +73,12 @@ fun createSelectionActionModeCallback(
}
}

val imageUrls = textView.getImageUrlsInSelection()
val imageUrls =
if (selectionMenuConfig.copyImageUrl) {
textView.getImageUrlsInSelection()
} else {
emptyList()
}
if (imageUrls.isNotEmpty()) {
val title =
if (imageUrls.size == 1) {
Expand Down
6 changes: 3 additions & 3 deletions apps/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2117,7 +2117,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da
hermes-engine: b0f9c82a51be8e938eb979ada628323e1e093f1d
hermes-engine: 40811a005e96e04818cff405ec04a5b4c4411c1c
iosMath: f7a6cbadf9d836d2149c2a84c435b1effc244cba
RCTDeprecation: af44b104091a34482596cd9bd7e8d90c4e9b4bd7
RCTRequired: bb77b070f75f53398ce43c0aaaa58337cebe2bf6
Expand All @@ -2127,7 +2127,7 @@ SPEC CHECKSUMS:
React: 1ba7d364ade7d883a1ec055bfc3606f35fdee17b
React-callinvoker: bc2a26f8d84fb01f003fc6de6c9337b64715f95b
React-Core: 7840d3a80b43a95c5e80ef75146bd70925ebab0f
React-Core-prebuilt: 3dc04e91547fc0f260f4b84c12da0f672b813862
React-Core-prebuilt: 7965d06a81dcc544164f8e98b26d35ae2a4eb36e
React-CoreModules: 2eb010400b63b89e53a324ffb3c112e4c7c3ce42
React-cxxreact: a558e92199d26f145afa9e62c4233cf8e7950efe
React-debug: 755200a6e7f5e6e0a40ff8d215493d43cce285fc
Expand Down Expand Up @@ -2189,7 +2189,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: e96e93b493d8d86eeaee3e590ba0be53f6abe46f
ReactCodegen: 797de5178718324c6eba3327b07f9a423fbd5787
ReactCommon: 07572bf9e687c8a52fbe4a3641e9e3a1a477c78e
ReactNativeDependencies: 44f7326a697de7f6c8629036b1a4689f0e64c684
ReactNativeDependencies: 0811b43c669e637a9f3c485fdb106f187fa88398
ReactNativeEnrichedMarkdown: e6fea750b1ca4cbd853c1c78de9bd1ccb638ab77
Yoga: c0b3f2c7e8d3e327e450223a2414ca3fa296b9a2

Expand Down
4 changes: 4 additions & 0 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export default function App() {
contextMenuItems={contextMenuItems}
selectionColor={Platform.OS === 'ios' ? '#5A52FA' : '#DCDDFE'}
selectionHandleColor="#5A52FA"
selectionMenuConfig={{
copyAsMarkdown: true,
copyImageUrl: true,
}}
/>
</ScrollView>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/macos-example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export default function App() {
markdownStyle={markdownStyle}
contextMenuItems={contextMenuItems}
selectionColor="#DCDDFE"
selectionMenuConfig={{
copyAsMarkdown: true,
copyImageUrl: true,
}}
/>
</ScrollView>
{lastLink != null && (
Expand Down
31 changes: 31 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,37 @@ interface ContextMenuItem {
/>
```

### `selectionMenuConfig`

Controls built-in actions added to the native text selection menu. Custom app-provided actions are controlled separately with `contextMenuItems`.

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

**`SelectionMenuConfig` shape:**

```ts
interface SelectionMenuConfig {
/** Shows the built-in "Copy as Markdown" action for text selections. */
copyAsMarkdown?: boolean;
/** Shows the built-in "Copy Image URL" action when selected content contains images. */
copyImageUrl?: boolean;
}
```

**Example:**

```tsx
<EnrichedMarkdownText
markdown={content}
selectionMenuConfig={{
copyAsMarkdown: false,
copyImageUrl: false,
}}
/>
```

> **Note:** When using `flavor="github"`, `selection.start` and `selection.end` are relative to the text segment the selection is in, not the full markdown string. With `flavor="commonmark"` (default) they are always absolute within the full rendered text.

---
Expand Down
14 changes: 14 additions & 0 deletions docs/COPY_OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,17 @@ A dedicated **Copy as Markdown** option is available in the context menu on both
## Copy Image URL

When selecting text that contains images, a **Copy Image URL** option appears to copy the image's source URL. On Android, if multiple images are selected, all URLs are copied (one per line).

## Controlling Built-in Menu Items

Use `selectionMenuConfig` to hide built-in selection menu actions while keeping the native menu and any `contextMenuItems` intact:

```tsx
<EnrichedMarkdownText
markdown={content}
selectionMenuConfig={{
copyAsMarkdown: false,
copyImageUrl: false,
}}
/>
```
1 change: 1 addition & 0 deletions docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The web implementation also exports `WebMarkdownTextProps` which extends `Enrich
| `streamingAnimation` | Native-only tail fade-in animation. Not yet implemented on web. |
| `streamingConfig` | Native-only streaming table configuration. Not yet implemented on web. |
| `contextMenuItems` | Not supported — browsers don't allow extending the native context menu. |
| `selectionMenuConfig` | Not supported — native-only built-in selection menu actions. |
| `selectionHandleColor` | Android-only — desktop browsers don't render selection handles. |

## Not supported on web
Expand Down
11 changes: 9 additions & 2 deletions ios/EnrichedMarkdown.mm
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ @implementation EnrichedMarkdown {

NSArray<NSString *> *_contextMenuItemTexts;
NSArray<NSString *> *_contextMenuItemIcons;
ENRMSelectionMenuConfig _selectionMenuConfig;

ENRMSpoilerOverlay _spoilerOverlay;
}
Expand Down Expand Up @@ -124,6 +125,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_enableLinkPreview = YES;
_streamingAnimation = NO;
_tableStreamingMode = ENRMTableStreamingModeHidden;
_selectionMenuConfig = (ENRMSelectionMenuConfig){.copyAsMarkdown = YES, .copyImageURL = YES};

_fontScaleObserver = [[FontScaleObserver alloc] init];
__weak EnrichedMarkdown *weakSelf = self;
Expand Down Expand Up @@ -517,7 +519,7 @@ - (EnrichedMarkdownInternalText *)createTextViewForRenderedSegment:(ENRMRenderRe
}
});
return buildEditMenuForSelection(textView.textStorage, textView.selectedRange, segmentMarkdown, strongSelf->_config,
@[ baseMenu ], customItems);
@[ baseMenu ], customItems, strongSelf -> _selectionMenuConfig);
}];
#endif

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

_selectionMenuConfig = (ENRMSelectionMenuConfig){
.copyAsMarkdown = newViewProps.selectionMenuConfig.copyAsMarkdown,
.copyImageURL = newViewProps.selectionMenuConfig.copyImageUrl,
};

if (newViewProps.spoilerOverlay != oldViewProps.spoilerOverlay) {
NSString *modeStr = [[NSString alloc] initWithUTF8String:newViewProps.spoilerOverlay.c_str()];
_spoilerOverlay = ENRMSpoilerOverlayFromString(modeStr);
Expand Down Expand Up @@ -913,7 +920,7 @@ - (UIMenu *)textView:(UITextView *)textView

NSString *segmentMarkdown = extractMarkdownFromAttributedString(textView.attributedText, range);
return buildEditMenuForSelection(textView.attributedText, range, segmentMarkdown, _config, suggestedActions,
customActions);
customActions, _selectionMenuConfig);
}

- (BOOL)textView:(UITextView *)textView
Expand Down
Loading
Loading