diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 4dfb2396..08c42803 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -109,6 +109,9 @@ class EnrichedTextInputView : var experimentalSynchronousEvents: Boolean = false var useHtmlNormalizer: Boolean = false + // Triple: (trigger, style, type) where type is "block" or "inline" + var textShortcuts: List> = emptyList() + var fontSize: Float? = null private var lineHeight: Float? = null var submitBehavior: String? = null diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 6b24ac27..0b36d1be 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -307,6 +307,23 @@ class EnrichedTextInputViewManager : view?.useHtmlNormalizer = value } + override fun setTextShortcuts( + view: EnrichedTextInputView?, + value: ReadableArray?, + ) { + val shortcuts = mutableListOf>() + if (value != null) { + for (i in 0 until value.size()) { + val map = value.getMap(i) ?: continue + val trigger = map.getString("trigger") ?: continue + val style = map.getString("style") ?: continue + val type = map.getString("type") ?: "block" + shortcuts.add(Triple(trigger, style, type)) + } + } + view?.textShortcuts = shortcuts + } + override fun focus(view: EnrichedTextInputView?) { view?.requestFocusProgrammatically() } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt index f680cd5d..9e0f33fe 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt @@ -78,8 +78,8 @@ object EnrichedSpans { val listSpans: Map = mapOf( - UNORDERED_LIST to ListSpanConfig(EnrichedInputUnorderedListSpan::class.java, "- "), - ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, "1. "), + UNORDERED_LIST to ListSpanConfig(EnrichedInputUnorderedListSpan::class.java, null), + ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, null), CHECKBOX_LIST to ListSpanConfig(EnrichedInputCheckboxListSpan::class.java, null), ) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index 01801239..8f03088e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -239,6 +239,111 @@ class ListStyles( } } + private fun resolveInlineStyleName(name: String): String? = when (name) { + "bold" -> EnrichedSpans.BOLD + "italic" -> EnrichedSpans.ITALIC + "underline" -> EnrichedSpans.UNDERLINE + "strikethrough" -> EnrichedSpans.STRIKETHROUGH + "inline_code" -> EnrichedSpans.INLINE_CODE + else -> null + } + + private fun resolveStyleName(name: String): String? = when (name) { + "h1" -> EnrichedSpans.H1 + "h2" -> EnrichedSpans.H2 + "h3" -> EnrichedSpans.H3 + "h4" -> EnrichedSpans.H4 + "h5" -> EnrichedSpans.H5 + "h6" -> EnrichedSpans.H6 + "blockquote" -> EnrichedSpans.BLOCK_QUOTE + "codeblock" -> EnrichedSpans.CODE_BLOCK + "unordered_list" -> EnrichedSpans.UNORDERED_LIST + "ordered_list" -> EnrichedSpans.ORDERED_LIST + "checkbox_list" -> EnrichedSpans.CHECKBOX_LIST + else -> null + } + + private fun handleConfigurableShortcuts( + s: Editable, + endCursorPosition: Int, + previousTextLength: Int, + ) { + val shortcuts = view.textShortcuts + if (shortcuts.isEmpty()) return + if (previousTextLength >= s.length) return + + val cursorPosition = endCursorPosition.coerceAtMost(s.length) + val (start, end) = s.getParagraphBounds(cursorPosition) + val paragraphText = s.substring(start, end) + + for ((trigger, styleName, type) in shortcuts) { + if (type == "inline") continue + if (trigger.isEmpty()) continue + if (!paragraphText.startsWith(trigger)) continue + + val resolvedStyle = resolveStyleName(styleName) ?: continue + + s.replace(start, start + trigger.length, EnrichedConstants.ZWS_STRING) + + val listConfig = EnrichedSpans.listSpans[resolvedStyle] + if (listConfig != null) { + setSpan(s, resolvedStyle, start, start + 1) + view.selection?.validateStyles() + } else { + view.paragraphStyles?.toggleStyle(resolvedStyle) + } + return + } + } + + private fun handleInlineShortcuts( + s: Editable, + endCursorPosition: Int, + previousTextLength: Int, + ) { + val shortcuts = view.textShortcuts + if (shortcuts.isEmpty()) return + if (previousTextLength >= s.length) return + + val cursorPosition = endCursorPosition.coerceAtMost(s.length) + val text = s.toString() + val (paraStart, _) = s.getParagraphBounds(cursorPosition) + + for ((trigger, styleName, type) in shortcuts) { + if (type != "inline") continue + if (trigger.isEmpty()) continue + + val resolvedStyle = resolveInlineStyleName(styleName) ?: continue + + if (cursorPosition < trigger.length) continue + val closingDelim = text.substring(cursorPosition - trigger.length, cursorPosition) + if (closingDelim != trigger) continue + + val closeDelimStart = cursorPosition - trigger.length + + val searchText = text.substring(paraStart, closeDelimStart) + val openIdx = searchText.lastIndexOf(trigger) + if (openIdx < 0) continue + + val openAbsolute = paraStart + openIdx + val contentStart = openAbsolute + trigger.length + val contentEnd = closeDelimStart + if (contentEnd <= contentStart) continue + + s.delete(closeDelimStart, cursorPosition) + s.delete(openAbsolute, openAbsolute + trigger.length) + + val adjustedStart = openAbsolute + val adjustedEnd = contentEnd - trigger.length + + view.setCustomSelection(adjustedStart, adjustedEnd) + view.inlineStyles?.toggleStyle(resolvedStyle) + + view.setCustomSelection(adjustedEnd, adjustedEnd) + return + } + } + fun afterTextChanged( s: Editable, endCursorPosition: Int, @@ -247,6 +352,8 @@ class ListStyles( handleAfterTextChanged(s, EnrichedSpans.ORDERED_LIST, endCursorPosition, previousTextLength) handleAfterTextChanged(s, EnrichedSpans.UNORDERED_LIST, endCursorPosition, previousTextLength) handleAfterTextChanged(s, EnrichedSpans.CHECKBOX_LIST, endCursorPosition, previousTextLength) + handleConfigurableShortcuts(s, endCursorPosition, previousTextLength) + handleInlineShortcuts(s, endCursorPosition, previousTextLength) } fun getStyleRange(): Pair = view.selection?.getParagraphSelection() ?: Pair(0, 0) diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index c2eb4a97..83caed1e 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -32,6 +32,8 @@ NS_ASSUME_NONNULL_BEGIN BOOL blockEmitting; @public BOOL useHtmlNormalizer; +@public + NSArray *textShortcuts; } - (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkDetectedEvent:(NSString *)text diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index cdd72fab..e4806041 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -8,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" @@ -763,6 +764,21 @@ - (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.empty() ? @"block" : [NSString fromCppString:item.type]; + [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 +2017,215 @@ - (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 +2269,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 +2310,14 @@ - (bool)textView:(UITextView *)textView return NO; } + // Check configurable text shortcuts (block: "# " → h1, inline: `code` → + // inline_code) + if ([self tryHandlingTextShortcutInRange:range replacementText:text] || + [self tryHandlingInlineShortcutInRange:range replacementText:text]) { + [self anyTextMayHaveBeenModified]; + return NO; + } + // Tapping near a link causes iOS to re-derive typingAttributes from // character attributes after textViewDidChangeSelection returns, undoing // the cleanup in manageSelectionBasedChanges. Strip them again here, right diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 7ff8120b..367ea71f 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -45,6 +45,15 @@ type HtmlRequest = { reject: (error: Error) => void; }; +/** + * Default text shortcuts matching the previously hardcoded behavior. + * Consumers can override by passing their own textShortcuts prop. + */ +const DEFAULT_TEXT_SHORTCUTS: Array<{ trigger: string; style: string }> = [ + { trigger: '- ', style: 'unordered_list' }, + { trigger: '1.', style: 'ordered_list' }, +]; + export const EnrichedTextInput = ({ ref, autoFocus, @@ -76,6 +85,7 @@ export const EnrichedTextInput = ({ returnKeyLabel, submitBehavior, contextMenuItems, + textShortcuts, androidExperimentalSynchronousEvents = false, useHtmlNormalizer = false, scrollEnabled = true, @@ -348,6 +358,7 @@ export const EnrichedTextInput = ({ onRequestHtmlResult={handleRequestHtmlResult} onInputKeyPress={onKeyPress} contextMenuItems={nativeContextMenuItems} + textShortcuts={textShortcuts ?? DEFAULT_TEXT_SHORTCUTS} onContextMenuItemPress={handleContextMenuItemPress} onSubmitEditing={onSubmitEditing} returnKeyType={returnKeyType} diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 83e557d5..0b20da7b 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -365,6 +365,7 @@ export interface NativeProps extends ViewProps { scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; contextMenuItems?: ReadonlyArray>; + textShortcuts: ReadonlyArray>; returnKeyType?: string; returnKeyLabel?: string; submitBehavior?: string; diff --git a/src/types.ts b/src/types.ts index aeee6e93..ed7bca51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,6 +305,24 @@ 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" + * + * 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' }>; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size.