From fe87d788e648252962eaf185f7c0f478be796f00 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 25 Mar 2026 23:07:23 +0800 Subject: [PATCH 01/10] 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 4dfb23968..08c428031 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 6b24ac27b..0b36d1be1 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 f680cd5d9..9e0f33fe9 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 01801239e..8f03088e6 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 c2eb4a97e..83caed1ec 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 cdd72fab3..ff04e0ab7 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 7ff8120b4..3df910ece 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 83e557d5f..0b20da7b6 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 aeee6e931..83e2396c5 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 02/10] 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 3df910ece..367ea71f7 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 83e2396c5..ed7bca51e 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 03/10] 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 ff04e0ab7..e4806041e 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]; From a4e9aa9d6e226d5c1f84ecaf543492ccfcd917cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Fri, 22 May 2026 10:57:14 +0200 Subject: [PATCH 04/10] fix(iOS): shortcuts detection --- ios/EnrichedTextInputView.mm | 236 ++----------- ios/interfaces/StyleHeaders.h | 4 - ios/styles/OrderedListStyle.mm | 40 --- ios/styles/UnorderedListStyle.mm | 40 --- ios/utils/ShortcutsUtils.h | 21 ++ ios/utils/ShortcutsUtils.mm | 549 +++++++++++++++++++++++++++++++ ios/utils/StyleUtils.h | 3 + ios/utils/StyleUtils.mm | 16 +- 8 files changed, 606 insertions(+), 303 deletions(-) create mode 100644 ios/utils/ShortcutsUtils.h create mode 100644 ios/utils/ShortcutsUtils.mm diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 306cd5e03..6b615b47a 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -9,6 +9,7 @@ #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" #import "RCTFabricComponentsPlugins.h" +#import "ShortcutsUtils.h" #import "StringExtension.h" #import "StyleHeaders.h" #import "StyleUtils.h" @@ -688,7 +689,21 @@ - (void)updateProps:(Props::Shared const &)props } // textShortcuts - if (newViewProps.textShortcuts != oldViewProps.textShortcuts) { + bool textShortcutsChanged = + newViewProps.textShortcuts.size() != oldViewProps.textShortcuts.size(); + if (!textShortcutsChanged) { + for (size_t i = 0; i < newViewProps.textShortcuts.size(); i++) { + const auto &newItem = newViewProps.textShortcuts[i]; + const auto &oldItem = oldViewProps.textShortcuts[i]; + if (newItem.trigger != oldItem.trigger || + newItem.style != oldItem.style || newItem.type != oldItem.type) { + textShortcutsChanged = true; + break; + } + } + } + + if (textShortcutsChanged) { NSMutableArray *shortcuts = [NSMutableArray new]; for (const auto &item : newViewProps.textShortcuts) { NSString *type = @@ -1875,215 +1890,6 @@ - (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 { @@ -2116,8 +1922,6 @@ - (bool)textView:(UITextView *)textView [self handleKeyPressInRange:text range:range]; - UnorderedListStyle *uStyle = stylesDict[@([UnorderedListStyle getType])]; - OrderedListStyle *oStyle = stylesDict[@([OrderedListStyle getType])]; CheckboxListStyle *cbLStyle = (CheckboxListStyle *)stylesDict[@([CheckboxListStyle getType])]; H1Style *h1Style = stylesDict[@([H1Style getType])]; @@ -2165,8 +1969,12 @@ - (bool)textView:(UITextView *)textView // Check configurable text shortcuts (block: "# " → h1, inline: `code` → // inline_code) - if ([self tryHandlingTextShortcutInRange:range replacementText:text] || - [self tryHandlingInlineShortcutInRange:range replacementText:text]) { + if ([ShortcutsUtils tryHandlingBlockShortcutInRange:range + replacementText:text + input:self] || + [ShortcutsUtils tryHandlingInlineShortcutInRange:range + replacementText:text + input:self]) { [self anyTextMayHaveBeenModified]; return NO; } diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index 568b8e55a..1a16d3819 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -69,13 +69,9 @@ @end @interface UnorderedListStyle : StyleBase -- (BOOL)tryHandlingListShorcutInRange:(NSRange)range - replacementText:(NSString *)text; @end @interface OrderedListStyle : StyleBase -- (BOOL)tryHandlingListShorcutInRange:(NSRange)range - replacementText:(NSString *)text; @end @interface CheckboxListStyle : StyleBase diff --git a/ios/styles/OrderedListStyle.mm b/ios/styles/OrderedListStyle.mm index 6d3f2fd58..cc0cfea5b 100644 --- a/ios/styles/OrderedListStyle.mm +++ b/ios/styles/OrderedListStyle.mm @@ -45,44 +45,4 @@ - (void)applyStyling:(NSRange)range { }]; } -- (BOOL)tryHandlingListShorcutInRange:(NSRange)range - replacementText:(NSString *)text { - NSRange paragraphRange = - [self.host.textView.textStorage.string paragraphRangeForRange:range]; - // a dot was added - check if we are both at the paragraph beginning + 1 - // character (which we want to be a digit '1') - if ([text isEqualToString:@"."] && - range.location - 1 == paragraphRange.location) { - unichar charBefore = [self.host.textView.textStorage.string - characterAtIndex:range.location - 1]; - if (charBefore == '1') { - // we got a match - add a list if possible - if ([StyleUtils handleStyleBlocksAndConflicts:[[self class] getType] - range:paragraphRange - forHost:self.host]) { - // don't emit during the replacing - self.host.blockEmitting = YES; - - // remove the number - [TextInsertionUtils replaceText:@"" - at:NSMakeRange(paragraphRange.location, 1) - additionalAttributes:nullptr - host:self.host - withSelection:YES]; - - self.host.blockEmitting = NO; - - // add attributes on the paragraph - [self add:NSMakeRange(paragraphRange.location, - paragraphRange.length - 1) - withTyping:YES - withDirtyRange:YES]; - - return YES; - } - } - } - return NO; -} - @end diff --git a/ios/styles/UnorderedListStyle.mm b/ios/styles/UnorderedListStyle.mm index c815a4b8d..8b949e4c2 100644 --- a/ios/styles/UnorderedListStyle.mm +++ b/ios/styles/UnorderedListStyle.mm @@ -45,44 +45,4 @@ - (void)applyStyling:(NSRange)range { }]; } -- (BOOL)tryHandlingListShorcutInRange:(NSRange)range - replacementText:(NSString *)text { - NSRange paragraphRange = - [self.host.textView.textStorage.string paragraphRangeForRange:range]; - // space was added - check if we are both at the paragraph beginning + 1 - // character (which we want to be a dash) - if ([text isEqualToString:@" "] && - range.location - 1 == paragraphRange.location) { - unichar charBefore = [self.host.textView.textStorage.string - characterAtIndex:range.location - 1]; - if (charBefore == '-') { - // we got a match - add a list if possible - if ([StyleUtils handleStyleBlocksAndConflicts:[[self class] getType] - range:paragraphRange - forHost:self.host]) { - // don't emit during the replacing - self.host.blockEmitting = YES; - - // remove the dash - [TextInsertionUtils replaceText:@"" - at:NSMakeRange(paragraphRange.location, 1) - additionalAttributes:nullptr - host:self.host - withSelection:YES]; - - self.host.blockEmitting = NO; - - // add attributes on the dashless paragraph - [self add:NSMakeRange(paragraphRange.location, - paragraphRange.length - 1) - withTyping:YES - withDirtyRange:YES]; - - return YES; - } - } - } - return NO; -} - @end diff --git a/ios/utils/ShortcutsUtils.h b/ios/utils/ShortcutsUtils.h new file mode 100644 index 000000000..be3c9bd78 --- /dev/null +++ b/ios/utils/ShortcutsUtils.h @@ -0,0 +1,21 @@ +#pragma once + +#import "EnrichedTextInputView.h" +#import "StyleTypeEnum.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ShortcutsUtils : NSObject + ++ (BOOL)tryHandlingBlockShortcutInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input; + ++ (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm new file mode 100644 index 000000000..007e8ebe8 --- /dev/null +++ b/ios/utils/ShortcutsUtils.mm @@ -0,0 +1,549 @@ +#import "ShortcutsUtils.h" +#import "ParagraphAttributesUtils.h" +#import "StyleBase.h" +#import "StyleUtils.h" +#import "TextInsertionUtils.h" + +typedef struct { + EnrichedTextInputView *input; + NSString *fullText; + NSRange paragraphRange; + NSRange changeRange; + NSString *replacementText; +} ShortcutsTextContext; + +typedef struct { + ShortcutsTextContext text; + NSArray *inlineShortcuts; +} ShortcutsInlineContext; + +typedef struct { + NSString *trigger; + StyleType styleType; + NSInteger delimStart; + NSInteger delimPrefixLen; +} ShortcutsTriggerMatch; + +typedef struct { + NSRange finalContentRange; + NSRange closeDeleteRange; + NSRange openDeleteRange; +} ShortcutsInlineApplyRanges; + +@implementation ShortcutsUtils + ++ (NSDictionary *)shortcutStyleNameMap { + static NSDictionary *map = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + map = @{ + // Block shortcuts + @"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), + // Inline shortcuts + @"bold" : @(Bold), + @"italic" : @(Italic), + @"underline" : @(Underline), + @"strikethrough" : @(Strikethrough), + @"inline_code" : @(InlineCode), + }; + }); + return map; +} + ++ (StyleType)styleTypeForShortcutName:(NSString *)name { + NSNumber *styleType = [self shortcutStyleNameMap][name]; + return styleType ? (StyleType)[styleType integerValue] : None; +} + ++ (BOOL)hasTextShortcutsInInput:(EnrichedTextInputView *)input { + return input != nullptr && input->textShortcuts != nil && + input->textShortcuts.count > 0; +} + ++ (ShortcutsTextContext)textContextWithChangeRange:(NSRange)changeRange + replacementText:(NSString *)replacementText + input:(EnrichedTextInputView *) + input { + NSString *fullText = input->textView.textStorage.string; + return (ShortcutsTextContext){ + .input = input, + .fullText = fullText, + .paragraphRange = [fullText paragraphRangeForRange:changeRange], + .changeRange = changeRange, + .replacementText = replacementText, + }; +} + ++ (ShortcutsInlineContext) + inlineContextWithChangeRange:(NSRange)changeRange + replacementText:(NSString *)replacementText + input:(EnrichedTextInputView *)input { + return (ShortcutsInlineContext){ + .text = [self textContextWithChangeRange:changeRange + replacementText:replacementText + input:input], + .inlineShortcuts = [self inlineShortcutsFrom:input->textShortcuts], + }; +} + ++ (NSArray *)inlineShortcutsFrom: + (NSArray *)textShortcuts { + NSMutableArray *inlineShortcuts = [NSMutableArray array]; + for (NSDictionary *shortcut in textShortcuts) { + if ([shortcut[@"type"] isEqualToString:@"inline"]) { + [inlineShortcuts addObject:shortcut]; + } + } + [inlineShortcuts sortUsingComparator:^NSComparisonResult(NSDictionary *a, + NSDictionary *b) { + NSUInteger lenA = [a[@"trigger"] length]; + NSUInteger lenB = [b[@"trigger"] length]; + if (lenA > lenB) { + return NSOrderedAscending; + } + if (lenA < lenB) { + return NSOrderedDescending; + } + return NSOrderedSame; + }]; + return inlineShortcuts; +} + +/// When [requiredDelimStart] is NSNotFound, the trigger may appear anywhere in +/// the text. Otherwise the matched delimiter must start at that index (block +/// shortcuts at paragraph start). ++ (BOOL)isCompletingTrigger:(NSString *)trigger + context:(const ShortcutsTextContext *)context + requiredDelimStart:(NSInteger)requiredDelimStart + match:(ShortcutsTriggerMatch *)outMatch { + if (trigger.length == 0) { + return NO; + } + + NSString *lastTriggerChar = [trigger substringFromIndex:trigger.length - 1]; + if (![context->replacementText isEqualToString:lastTriggerChar]) { + return NO; + } + + NSInteger delimPrefixLen = (NSInteger)trigger.length - 1; + if (delimPrefixLen > 0) { + if (context->changeRange.location < delimPrefixLen) { + return NO; + } + NSString *prefix = [trigger substringToIndex:delimPrefixLen]; + NSString *beforeCursor = [context->fullText + substringWithRange:NSMakeRange(context->changeRange.location - + delimPrefixLen, + delimPrefixLen)]; + if (![beforeCursor isEqualToString:prefix]) { + return NO; + } + } + + NSInteger delimStart = context->changeRange.location - delimPrefixLen; + if (requiredDelimStart != NSNotFound && delimStart != requiredDelimStart) { + return NO; + } + + if (outMatch != nullptr) { + outMatch->trigger = trigger; + outMatch->delimStart = delimStart; + outMatch->delimPrefixLen = delimPrefixLen; + } + return YES; +} + +/// Delimiter at [delimStart] is part of a longer inline trigger (e.g. `*` +/// inside `**`). ++ (BOOL)isDelimiterPartOfLongerInlineTrigger:(NSString *)trigger + delimStart:(NSInteger)delimStart + context: + (const ShortcutsInlineContext *)context + isOpening:(BOOL)isOpening { + NSInteger delimEnd = delimStart + trigger.length; + NSString *fullText = context->text.fullText; + + for (NSDictionary *shortcut in context->inlineShortcuts) { + NSString *longerTrigger = shortcut[@"trigger"]; + if (longerTrigger.length <= trigger.length) { + continue; + } + + NSInteger longerStart; + if (isOpening) { + if (![longerTrigger hasSuffix:trigger]) { + continue; + } + longerStart = delimEnd - longerTrigger.length; + } else if ([longerTrigger hasPrefix:trigger]) { + longerStart = delimStart; + } else if ([longerTrigger hasSuffix:trigger]) { + longerStart = delimStart - (longerTrigger.length - trigger.length); + } else { + continue; + } + + if (longerStart < 0 || + longerStart + longerTrigger.length > fullText.length) { + continue; + } + + NSRange longerRange = NSMakeRange(longerStart, longerTrigger.length); + if ([[fullText substringWithRange:longerRange] + isEqualToString:longerTrigger]) { + return YES; + } + } + return NO; +} + +/// Shorter trigger is only a prefix so far (e.g. first `*` of `**`) while a +/// longer closing delimiter already exists after the cursor. ++ (BOOL)shouldDeferShorterInlineTrigger:(const ShortcutsTriggerMatch *)match + context: + (const ShortcutsInlineContext *)context { + NSInteger writtenLen = match->delimPrefixLen + 1; + NSInteger searchFrom = context->text.changeRange.location; + NSInteger searchEnd = context->text.paragraphRange.location + + context->text.paragraphRange.length; + if (searchFrom >= searchEnd) { + return NO; + } + + for (NSDictionary *shortcut in context->inlineShortcuts) { + NSString *longerTrigger = shortcut[@"trigger"]; + if (longerTrigger.length <= match->trigger.length) { + continue; + } + if (![longerTrigger hasPrefix:match->trigger]) { + continue; + } + if (writtenLen >= longerTrigger.length) { + continue; + } + + NSRange longerAhead = [context->text.fullText + rangeOfString:longerTrigger + options:0 + range:NSMakeRange(searchFrom, searchEnd - searchFrom)]; + if (longerAhead.location != NSNotFound) { + return YES; + } + } + return NO; +} + +/// Removes delimiters (close first, then open), then applies [style] on +/// [contentRange]. ++ (void)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match + context:(const ShortcutsInlineContext *)context + ranges: + (const ShortcutsInlineApplyRanges *)ranges { + EnrichedTextInputView *input = context->text.input; + input->blockEmitting = YES; + + if (ranges->closeDeleteRange.length > 0) { + [TextInsertionUtils replaceText:@"" + at:ranges->closeDeleteRange + additionalAttributes:nullptr + host:input + withSelection:NO]; + } + + if (ranges->openDeleteRange.length > 0) { + [TextInsertionUtils replaceText:@"" + at:ranges->openDeleteRange + additionalAttributes:nullptr + host:input + withSelection:NO]; + } + + input->blockEmitting = NO; + + StyleBase *style = input->stylesDict[@(match->styleType)]; + if (style == nil) { + return; + } + + [style add:ranges->finalContentRange withTyping:YES withDirtyRange:YES]; + input->textView.selectedRange = + NSMakeRange(NSMaxRange(ranges->finalContentRange), 0); +} + ++ (BOOL)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match + context:(const ShortcutsInlineContext *)context + contentRange:(NSRange)contentRange + ranges: + (const ShortcutsInlineApplyRanges *)ranges { + if (![StyleUtils handleStyleBlocksAndConflicts:match->styleType + range:contentRange + forHost:context->text.input]) { + return NO; + } + + [self applyInlineShortcutWithMatch:match context:context ranges:ranges]; + return YES; +} + +/// Closing delimiter just completed: find opening trigger before content. ++ (BOOL)tryInlineShortcutClosingFirst:(const ShortcutsTriggerMatch *)match + context:(const ShortcutsInlineContext *)context { + const ShortcutsTextContext *text = &context->text; + NSInteger searchStart = text->paragraphRange.location; + NSInteger searchLength = match->delimStart - searchStart; + if (searchLength <= 0) { + return NO; + } + + NSRange openRange = + [text->fullText rangeOfString:match->trigger + options:NSBackwardsSearch + range:NSMakeRange(searchStart, searchLength)]; + if (openRange.location == NSNotFound) { + return NO; + } + + if ([self isDelimiterPartOfLongerInlineTrigger:match->trigger + delimStart:openRange.location + context:context + isOpening:YES]) { + return NO; + } + + NSInteger contentStart = openRange.location + match->trigger.length; + NSInteger contentEnd = match->delimStart; + if (contentEnd <= contentStart) { + return NO; + } + + NSInteger finalContentEnd = match->delimStart - match->trigger.length; + ShortcutsInlineApplyRanges ranges = { + .finalContentRange = + NSMakeRange(openRange.location, finalContentEnd - openRange.location), + .closeDeleteRange = NSMakeRange(match->delimStart, match->delimPrefixLen), + .openDeleteRange = NSMakeRange(openRange.location, match->trigger.length), + }; + + return + [self applyInlineShortcutWithMatch:match + context:context + contentRange:NSMakeRange(contentStart, + contentEnd - contentStart) + ranges:&ranges]; +} + +/// Opening delimiter just completed: find closing trigger after content. ++ (BOOL)tryInlineShortcutOpeningFirst:(const ShortcutsTriggerMatch *)match + context:(const ShortcutsInlineContext *)context { + const ShortcutsTextContext *text = &context->text; + NSInteger contentStart = (NSInteger)text->changeRange.location; + NSInteger searchStart = contentStart; + NSInteger searchEnd = + text->paragraphRange.location + text->paragraphRange.length; + if (searchStart >= searchEnd) { + return NO; + } + + NSRange closeRange = NSMakeRange(NSNotFound, 0); + NSInteger scan = searchStart; + while (scan < searchEnd) { + NSRange found = + [text->fullText rangeOfString:match->trigger + options:0 + range:NSMakeRange(scan, searchEnd - scan)]; + if (found.location == NSNotFound) { + break; + } + if (![self isDelimiterPartOfLongerInlineTrigger:match->trigger + delimStart:found.location + context:context + isOpening:NO]) { + closeRange = found; + break; + } + scan = found.location + 1; + } + + if (closeRange.location == NSNotFound) { + return NO; + } + + NSInteger contentEnd = closeRange.location; + if (contentEnd <= contentStart) { + return NO; + } + + NSRange contentRange = NSMakeRange(contentStart, contentEnd - contentStart); + ShortcutsInlineApplyRanges ranges = { + // After removing the opening prefix at delimStart, content spans + // [delimStart, delimStart + contentRange.length). + .finalContentRange = NSMakeRange(match->delimStart, contentRange.length), + .closeDeleteRange = + NSMakeRange(closeRange.location, match->trigger.length), + .openDeleteRange = NSMakeRange(match->delimStart, match->delimPrefixLen), + }; + + return [self applyInlineShortcutWithMatch:match + context:context + contentRange:contentRange + ranges:&ranges]; +} + +/// Paragraph already has a block-level style (list, quote, heading, …). +/// Alignment is ignored. ++ (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange + input:(EnrichedTextInputView *)input { + for (NSNumber *typeKey in input->stylesDict) { + StyleBase *style = input->stylesDict[typeKey]; + if (![style isParagraph] || [[style class] getType] == Alignment) { + continue; + } + if ([style detect:paragraphRange]) { + return YES; + } + } + return NO; +} + ++ (BOOL)tryHandlingBlockShortcutInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input { + if (![self hasTextShortcutsInInput:input]) { + return NO; + } + + ShortcutsTextContext context = [self textContextWithChangeRange:range + replacementText:text + input:input]; + + if ([self paragraphHasActiveParagraphStyleInRange:context.paragraphRange + input:input]) { + return NO; + } + + for (NSDictionary *shortcut in input->textShortcuts) { + if ([shortcut[@"type"] isEqualToString:@"inline"]) { + continue; + } + + NSString *trigger = shortcut[@"trigger"]; + NSString *styleName = shortcut[@"style"]; + if (trigger.length == 0 || styleName.length == 0) { + continue; + } + + ShortcutsTriggerMatch match = {}; + if (![self isCompletingTrigger:trigger + context:&context + requiredDelimStart:context.paragraphRange.location + match:&match]) { + continue; + } + + StyleType type = [self styleTypeForShortcutName:styleName]; + if (type == None) { + continue; + } + + if ([StyleUtils isStyleBlocked:type + range:context.paragraphRange + forHost:input]) { + continue; + } + + NSParagraphStyle *currentParaStyle = + input->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = + currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; + + NSRange triggerRange = NSMakeRange(match.delimStart, match.delimPrefixLen); + + input->blockEmitting = YES; + [TextInsertionUtils replaceText:@"" + at:triggerRange + additionalAttributes:nullptr + host:input + withSelection:YES]; + + input->blockEmitting = NO; + + // Drop conflicting inline typing attrs (e.g. italic) at the cursor before + // applying the codeblock style. + [StyleUtils handleStyleBlocksAndConflicts:type + range:input->textView.selectedRange + forHost:input]; + + [ParagraphAttributesUtils resetTypingAttributes:input + preservingAlignment:savedAlignment]; + + StyleBase *style = input->stylesDict[@(type)]; + if (style != nil) { + [style add:input->textView.selectedRange + withTyping:YES + withDirtyRange:YES]; + } + return YES; + } + + return NO; +} + ++ (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input { + if (![self hasTextShortcutsInInput:input]) { + return NO; + } + + ShortcutsInlineContext context = [self inlineContextWithChangeRange:range + replacementText:text + input:input]; + + for (NSDictionary *shortcut in context.inlineShortcuts) { + NSString *trigger = shortcut[@"trigger"]; + NSString *styleName = shortcut[@"style"]; + if (trigger.length == 0 || styleName.length == 0) { + continue; + } + + ShortcutsTriggerMatch match = {}; + if (![self isCompletingTrigger:trigger + context:&context.text + requiredDelimStart:NSNotFound + match:&match]) { + continue; + } + + if ([self shouldDeferShorterInlineTrigger:&match context:&context]) { + continue; + } + + StyleType type = [self styleTypeForShortcutName:styleName]; + if (type == None) { + continue; + } + match.styleType = type; + + if ([self tryInlineShortcutClosingFirst:&match context:&context]) { + return YES; + } + + if ([self tryInlineShortcutOpeningFirst:&match context:&context]) { + return YES; + } + } + + return NO; +} + +@end diff --git a/ios/utils/StyleUtils.h b/ios/utils/StyleUtils.h index d0850ddb2..ae3763cb4 100644 --- a/ios/utils/StyleUtils.h +++ b/ios/utils/StyleUtils.h @@ -8,6 +8,9 @@ (id)host isInput:(BOOL)isInput; ++ (BOOL)isStyleBlocked:(StyleType)type + range:(NSRange)range + forHost:(id)host; + (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range forHost:(id)host; diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm index f7205b171..838269221 100644 --- a/ios/utils/StyleUtils.mm +++ b/ios/utils/StyleUtils.mm @@ -182,15 +182,21 @@ + (NSDictionary *)stylesDictForHost:(id)host } // returns false when style shouldn't be applied and true when it can be -+ (BOOL)handleStyleBlocksAndConflicts:(StyleType)type - range:(NSRange)range - forHost:(id)host { - // handle blocking styles: if any is present we do not apply the toggled style ++ (BOOL)isStyleBlocked:(StyleType)type + range:(NSRange)range + forHost:(id)host { NSArray *blocking = [self getPresentStyleTypesFrom:host.blockingStyles[@(type)] range:range forHost:host]; - if (blocking.count != 0) { + return blocking.count != 0; +} + +// returns false when style shouldn't be applied and true when it can be ++ (BOOL)handleStyleBlocksAndConflicts:(StyleType)type + range:(NSRange)range + forHost:(id)host { + if ([self isStyleBlocked:type range:range forHost:host]) { return NO; } From 3cb0da830fb9c74c39988d0a6dc20b0131b6af79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Fri, 22 May 2026 13:13:50 +0200 Subject: [PATCH 05/10] fix(android): shortcut detection --- .../enriched/textinput/styles/InlineStyles.kt | 17 +++++ .../enriched/textinput/styles/ListStyles.kt | 69 ++++++++++++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt index 4e3b86601..a64f65274 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/InlineStyles.kt @@ -119,6 +119,23 @@ class InlineStyles( } } + fun applyStyleOnRange( + name: String, + start: Int, + end: Int, + ) { + val config = EnrichedSpans.inlineSpans[name] ?: return + val type = config.clazz + val spannable = view.text as Spannable + val spans = spannable.getSpans(start, end, type) + + if (spans.any { spannable.getSpanStart(it) <= start && spannable.getSpanEnd(it) >= end }) { + return + } + + setAndMergeSpans(spannable, type, start, end) + } + fun toggleStyle(name: String) { if (view.selection == null) return val (start, end) = view.selection.getInlineSelection() 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 3df26f5c1..a10fcccf1 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 @@ -298,6 +298,53 @@ class ListStyles( } } + private fun inlineShortcutsSorted(): List> = + view.textShortcuts + .filter { (trigger, _, type) -> type == "inline" && trigger.isNotEmpty() } + .sortedByDescending { it.first.length } + + private fun isDelimiterPartOfLongerInlineTrigger( + trigger: String, + delimStart: Int, + text: String, + inlineShortcuts: List>, + isOpening: Boolean, + ): Boolean { + val delimEnd = delimStart + trigger.length + + for ((longerTrigger, _, _) in inlineShortcuts) { + if (longerTrigger.length <= trigger.length) continue + + val longerStart = + when { + isOpening -> { + if (!longerTrigger.endsWith(trigger)) continue + delimEnd - longerTrigger.length + } + + longerTrigger.startsWith(trigger) -> { + delimStart + } + + longerTrigger.endsWith(trigger) -> { + delimStart - (longerTrigger.length - trigger.length) + } + + else -> { + continue + } + } + + if (longerStart < 0 || longerStart + longerTrigger.length > text.length) continue + + if (text.substring(longerStart, longerStart + longerTrigger.length) == longerTrigger) { + return true + } + } + + return false + } + private fun handleInlineShortcuts( s: Editable, endCursorPosition: Int, @@ -310,11 +357,9 @@ class ListStyles( val cursorPosition = endCursorPosition.coerceAtMost(s.length) val text = s.toString() val (paraStart, _) = s.getParagraphBounds(cursorPosition) + val inlineShortcuts = inlineShortcutsSorted() - for ((trigger, styleName, type) in shortcuts) { - if (type != "inline") continue - if (trigger.isEmpty()) continue - + for ((trigger, styleName, _) in inlineShortcuts) { val resolvedStyle = resolveInlineStyleName(styleName) ?: continue if (cursorPosition < trigger.length) continue @@ -323,11 +368,20 @@ class ListStyles( val closeDelimStart = cursorPosition - trigger.length + if (isDelimiterPartOfLongerInlineTrigger(trigger, closeDelimStart, text, inlineShortcuts, isOpening = false)) { + continue + } + val searchText = text.substring(paraStart, closeDelimStart) val openIdx = searchText.lastIndexOf(trigger) if (openIdx < 0) continue val openAbsolute = paraStart + openIdx + + if (isDelimiterPartOfLongerInlineTrigger(trigger, openAbsolute, text, inlineShortcuts, isOpening = true)) { + continue + } + val contentStart = openAbsolute + trigger.length val contentEnd = closeDelimStart if (contentEnd <= contentStart) continue @@ -338,10 +392,9 @@ class ListStyles( val adjustedStart = openAbsolute val adjustedEnd = contentEnd - trigger.length - view.setCustomSelection(adjustedStart, adjustedEnd) - view.inlineStyles?.toggleStyle(resolvedStyle) - - view.setCustomSelection(adjustedEnd, adjustedEnd) + view.inlineStyles?.applyStyleOnRange(resolvedStyle, adjustedStart, adjustedEnd) + view.setSelection(adjustedEnd, adjustedEnd) + view.spanState?.setStart(resolvedStyle, null) return } } From 807af51f708d88585fb3ef06e99798b5a0bcc51d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Fri, 22 May 2026 14:54:09 +0200 Subject: [PATCH 06/10] fix(iOS): forward shortcut verification --- ios/utils/ShortcutsUtils.mm | 126 ++---------------------------------- 1 file changed, 7 insertions(+), 119 deletions(-) diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index 007e8ebe8..c09ebb9b4 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -167,9 +167,8 @@ + (BOOL)isCompletingTrigger:(NSString *)trigger /// inside `**`). + (BOOL)isDelimiterPartOfLongerInlineTrigger:(NSString *)trigger delimStart:(NSInteger)delimStart - context: - (const ShortcutsInlineContext *)context - isOpening:(BOOL)isOpening { + context:(const ShortcutsInlineContext *) + context { NSInteger delimEnd = delimStart + trigger.length; NSString *fullText = context->text.fullText; @@ -178,21 +177,11 @@ + (BOOL)isDelimiterPartOfLongerInlineTrigger:(NSString *)trigger if (longerTrigger.length <= trigger.length) { continue; } - - NSInteger longerStart; - if (isOpening) { - if (![longerTrigger hasSuffix:trigger]) { - continue; - } - longerStart = delimEnd - longerTrigger.length; - } else if ([longerTrigger hasPrefix:trigger]) { - longerStart = delimStart; - } else if ([longerTrigger hasSuffix:trigger]) { - longerStart = delimStart - (longerTrigger.length - trigger.length); - } else { + if (![longerTrigger hasSuffix:trigger]) { continue; } + NSInteger longerStart = delimEnd - longerTrigger.length; if (longerStart < 0 || longerStart + longerTrigger.length > fullText.length) { continue; @@ -207,42 +196,6 @@ + (BOOL)isDelimiterPartOfLongerInlineTrigger:(NSString *)trigger return NO; } -/// Shorter trigger is only a prefix so far (e.g. first `*` of `**`) while a -/// longer closing delimiter already exists after the cursor. -+ (BOOL)shouldDeferShorterInlineTrigger:(const ShortcutsTriggerMatch *)match - context: - (const ShortcutsInlineContext *)context { - NSInteger writtenLen = match->delimPrefixLen + 1; - NSInteger searchFrom = context->text.changeRange.location; - NSInteger searchEnd = context->text.paragraphRange.location + - context->text.paragraphRange.length; - if (searchFrom >= searchEnd) { - return NO; - } - - for (NSDictionary *shortcut in context->inlineShortcuts) { - NSString *longerTrigger = shortcut[@"trigger"]; - if (longerTrigger.length <= match->trigger.length) { - continue; - } - if (![longerTrigger hasPrefix:match->trigger]) { - continue; - } - if (writtenLen >= longerTrigger.length) { - continue; - } - - NSRange longerAhead = [context->text.fullText - rangeOfString:longerTrigger - options:0 - range:NSMakeRange(searchFrom, searchEnd - searchFrom)]; - if (longerAhead.location != NSNotFound) { - return YES; - } - } - return NO; -} - /// Removes delimiters (close first, then open), then applies [style] on /// [contentRange]. + (void)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match @@ -275,9 +228,10 @@ + (void)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match return; } - [style add:ranges->finalContentRange withTyping:YES withDirtyRange:YES]; + [style add:ranges->finalContentRange withTyping:NO withDirtyRange:YES]; input->textView.selectedRange = NSMakeRange(NSMaxRange(ranges->finalContentRange), 0); + [style removeTyping]; } + (BOOL)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match @@ -315,8 +269,7 @@ + (BOOL)tryInlineShortcutClosingFirst:(const ShortcutsTriggerMatch *)match if ([self isDelimiterPartOfLongerInlineTrigger:match->trigger delimStart:openRange.location - context:context - isOpening:YES]) { + context:context]) { return NO; } @@ -342,63 +295,6 @@ + (BOOL)tryInlineShortcutClosingFirst:(const ShortcutsTriggerMatch *)match ranges:&ranges]; } -/// Opening delimiter just completed: find closing trigger after content. -+ (BOOL)tryInlineShortcutOpeningFirst:(const ShortcutsTriggerMatch *)match - context:(const ShortcutsInlineContext *)context { - const ShortcutsTextContext *text = &context->text; - NSInteger contentStart = (NSInteger)text->changeRange.location; - NSInteger searchStart = contentStart; - NSInteger searchEnd = - text->paragraphRange.location + text->paragraphRange.length; - if (searchStart >= searchEnd) { - return NO; - } - - NSRange closeRange = NSMakeRange(NSNotFound, 0); - NSInteger scan = searchStart; - while (scan < searchEnd) { - NSRange found = - [text->fullText rangeOfString:match->trigger - options:0 - range:NSMakeRange(scan, searchEnd - scan)]; - if (found.location == NSNotFound) { - break; - } - if (![self isDelimiterPartOfLongerInlineTrigger:match->trigger - delimStart:found.location - context:context - isOpening:NO]) { - closeRange = found; - break; - } - scan = found.location + 1; - } - - if (closeRange.location == NSNotFound) { - return NO; - } - - NSInteger contentEnd = closeRange.location; - if (contentEnd <= contentStart) { - return NO; - } - - NSRange contentRange = NSMakeRange(contentStart, contentEnd - contentStart); - ShortcutsInlineApplyRanges ranges = { - // After removing the opening prefix at delimStart, content spans - // [delimStart, delimStart + contentRange.length). - .finalContentRange = NSMakeRange(match->delimStart, contentRange.length), - .closeDeleteRange = - NSMakeRange(closeRange.location, match->trigger.length), - .openDeleteRange = NSMakeRange(match->delimStart, match->delimPrefixLen), - }; - - return [self applyInlineShortcutWithMatch:match - context:context - contentRange:contentRange - ranges:&ranges]; -} - /// Paragraph already has a block-level style (list, quote, heading, …). /// Alignment is ignored. + (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange @@ -524,10 +420,6 @@ + (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range continue; } - if ([self shouldDeferShorterInlineTrigger:&match context:&context]) { - continue; - } - StyleType type = [self styleTypeForShortcutName:styleName]; if (type == None) { continue; @@ -537,10 +429,6 @@ + (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range if ([self tryInlineShortcutClosingFirst:&match context:&context]) { return YES; } - - if ([self tryInlineShortcutOpeningFirst:&match context:&context]) { - return YES; - } } return NO; From 7770ab2206ac1a7b97b9c1b31b7fe1eba86e8a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Fri, 22 May 2026 15:56:37 +0200 Subject: [PATCH 07/10] fix(android): refactor shortcuts detection --- .../textinput/EnrichedTextInputView.kt | 4 +- .../enriched/textinput/spans/EnrichedSpans.kt | 13 +- .../enriched/textinput/styles/ListStyles.kt | 171 ------------------ .../textinput/utils/ShortcutsHandler.kt | 149 +++++++++++++++ .../enriched/textinput/utils/StyleUtils.kt | 46 +++++ .../textinput/watchers/EnrichedTextWatcher.kt | 1 + 6 files changed, 203 insertions(+), 181 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt 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 20fee986e..6b08f3bb0 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -66,6 +66,7 @@ import com.swmansion.enriched.textinput.utils.EnrichedEditableFactory import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState import com.swmansion.enriched.textinput.utils.RichContentReceiver +import com.swmansion.enriched.textinput.utils.ShortcutsHandler import com.swmansion.enriched.textinput.utils.mergeSpannables import com.swmansion.enriched.textinput.utils.setCheckboxClickListener import com.swmansion.enriched.textinput.utils.zwsCountBefore @@ -84,6 +85,7 @@ class EnrichedTextInputView : val inlineStyles: InlineStyles? = InlineStyles(this) val paragraphStyles: ParagraphStyles? = ParagraphStyles(this) val listStyles: ListStyles? = ListStyles(this) + val shortcutsHandler: ShortcutsHandler? = ShortcutsHandler(this) val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this) var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false @@ -769,7 +771,7 @@ class EnrichedTextInputView : layoutManager.invalidateLayout() } - private fun toggleStyle(name: String) { + internal fun toggleStyle(name: String) { when (name) { EnrichedSpans.BOLD -> inlineStyles?.toggleStyle(EnrichedSpans.BOLD) EnrichedSpans.ITALIC -> inlineStyles?.toggleStyle(EnrichedSpans.ITALIC) 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 5a0511581..0e40e9e8a 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 @@ -15,11 +15,6 @@ data class ParagraphSpanConfig( val isContinuous: Boolean, ) : ISpanConfig -data class ListSpanConfig( - override val clazz: Class<*>, - val shortcut: String?, -) : ISpanConfig - data class StylesMergingConfig( // styles that should be removed when we apply specific style val conflictingStyles: Array = emptyArray(), @@ -76,11 +71,11 @@ object EnrichedSpans { CODE_BLOCK to ParagraphSpanConfig(EnrichedInputCodeBlockSpan::class.java, true), ) - val listSpans: Map = + val listSpans: Map = mapOf( - UNORDERED_LIST to ListSpanConfig(EnrichedInputUnorderedListSpan::class.java, null), - ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, null), - CHECKBOX_LIST to ListSpanConfig(EnrichedInputCheckboxListSpan::class.java, null), + UNORDERED_LIST to BaseSpanConfig(EnrichedInputUnorderedListSpan::class.java), + ORDERED_LIST to BaseSpanConfig(EnrichedInputOrderedListSpan::class.java), + CHECKBOX_LIST to BaseSpanConfig(EnrichedInputCheckboxListSpan::class.java), ) val parametrizedStyles: Map = 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 a10fcccf1..41f074888 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 @@ -177,7 +177,6 @@ class ListStyles( val isBackspace = previousTextLength > s.length val isNewLine = cursorPosition > 0 && s[cursorPosition - 1] == '\n' - val isShortcut = config.shortcut?.let { s.substring(start, end).startsWith(it) } ?: false val spans = s.getSpans(start, end, config.clazz) // Remove spans if cursor is at the start of the paragraph and spans exist @@ -186,14 +185,6 @@ class ListStyles( return } - if (!isBackspace && isShortcut) { - s.replace(start, cursorPosition, EnrichedConstants.ZWS_STRING) - setSpan(s, name, start, start + 1) - // Inform that new span has been added - view.selection?.validateStyles() - return - } - if (!isBackspace && isNewLine && isPreviousParagraphList(s, start, config.clazz)) { // Check if the span from the previous line "leaked" into this one if (spans.isNotEmpty()) { @@ -239,166 +230,6 @@ 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 inlineShortcutsSorted(): List> = - view.textShortcuts - .filter { (trigger, _, type) -> type == "inline" && trigger.isNotEmpty() } - .sortedByDescending { it.first.length } - - private fun isDelimiterPartOfLongerInlineTrigger( - trigger: String, - delimStart: Int, - text: String, - inlineShortcuts: List>, - isOpening: Boolean, - ): Boolean { - val delimEnd = delimStart + trigger.length - - for ((longerTrigger, _, _) in inlineShortcuts) { - if (longerTrigger.length <= trigger.length) continue - - val longerStart = - when { - isOpening -> { - if (!longerTrigger.endsWith(trigger)) continue - delimEnd - longerTrigger.length - } - - longerTrigger.startsWith(trigger) -> { - delimStart - } - - longerTrigger.endsWith(trigger) -> { - delimStart - (longerTrigger.length - trigger.length) - } - - else -> { - continue - } - } - - if (longerStart < 0 || longerStart + longerTrigger.length > text.length) continue - - if (text.substring(longerStart, longerStart + longerTrigger.length) == longerTrigger) { - return true - } - } - - return false - } - - 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) - val inlineShortcuts = inlineShortcutsSorted() - - for ((trigger, styleName, _) in inlineShortcuts) { - 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 - - if (isDelimiterPartOfLongerInlineTrigger(trigger, closeDelimStart, text, inlineShortcuts, isOpening = false)) { - continue - } - - val searchText = text.substring(paraStart, closeDelimStart) - val openIdx = searchText.lastIndexOf(trigger) - if (openIdx < 0) continue - - val openAbsolute = paraStart + openIdx - - if (isDelimiterPartOfLongerInlineTrigger(trigger, openAbsolute, text, inlineShortcuts, isOpening = true)) { - continue - } - - 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.inlineStyles?.applyStyleOnRange(resolvedStyle, adjustedStart, adjustedEnd) - view.setSelection(adjustedEnd, adjustedEnd) - view.spanState?.setStart(resolvedStyle, null) - return - } - } - fun afterTextChanged( s: Editable, endCursorPosition: Int, @@ -407,8 +238,6 @@ 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/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt new file mode 100644 index 000000000..a27f099b7 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt @@ -0,0 +1,149 @@ +package com.swmansion.enriched.textinput.utils + +import android.text.Editable +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.EnrichedTextInputView + +class ShortcutsHandler( + private val view: EnrichedTextInputView, +) { + fun afterTextChanged( + s: Editable, + endCursorPosition: Int, + previousTextLength: Int, + ) { + handleConfigurableShortcuts(s, endCursorPosition, previousTextLength) + handleInlineShortcuts(s, endCursorPosition, previousTextLength) + } + + 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, "") + view.toggleStyle(resolvedStyle) + return + } + } + + private fun inlineShortcutsSorted(): List> = + view.textShortcuts + .filter { (trigger, _, type) -> type == "inline" && trigger.isNotEmpty() } + .sortedByDescending { it.first.length } + + private fun isDelimiterPartOfLongerInlineTrigger( + trigger: String, + delimStart: Int, + text: String, + inlineShortcuts: List>, + isOpening: Boolean, + ): Boolean { + val delimEnd = delimStart + trigger.length + + for ((longerTrigger, _, _) in inlineShortcuts) { + if (longerTrigger.length <= trigger.length) continue + + val longerStart = + when { + isOpening -> { + if (!longerTrigger.endsWith(trigger)) continue + delimEnd - longerTrigger.length + } + + longerTrigger.startsWith(trigger) -> { + delimStart + } + + longerTrigger.endsWith(trigger) -> { + delimStart - (longerTrigger.length - trigger.length) + } + + else -> { + continue + } + } + + if (longerStart < 0 || longerStart + longerTrigger.length > text.length) continue + + if (text.substring(longerStart, longerStart + longerTrigger.length) == longerTrigger) { + return true + } + } + + return false + } + + 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) + val inlineShortcuts = inlineShortcutsSorted() + + for ((trigger, styleName, _) in inlineShortcuts) { + val resolvedStyle = resolveStyleName(styleName) ?: continue + + if (cursorPosition < trigger.length) continue + val closingDelim = text.substring(cursorPosition - trigger.length, cursorPosition) + if (closingDelim != trigger) continue + + val closeDelimStart = cursorPosition - trigger.length + + if (isDelimiterPartOfLongerInlineTrigger(trigger, closeDelimStart, text, inlineShortcuts, isOpening = false)) { + continue + } + + val searchText = text.substring(paraStart, closeDelimStart) + val openIdx = searchText.lastIndexOf(trigger) + if (openIdx < 0) continue + + val openAbsolute = paraStart + openIdx + + if (isDelimiterPartOfLongerInlineTrigger(trigger, openAbsolute, text, inlineShortcuts, isOpening = true)) { + continue + } + + val contentStart = openAbsolute + trigger.length + val contentEnd = closeDelimStart + if (contentEnd <= contentStart) continue + + if (isStyleBlockedOnRange(resolvedStyle, contentStart, contentEnd, s, view.htmlStyle)) { + continue + } + + s.delete(closeDelimStart, cursorPosition) + s.delete(openAbsolute, openAbsolute + trigger.length) + + val adjustedStart = openAbsolute + val adjustedEnd = contentEnd - trigger.length + + view.inlineStyles?.applyStyleOnRange(resolvedStyle, adjustedStart, adjustedEnd) + view.setSelection(adjustedEnd, adjustedEnd) + view.spanState?.setStart(resolvedStyle, null) + return + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt new file mode 100644 index 000000000..08aa82fe7 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt @@ -0,0 +1,46 @@ +package com.swmansion.enriched.textinput.utils + +import android.text.Spannable +import com.swmansion.enriched.textinput.spans.EnrichedSpans +import com.swmansion.enriched.textinput.styles.HtmlStyle + +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 + "bold" -> EnrichedSpans.BOLD + "italic" -> EnrichedSpans.ITALIC + "underline" -> EnrichedSpans.UNDERLINE + "strikethrough" -> EnrichedSpans.STRIKETHROUGH + "inline_code" -> EnrichedSpans.INLINE_CODE + else -> null + } + +fun isStyleBlockedOnRange( + styleName: String, + start: Int, + end: Int, + spannable: Spannable, + htmlStyle: HtmlStyle, +): Boolean { + val mergingConfig = + EnrichedSpans.getMergingConfigForStyle(styleName, htmlStyle) ?: return false + + for (blockingStyleName in mergingConfig.blockingStyles) { + val spanClass = EnrichedSpans.allSpans[blockingStyleName]?.clazz ?: continue + if (spannable.getSpans(start, end, spanClass).isNotEmpty()) { + return true + } + } + + return false +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index 028b41c15..014d7e05e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -47,6 +47,7 @@ class EnrichedTextWatcher( view.inlineStyles?.afterTextChanged(s, endCursorPosition) view.paragraphStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.listStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) + view.shortcutsHandler?.afterTextChanged(s, endCursorPosition, previousTextLength) view.parametrizedStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) } From 4743e6c46cb3afba290c5ec1efff7c911103ad80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Mon, 25 May 2026 09:48:52 +0200 Subject: [PATCH 08/10] fix: refactor textShortcuts API --- .../textinput/EnrichedTextInputView.kt | 4 +- .../textinput/EnrichedTextInputViewManager.kt | 5 +-- .../textinput/utils/ShortcutsHandler.kt | 18 ++++---- .../enriched/textinput/utils/StyleUtils.kt | 5 +++ ios/EnrichedTextInputView.mm | 5 +-- ios/utils/ShortcutsUtils.mm | 27 +++++++++--- src/index.native.tsx | 2 + src/native/EnrichedTextInput.tsx | 3 +- src/spec/EnrichedTextInputNativeComponent.ts | 9 ++-- src/types.ts | 42 +++++++++++++------ 10 files changed, 80 insertions(+), 40 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 6b08f3bb0..796d15cc5 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -110,8 +110,8 @@ class EnrichedTextInputView : var experimentalSynchronousEvents: Boolean = false var useHtmlNormalizer: Boolean = false - // Triple: (trigger, style, type) where type is "block" or "inline" - var textShortcuts: List> = emptyList() + // Pair: (trigger, style) + var textShortcuts: List> = emptyList() var fontSize: Float? = null private var lineHeight: Float? = 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 51ddb2364..30cb45f44 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -311,14 +311,13 @@ class EnrichedTextInputViewManager : view: EnrichedTextInputView?, value: ReadableArray?, ) { - val shortcuts = mutableListOf>() + 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)) + shortcuts.add(Pair(trigger, style)) } } view?.textShortcuts = shortcuts diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt index a27f099b7..b862f8009 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt @@ -1,7 +1,6 @@ package com.swmansion.enriched.textinput.utils import android.text.Editable -import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.textinput.EnrichedTextInputView class ShortcutsHandler( @@ -29,8 +28,8 @@ class ShortcutsHandler( val (start, end) = s.getParagraphBounds(cursorPosition) val paragraphText = s.substring(start, end) - for ((trigger, styleName, type) in shortcuts) { - if (type == "inline") continue + for ((trigger, styleName) in shortcuts) { + if (isInlineShortcutStyle(styleName)) continue if (trigger.isEmpty()) continue if (!paragraphText.startsWith(trigger)) continue @@ -42,21 +41,22 @@ class ShortcutsHandler( } } - private fun inlineShortcutsSorted(): List> = + private fun inlineShortcutsSorted(): List> = view.textShortcuts - .filter { (trigger, _, type) -> type == "inline" && trigger.isNotEmpty() } - .sortedByDescending { it.first.length } + .filter { (trigger, styleName) -> + isInlineShortcutStyle(styleName) && trigger.isNotEmpty() + }.sortedByDescending { it.first.length } private fun isDelimiterPartOfLongerInlineTrigger( trigger: String, delimStart: Int, text: String, - inlineShortcuts: List>, + inlineShortcuts: List>, isOpening: Boolean, ): Boolean { val delimEnd = delimStart + trigger.length - for ((longerTrigger, _, _) in inlineShortcuts) { + for ((longerTrigger, _) in inlineShortcuts) { if (longerTrigger.length <= trigger.length) continue val longerStart = @@ -103,7 +103,7 @@ class ShortcutsHandler( val (paraStart, _) = s.getParagraphBounds(cursorPosition) val inlineShortcuts = inlineShortcutsSorted() - for ((trigger, styleName, _) in inlineShortcuts) { + for ((trigger, styleName) in inlineShortcuts) { val resolvedStyle = resolveStyleName(styleName) ?: continue if (cursorPosition < trigger.length) continue diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt index 08aa82fe7..111d98412 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt @@ -25,6 +25,11 @@ fun resolveStyleName(name: String): String? = else -> null } +fun isInlineShortcutStyle(styleName: String): Boolean { + val resolvedStyle = resolveStyleName(styleName) ?: return false + return EnrichedSpans.inlineSpans.containsKey(resolvedStyle) +} + fun isStyleBlockedOnRange( styleName: String, start: Int, diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 6b615b47a..e8149c33d 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -696,7 +696,7 @@ - (void)updateProps:(Props::Shared const &)props const auto &newItem = newViewProps.textShortcuts[i]; const auto &oldItem = oldViewProps.textShortcuts[i]; if (newItem.trigger != oldItem.trigger || - newItem.style != oldItem.style || newItem.type != oldItem.type) { + newItem.style != oldItem.style) { textShortcutsChanged = true; break; } @@ -706,12 +706,9 @@ - (void)updateProps:(Props::Shared const &)props if (textShortcutsChanged) { NSMutableArray *shortcuts = [NSMutableArray new]; for (const auto &item : newViewProps.textShortcuts) { - NSString *type = - item.type.empty() ? @"block" : [NSString fromCppString:item.type]; [shortcuts addObject:@{ @"trigger" : [NSString fromCppString:item.trigger], @"style" : [NSString fromCppString:item.style], - @"type" : type }]; } textShortcuts = shortcuts; diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index c09ebb9b4..f2f889439 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -65,6 +65,21 @@ + (StyleType)styleTypeForShortcutName:(NSString *)name { return styleType ? (StyleType)[styleType integerValue] : None; } ++ (BOOL)isInlineShortcutStyleName:(NSString *)name + input:(EnrichedTextInputView *)input { + StyleType type = [self styleTypeForShortcutName:name]; + if (type == None) { + return NO; + } + + StyleBase *style = input->stylesDict[@(type)]; + if (style == nil) { + return NO; + } + + return ![style isParagraph]; +} + + (BOOL)hasTextShortcutsInInput:(EnrichedTextInputView *)input { return input != nullptr && input->textShortcuts != nil && input->textShortcuts.count > 0; @@ -92,15 +107,17 @@ + (ShortcutsTextContext)textContextWithChangeRange:(NSRange)changeRange .text = [self textContextWithChangeRange:changeRange replacementText:replacementText input:input], - .inlineShortcuts = [self inlineShortcutsFrom:input->textShortcuts], + .inlineShortcuts = [self inlineShortcutsFrom:input->textShortcuts + input:input], }; } -+ (NSArray *)inlineShortcutsFrom: - (NSArray *)textShortcuts { ++ (NSArray *) + inlineShortcutsFrom:(NSArray *)textShortcuts + input:(EnrichedTextInputView *)input { NSMutableArray *inlineShortcuts = [NSMutableArray array]; for (NSDictionary *shortcut in textShortcuts) { - if ([shortcut[@"type"] isEqualToString:@"inline"]) { + if ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) { [inlineShortcuts addObject:shortcut]; } } @@ -328,7 +345,7 @@ + (BOOL)tryHandlingBlockShortcutInRange:(NSRange)range } for (NSDictionary *shortcut in input->textShortcuts) { - if ([shortcut[@"type"] isEqualToString:@"inline"]) { + if ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) { continue; } diff --git a/src/index.native.tsx b/src/index.native.tsx index fb19db14e..82d450b16 100644 --- a/src/index.native.tsx +++ b/src/index.native.tsx @@ -19,6 +19,8 @@ export type { EnrichedTextInputInstance, ContextMenuItem, OnChangeMentionEvent, + TextShortcut, + TextShortcutStyle, } from './types'; // EnrichedText diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 37eb7bd7f..a65a8eff0 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -31,6 +31,7 @@ import type { EnrichedTextInputProps, OnLinkDetected, OnMentionDetected, + TextShortcut, } from '../types'; const warnMentionIndicators = (indicator: string) => { @@ -50,7 +51,7 @@ type HtmlRequest = { * 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 }> = [ +const DEFAULT_TEXT_SHORTCUTS: TextShortcut[] = [ { trigger: '- ', style: 'unordered_list' }, { trigger: '1.', style: 'ordered_list' }, ]; diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 678e7d79a..eed034381 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -173,6 +173,11 @@ export interface ContextMenuItemConfig { text: string; } +export interface TextShortcut { + trigger: string; + style: string; +} + export interface OnContextMenuItemPressEvent { itemText: string; selectedText: string; @@ -367,9 +372,7 @@ export interface NativeProps extends ViewProps { scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; contextMenuItems?: ReadonlyArray>; - textShortcuts: ReadonlyArray< - Readonly<{ trigger: string; style: string; type?: string }> - >; + textShortcuts: ReadonlyArray>; returnKeyType?: string; returnKeyLabel?: string; submitBehavior?: string; diff --git a/src/types.ts b/src/types.ts index 4032fb38d..5e8105d6a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -241,6 +241,29 @@ export interface HtmlStyle { }; } +export type TextShortcutStyle = + | 'bold' + | 'italic' + | 'underline' + | 'strikethrough' + | 'inline_code' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'blockquote' + | 'codeblock' + | 'unordered_list' + | 'ordered_list' + | 'checkbox_list'; + +export interface TextShortcut { + trigger: string; + style: TextShortcutStyle; +} + // Event types export interface OnChangeTextEvent { @@ -485,25 +508,18 @@ export interface EnrichedTextInputProps extends Omit { /** * Configure text shortcuts that auto-convert typed patterns into styles. * - * Two types of shortcuts are supported: + * Shortcut behavior is determined by `style`: * - * **Block shortcuts** (type: 'block', default): - * Trigger at the start of a paragraph. E.g. typing "# " converts the line to H1. + * **Block styles** 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. + * **Inline styles** trigger when a closing delimiter is typed around text. E.g. typing `**text**` applies bold. * The trigger is the delimiter string (e.g. "`", "**", "*", "~~"). - * - style: "bold", "italic", "strikethrough", "inline_code" + * - style: "bold", "italic", "underline", "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. + * Defaults to a built-in markdown-like set. Pass an empty array to disable all shortcuts. */ - textShortcuts?: Array<{ - trigger: string; - style: string; - type?: 'block' | 'inline'; - }>; + textShortcuts?: TextShortcut[]; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size. From 015a40115f6ec2388a08bd37fc3a844c97b922ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Mon, 25 May 2026 10:23:18 +0200 Subject: [PATCH 09/10] docs: add textShortcuts API --- .../textinput/utils/ShortcutsHandler.kt | 31 ++--------- docs/INPUT_API_REFERENCE.md | 55 ++++++++++++++++++- src/native/EnrichedTextInput.tsx | 4 -- src/types.ts | 14 ----- 4 files changed, 59 insertions(+), 45 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt index b862f8009..b6da278af 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt @@ -47,38 +47,21 @@ class ShortcutsHandler( isInlineShortcutStyle(styleName) && trigger.isNotEmpty() }.sortedByDescending { it.first.length } + // Delimiter at [delimStart] is part of a longer inline trigger (e.g. `*` + // inside `**`). private fun isDelimiterPartOfLongerInlineTrigger( trigger: String, delimStart: Int, text: String, inlineShortcuts: List>, - isOpening: Boolean, ): Boolean { val delimEnd = delimStart + trigger.length for ((longerTrigger, _) in inlineShortcuts) { if (longerTrigger.length <= trigger.length) continue + if (!longerTrigger.endsWith(trigger)) continue - val longerStart = - when { - isOpening -> { - if (!longerTrigger.endsWith(trigger)) continue - delimEnd - longerTrigger.length - } - - longerTrigger.startsWith(trigger) -> { - delimStart - } - - longerTrigger.endsWith(trigger) -> { - delimStart - (longerTrigger.length - trigger.length) - } - - else -> { - continue - } - } - + val longerStart = delimEnd - longerTrigger.length if (longerStart < 0 || longerStart + longerTrigger.length > text.length) continue if (text.substring(longerStart, longerStart + longerTrigger.length) == longerTrigger) { @@ -112,17 +95,13 @@ class ShortcutsHandler( val closeDelimStart = cursorPosition - trigger.length - if (isDelimiterPartOfLongerInlineTrigger(trigger, closeDelimStart, text, inlineShortcuts, isOpening = false)) { - continue - } - val searchText = text.substring(paraStart, closeDelimStart) val openIdx = searchText.lastIndexOf(trigger) if (openIdx < 0) continue val openAbsolute = paraStart + openIdx - if (isDelimiterPartOfLongerInlineTrigger(trigger, openAbsolute, text, inlineShortcuts, isOpening = true)) { + if (isDelimiterPartOfLongerInlineTrigger(trigger, openAbsolute, text, inlineShortcuts)) { continue } diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index b3d0efa8a..9f4991523 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -473,7 +473,60 @@ The `style` prop controls the layout, dimensions, typography, borders, shadows, | Type | Default Value | Platform | | --------------------------------------------------- | ------------- | -------- | -| [EnrichedInputStyle](ENRICHED_INPUT_STYLE.md) | - | Both | +| [EnrichedInputStyle](ENRICHED_INPUT_STYLE.md) | - | Both | + +### `textShortcuts` + +An array of shortcuts that auto-convert typed patterns into styles. Each entry maps a `trigger` string to a `style`. + +Item type: + +```ts +interface TextShortcut { + trigger: string; + style: TextShortcutStyle; +} + +type TextShortcutStyle = + | 'bold' + | 'italic' + | 'underline' + | 'strikethrough' + | 'inline_code' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'blockquote' + | 'codeblock' + | 'unordered_list' + | 'ordered_list' + | 'checkbox_list'; +``` + +- `trigger` is the typed pattern that activates the shortcut. +- `style` is the style to apply when the trigger completes. + +**Block styles** fire at the start of a paragraph (e.g. `# ` → H1, `- ` → unordered list). Supported styles: `h1`–`h6`, `blockquote`, `codeblock`, `unordered_list`, `ordered_list`, `checkbox_list`. + +**Inline styles** fire when a closing delimiter is typed around text (e.g. `**text**` → bold). The trigger is the delimiter string (e.g. `**`, `*`, `~~`). Supported styles: `bold`, `italic`, `underline`, `strikethrough`, `inline_code`. + +Default value: + +```ts +[ + { trigger: '- ', style: 'unordered_list' }, + { trigger: '1. ', style: 'ordered_list' }, +]; +``` + +| Type | Default Value | Platform | +| ---------------- | ------------- | -------- | +| `TextShortcut[]` | see above | Both | + +Pass an empty array to disable all shortcuts. ### `ViewProps` diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index a65a8eff0..6823e6764 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -47,10 +47,6 @@ 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: TextShortcut[] = [ { trigger: '- ', style: 'unordered_list' }, { trigger: '1.', style: 'ordered_list' }, diff --git a/src/types.ts b/src/types.ts index 5e8105d6a..f799e621a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -505,20 +505,6 @@ 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. - * - * Shortcut behavior is determined by `style`: - * - * **Block styles** 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 styles** trigger when a closing delimiter is typed around text. E.g. typing `**text**` applies bold. - * The trigger is the delimiter string (e.g. "`", "**", "*", "~~"). - * - style: "bold", "italic", "underline", "strikethrough", "inline_code" - * - * Defaults to a built-in markdown-like set. Pass an empty array to disable all shortcuts. - */ textShortcuts?: TextShortcut[]; /** * If true, Android will use experimental synchronous events. From 4a274d75d55303ab075deef2b603b65e3b955b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Mon, 25 May 2026 10:37:40 +0200 Subject: [PATCH 10/10] fix: add space after shortcut --- src/native/EnrichedTextInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 6823e6764..4fbdd9186 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -49,7 +49,7 @@ type HtmlRequest = { const DEFAULT_TEXT_SHORTCUTS: TextShortcut[] = [ { trigger: '- ', style: 'unordered_list' }, - { trigger: '1.', style: 'ordered_list' }, + { trigger: '1. ', style: 'ordered_list' }, ]; export const EnrichedTextInput = ({