From fe87d788e648252962eaf185f7c0f478be796f00 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 25 Mar 2026 23:07:23 +0800 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 = ({ From 757ae0fae9ed34ac6ab7dd64819199999cb94343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= <74975508+kacperzolkiewski@users.noreply.github.com> Date: Fri, 29 May 2026 08:27:47 +0200 Subject: [PATCH 11/16] Update ios/utils/ShortcutsUtils.mm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com> --- ios/utils/ShortcutsUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index f2f889439..eeee1d10d 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -37,7 +37,7 @@ @implementation ShortcutsUtils static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ map = @{ - // Block shortcuts + // Paragraph shortcuts @"h1" : @(H1), @"h2" : @(H2), @"h3" : @(H3), From dd1d46ed36f7f2c6a632e35c3211df7c340fab16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= <74975508+kacperzolkiewski@users.noreply.github.com> Date: Fri, 29 May 2026 08:28:15 +0200 Subject: [PATCH 12/16] Update ios/utils/ShortcutsUtils.mm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com> --- ios/utils/ShortcutsUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index eeee1d10d..1fef6420c 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -80,7 +80,7 @@ + (BOOL)isInlineShortcutStyleName:(NSString *)name return ![style isParagraph]; } -+ (BOOL)hasTextShortcutsInInput:(EnrichedTextInputView *)input { ++ (BOOL)anyTextShortcutsInInput:(EnrichedTextInputView *)input { return input != nullptr && input->textShortcuts != nil && input->textShortcuts.count > 0; } From fd28dcf2b2c608623aafffb4c2c7084b3485aede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= <74975508+kacperzolkiewski@users.noreply.github.com> Date: Fri, 29 May 2026 08:28:28 +0200 Subject: [PATCH 13/16] Update ios/utils/ShortcutsUtils.mm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com> --- ios/utils/ShortcutsUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index 1fef6420c..2c2818361 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -328,7 +328,7 @@ + (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange return NO; } -+ (BOOL)tryHandlingBlockShortcutInRange:(NSRange)range ++ (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range replacementText:(NSString *)text input:(EnrichedTextInputView *)input { if (![self hasTextShortcutsInInput:input]) { From 8d830b8cd0e20b1e7396abf41f703347dfe68d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= <74975508+kacperzolkiewski@users.noreply.github.com> Date: Fri, 29 May 2026 08:28:45 +0200 Subject: [PATCH 14/16] Update ios/utils/ShortcutsUtils.mm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com> --- ios/utils/ShortcutsUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index 2c2818361..10ac01322 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -137,7 +137,7 @@ + (ShortcutsTextContext)textContextWithChangeRange:(NSRange)changeRange } /// When [requiredDelimStart] is NSNotFound, the trigger may appear anywhere in -/// the text. Otherwise the matched delimiter must start at that index (block +/// the text. Otherwise the matched delimiter must start at that index (paragraph /// shortcuts at paragraph start). + (BOOL)isCompletingTrigger:(NSString *)trigger context:(const ShortcutsTextContext *)context From d76abcd14ee12ccd4bd1161d3fc7c61d1d21d38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= <74975508+kacperzolkiewski@users.noreply.github.com> Date: Fri, 29 May 2026 08:29:03 +0200 Subject: [PATCH 15/16] Update ios/utils/ShortcutsUtils.mm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com> --- ios/utils/ShortcutsUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index 10ac01322..5a76c70a5 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -312,7 +312,7 @@ + (BOOL)tryInlineShortcutClosingFirst:(const ShortcutsTriggerMatch *)match ranges:&ranges]; } -/// Paragraph already has a block-level style (list, quote, heading, …). +/// Paragraph already has a paragraph-level style (list, quote, heading, …). /// Alignment is ignored. + (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange input:(EnrichedTextInputView *)input { From 86350edf4b7098a1684e9cf37c295238e188e024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Fri, 29 May 2026 10:21:06 +0200 Subject: [PATCH 16/16] fix: review changes --- docs/INPUT_API_REFERENCE.md | 14 +++++++--- ios/EnrichedTextInputView.mm | 27 ++++++++----------- ios/utils/ShortcutsUtils.h | 12 ++++----- ios/utils/ShortcutsUtils.mm | 50 +++++++++++++++++++++++++++++------- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 9f4991523..fc7354ff2 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -478,6 +478,7 @@ The `style` prop controls the layout, dimensions, typography, borders, shadows, ### `textShortcuts` An array of shortcuts that auto-convert typed patterns into styles. Each entry maps a `trigger` string to a `style`. +These shortcuts allow users to format text similarly to modern Markdown editors by typing familiar patterns directly in the input. Item type: @@ -509,9 +510,15 @@ type TextShortcutStyle = - `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`. +**[Paragraph styles](../README.md#paragraph-tags)** 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`. +> [!NOTE] +> Paragraph shortcuts are only effective on plain paragraphs. If the paragraph already has an active paragraph style (e.g. it is already a heading or a list item), typing the trigger pattern has no effect. + +**[Inline styles](../README.md#inline-tags)** 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`. + +> [!NOTE] +> Style rules still apply to shortcut-triggered styles: if the target style is **blocked** by another currently active style (e.g. bold inside a codeblock), the shortcut has no effect. If the target style **conflicts** with another active style, the conflicting style is removed when the new one is applied. See the [inline](../README.md#inline-tags) and [paragraph](../README.md#paragraph-tags) tag tables for the full conflict and blocking rules. Default value: @@ -526,7 +533,8 @@ Default value: | ---------------- | ------------- | -------- | | `TextShortcut[]` | see above | Both | -Pass an empty array to disable all shortcuts. +> [!NOTE] +> Pass an empty array to disable all shortcuts. ### `ViewProps` diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index e8149c33d..930659a70 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1956,22 +1956,17 @@ - (bool)textView:(UITextView *)textView // This function is the "Generic Fallback": if no specific style // claims the backspace action to change its state, only then do we // proceed to physically delete the newline and merge paragraphs. - || - [ParagraphAttributesUtils handleParagraphStylesMergeOnBackspace:range - replacementText:text - input:self]) { - [self anyTextMayHaveBeenModified]; - return NO; - } - - // Check configurable text shortcuts (block: "# " → h1, inline: `code` → - // inline_code) - if ([ShortcutsUtils tryHandlingBlockShortcutInRange:range - replacementText:text - input:self] || - [ShortcutsUtils tryHandlingInlineShortcutInRange:range - replacementText:text - input:self]) { + || [ParagraphAttributesUtils handleParagraphStylesMergeOnBackspace:range + replacementText:text + input:self] + // Check configurable text shortcuts (block: "# " → h1, inline: `code` → + // inline_code) + || [ShortcutsUtils tryHandlingParagraphShortcutsInRange:range + replacementText:text + input:self] || + [ShortcutsUtils tryHandlingInlineShortcutsInRange:range + replacementText:text + input:self]) { [self anyTextMayHaveBeenModified]; return NO; } diff --git a/ios/utils/ShortcutsUtils.h b/ios/utils/ShortcutsUtils.h index be3c9bd78..e57a30d1d 100644 --- a/ios/utils/ShortcutsUtils.h +++ b/ios/utils/ShortcutsUtils.h @@ -8,13 +8,13 @@ NS_ASSUME_NONNULL_BEGIN @interface ShortcutsUtils : NSObject -+ (BOOL)tryHandlingBlockShortcutInRange:(NSRange)range - replacementText:(NSString *)text - input:(EnrichedTextInputView *)input; ++ (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input; -+ (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range - replacementText:(NSString *)text - input:(EnrichedTextInputView *)input; ++ (BOOL)tryHandlingInlineShortcutsInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input; @end diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index 5a76c70a5..871e02251 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -137,8 +137,8 @@ + (ShortcutsTextContext)textContextWithChangeRange:(NSRange)changeRange } /// When [requiredDelimStart] is NSNotFound, the trigger may appear anywhere in -/// the text. Otherwise the matched delimiter must start at that index (paragraph -/// shortcuts at paragraph start). +/// the text. Otherwise the matched delimiter must start at that index +/// (paragraph shortcuts at paragraph start). + (BOOL)isCompletingTrigger:(NSString *)trigger context:(const ShortcutsTextContext *)context requiredDelimStart:(NSInteger)requiredDelimStart @@ -328,10 +328,27 @@ + (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange return NO; } +/// Handles a paragraph-level shortcut (e.g. `# ` → H1, `- ` → unordered list) +/// on character insertion. +/// +/// 1. Skip if no shortcuts configured, or the paragraph already has an active +/// paragraph style — triggers only apply to plain paragraphs. +/// 2. Find a paragraph shortcut whose trigger is anchored to the paragraph +/// start. Skip if the resolved style is blocked by another active style. +/// 3. Save the current text alignment. +/// 4. Suppress events, delete the trigger text, unsuppress. +/// 5. Remove styles from the range that conflict with the new style (e.g. +/// italic is removed when applying codeblock). +// 6. Reset typing attrs to defaults preserving alignment — without this, the +// new paragraph +/// style would inherit the alignment of the previous paragraph. +/// 7. Apply the paragraph style with withTyping:YES so the next typed +/// character +/// inherits it immediately. + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range - replacementText:(NSString *)text - input:(EnrichedTextInputView *)input { - if (![self hasTextShortcutsInInput:input]) { + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input { + if (![self anyTextShortcutsInInput:input]) { return NO; } @@ -411,10 +428,25 @@ + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range return NO; } -+ (BOOL)tryHandlingInlineShortcutInRange:(NSRange)range - replacementText:(NSString *)text - input:(EnrichedTextInputView *)input { - if (![self hasTextShortcutsInInput:input]) { +/// Handles an inline shortcut (e.g. `**text**` → bold) on character insertion. +/// Inline shortcuts are symmetric delimiter pairs — the same string opens and +/// closes the style (e.g. `**`). +/// +/// 1. Build the inline context: inline-only shortcuts sorted longest-first so +/// `**` is never pre-empted by its shorter suffix `*`. +/// 2. Check if the just-typed character, together with the characters +/// immediately before the cursor, completes a closing delimiter. +/// 3. Search backwards for a matching opening delimiter. Reject if it is part +/// of a longer trigger, or if there is no content between the pair. +/// 4. Check style blocks/conflicts; skip if the style cannot be applied. +/// 5. Suppress events, delete the closing-delimiter prefix then the opening +/// delimiter (close first so the open's earlier index stays valid). +/// 6. Apply the style to the content range, move the cursor to its end, and +/// clear the typing style. ++ (BOOL)tryHandlingInlineShortcutsInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input { + if (![self anyTextShortcutsInInput:input]) { return NO; }