diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index dceb56a7..c52877f3 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -18,6 +18,7 @@ import com.swmansion.enriched.markdown.utils.common.FeatureFlags import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer import com.swmansion.enriched.markdown.utils.common.RenderedSegment import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments +import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent import com.swmansion.enriched.markdown.views.BlockSegmentView @@ -54,6 +55,8 @@ class EnrichedMarkdown private var maxFontSizeMultiplier: Float = 0f private var allowTrailingMargin: Boolean = false private var selectable: Boolean = true + private var selectionColor: Int? = null + private var selectionHandleColor: Int? = null private var onLinkPressCallback: ((String) -> Unit)? = null private var onLinkLongPressCallback: ((String) -> Unit)? = null @@ -128,6 +131,22 @@ class EnrichedMarkdown } } + fun setSelectionColor(color: Int?) { + selectionColor = color + applySelectionColorsToSegments() + } + + fun setSelectionHandleColor(color: Int?) { + selectionHandleColor = color + applySelectionColorsToSegments() + } + + private fun applySelectionColorsToSegments() { + segmentViews.filterIsInstance().forEach { + it.applySelectionColors(selectionColor, selectionHandleColor) + } + } + fun setOnLinkPressCallback(callback: (String) -> Unit) { onLinkPressCallback = callback } @@ -234,6 +253,8 @@ class EnrichedMarkdown if (contextMenuItemTexts.isNotEmpty()) { setContextMenuItems(contextMenuItemTexts, ::forwardContextMenuItemPress) } + + applySelectionColors(selectionColor, selectionHandleColor) } private fun createTableView( diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index 89a31534..2684a129 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -80,6 +80,20 @@ class EnrichedMarkdownManager : view?.setIsSelectable(selectable) } + override fun setSelectionColor( + view: EnrichedMarkdown?, + value: Int?, + ) { + view?.setSelectionColor(value) + } + + override fun setSelectionHandleColor( + view: EnrichedMarkdown?, + value: Int?, + ) { + view?.setSelectionHandleColor(value) + } + @ReactProp(name = "md4cFlags") override fun setMd4cFlags( view: EnrichedMarkdown?, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt index aa9614e4..891c86e8 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt @@ -23,6 +23,7 @@ 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.applySelectableState +import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback @@ -81,6 +82,9 @@ class EnrichedMarkdownText private set var spoilerOverlay: SpoilerOverlay = SpoilerOverlay.PARTICLES + private var selectionColor: Int? = null + private var selectionHandleColor: Int? = null + init { setupAsMarkdownTextView() customSelectionActionModeCallback = @@ -248,6 +252,8 @@ class EnrichedMarkdownText fadeAnimator?.animate(tailStart, styledText.length) previousTextLength = styledText.length } + + applySelectionColors(selectionColor, selectionHandleColor) } fun setContextMenuItems(items: List) { @@ -258,6 +264,16 @@ class EnrichedMarkdownText applySelectableState(selectable) } + fun setSelectionColor(color: Int?) { + selectionColor = color + applySelectionColors(selectionColor, selectionHandleColor) + } + + fun setSelectionHandleColor(color: Int?) { + selectionHandleColor = color + applySelectionColors(selectionColor, selectionHandleColor) + } + fun emitOnLinkPress(url: String) { emitLinkPressEvent(url) } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt index 4cce5323..ed860bc8 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt @@ -100,6 +100,20 @@ class EnrichedMarkdownTextManager : view?.setIsSelectable(selectable) } + override fun setSelectionColor( + view: EnrichedMarkdownText?, + value: Int?, + ) { + view?.setSelectionColor(value) + } + + override fun setSelectionHandleColor( + view: EnrichedMarkdownText?, + value: Int?, + ) { + view?.setSelectionHandleColor(value) + } + @ReactProp(name = "md4cFlags") override fun setMd4cFlags( view: EnrichedMarkdownText?, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextSelectionColors.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextSelectionColors.kt new file mode 100644 index 00000000..1a6d7242 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextSelectionColors.kt @@ -0,0 +1,48 @@ +package com.swmansion.enriched.markdown.utils.text.view + +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.Log +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.graphics.drawable.DrawableCompat + +private const val TAG = "TextSelectionColors" + +private typealias HandleGetter = (TextView) -> Drawable? +private typealias HandleSetter = (TextView, Drawable) -> Unit + +/** + * Applies selection highlight and (where supported) handle tinting to a [TextView]. + * + * Handle drawables are only tinted on API 29+ where the framework exposes getters; + * on older versions the handle theme defaults remain unchanged. + */ +fun TextView.applySelectionColors( + selectionColor: Int?, + selectionHandleColor: Int?, +) { + selectionColor?.let { highlightColor = it } + selectionHandleColor?.let { applySelectionHandleTint(it) } +} + +private fun TextView.applySelectionHandleTint( + @ColorInt color: Int, +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + val handles: List> = + listOf( + TextView::getTextSelectHandleLeft to { tv, d -> tv.setTextSelectHandleLeft(d) }, + TextView::getTextSelectHandle to { tv, d -> tv.setTextSelectHandle(d) }, + TextView::getTextSelectHandleRight to { tv, d -> tv.setTextSelectHandleRight(d) }, + ) + + handles.forEach { (getter, setter) -> + try { + getter(this)?.mutate()?.also { DrawableCompat.setTint(it, color) }?.let { setter(this, it) } + } catch (e: LinkageError) { + Log.w(TAG, "Selection handle tint skipped: ${e.message}") + } + } +} diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index e4c44a7a..6de1b119 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -93,6 +93,8 @@ export default function App() { onLinkPress={handleLinkPress} markdownStyle={markdownStyle} contextMenuItems={contextMenuItems} + selectionColor={Platform.OS === 'ios' ? '#5A52FA' : '#DCDDFE'} + selectionHandleColor="#5A52FA" /> )} diff --git a/apps/web-example/src/App.tsx b/apps/web-example/src/App.tsx index 7547f280..0564d438 100644 --- a/apps/web-example/src/App.tsx +++ b/apps/web-example/src/App.tsx @@ -153,6 +153,8 @@ export default function App() { onLinkPress={onLinkPress} onLinkLongPress={onLinkLongPress} onTaskListItemPress={onTaskListItemPress} + selectionColor="#DCDDFE" + selectionHandleColor="#5A52FA" /> diff --git a/ios/EnrichedMarkdown.mm b/ios/EnrichedMarkdown.mm index 9227b789..92c73f07 100644 --- a/ios/EnrichedMarkdown.mm +++ b/ios/EnrichedMarkdown.mm @@ -7,6 +7,7 @@ #import "EditMenuUtils.h" #import "ENRMFeatureFlags.h" +#import "ENRMUIKit.h" #if ENRICHED_MARKDOWN_MATH #import "ENRMMathContainerView.h" @@ -90,6 +91,7 @@ + (instancetype)segmentWithLatex:(NSString *)latex #endif @interface EnrichedMarkdown () +- (void)applySelectionColor:(const EnrichedMarkdownProps &)props toTextView:(ENRMPlatformTextView *)textView; @end @implementation EnrichedMarkdown { @@ -493,6 +495,9 @@ - (EnrichedMarkdownInternalText *)createTextViewForRenderedSegment:(ENRMRenderRe view.textView.selectable = _selectable; [view applyAttributedText:segment.attributedText context:segment.context]; + const auto &selectionProps = *std::static_pointer_cast(self->_props); + [self applySelectionColor:selectionProps toTextView:view.textView]; + ENRMTapRecognizer *tapRecognizer = [[ENRMTapRecognizer alloc] initWithTarget:self action:@selector(textTapped:)]; [view.textView addGestureRecognizer:tapRecognizer]; @@ -657,6 +662,17 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & } } + if (newViewProps.selectionColor != oldViewProps.selectionColor) { +#if !TARGET_OS_OSX + for (RCTUIView *segment in _segmentViews) { + if ([segment isKindOfClass:[EnrichedMarkdownInternalText class]]) { + ENRMPlatformTextView *tv = ((EnrichedMarkdownInternalText *)segment).textView; + [self applySelectionColor:newViewProps toTextView:tv]; + } + } +#endif + } + if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged) { NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; [self renderMarkdownContent:markdownString]; @@ -880,4 +896,15 @@ - (NSInteger)indexOfAccessibilityElement:(id)element } #endif +- (void)applySelectionColor:(const EnrichedMarkdownProps &)props toTextView:(ENRMPlatformTextView *)textView +{ +#if !TARGET_OS_OSX + if (isColorMeaningful(props.selectionColor)) { + ENRMSetSelectionColor(textView, RCTUIColorFromSharedColor(props.selectionColor)); + } else { + ENRMSetSelectionColor(textView, nil); + } +#endif +} + @end diff --git a/ios/EnrichedMarkdownText.mm b/ios/EnrichedMarkdownText.mm index 239e435a..47bb9970 100644 --- a/ios/EnrichedMarkdownText.mm +++ b/ios/EnrichedMarkdownText.mm @@ -9,6 +9,7 @@ #import "ENRMTailFadeInAnimator.h" #import "ENRMTextRenderer.h" #import "ENRMTextViewSetup.h" +#import "ENRMUIKit.h" #import "EditMenuUtils.h" #import "FontScaleObserver.h" #import "FontUtils.h" @@ -411,6 +412,16 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _textView.selectable = newViewProps.selectable; } + if (newViewProps.selectionColor != oldViewProps.selectionColor) { +#if !TARGET_OS_OSX + if (isColorMeaningful(newViewProps.selectionColor)) { + ENRMSetSelectionColor(_textView, RCTUIColorFromSharedColor(newViewProps.selectionColor)); + } else { + ENRMSetSelectionColor(_textView, nil); + } +#endif + } + if (newViewProps.allowFontScaling != oldViewProps.allowFontScaling) { _fontScaleObserver.allowFontScaling = newViewProps.allowFontScaling; diff --git a/ios/input/EnrichedMarkdownTextInput.mm b/ios/input/EnrichedMarkdownTextInput.mm index 016d341e..a01345cc 100644 --- a/ios/input/EnrichedMarkdownTextInput.mm +++ b/ios/input/EnrichedMarkdownTextInput.mm @@ -292,6 +292,8 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & if (newViewProps.selectionColor != oldViewProps.selectionColor) { if (isColorMeaningful(newViewProps.selectionColor)) { ENRMSetSelectionColor(_textView, RCTUIColorFromSharedColor(newViewProps.selectionColor)); + } else { + ENRMSetSelectionColor(_textView, nil); } } diff --git a/src/EnrichedMarkdownNativeComponent.ts b/src/EnrichedMarkdownNativeComponent.ts index 06024b47..71b3d8ac 100644 --- a/src/EnrichedMarkdownNativeComponent.ts +++ b/src/EnrichedMarkdownNativeComponent.ts @@ -273,6 +273,22 @@ export interface NativeProps extends ViewProps { * @default true */ selectable?: boolean; + /** + * Color of the text selection highlight. + * + * On iOS, this also affects the caret and selection handle colors + * (they share a single tint). + * + * @platform ios, android, web + */ + selectionColor?: ColorValue; + /** + * Color of the selection handles (drag anchors). + * No-op on API levels below 29. + * + * @platform android + */ + selectionHandleColor?: ColorValue; /** * MD4C parser flags configuration. * Controls how the markdown parser interprets certain syntax. diff --git a/src/EnrichedMarkdownTextNativeComponent.ts b/src/EnrichedMarkdownTextNativeComponent.ts index e238ecfc..13add176 100644 --- a/src/EnrichedMarkdownTextNativeComponent.ts +++ b/src/EnrichedMarkdownTextNativeComponent.ts @@ -273,6 +273,22 @@ export interface NativeProps extends ViewProps { * @default true */ selectable?: boolean; + /** + * Color of the text selection highlight. + * + * On iOS, this also affects the caret and selection handle colors + * (they share a single tint). + * + * @platform ios, android, web + */ + selectionColor?: ColorValue; + /** + * Color of the selection handles (drag anchors). + * No-op on API levels below 29. + * + * @platform android + */ + selectionHandleColor?: ColorValue; /** * MD4C parser flags configuration. * Controls how the markdown parser interprets certain syntax. diff --git a/src/native/EnrichedMarkdownText.tsx b/src/native/EnrichedMarkdownText.tsx index 236c2b7c..9d43fb9f 100644 --- a/src/native/EnrichedMarkdownText.tsx +++ b/src/native/EnrichedMarkdownText.tsx @@ -42,6 +42,8 @@ export const EnrichedMarkdownText = ({ streamingAnimation = false, spoilerOverlay = 'particles', contextMenuItems, + selectionColor, + selectionHandleColor, ...rest }: EnrichedMarkdownTextProps) => { const normalizedStyleRef = useRef(null); @@ -137,6 +139,8 @@ export const EnrichedMarkdownText = ({ style: containerStyle, contextMenuItems: nativeContextMenuItems, onContextMenuItemPress: handleContextMenuItemPress, + selectionColor, + selectionHandleColor, ...rest, }; diff --git a/src/types/MarkdownTextProps.ts b/src/types/MarkdownTextProps.ts index d450699e..bade9723 100644 --- a/src/types/MarkdownTextProps.ts +++ b/src/types/MarkdownTextProps.ts @@ -1,4 +1,4 @@ -import type { ViewProps, ViewStyle, TextStyle } from 'react-native'; +import type { ColorValue, ViewProps, ViewStyle, TextStyle } from 'react-native'; import type { MarkdownStyle, Md4cFlags } from './MarkdownStyle'; import type { LinkPressEvent, @@ -91,6 +91,22 @@ export interface EnrichedMarkdownTextProps extends Omit { * @platform ios, android, web */ selectable?: boolean; + /** + * Color of the text selection highlight. + * + * On iOS, this also affects the caret and selection handle colors + * (they share a single tint). + * + * @platform ios, android, web + */ + selectionColor?: ColorValue; + /** + * Color of the selection handles (drag anchors). + * No-op on API levels below 29. + * + * @platform android + */ + selectionHandleColor?: ColorValue; /** * Specifies whether fonts should scale to respect Text Size accessibility settings. * When false, text will not scale with the user's accessibility settings. diff --git a/src/types/MarkdownTextProps.web.ts b/src/types/MarkdownTextProps.web.ts index fa9d4014..91034b54 100644 --- a/src/types/MarkdownTextProps.web.ts +++ b/src/types/MarkdownTextProps.web.ts @@ -1,3 +1,4 @@ +import type { ColorValue } from 'react-native'; import type { CSSProperties, HTMLAttributes } from 'react'; import type { MarkdownStyle, Md4cFlags } from './MarkdownStyle'; import type { @@ -65,6 +66,11 @@ export interface EnrichedMarkdownTextProps * @platform ios, android, web */ selectable?: boolean; + /** + * Color of the text selection highlight. + * @platform web + */ + selectionColor?: ColorValue; /** * When false (default), removes trailing margin from the last element to * eliminate bottom spacing. diff --git a/src/web/EnrichedMarkdownText.tsx b/src/web/EnrichedMarkdownText.tsx index 70eb3a4c..e26ee273 100644 --- a/src/web/EnrichedMarkdownText.tsx +++ b/src/web/EnrichedMarkdownText.tsx @@ -1,4 +1,10 @@ -import { useState, useEffect, useMemo, type CSSProperties } from 'react'; +import { + useState, + useEffect, + useMemo, + Fragment, + type CSSProperties, +} from 'react'; import type { EnrichedMarkdownTextProps } from '../types/MarkdownTextProps.web'; import { normalizeMarkdownStyle } from '../normalizeMarkdownStyle.web'; import { @@ -12,6 +18,7 @@ import type { ASTNode, RendererCallbacks, RenderCapabilities } from './types'; import { indexTaskItems, markInlineImages } from './utils'; import { loadKaTeX } from './katex'; import type { KaTeXInstance } from './katex'; +import { normalizeColor } from '../styleUtils'; export const EnrichedMarkdownText = ({ markdown, @@ -24,6 +31,7 @@ export const EnrichedMarkdownText = ({ containerStyle, selectable = true, dir, + selectionColor, ...rest }: EnrichedMarkdownTextProps) => { const normalizedStyle = useMemo( @@ -95,21 +103,41 @@ export const EnrichedMarkdownText = ({ [lastChildStyle] ); - const wrapperStyle = useMemo( - () => ({ + const wrapperStyle = useMemo(() => { + const selectionColorCss = selectionColor + ? normalizeColor(String(selectionColor)) + : undefined; + + return { display: 'flex', flexDirection: 'column', ...(containerStyle as CSSProperties), ...(selectable ? undefined : { userSelect: 'none' }), - }), - [containerStyle, selectable] - ); + ...(selectionColorCss != null + ? ({ ['--enrm-selection-bg']: selectionColorCss } as CSSProperties) + : null), + }; + }, [containerStyle, selectable, selectionColor]); + + const selectionStyle = selectionColor ? ( + + ) : null; if (parseError) { return ( -
-
{markdown}
-
+ + {selectionStyle} +
+
{markdown}
+
+
); } @@ -119,18 +147,21 @@ export const EnrichedMarkdownText = ({ const lastIdx = children.length - 1; return ( -
- {children.map((child, index) => ( - - ))} -
+ + {selectionStyle} +
+ {children.map((child, index) => ( + + ))} +
+
); };