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 885a12ea4..796d15cc5 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 @@ -108,6 +110,9 @@ class EnrichedTextInputView : var experimentalSynchronousEvents: Boolean = false var useHtmlNormalizer: Boolean = false + // Pair: (trigger, style) + var textShortcuts: List> = emptyList() + var fontSize: Float? = null private var lineHeight: Float? = null var submitBehavior: String? = null @@ -766,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/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 7b5313462..30cb45f44 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,22 @@ 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 + shortcuts.add(Pair(trigger, style)) + } + } + 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 bc844c2e7..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, "- "), - ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, "1. "), - 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/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 01801239e..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()) { 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..b6da278af --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt @@ -0,0 +1,128 @@ +package com.swmansion.enriched.textinput.utils + +import android.text.Editable +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) in shortcuts) { + if (isInlineShortcutStyle(styleName)) 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, styleName) -> + 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>, + ): Boolean { + val delimEnd = delimStart + trigger.length + + for ((longerTrigger, _) in inlineShortcuts) { + if (longerTrigger.length <= trigger.length) continue + if (!longerTrigger.endsWith(trigger)) continue + + val longerStart = delimEnd - longerTrigger.length + 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 + + 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)) { + 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..111d98412 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt @@ -0,0 +1,51 @@ +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 isInlineShortcutStyle(styleName: String): Boolean { + val resolvedStyle = resolveStyleName(styleName) ?: return false + return EnrichedSpans.inlineSpans.containsKey(resolvedStyle) +} + +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) } diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index b3d0efa8a..fc7354ff2 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -473,7 +473,68 @@ 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`. +These shortcuts allow users to format text similarly to modern Markdown editors by typing familiar patterns directly in the input. + +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. + +**[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`. + +> [!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: + +```ts +[ + { trigger: '- ', style: 'unordered_list' }, + { trigger: '1. ', style: 'ordered_list' }, +]; +``` + +| Type | Default Value | Platform | +| ---------------- | ------------- | -------- | +| `TextShortcut[]` | see above | Both | + +> [!NOTE] +> Pass an empty array to disable all shortcuts. ### `ViewProps` diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 7e6f0b371..4fde33a47 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -37,6 +37,8 @@ NS_ASSUME_NONNULL_BEGIN BOOL useHtmlNormalizer; @public NSValue *dotReplacementRange; +@public + NSArray *textShortcuts; } - (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 95b8bd5c4..930659a70 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -9,10 +9,12 @@ #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" #import "RCTFabricComponentsPlugins.h" +#import "ShortcutsUtils.h" #import "StringExtension.h" #import "StyleHeaders.h" #import "StyleUtils.h" #import "TextBlockTapGestureRecognizer.h" +#import "TextInsertionUtils.h" #import "UIView+React.h" #import "WordsUtils.h" #import "ZeroWidthSpaceUtils.h" @@ -686,6 +688,32 @@ - (void)updateProps:(Props::Shared const &)props useHtmlNormalizer = newViewProps.useHtmlNormalizer; } + // 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) { + textShortcutsChanged = true; + break; + } + } + } + + if (textShortcutsChanged) { + NSMutableArray *shortcuts = [NSMutableArray new]; + for (const auto &item : newViewProps.textShortcuts) { + [shortcuts addObject:@{ + @"trigger" : [NSString fromCppString:item.trigger], + @"style" : [NSString fromCppString:item.style], + }]; + } + textShortcuts = shortcuts; + } + // default value - must be set before placeholder to make sure it correctly // shows on first mount if (newViewProps.defaultValue != oldViewProps.defaultValue) { @@ -1891,8 +1919,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])]; @@ -1911,8 +1937,6 @@ - (bool)textView:(UITextView *)textView [ZeroWidthSpaceUtils handleBackspaceInRange:range replacementText:text host:self] || - [uStyle tryHandlingListShorcutInRange:range replacementText:text] || - [oStyle tryHandlingListShorcutInRange:range replacementText:text] || [cbLStyle handleNewlinesInRange:range replacementText:text] || [h1Style handleNewlinesInRange:range replacementText:text] || [h2Style handleNewlinesInRange:range replacementText:text] || @@ -1932,10 +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]) { + || [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/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..e57a30d1d --- /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)tryHandlingParagraphShortcutsInRange:(NSRange)range + replacementText:(NSString *)text + input:(EnrichedTextInputView *)input; + ++ (BOOL)tryHandlingInlineShortcutsInRange:(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..871e02251 --- /dev/null +++ b/ios/utils/ShortcutsUtils.mm @@ -0,0 +1,486 @@ +#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 = @{ + // Paragraph 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)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)anyTextShortcutsInInput:(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 + input:input], + }; +} + ++ (NSArray *) + inlineShortcutsFrom:(NSArray *)textShortcuts + input:(EnrichedTextInputView *)input { + NSMutableArray *inlineShortcuts = [NSMutableArray array]; + for (NSDictionary *shortcut in textShortcuts) { + if ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) { + [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 +/// (paragraph 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 { + 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; + } + if (![longerTrigger hasSuffix:trigger]) { + continue; + } + + NSInteger longerStart = delimEnd - longerTrigger.length; + 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; +} + +/// 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:NO withDirtyRange:YES]; + input->textView.selectedRange = + NSMakeRange(NSMaxRange(ranges->finalContentRange), 0); + [style removeTyping]; +} + ++ (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]) { + 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]; +} + +/// Paragraph already has a paragraph-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; +} + +/// 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 anyTextShortcutsInInput: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 ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) { + 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; +} + +/// 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; + } + + 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; + } + + StyleType type = [self styleTypeForShortcutName:styleName]; + if (type == None) { + continue; + } + match.styleType = type; + + if ([self tryInlineShortcutClosingFirst:&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; } 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 51e68fa14..4fbdd9186 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) => { @@ -46,6 +47,11 @@ type HtmlRequest = { reject: (error: Error) => void; }; +const DEFAULT_TEXT_SHORTCUTS: TextShortcut[] = [ + { trigger: '- ', style: 'unordered_list' }, + { trigger: '1. ', style: 'ordered_list' }, +]; + export const EnrichedTextInput = ({ ref, autoFocus, @@ -77,6 +83,7 @@ export const EnrichedTextInput = ({ returnKeyLabel, submitBehavior, contextMenuItems, + textShortcuts, androidExperimentalSynchronousEvents = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.androidExperimentalSynchronousEvents, useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer, scrollEnabled = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.scrollEnabled, @@ -354,6 +361,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 b344aff64..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,6 +372,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 fffd83c9f..f799e621a 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 { @@ -482,6 +505,7 @@ export interface EnrichedTextInputProps extends Omit { onSubmitEditing?: (e: NativeSyntheticEvent) => void; onPasteImages?: (e: NativeSyntheticEvent) => void; contextMenuItems?: ContextMenuItem[]; + textShortcuts?: TextShortcut[]; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size.