From fe87d788e648252962eaf185f7c0f478be796f00 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 25 Mar 2026 23:07:23 +0800 Subject: [PATCH 1/3] feat: add configurable textShortcuts prop for block and inline shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `textShortcuts` prop that enables configurable markdown-like text shortcuts. This replaces the hardcoded "- " and "1." list shortcuts with a generic, data-driven system that supports both block-level and inline formatting shortcuts. ## API ```typescript textShortcuts?: Array<{ trigger: string; style: string; type?: 'block' | 'inline'; }> ``` ### Block shortcuts (type: 'block', default) Trigger at the start of a paragraph when the user types the trigger text. The trigger is removed and the paragraph style is applied. Supported styles: h1-h6, blockquote, codeblock, unordered_list, ordered_list, checkbox_list ### Inline shortcuts (type: 'inline') Trigger when a closing delimiter is typed around text. The opening delimiter is found by scanning backwards, both delimiters are removed, and the inline style is applied to the text between them. Supported styles: bold, italic, underline, strikethrough, inline_code ## Example ```tsx ', style: 'blockquote' }, { trigger: '- ', style: 'unordered_list' }, { trigger: '1.', style: 'ordered_list' }, // Inline shortcuts { trigger: '`', style: 'inline_code', type: 'inline' }, { trigger: '**', style: 'bold', type: 'inline' }, { trigger: '*', style: 'italic', type: 'inline' }, ]} /> ``` ## Implementation - iOS: Shortcut detection in `shouldChangeTextInRange` (before text is committed), same mechanism as the previous hardcoded list shortcuts - Android: Shortcut detection in `afterTextChanged`, extending the existing `ListStyles` text change handling - The previously hardcoded "- " and "1." shortcuts are removed from native code — consumers should include them in the textShortcuts config if they want to preserve the behavior --- .../textinput/EnrichedTextInputView.kt | 3 + .../textinput/EnrichedTextInputViewManager.kt | 17 ++ .../enriched/textinput/spans/EnrichedSpans.kt | 4 +- .../enriched/textinput/styles/ListStyles.kt | 107 ++++++++ ios/EnrichedTextInputView.h | 2 + ios/EnrichedTextInputView.mm | 239 +++++++++++++++++- src/native/EnrichedTextInput.tsx | 2 + src/spec/EnrichedTextInputNativeComponent.ts | 1 + src/types.ts | 15 ++ 9 files changed, 386 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 4dfb2396..08c42803 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -109,6 +109,9 @@ class EnrichedTextInputView : var experimentalSynchronousEvents: Boolean = false var useHtmlNormalizer: Boolean = false + // Triple: (trigger, style, type) where type is "block" or "inline" + var textShortcuts: List> = emptyList() + var fontSize: Float? = null private var lineHeight: Float? = null var submitBehavior: String? = null diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 6b24ac27..0b36d1be 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -307,6 +307,23 @@ class EnrichedTextInputViewManager : view?.useHtmlNormalizer = value } + override fun setTextShortcuts( + view: EnrichedTextInputView?, + value: ReadableArray?, + ) { + val shortcuts = mutableListOf>() + if (value != null) { + for (i in 0 until value.size()) { + val map = value.getMap(i) ?: continue + val trigger = map.getString("trigger") ?: continue + val style = map.getString("style") ?: continue + val type = map.getString("type") ?: "block" + shortcuts.add(Triple(trigger, style, type)) + } + } + view?.textShortcuts = shortcuts + } + override fun focus(view: EnrichedTextInputView?) { view?.requestFocusProgrammatically() } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt index f680cd5d..9e0f33fe 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt @@ -78,8 +78,8 @@ object EnrichedSpans { val listSpans: Map = mapOf( - UNORDERED_LIST to ListSpanConfig(EnrichedInputUnorderedListSpan::class.java, "- "), - ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, "1. "), + UNORDERED_LIST to ListSpanConfig(EnrichedInputUnorderedListSpan::class.java, null), + ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, null), CHECKBOX_LIST to ListSpanConfig(EnrichedInputCheckboxListSpan::class.java, null), ) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index 01801239..8f03088e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -239,6 +239,111 @@ class ListStyles( } } + private fun resolveInlineStyleName(name: String): String? = when (name) { + "bold" -> EnrichedSpans.BOLD + "italic" -> EnrichedSpans.ITALIC + "underline" -> EnrichedSpans.UNDERLINE + "strikethrough" -> EnrichedSpans.STRIKETHROUGH + "inline_code" -> EnrichedSpans.INLINE_CODE + else -> null + } + + private fun resolveStyleName(name: String): String? = when (name) { + "h1" -> EnrichedSpans.H1 + "h2" -> EnrichedSpans.H2 + "h3" -> EnrichedSpans.H3 + "h4" -> EnrichedSpans.H4 + "h5" -> EnrichedSpans.H5 + "h6" -> EnrichedSpans.H6 + "blockquote" -> EnrichedSpans.BLOCK_QUOTE + "codeblock" -> EnrichedSpans.CODE_BLOCK + "unordered_list" -> EnrichedSpans.UNORDERED_LIST + "ordered_list" -> EnrichedSpans.ORDERED_LIST + "checkbox_list" -> EnrichedSpans.CHECKBOX_LIST + else -> null + } + + private fun handleConfigurableShortcuts( + s: Editable, + endCursorPosition: Int, + previousTextLength: Int, + ) { + val shortcuts = view.textShortcuts + if (shortcuts.isEmpty()) return + if (previousTextLength >= s.length) return + + val cursorPosition = endCursorPosition.coerceAtMost(s.length) + val (start, end) = s.getParagraphBounds(cursorPosition) + val paragraphText = s.substring(start, end) + + for ((trigger, styleName, type) in shortcuts) { + if (type == "inline") continue + if (trigger.isEmpty()) continue + if (!paragraphText.startsWith(trigger)) continue + + val resolvedStyle = resolveStyleName(styleName) ?: continue + + s.replace(start, start + trigger.length, EnrichedConstants.ZWS_STRING) + + val listConfig = EnrichedSpans.listSpans[resolvedStyle] + if (listConfig != null) { + setSpan(s, resolvedStyle, start, start + 1) + view.selection?.validateStyles() + } else { + view.paragraphStyles?.toggleStyle(resolvedStyle) + } + return + } + } + + private fun handleInlineShortcuts( + s: Editable, + endCursorPosition: Int, + previousTextLength: Int, + ) { + val shortcuts = view.textShortcuts + if (shortcuts.isEmpty()) return + if (previousTextLength >= s.length) return + + val cursorPosition = endCursorPosition.coerceAtMost(s.length) + val text = s.toString() + val (paraStart, _) = s.getParagraphBounds(cursorPosition) + + for ((trigger, styleName, type) in shortcuts) { + if (type != "inline") continue + if (trigger.isEmpty()) continue + + val resolvedStyle = resolveInlineStyleName(styleName) ?: continue + + if (cursorPosition < trigger.length) continue + val closingDelim = text.substring(cursorPosition - trigger.length, cursorPosition) + if (closingDelim != trigger) continue + + val closeDelimStart = cursorPosition - trigger.length + + val searchText = text.substring(paraStart, closeDelimStart) + val openIdx = searchText.lastIndexOf(trigger) + if (openIdx < 0) continue + + val openAbsolute = paraStart + openIdx + val contentStart = openAbsolute + trigger.length + val contentEnd = closeDelimStart + if (contentEnd <= contentStart) continue + + s.delete(closeDelimStart, cursorPosition) + s.delete(openAbsolute, openAbsolute + trigger.length) + + val adjustedStart = openAbsolute + val adjustedEnd = contentEnd - trigger.length + + view.setCustomSelection(adjustedStart, adjustedEnd) + view.inlineStyles?.toggleStyle(resolvedStyle) + + view.setCustomSelection(adjustedEnd, adjustedEnd) + return + } + } + fun afterTextChanged( s: Editable, endCursorPosition: Int, @@ -247,6 +352,8 @@ class ListStyles( handleAfterTextChanged(s, EnrichedSpans.ORDERED_LIST, endCursorPosition, previousTextLength) handleAfterTextChanged(s, EnrichedSpans.UNORDERED_LIST, endCursorPosition, previousTextLength) handleAfterTextChanged(s, EnrichedSpans.CHECKBOX_LIST, endCursorPosition, previousTextLength) + handleConfigurableShortcuts(s, endCursorPosition, previousTextLength) + handleInlineShortcuts(s, endCursorPosition, previousTextLength) } fun getStyleRange(): Pair = view.selection?.getParagraphSelection() ?: Pair(0, 0) diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index c2eb4a97..83caed1e 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -32,6 +32,8 @@ NS_ASSUME_NONNULL_BEGIN BOOL blockEmitting; @public BOOL useHtmlNormalizer; +@public + NSArray *textShortcuts; } - (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkDetectedEvent:(NSString *)text diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index cdd72fab..ff04e0ab 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,5 +1,6 @@ #import "EnrichedTextInputView.h" #import "CoreText/CoreText.h" +#import "TextInsertionUtils.h" #import "ImageAttachment.h" #import "KeyboardUtils.h" #import "LayoutManagerExtension.h" @@ -763,6 +764,22 @@ - (void)updateProps:(Props::Shared const &)props useHtmlNormalizer = newViewProps.useHtmlNormalizer; } + // textShortcuts + if (newViewProps.textShortcuts != oldViewProps.textShortcuts) { + NSMutableArray *shortcuts = [NSMutableArray new]; + for (const auto &item : newViewProps.textShortcuts) { + NSString *type = item.type.has_value() + ? [NSString fromCppString:item.type.value()] + : @"block"; + [shortcuts addObject:@{ + @"trigger" : [NSString fromCppString:item.trigger], + @"style" : [NSString fromCppString:item.style], + @"type" : type + }]; + } + textShortcuts = shortcuts; + } + // default value - must be set before placeholder to make sure it correctly // shows on first mount if (newViewProps.defaultValue != oldViewProps.defaultValue) { @@ -2001,6 +2018,219 @@ - (void)handleKeyPressInRange:(NSString *)text range:(NSRange)range { } } ++ (NSNumber *_Nullable)styleTypeForName:(NSString *)name { + static NSDictionary *map = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + map = @{ + @"h1" : @(H1), + @"h2" : @(H2), + @"h3" : @(H3), + @"h4" : @(H4), + @"h5" : @(H5), + @"h6" : @(H6), + @"blockquote" : @(BlockQuote), + @"codeblock" : @(CodeBlock), + @"unordered_list" : @(UnorderedList), + @"ordered_list" : @(OrderedList), + @"checkbox_list" : @(CheckboxList), + }; + }); + return map[name]; +} + +- (BOOL)tryHandlingTextShortcutInRange:(NSRange)range + replacementText:(NSString *)text { + if (textShortcuts == nil || textShortcuts.count == 0) { + return NO; + } + + NSString *fullText = textView.textStorage.string; + NSRange paragraphRange = [fullText paragraphRangeForRange:range]; + + for (NSDictionary *shortcut in textShortcuts) { + NSString *shortcutType = shortcut[@"type"]; + if ([shortcutType isEqualToString:@"inline"]) { + continue; + } + + NSString *trigger = shortcut[@"trigger"]; + NSString *styleName = shortcut[@"style"]; + if (trigger == nil || styleName == nil || trigger.length == 0) { + continue; + } + + NSString *lastTriggerChar = + [trigger substringFromIndex:trigger.length - 1]; + NSString *prefixBeforeCursor = + [trigger substringToIndex:trigger.length - 1]; + + if (![text isEqualToString:lastTriggerChar]) { + continue; + } + + NSInteger charsBeforeCursor = range.location - paragraphRange.location; + if (charsBeforeCursor != (NSInteger)prefixBeforeCursor.length) { + continue; + } + + if (prefixBeforeCursor.length > 0) { + NSString *paragraphPrefix = + [fullText substringWithRange:NSMakeRange(paragraphRange.location, + prefixBeforeCursor.length)]; + if (![paragraphPrefix isEqualToString:prefixBeforeCursor]) { + continue; + } + } + + NSNumber *styleType = + [EnrichedTextInputView styleTypeForName:styleName]; + if (styleType == nil) { + continue; + } + + if ([self handleStyleBlocksAndConflicts:(StyleType)[styleType integerValue] + range:paragraphRange]) { + blockEmitting = YES; + [TextInsertionUtils + replaceText:@"" + at:NSMakeRange(paragraphRange.location, + prefixBeforeCursor.length) + additionalAttributes:nullptr + input:self + withSelection:YES]; + blockEmitting = NO; + + id style = stylesDict[styleType]; + if (style != nil) { + NSRange newParagraphRange = NSMakeRange( + paragraphRange.location, + paragraphRange.length - prefixBeforeCursor.length); + [style addAttributes:newParagraphRange withTypingAttr:YES]; + } + return YES; + } + } + + return NO; +} + ++ (NSNumber *_Nullable)inlineStyleTypeForName:(NSString *)name { + static NSDictionary *map = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + map = @{ + @"bold" : @(Bold), + @"italic" : @(Italic), + @"underline" : @(Underline), + @"strikethrough" : @(Strikethrough), + @"inline_code" : @(InlineCode), + }; + }); + return map[name]; +} + +- (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range + replacementText:(NSString *)text { + if (textShortcuts == nil || textShortcuts.count == 0) { + return NO; + } + + NSString *fullText = textView.textStorage.string; + + for (NSDictionary *shortcut in textShortcuts) { + NSString *shortcutType = shortcut[@"type"]; + if (![shortcutType isEqualToString:@"inline"]) { + continue; + } + + NSString *trigger = shortcut[@"trigger"]; + NSString *styleName = shortcut[@"style"]; + if (trigger == nil || styleName == nil || trigger.length == 0) { + continue; + } + + NSString *lastTriggerChar = + [trigger substringFromIndex:trigger.length - 1]; + if (![text isEqualToString:lastTriggerChar]) { + continue; + } + + NSInteger delimPrefixLen = trigger.length - 1; + if (delimPrefixLen > 0) { + if ((NSInteger)range.location < delimPrefixLen) { + continue; + } + NSString *beforeCursor = + [fullText substringWithRange:NSMakeRange(range.location - delimPrefixLen, + delimPrefixLen)]; + if (![beforeCursor isEqualToString: + [trigger substringToIndex:delimPrefixLen]]) { + continue; + } + } + + NSInteger closeDelimStart = range.location - delimPrefixLen; + + NSRange searchRange = NSMakeRange(0, closeDelimStart); + NSRange openRange = [fullText rangeOfString:trigger + options:NSBackwardsSearch + range:searchRange]; + if (openRange.location == NSNotFound) { + continue; + } + + NSInteger contentStart = openRange.location + trigger.length; + NSInteger contentEnd = closeDelimStart; + if (contentEnd <= contentStart) { + continue; + } + + NSRange paragraphRange = [fullText paragraphRangeForRange:range]; + if (openRange.location < paragraphRange.location) { + continue; + } + + NSNumber *styleType = + [EnrichedTextInputView inlineStyleTypeForName:styleName]; + if (styleType == nil) { + continue; + } + + blockEmitting = YES; + + if (delimPrefixLen > 0) { + [TextInsertionUtils + replaceText:@"" + at:NSMakeRange(closeDelimStart, delimPrefixLen) + additionalAttributes:nullptr + input:self + withSelection:NO]; + contentEnd -= delimPrefixLen; + } + + [TextInsertionUtils + replaceText:@"" + at:openRange + additionalAttributes:nullptr + input:self + withSelection:NO]; + contentStart -= trigger.length; + contentEnd -= trigger.length; + + blockEmitting = NO; + + textView.selectedRange = NSMakeRange(contentStart, contentEnd - contentStart); + [self toggleRegularStyle:(StyleType)[styleType integerValue]]; + + textView.selectedRange = NSMakeRange(contentEnd, 0); + + return YES; + } + + return NO; +} + - (bool)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { @@ -2044,9 +2274,7 @@ - (bool)textView:(UITextView *)textView // expression either way it's not possible to have two of them come off at the // same time if ([uStyle handleBackspaceInRange:range replacementText:text] || - [uStyle tryHandlingListShorcutInRange:range replacementText:text] || [oStyle handleBackspaceInRange:range replacementText:text] || - [oStyle tryHandlingListShorcutInRange:range replacementText:text] || [cbLStyle handleBackspaceInRange:range replacementText:text] || [cbLStyle handleNewlinesInRange:range replacementText:text] || [bqStyle handleBackspaceInRange:range replacementText:text] || @@ -2087,6 +2315,13 @@ - (bool)textView:(UITextView *)textView return NO; } + // Check configurable text shortcuts (block: "# " → h1, inline: `code` → inline_code) + if ([self tryHandlingTextShortcutInRange:range replacementText:text] || + [self tryHandlingInlineShortcutInRange:range replacementText:text]) { + [self anyTextMayHaveBeenModified]; + return NO; + } + // Tapping near a link causes iOS to re-derive typingAttributes from // character attributes after textViewDidChangeSelection returns, undoing // the cleanup in manageSelectionBasedChanges. Strip them again here, right diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 7ff8120b..3df910ec 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -76,6 +76,7 @@ export const EnrichedTextInput = ({ returnKeyLabel, submitBehavior, contextMenuItems, + textShortcuts, androidExperimentalSynchronousEvents = false, useHtmlNormalizer = false, scrollEnabled = true, @@ -348,6 +349,7 @@ export const EnrichedTextInput = ({ onRequestHtmlResult={handleRequestHtmlResult} onInputKeyPress={onKeyPress} contextMenuItems={nativeContextMenuItems} + textShortcuts={textShortcuts ?? []} onContextMenuItemPress={handleContextMenuItemPress} onSubmitEditing={onSubmitEditing} returnKeyType={returnKeyType} diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 83e557d5..0b20da7b 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -365,6 +365,7 @@ export interface NativeProps extends ViewProps { scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; contextMenuItems?: ReadonlyArray>; + textShortcuts: ReadonlyArray>; returnKeyType?: string; returnKeyLabel?: string; submitBehavior?: string; diff --git a/src/types.ts b/src/types.ts index aeee6e93..83e2396c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,6 +305,21 @@ export interface EnrichedTextInputProps extends Omit { onSubmitEditing?: (e: NativeSyntheticEvent) => void; onPasteImages?: (e: NativeSyntheticEvent) => void; contextMenuItems?: ContextMenuItem[]; + /** + * Configure text shortcuts that auto-convert typed patterns into styles. + * + * Two types of shortcuts are supported: + * + * **Block shortcuts** (type: 'block', default): + * Trigger at the start of a paragraph. E.g. typing "# " converts the line to H1. + * - style: "h1"-"h6", "blockquote", "codeblock", "unordered_list", "ordered_list", "checkbox_list" + * + * **Inline shortcuts** (type: 'inline'): + * Trigger when a closing delimiter is typed around text. E.g. typing `code` applies inline code. + * The trigger is the delimiter string (e.g. "`", "**", "*", "~~"). + * - style: "bold", "italic", "strikethrough", "inline_code" + */ + textShortcuts?: Array<{ trigger: string; style: string; type?: 'block' | 'inline' }>; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size. From 6466e666f2f73d4048b7881502e5ece35a42acf3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 25 Mar 2026 23:15:39 +0800 Subject: [PATCH 2/3] fix: default textShortcuts to built-in list shortcuts to avoid breaking change Default the textShortcuts prop to [{ trigger: '- ', style: 'unordered_list' }, { trigger: '1.', style: 'ordered_list' }] when not provided, preserving the previous built-in behavior without requiring any config from existing consumers. Pass an empty array to explicitly disable all shortcuts. --- src/native/EnrichedTextInput.tsx | 11 ++++++++++- src/types.ts | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 3df910ec..367ea71f 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -45,6 +45,15 @@ type HtmlRequest = { reject: (error: Error) => void; }; +/** + * Default text shortcuts matching the previously hardcoded behavior. + * Consumers can override by passing their own textShortcuts prop. + */ +const DEFAULT_TEXT_SHORTCUTS: Array<{ trigger: string; style: string }> = [ + { trigger: '- ', style: 'unordered_list' }, + { trigger: '1.', style: 'ordered_list' }, +]; + export const EnrichedTextInput = ({ ref, autoFocus, @@ -349,7 +358,7 @@ export const EnrichedTextInput = ({ onRequestHtmlResult={handleRequestHtmlResult} onInputKeyPress={onKeyPress} contextMenuItems={nativeContextMenuItems} - textShortcuts={textShortcuts ?? []} + textShortcuts={textShortcuts ?? DEFAULT_TEXT_SHORTCUTS} onContextMenuItemPress={handleContextMenuItemPress} onSubmitEditing={onSubmitEditing} returnKeyType={returnKeyType} diff --git a/src/types.ts b/src/types.ts index 83e2396c..ed7bca51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -318,6 +318,9 @@ export interface EnrichedTextInputProps extends Omit { * Trigger when a closing delimiter is typed around text. E.g. typing `code` applies inline code. * The trigger is the delimiter string (e.g. "`", "**", "*", "~~"). * - style: "bold", "italic", "strikethrough", "inline_code" + * + * Defaults to `[{ trigger: '- ', style: 'unordered_list' }, { trigger: '1.', style: 'ordered_list' }]` + * to match the previously built-in behavior. Pass an empty array to disable all shortcuts. */ textShortcuts?: Array<{ trigger: string; style: string; type?: 'block' | 'inline' }>; /** From cdb337281abcda0a7112e2c117a917a401c42723 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 26 Mar 2026 00:15:21 +0800 Subject: [PATCH 3/3] fix: use std::string::empty() instead of has_value() for codegen type field The codegen generates type as std::string (not std::optional), so use empty() to check for the default case. --- ios/EnrichedTextInputView.mm | 72 +++++++++++++++++------------------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index ff04e0ab..e4806041 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,6 +1,5 @@ #import "EnrichedTextInputView.h" #import "CoreText/CoreText.h" -#import "TextInsertionUtils.h" #import "ImageAttachment.h" #import "KeyboardUtils.h" #import "LayoutManagerExtension.h" @@ -9,6 +8,7 @@ #import "StringExtension.h" #import "StyleHeaders.h" #import "TextBlockTapGestureRecognizer.h" +#import "TextInsertionUtils.h" #import "UIView+React.h" #import "WordsUtils.h" #import "ZeroWidthSpaceUtils.h" @@ -768,9 +768,8 @@ - (void)updateProps:(Props::Shared const &)props if (newViewProps.textShortcuts != oldViewProps.textShortcuts) { NSMutableArray *shortcuts = [NSMutableArray new]; for (const auto &item : newViewProps.textShortcuts) { - NSString *type = item.type.has_value() - ? [NSString fromCppString:item.type.value()] - : @"block"; + NSString *type = + item.type.empty() ? @"block" : [NSString fromCppString:item.type]; [shortcuts addObject:@{ @"trigger" : [NSString fromCppString:item.trigger], @"style" : [NSString fromCppString:item.style], @@ -2060,8 +2059,7 @@ - (BOOL)tryHandlingTextShortcutInRange:(NSRange)range continue; } - NSString *lastTriggerChar = - [trigger substringFromIndex:trigger.length - 1]; + NSString *lastTriggerChar = [trigger substringFromIndex:trigger.length - 1]; NSString *prefixBeforeCursor = [trigger substringToIndex:trigger.length - 1]; @@ -2083,8 +2081,7 @@ - (BOOL)tryHandlingTextShortcutInRange:(NSRange)range } } - NSNumber *styleType = - [EnrichedTextInputView styleTypeForName:styleName]; + NSNumber *styleType = [EnrichedTextInputView styleTypeForName:styleName]; if (styleType == nil) { continue; } @@ -2092,20 +2089,19 @@ - (BOOL)tryHandlingTextShortcutInRange:(NSRange)range if ([self handleStyleBlocksAndConflicts:(StyleType)[styleType integerValue] range:paragraphRange]) { blockEmitting = YES; - [TextInsertionUtils - replaceText:@"" - at:NSMakeRange(paragraphRange.location, - prefixBeforeCursor.length) - additionalAttributes:nullptr - input:self - withSelection:YES]; + [TextInsertionUtils replaceText:@"" + at:NSMakeRange(paragraphRange.location, + prefixBeforeCursor.length) + additionalAttributes:nullptr + input:self + withSelection:YES]; blockEmitting = NO; id style = stylesDict[styleType]; if (style != nil) { - NSRange newParagraphRange = NSMakeRange( - paragraphRange.location, - paragraphRange.length - prefixBeforeCursor.length); + NSRange newParagraphRange = + NSMakeRange(paragraphRange.location, + paragraphRange.length - prefixBeforeCursor.length); [style addAttributes:newParagraphRange withTypingAttr:YES]; } return YES; @@ -2150,8 +2146,7 @@ - (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range continue; } - NSString *lastTriggerChar = - [trigger substringFromIndex:trigger.length - 1]; + NSString *lastTriggerChar = [trigger substringFromIndex:trigger.length - 1]; if (![text isEqualToString:lastTriggerChar]) { continue; } @@ -2161,11 +2156,11 @@ - (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range if ((NSInteger)range.location < delimPrefixLen) { continue; } - NSString *beforeCursor = - [fullText substringWithRange:NSMakeRange(range.location - delimPrefixLen, - delimPrefixLen)]; - if (![beforeCursor isEqualToString: - [trigger substringToIndex:delimPrefixLen]]) { + NSString *beforeCursor = [fullText + substringWithRange:NSMakeRange(range.location - delimPrefixLen, + delimPrefixLen)]; + if (![beforeCursor + isEqualToString:[trigger substringToIndex:delimPrefixLen]]) { continue; } } @@ -2201,26 +2196,26 @@ - (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range if (delimPrefixLen > 0) { [TextInsertionUtils - replaceText:@"" - at:NSMakeRange(closeDelimStart, delimPrefixLen) - additionalAttributes:nullptr - input:self - withSelection:NO]; + replaceText:@"" + at:NSMakeRange(closeDelimStart, delimPrefixLen) + additionalAttributes:nullptr + input:self + withSelection:NO]; contentEnd -= delimPrefixLen; } - [TextInsertionUtils - replaceText:@"" - at:openRange - additionalAttributes:nullptr - input:self - withSelection:NO]; + [TextInsertionUtils replaceText:@"" + at:openRange + additionalAttributes:nullptr + input:self + withSelection:NO]; contentStart -= trigger.length; contentEnd -= trigger.length; blockEmitting = NO; - textView.selectedRange = NSMakeRange(contentStart, contentEnd - contentStart); + textView.selectedRange = + NSMakeRange(contentStart, contentEnd - contentStart); [self toggleRegularStyle:(StyleType)[styleType integerValue]]; textView.selectedRange = NSMakeRange(contentEnd, 0); @@ -2315,7 +2310,8 @@ - (bool)textView:(UITextView *)textView return NO; } - // Check configurable text shortcuts (block: "# " → h1, inline: `code` → inline_code) + // Check configurable text shortcuts (block: "# " → h1, inline: `code` → + // inline_code) if ([self tryHandlingTextShortcutInRange:range replacementText:text] || [self tryHandlingInlineShortcutInRange:range replacementText:text]) { [self anyTextMayHaveBeenModified];