Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -108,6 +110,9 @@ class EnrichedTextInputView :
var experimentalSynchronousEvents: Boolean = false
var useHtmlNormalizer: Boolean = false

// Pair: (trigger, style)
var textShortcuts: List<Pair<String, String>> = emptyList()

var fontSize: Float? = null
private var lineHeight: Float? = null
var submitBehavior: String? = null
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,22 @@ class EnrichedTextInputViewManager :
view?.useHtmlNormalizer = value
}

override fun setTextShortcuts(
view: EnrichedTextInputView?,
value: ReadableArray?,
) {
val shortcuts = mutableListOf<Pair<String, String>>()
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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptyArray(),
Expand Down Expand Up @@ -76,11 +71,11 @@ object EnrichedSpans {
CODE_BLOCK to ParagraphSpanConfig(EnrichedInputCodeBlockSpan::class.java, true),
)

val listSpans: Map<String, ListSpanConfig> =
val listSpans: Map<String, BaseSpanConfig> =
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<String, BaseSpanConfig> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<String, String>> =
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<Pair<String, String>>,
): 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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
55 changes: 54 additions & 1 deletion docs/INPUT_API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 2 additions & 0 deletions ios/EnrichedTextInputView.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ NS_ASSUME_NONNULL_BEGIN
BOOL useHtmlNormalizer;
@public
NSValue *dotReplacementRange;
@public
NSArray<NSDictionary *> *textShortcuts;
}
- (CGSize)measureSize:(CGFloat)maxWidth;
- (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range;
Expand Down
Loading