Skip to content

Commit d0a430b

Browse files
authored
feat: add auto-link detection (#206)
1 parent 69e89f8 commit d0a430b

28 files changed

Lines changed: 1216 additions & 9 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- 🕹️ Imperative API for toggling styles and managing links
2828
- 📋 Native format bar and context menu with formatting options
2929
- 🔍 Real-time style state detection
30+
- 🔗 Auto-link detection with customizable regex
3031
- 🔄 Smart copy/paste with Markdown preservation
3132
- 🎨 Customizable bold, italic, and link colors
3233

@@ -52,6 +53,7 @@ We can help you build your next dream product –
5253
- [Usage](docs/INPUT.md#usage)
5354
- [Inline Styles](docs/INPUT.md#inline-styles)
5455
- [Links](docs/INPUT.md#links)
56+
- [Auto-Link Detection](docs/INPUT.md#auto-link-detection)
5557
- [Style Detection](docs/INPUT.md#style-detection)
5658
- [Other Events](docs/INPUT.md#other-events)
5759
- [Customizing Styles](docs/INPUT.md#customizing-enrichedmarkdowninput--styles)

android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownInputManager.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import com.facebook.react.uimanager.annotations.ReactProp
1414
import com.facebook.react.viewmanagers.EnrichedMarkdownInputManagerDelegate
1515
import com.facebook.react.viewmanagers.EnrichedMarkdownInputManagerInterface
1616
import com.facebook.yoga.YogaMeasureMode
17+
import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig
1718
import com.swmansion.enriched.markdown.input.events.OnChangeMarkdownEvent
1819
import com.swmansion.enriched.markdown.input.events.OnChangeSelectionEvent
1920
import com.swmansion.enriched.markdown.input.events.OnChangeStateEvent
2021
import com.swmansion.enriched.markdown.input.events.OnChangeTextEvent
2122
import com.swmansion.enriched.markdown.input.events.OnContextMenuItemPressEvent
2223
import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
2324
import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
25+
import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
2426
import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
2527
import com.swmansion.enriched.markdown.input.layout.InputMeasurementStore
2628
import com.swmansion.enriched.markdown.input.model.StyleType
@@ -84,6 +86,7 @@ class EnrichedMarkdownInputManager :
8486
OnInputFocusEvent.EVENT_NAME,
8587
OnInputBlurEvent.EVENT_NAME,
8688
OnContextMenuItemPressEvent.EVENT_NAME,
89+
OnLinkDetectedEvent.EVENT_NAME,
8790
).associateWithTo(mutableMapOf()) { name -> mapOf("registrationName" to name) }
8891

8992
// Props
@@ -181,6 +184,7 @@ class EnrichedMarkdownInputManager :
181184
if (view == null || value == null) return
182185

183186
val style = MarkdownStyleParser.parse(value)
187+
view.setAutoLinkStyle(style)
184188
val changed = view.formatter.updateStyle(style)
185189
if (changed) {
186190
view.applyFormatting()
@@ -247,6 +251,27 @@ class EnrichedMarkdownInputManager :
247251
view.setContextMenuItems(items)
248252
}
249253

254+
@ReactProp(name = "linkRegex")
255+
override fun setLinkRegex(
256+
view: EnrichedMarkdownInputView?,
257+
value: ReadableMap?,
258+
) {
259+
if (view == null) return
260+
val config =
261+
if (value != null) {
262+
LinkRegexConfig(
263+
pattern = value.getString("pattern") ?: "",
264+
caseInsensitive = value.getBoolean("caseInsensitive"),
265+
dotAll = value.getBoolean("dotAll"),
266+
isDisabled = value.getBoolean("isDisabled"),
267+
isDefault = value.getBoolean("isDefault"),
268+
)
269+
} else {
270+
LinkRegexConfig("", caseInsensitive = false, dotAll = false, isDisabled = false, isDefault = true)
271+
}
272+
view.setLinkRegex(config)
273+
}
274+
250275
override fun updateProperties(
251276
view: EnrichedMarkdownInputView,
252277
props: ReactStylesDiffMap,

android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownInputView.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator
2020
import com.facebook.react.uimanager.PixelUtil
2121
import com.facebook.react.uimanager.StateWrapper
2222
import com.facebook.react.views.text.ReactTypefaceUtils
23+
import com.swmansion.enriched.markdown.input.autolink.AutoLinkDetector
24+
import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig
25+
import com.swmansion.enriched.markdown.input.detection.DetectorPipeline
2326
import com.swmansion.enriched.markdown.input.editing.InputConnectionWrapper
2427
import com.swmansion.enriched.markdown.input.editing.MarkdownEditableFactory
2528
import com.swmansion.enriched.markdown.input.editing.MarkdownTextWatcher
@@ -29,6 +32,7 @@ import com.swmansion.enriched.markdown.input.formatting.InputParser
2932
import com.swmansion.enriched.markdown.input.layout.InputEventEmitter
3033
import com.swmansion.enriched.markdown.input.layout.InputLayoutManager
3134
import com.swmansion.enriched.markdown.input.model.FormattingRange
35+
import com.swmansion.enriched.markdown.input.model.InputFormatterStyle
3236
import com.swmansion.enriched.markdown.input.model.StyleType
3337
import com.swmansion.enriched.markdown.input.toolbar.FormatBar
3438
import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu
@@ -70,17 +74,27 @@ class EnrichedMarkdownInputView(
7074
val contextMenu = InputContextMenu(this)
7175
val formatBar = FormatBar(this)
7276
val eventEmitter = InputEventEmitter(this)
77+
private val autoLinkDetector = AutoLinkDetector(formattingStore)
78+
private val detectorPipeline = DetectorPipeline()
7379

7480
private var textWatcher: MarkdownTextWatcher? = null
7581
private var inputMethodManager: InputMethodManager? = null
7682
private var detectScrollMovement = false
7783
var scrollEnabled: Boolean = true
7884

7985
init {
86+
setupDetectorPipeline()
8087
prepareComponent()
8188
isComponentReady = true
8289
}
8390

91+
private fun setupDetectorPipeline() {
92+
autoLinkDetector.onLinkDetected = { text, url, start, end ->
93+
eventEmitter.emitLinkDetected(text, url, start, end)
94+
}
95+
detectorPipeline.addDetector(autoLinkDetector)
96+
}
97+
8498
private fun prepareComponent() {
8599
isSingleLine = false
86100
isHorizontalScrollBarEnabled = false
@@ -201,6 +215,12 @@ class EnrichedMarkdownInputView(
201215
formattingStore.adjustForEdit(editStart, deletedLength, insertedLength)
202216
applyPendingStyles(editStart, insertedLength)
203217
applyFormatting()
218+
219+
val editable = text
220+
if (editable != null) {
221+
detectorPipeline.processTextChange(editable, currentText, editStart, insertedLength)
222+
}
223+
204224
forceScrollToSelection()
205225
eventEmitter.emitChangeText()
206226
if (emitMarkdown) eventEmitter.emitChangeMarkdown()
@@ -343,6 +363,10 @@ class EnrichedMarkdownInputView(
343363
val selEnd = selectionEnd
344364
if (selStart == selEnd) return
345365

366+
val editable = text
367+
if (editable != null) {
368+
autoLinkDetector.clearAutoLinkInRange(editable, selStart, selEnd)
369+
}
346370
formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, selEnd, url))
347371
applyFormattingAndEmit()
348372
}
@@ -360,6 +384,7 @@ class EnrichedMarkdownInputView(
360384
try {
361385
editable.replace(selStart, selEnd, displayText)
362386
formattingStore.adjustForEdit(selStart, selEnd - selStart, displayText.length)
387+
autoLinkDetector.clearAutoLinkInRange(editable, selStart, linkEnd)
363388
formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, linkEnd, url))
364389
lastProcessedText = editable.toString()
365390

@@ -382,6 +407,21 @@ class EnrichedMarkdownInputView(
382407
contextMenu.setContextMenuItems(items)
383408
}
384409

410+
fun setLinkRegex(config: LinkRegexConfig) {
411+
autoLinkDetector.setRegexConfig(config)
412+
}
413+
414+
fun setAutoLinkStyle(style: InputFormatterStyle) {
415+
autoLinkDetector.style = style
416+
}
417+
418+
fun allFormattingRangesForSerialization(): List<FormattingRange> {
419+
val editable = text ?: return formattingStore.allRanges
420+
val transientRanges = detectorPipeline.allTransientFormattingRanges(editable)
421+
if (transientRanges.isEmpty()) return formattingStore.allRanges
422+
return formattingStore.allRanges + transientRanges
423+
}
424+
385425
fun setValueFromJS(markdown: String) {
386426
val parsed = InputParser.parseToPlainTextAndRanges(markdown)
387427
blockEmitting = true
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.swmansion.enriched.markdown.input.autolink
2+
3+
import android.text.TextPaint
4+
import android.text.style.CharacterStyle
5+
import android.text.style.ForegroundColorSpan
6+
import android.text.style.UnderlineSpan
7+
8+
/**
9+
* Visual spans for auto-detected links. These intentionally do NOT implement
10+
* MarkdownSpan so they survive the diff-based removal in InputFormatter.applyFormatting.
11+
*/
12+
class AutoDetectedLinkColorSpan(
13+
color: Int,
14+
) : ForegroundColorSpan(color)
15+
16+
class AutoDetectedLinkUnderlineSpan : UnderlineSpan()
17+
18+
/**
19+
* Zero-width marker span that carries the matched URL and marks a range as
20+
* auto-detected. Used to enumerate existing auto-link ranges without confusing
21+
* them with manual MarkdownSpan link spans.
22+
*/
23+
class AutoDetectedLinkMarkerSpan(
24+
val url: String,
25+
) : CharacterStyle() {
26+
override fun updateDrawState(tp: TextPaint) {
27+
// no-op — styling is handled by the color/underline spans
28+
}
29+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package com.swmansion.enriched.markdown.input.autolink
2+
3+
import android.text.Spannable
4+
import com.swmansion.enriched.markdown.input.detection.TextDetector
5+
import com.swmansion.enriched.markdown.input.detection.WordResult
6+
import com.swmansion.enriched.markdown.input.formatting.FormattingStore
7+
import com.swmansion.enriched.markdown.input.model.FormattingRange
8+
import com.swmansion.enriched.markdown.input.model.InputFormatterStyle
9+
import com.swmansion.enriched.markdown.input.model.StyleType
10+
import java.util.regex.Pattern
11+
12+
typealias OnLinkDetectedCallback = (text: String, url: String, start: Int, end: Int) -> Unit
13+
14+
class AutoLinkDetector(
15+
private val formattingStore: FormattingStore,
16+
) : TextDetector {
17+
private var config: LinkRegexConfig? = null
18+
private var compiledPattern: Pattern? = null
19+
var style: InputFormatterStyle? = null
20+
var onLinkDetected: OnLinkDetectedCallback? = null
21+
22+
fun setRegexConfig(newConfig: LinkRegexConfig) {
23+
if (newConfig == config) return
24+
config = newConfig
25+
compiledPattern = null
26+
27+
if (!newConfig.isDefault && !newConfig.isDisabled && newConfig.pattern.isNotEmpty()) {
28+
var flags = 0
29+
if (newConfig.caseInsensitive) flags = flags or Pattern.CASE_INSENSITIVE
30+
if (newConfig.dotAll) flags = flags or Pattern.DOTALL
31+
compiledPattern =
32+
try {
33+
Pattern.compile(newConfig.pattern, flags)
34+
} catch (_: Exception) {
35+
null
36+
}
37+
}
38+
}
39+
40+
override fun processWord(
41+
spannable: Spannable,
42+
wordResult: WordResult,
43+
) {
44+
val detectedUrl = detectNewLinkInWord(spannable, wordResult)
45+
if (detectedUrl != null) {
46+
onLinkDetected?.invoke(wordResult.word, detectedUrl, wordResult.start, wordResult.end)
47+
}
48+
}
49+
50+
override fun refreshStyling(spannable: Spannable) {
51+
val currentStyle = style ?: return
52+
val markers = spannable.getSpans(0, spannable.length, AutoDetectedLinkMarkerSpan::class.java)
53+
for (marker in markers) {
54+
val start = spannable.getSpanStart(marker)
55+
val end = spannable.getSpanEnd(marker)
56+
applyVisualStyling(spannable, start, end, currentStyle)
57+
}
58+
}
59+
60+
override fun transientFormattingRanges(spannable: Spannable): List<FormattingRange> {
61+
val markers = spannable.getSpans(0, spannable.length, AutoDetectedLinkMarkerSpan::class.java)
62+
return markers.map { marker ->
63+
FormattingRange(
64+
StyleType.LINK,
65+
spannable.getSpanStart(marker),
66+
spannable.getSpanEnd(marker),
67+
marker.url,
68+
)
69+
}
70+
}
71+
72+
fun clearAutoLinkInRange(
73+
spannable: Spannable,
74+
start: Int,
75+
end: Int,
76+
) {
77+
removeAutoLinkSpans(spannable, start, end)
78+
}
79+
80+
private fun detectNewLinkInWord(
81+
spannable: Spannable,
82+
wordResult: WordResult,
83+
): String? {
84+
if (config?.isDisabled == true) return null
85+
86+
val (word, wordStart, wordEnd) = wordResult
87+
88+
val hasManualLink = formattingStore.rangeOfType(StyleType.LINK, wordStart) != null
89+
if (hasManualLink) {
90+
removeAutoLinkSpans(spannable, wordStart, wordEnd)
91+
return null
92+
}
93+
94+
val matchedUrl = matchWord(word)
95+
val existingMarker =
96+
spannable
97+
.getSpans(wordStart, wordEnd, AutoDetectedLinkMarkerSpan::class.java)
98+
.firstOrNull { spannable.getSpanStart(it) == wordStart && spannable.getSpanEnd(it) == wordEnd }
99+
100+
if (matchedUrl != null) {
101+
if (existingMarker?.url == matchedUrl) return null
102+
103+
removeAutoLinkSpans(spannable, wordStart, wordEnd)
104+
spannable.setSpan(
105+
AutoDetectedLinkMarkerSpan(matchedUrl),
106+
wordStart,
107+
wordEnd,
108+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
109+
)
110+
val currentStyle = style
111+
if (currentStyle != null) {
112+
applyVisualStyling(spannable, wordStart, wordEnd, currentStyle)
113+
}
114+
return matchedUrl
115+
} else {
116+
if (existingMarker != null) {
117+
removeAutoLinkSpans(spannable, wordStart, wordEnd)
118+
}
119+
return null
120+
}
121+
}
122+
123+
private fun matchWord(word: String): String? {
124+
if (word.isEmpty()) return null
125+
126+
val custom = compiledPattern
127+
if (custom != null) {
128+
return if (custom.matcher(word).matches()) normalizeUrl(word) else null
129+
}
130+
131+
if (DEFAULT_PATTERN.matcher(word).matches()) return normalizeUrl(word)
132+
133+
return null
134+
}
135+
136+
private fun removeAutoLinkSpans(
137+
spannable: Spannable,
138+
start: Int,
139+
end: Int,
140+
) {
141+
for (span in spannable.getSpans(start, end, AutoDetectedLinkMarkerSpan::class.java)) {
142+
spannable.removeSpan(span)
143+
}
144+
for (span in spannable.getSpans(start, end, AutoDetectedLinkColorSpan::class.java)) {
145+
spannable.removeSpan(span)
146+
}
147+
for (span in spannable.getSpans(start, end, AutoDetectedLinkUnderlineSpan::class.java)) {
148+
spannable.removeSpan(span)
149+
}
150+
}
151+
152+
private fun applyVisualStyling(
153+
spannable: Spannable,
154+
start: Int,
155+
end: Int,
156+
style: InputFormatterStyle,
157+
) {
158+
spannable.setSpan(
159+
AutoDetectedLinkColorSpan(style.linkColor),
160+
start,
161+
end,
162+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
163+
)
164+
if (style.linkUnderline) {
165+
spannable.setSpan(
166+
AutoDetectedLinkUnderlineSpan(),
167+
start,
168+
end,
169+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
170+
)
171+
}
172+
}
173+
174+
companion object {
175+
private val DEFAULT_PATTERN: Pattern =
176+
Pattern.compile(
177+
"(?:https?://[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-z]{2,6}\\b[-a-zA-Z0-9@:%_+.~#?&//=]*" +
178+
"|www\\.[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-z]{2,6}\\b[-a-zA-Z0-9@:%_+.~#?&//=]*" +
179+
"|[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-z]{2,6}\\b[-a-zA-Z0-9@:%_+.~#?&//=]*)",
180+
Pattern.CASE_INSENSITIVE,
181+
)
182+
183+
private fun normalizeUrl(url: String): String {
184+
if (url.startsWith("http://") || url.startsWith("https://")) return url
185+
return "https://$url"
186+
}
187+
}
188+
}

0 commit comments

Comments
 (0)