From a505eb673bfbb5ff300fe8691eacab030f542576 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 26 Jul 2026 01:32:56 +0200 Subject: [PATCH] refactor(input): extract Link, Mention, and Clipboard coordinators (iOS & Android) Move link operations, mention lifecycle, and clipboard serialization out of the monolithic view classes into focused coordinators, reducing view complexity and aligning with the architecture refactor plan (phase 6). Co-authored-by: Cursor --- .../input/EnrichedMarkdownTextInputView.kt | 194 +++----------- .../input/editing/ClipboardCoordinator.kt | 62 +++++ .../markdown/input/editing/LinkCoordinator.kt | 69 +++++ .../input/editing/MentionCoordinator.kt | 118 +++++++++ .../ios/input/ENRMAutoLinkDetector.h | 3 +- .../ios/input/ENRMClipboardCoordinator.h | 37 +++ .../ios/input/ENRMClipboardCoordinator.mm | 97 +++++++ .../ios/input/ENRMDetectorPipeline.h | 3 +- .../ios/input/ENRMLinkCoordinator.h | 41 +++ .../ios/input/ENRMLinkCoordinator.mm | 89 +++++++ .../ios/input/ENRMMentionCoordinator.h | 45 ++++ .../ios/input/ENRMMentionCoordinator.mm | 167 ++++++++++++ .../ios/input/EnrichedMarkdownTextInput.mm | 244 +++++------------- 13 files changed, 824 insertions(+), 345 deletions(-) create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/ClipboardCoordinator.kt create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/LinkCoordinator.kt create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/MentionCoordinator.kt create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.h create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.mm create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.h create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.mm create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.h create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.mm diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index e22d132c9..35844c17f 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -25,22 +25,24 @@ import com.facebook.react.views.text.ReactTypefaceUtils import com.swmansion.enriched.markdown.input.autolink.AutoLinkDetector import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig import com.swmansion.enriched.markdown.input.detection.DetectorPipeline -import com.swmansion.enriched.markdown.input.detection.WordsUtils import com.swmansion.enriched.markdown.input.editing.BlockEditCoordinator +import com.swmansion.enriched.markdown.input.editing.ClipboardCoordinator import com.swmansion.enriched.markdown.input.editing.EditContext import com.swmansion.enriched.markdown.input.editing.EditPhase import com.swmansion.enriched.markdown.input.editing.EditPipeline import com.swmansion.enriched.markdown.input.editing.EditPipelineHost import com.swmansion.enriched.markdown.input.editing.EditSession import com.swmansion.enriched.markdown.input.editing.InputConnectionWrapper +import com.swmansion.enriched.markdown.input.editing.LinkCoordinator import com.swmansion.enriched.markdown.input.editing.MarkdownEditableFactory import com.swmansion.enriched.markdown.input.editing.MarkdownTextWatcher +import com.swmansion.enriched.markdown.input.editing.MentionCoordinator +import com.swmansion.enriched.markdown.input.editing.MentionEvent import com.swmansion.enriched.markdown.input.editing.ZWSP import com.swmansion.enriched.markdown.input.formatting.BlockStore import com.swmansion.enriched.markdown.input.formatting.FormattingStore import com.swmansion.enriched.markdown.input.formatting.InputFormatter import com.swmansion.enriched.markdown.input.formatting.InputParser -import com.swmansion.enriched.markdown.input.formatting.MarkdownSerializer import com.swmansion.enriched.markdown.input.layout.InputEventEmitter import com.swmansion.enriched.markdown.input.layout.InputLayoutManager import com.swmansion.enriched.markdown.input.model.BlockRange @@ -84,6 +86,8 @@ class EnrichedMarkdownTextInputView( val eventEmitter = InputEventEmitter(this) private val autoLinkDetector = AutoLinkDetector(formattingStore) private val detectorPipeline = DetectorPipeline() + private val mentionCoordinator = MentionCoordinator(formattingStore) + private val linkCoordinator = LinkCoordinator(formattingStore, autoLinkDetector) private val editPipelineHost = object : EditPipelineHost { @@ -96,7 +100,7 @@ class EnrichedMarkdownTextInputView( override fun syncCursorSizeWithBlock() = this@EnrichedMarkdownTextInputView.syncCursorSizeWithBlock() - override fun updateActiveMention() = this@EnrichedMarkdownTextInputView.updateActiveMention() + override fun updateActiveMention() = this@EnrichedMarkdownTextInputView.dispatchMentionUpdate() override fun runAsATransaction(block: () -> Unit) = this@EnrichedMarkdownTextInputView.runAsATransaction(block) @@ -118,11 +122,7 @@ class EnrichedMarkdownTextInputView( private var detectScrollMovement = false var scrollEnabled: Boolean = true - private var mentionIndicators: LinkedHashSet = linkedSetOf() - private var activeMentionIndicator: String? = null - private var activeMentionStart = -1 - private var activeMentionEnd = -1 - private var activeMentionText = "" + private val clipboardCoordinator = ClipboardCoordinator(formattingStore, blockStore, detectorPipeline, formatter) private var headingOverrideBaseSizePx: Float? = null private var baseHintColor: Int? = null @@ -376,7 +376,7 @@ class EnrichedMarkdownTextInputView( } eventEmitter.emitSelection(selStart, selEnd) - updateActiveMention() + dispatchMentionUpdate() eventEmitter.emitState() eventEmitter.emitCaretRectChangeIfNeeded() } @@ -631,14 +631,7 @@ class EnrichedMarkdownTextInputView( val content = text if (content.isNullOrEmpty()) return val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return - val markdown = - MarkdownSerializer.serialize( - content.toString(), - allFormattingRangesForSerialization(), - blockStore.allRanges, - ) { blockRange -> - formatter.handlerForBlock(blockRange.type)?.markdownLinePrefix(blockRange) ?: "" - } + val markdown = clipboardCoordinator.serializeFullDocument(content.toString(), content) clipboard.setPrimaryClip(MarkdownClipboard.newMarkdownClip(markdown, content.toString())) } @@ -674,40 +667,9 @@ class EnrichedMarkdownTextInputView( return super.onTextContextMenuItem(id) } - /** Serializes the current selection (inline + block ranges) to markdown, or null if empty. */ fun markdownForSelectedRange(): String? { - val selStart = selectionStart - val selEnd = selectionEnd - if (selStart >= selEnd) return null - val fullText = text?.toString() ?: return null - val selectedText = fullText.substring(selStart, selEnd) - - val clippedRanges = mutableListOf() - for (range in formattingStore.allRanges) { - if (range.end <= selStart || range.start >= selEnd) continue - - val clippedStart = maxOf(range.start, selStart) - val clippedEnd = minOf(range.end, selEnd) - clippedRanges.add( - FormattingRange(range.type, clippedStart - selStart, clippedEnd - selStart, range.url), - ) - } - - val clippedBlockRanges = mutableListOf() - for (blockRange in blockStore.allRanges) { - if (blockRange.end <= selStart || blockRange.start >= selEnd) continue - - val clippedStart = maxOf(blockRange.start, selStart) - val clippedEnd = minOf(blockRange.end, selEnd) - clippedBlockRanges.add( - BlockRange(blockRange.type, clippedStart - selStart, clippedEnd - selStart, blockRange.level), - ) - } - - return MarkdownSerializer.serialize(selectedText, clippedRanges, clippedBlockRanges) { blockRange -> - formatter.handlerForBlock(blockRange.type)?.markdownLinePrefix(blockRange) ?: "" - } + return clipboardCoordinator.serializeSelectedRange(fullText, selectionStart, selectionEnd, text) } private fun plainTextFromClipboard(): String? { @@ -803,12 +765,7 @@ class EnrichedMarkdownTextInputView( val selStart = selectionStart val selEnd = selectionEnd if (selStart == selEnd) return - - val editable = text - if (editable != null) { - autoLinkDetector.clearAutoLinkInRange(editable, selStart, selEnd) - } - formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, selEnd, url)) + linkCoordinator.setLinkForRange(url, selStart, selEnd, text) applyFormattingAndEmit() } @@ -821,8 +778,7 @@ class EnrichedMarkdownTextInputView( val linkEnd = selStart + displayText.length replaceTextInRange(selStart, selEnd, displayText) { editable -> - autoLinkDetector.clearAutoLinkInRange(editable, selStart, linkEnd) - formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, linkEnd, sanitizeLinkUrl(url))) + linkCoordinator.addLink(url, selStart, linkEnd, editable) setSelection(linkEnd) } } @@ -832,40 +788,36 @@ class EnrichedMarkdownTextInputView( url: String, ) { if (displayText.isEmpty()) return - val indicator = activeMentionIndicator ?: return - val start = activeMentionStart - val end = activeMentionEnd + val indicator = mentionCoordinator.currentIndicator ?: return + val start = mentionCoordinator.currentStart + val end = mentionCoordinator.currentEnd val editable = text ?: return if (start < 0 || end < start || end > editable.length) return - val sanitizedUrl = sanitizeLinkUrl(url) val shouldAppendSpace = end >= editable.length || !editable[end].isWhitespace() val replacement = if (shouldAppendSpace) "$displayText " else displayText val linkEnd = start + displayText.length replaceTextInRange(start, end, replacement) { ed -> - autoLinkDetector.clearAutoLinkInRange(ed, start, linkEnd) - formattingStore.addRange(FormattingRange(StyleType.LINK, start, linkEnd, sanitizedUrl)) - clearActiveMention(emit = true, indicatorOverride = indicator) + linkCoordinator.addLink(url, start, linkEnd, ed) + dispatchMentionEvents(mentionCoordinator.clear(indicatorOverride = indicator)) setSelection(start + replacement.length) } } fun startMention(indicator: String) { - if (indicator.isEmpty() || indicator !in mentionIndicators) return + if (indicator.isEmpty() || !mentionCoordinator.containsIndicator(indicator)) return val selStart = selectionStart val selEnd = selectionEnd replaceTextInRange(selStart, selEnd, indicator) { setSelection(selStart + indicator.length) } - updateActiveMention() + dispatchMentionUpdate() } fun removeLinkAtCursor() { - val pos = selectionStart - val linkRange = formattingStore.rangeOfType(StyleType.LINK, pos) ?: return - formattingStore.removeRange(linkRange) + if (!linkCoordinator.removeLink(selectionStart)) return applyFormattingAndEmit() } @@ -875,29 +827,22 @@ class EnrichedMarkdownTextInputView( if (cursorStart != cursorEnd || cursorStart <= 0) return false if (text == null) return false - val linkRange = formattingStore.rangeOfType(StyleType.LINK, cursorStart - 1) ?: return false + val linkRange = linkCoordinator.linkRangeForDeletion(cursorStart) ?: return false replaceTextInRange(linkRange.start, linkRange.end, "") { editable -> setSelection(linkRange.start.coerceAtMost(editable.length)) } - clearActiveMention() + dispatchMentionEvents(mentionCoordinator.clear()) return true } fun setMentionIndicators(indicators: List) { - val newIndicators = LinkedHashSet(indicators) - if (newIndicators == mentionIndicators) return - mentionIndicators = newIndicators - activeMentionIndicator?.let { indicator -> - if (indicator !in mentionIndicators) { - clearActiveMention(emit = true, indicatorOverride = indicator) - } - } - updateActiveMention() + dispatchMentionEvents(mentionCoordinator.setIndicators(indicators)) + dispatchMentionUpdate() } fun dismissActiveMention() { - clearActiveMention(emit = false) + mentionCoordinator.clear(emit = false) } fun setContextMenuItems(items: List) { @@ -923,12 +868,7 @@ class EnrichedMarkdownTextInputView( return formatter.updateStyle(style) } - fun allFormattingRangesForSerialization(): List { - val editable = text ?: return formattingStore.allRanges - val transientRanges = detectorPipeline.allTransientFormattingRanges(editable) - if (transientRanges.isEmpty()) return formattingStore.allRanges - return formattingStore.allRanges + transientRanges - } + fun allFormattingRangesForSerialization(): List = clipboardCoordinator.allRangesForSerialization(text) fun setValueFromJS(markdown: String) { val parsed = InputParser.parseToPlainTextAndRanges(markdown) @@ -1056,81 +996,21 @@ class EnrichedMarkdownTextInputView( start: Int, end: Int, ) { - if (start >= end) return - formattingStore.addRange(FormattingRange(StyleType.LINK, start, end, url)) + linkCoordinator.addLinkDirect(url, start, end) applyFormattingAndEmit() } - private fun updateActiveMention() { - val plainText = text?.toString() ?: return - val cursor = selectionStart - if (selectionStart != selectionEnd || cursor < 0 || cursor > plainText.length) { - clearActiveMention() - return - } - - val candidate = detectMentionAtCursor(plainText, cursor) - if (candidate == null) { - clearActiveMention() - return - } - - if (activeMentionIndicator != candidate.indicator || activeMentionStart != candidate.start) { - activeMentionIndicator?.let { eventEmitter.emitEndMention(it) } - activeMentionIndicator = candidate.indicator - activeMentionStart = candidate.start - eventEmitter.emitStartMention(candidate.indicator) - } - - activeMentionEnd = candidate.end - if (activeMentionText != candidate.text) { - activeMentionText = candidate.text - eventEmitter.emitChangeMention(candidate.indicator, candidate.text) - } - } - - private fun detectMentionAtCursor( - plainText: String, - cursor: Int, - ): MentionCandidate? { - if (mentionIndicators.isEmpty()) return null - - val start = WordsUtils.tokenStart(plainText, cursor) - val token = plainText.substring(start, cursor) - val indicator = mentionIndicators.firstOrNull { token.startsWith(it) } ?: return null - if (formattingStore.rangeOfType(StyleType.LINK, start) != null) return null - - return MentionCandidate( - indicator = indicator, - start = start, - end = cursor, - text = token.substring(indicator.length), - ) + private fun dispatchMentionUpdate() { + dispatchMentionEvents(mentionCoordinator.update(text?.toString(), selectionStart, selectionEnd)) } - private fun clearActiveMention( - emit: Boolean = true, - indicatorOverride: String? = null, - ) { - val indicator = indicatorOverride ?: activeMentionIndicator - activeMentionIndicator = null - activeMentionStart = -1 - activeMentionEnd = -1 - activeMentionText = "" - if (emit && indicator != null) { - eventEmitter.emitEndMention(indicator) + private fun dispatchMentionEvents(events: List) { + for (event in events) { + when (event) { + is MentionEvent.Start -> eventEmitter.emitStartMention(event.indicator) + is MentionEvent.Change -> eventEmitter.emitChangeMention(event.indicator, event.text) + is MentionEvent.End -> eventEmitter.emitEndMention(event.indicator) + } } } - - private fun sanitizeLinkUrl(url: String): String = - url - .replace("(", "%28") - .replace(")", "%29") - - private data class MentionCandidate( - val indicator: String, - val start: Int, - val end: Int, - val text: String, - ) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/ClipboardCoordinator.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/ClipboardCoordinator.kt new file mode 100644 index 000000000..90bdf6a38 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/ClipboardCoordinator.kt @@ -0,0 +1,62 @@ +package com.swmansion.enriched.markdown.input.editing + +import android.text.Editable +import com.swmansion.enriched.markdown.input.detection.DetectorPipeline +import com.swmansion.enriched.markdown.input.formatting.BlockStore +import com.swmansion.enriched.markdown.input.formatting.FormattingStore +import com.swmansion.enriched.markdown.input.formatting.InputFormatter +import com.swmansion.enriched.markdown.input.formatting.MarkdownSerializer +import com.swmansion.enriched.markdown.input.model.BlockRange +import com.swmansion.enriched.markdown.input.model.FormattingRange + +class ClipboardCoordinator( + private val formattingStore: FormattingStore, + private val blockStore: BlockStore, + private val detectorPipeline: DetectorPipeline, + private val formatter: InputFormatter, +) { + fun allRangesForSerialization(editable: Editable?): List { + if (editable == null) return formattingStore.allRanges + val transient = detectorPipeline.allTransientFormattingRanges(editable) + if (transient.isEmpty()) return formattingStore.allRanges + return formattingStore.allRanges + transient + } + + fun serializeFullDocument( + text: String, + editable: Editable?, + ): String = + MarkdownSerializer.serialize(text, allRangesForSerialization(editable), blockStore.allRanges) { block -> + formatter.handlerForBlock(block.type)?.markdownLinePrefix(block) ?: "" + } + + fun serializeSelectedRange( + fullText: String, + selStart: Int, + selEnd: Int, + editable: Editable?, + ): String? { + if (selStart >= selEnd) return null + val selectedText = fullText.substring(selStart, selEnd) + + val clippedRanges = mutableListOf() + for (range in allRangesForSerialization(editable)) { + if (range.end <= selStart || range.start >= selEnd) continue + val clippedStart = maxOf(range.start, selStart) + val clippedEnd = minOf(range.end, selEnd) + clippedRanges.add(FormattingRange(range.type, clippedStart - selStart, clippedEnd - selStart, range.url)) + } + + val clippedBlockRanges = mutableListOf() + for (block in blockStore.allRanges) { + if (block.end <= selStart || block.start >= selEnd) continue + val clippedStart = maxOf(block.start, selStart) + val clippedEnd = minOf(block.end, selEnd) + clippedBlockRanges.add(BlockRange(block.type, clippedStart - selStart, clippedEnd - selStart, block.level)) + } + + return MarkdownSerializer.serialize(selectedText, clippedRanges, clippedBlockRanges) { block -> + formatter.handlerForBlock(block.type)?.markdownLinePrefix(block) ?: "" + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/LinkCoordinator.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/LinkCoordinator.kt new file mode 100644 index 000000000..b86d645c5 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/LinkCoordinator.kt @@ -0,0 +1,69 @@ +package com.swmansion.enriched.markdown.input.editing + +import android.text.Spannable +import com.swmansion.enriched.markdown.input.autolink.AutoLinkDetector +import com.swmansion.enriched.markdown.input.formatting.FormattingStore +import com.swmansion.enriched.markdown.input.model.FormattingRange +import com.swmansion.enriched.markdown.input.model.StyleType + +class LinkCoordinator( + private val formattingStore: FormattingStore, + private val autoLinkDetector: AutoLinkDetector, +) { + fun sanitizeUrl(url: String): String = + url + .replace("(", "%28") + .replace(")", "%29") + + fun linkAtPosition(position: Int): FormattingRange? = formattingStore.rangeOfType(StyleType.LINK, position) + + fun setLinkForRange( + url: String, + start: Int, + end: Int, + editable: Spannable?, + ) { + if (start == end) return + if (editable != null) { + autoLinkDetector.clearAutoLinkInRange(editable, start, end) + } + formattingStore.addRange(FormattingRange(StyleType.LINK, start, end, url)) + } + + fun addLink( + url: String, + start: Int, + end: Int, + editable: Spannable?, + ) { + if (start >= end) return + if (editable != null) { + autoLinkDetector.clearAutoLinkInRange(editable, start, end) + } + formattingStore.addRange(FormattingRange(StyleType.LINK, start, end, sanitizeUrl(url))) + } + + fun addLinkDirect( + url: String, + start: Int, + end: Int, + ) { + if (start >= end) return + formattingStore.addRange(FormattingRange(StyleType.LINK, start, end, url)) + } + + fun removeLink(position: Int): Boolean { + val linkRange = formattingStore.rangeOfType(StyleType.LINK, position) ?: return false + formattingStore.removeRange(linkRange) + return true + } + + /** + * Finds the link containing `position - 1` and returns its range for deletion. + * Returns null if no link is found. + */ + fun linkRangeForDeletion(position: Int): FormattingRange? { + if (position <= 0) return null + return formattingStore.rangeOfType(StyleType.LINK, position - 1) + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/MentionCoordinator.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/MentionCoordinator.kt new file mode 100644 index 000000000..e29f61043 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/MentionCoordinator.kt @@ -0,0 +1,118 @@ +package com.swmansion.enriched.markdown.input.editing + +import com.swmansion.enriched.markdown.input.detection.WordsUtils +import com.swmansion.enriched.markdown.input.formatting.FormattingStore +import com.swmansion.enriched.markdown.input.model.StyleType + +sealed class MentionEvent { + data class Start( + val indicator: String, + ) : MentionEvent() + + data class Change( + val indicator: String, + val text: String, + ) : MentionEvent() + + data class End( + val indicator: String, + ) : MentionEvent() +} + +class MentionCoordinator( + private val formattingStore: FormattingStore, +) { + private var indicators: LinkedHashSet = linkedSetOf() + private var activeIndicator: String? = null + private var activeStart = -1 + private var activeEnd = -1 + private var activeText = "" + + val isActive: Boolean get() = activeIndicator != null + val currentIndicator: String? get() = activeIndicator + val currentStart: Int get() = activeStart + val currentEnd: Int get() = activeEnd + + fun containsIndicator(indicator: String): Boolean = indicator in indicators + + fun setIndicators(newIndicators: List): List { + val newSet = LinkedHashSet(newIndicators) + if (newSet == indicators) return emptyList() + indicators = newSet + val events = mutableListOf() + activeIndicator?.let { indicator -> + if (indicator !in indicators) { + events.addAll(clear(indicatorOverride = indicator)) + } + } + return events + } + + /** + * Re-evaluates the active mention based on the current cursor and text. + * Pass null [plainText] to force-clear (e.g. when the editable is absent). + */ + fun update( + plainText: String?, + selStart: Int, + selEnd: Int, + ): List { + if (plainText == null || selStart != selEnd || selStart < 0 || selStart > plainText.length) { + return clear() + } + + val candidate = detectCandidate(plainText, selStart) ?: return clear() + val events = mutableListOf() + + if (activeIndicator != candidate.indicator || activeStart != candidate.start) { + activeIndicator?.let { events.add(MentionEvent.End(it)) } + activeIndicator = candidate.indicator + activeStart = candidate.start + events.add(MentionEvent.Start(candidate.indicator)) + } + + activeEnd = candidate.end + if (activeText != candidate.text) { + activeText = candidate.text + events.add(MentionEvent.Change(candidate.indicator, candidate.text)) + } + return events + } + + fun clear( + emit: Boolean = true, + indicatorOverride: String? = null, + ): List { + val indicator = indicatorOverride ?: activeIndicator + activeIndicator = null + activeStart = -1 + activeEnd = -1 + activeText = "" + return if (emit && indicator != null) listOf(MentionEvent.End(indicator)) else emptyList() + } + + private fun detectCandidate( + plainText: String, + cursor: Int, + ): MentionCandidate? { + if (indicators.isEmpty()) return null + val start = WordsUtils.tokenStart(plainText, cursor) + val token = plainText.substring(start, cursor) + val indicator = indicators.firstOrNull { token.startsWith(it) } ?: return null + if (formattingStore.rangeOfType(StyleType.LINK, start) != null) return null + + return MentionCandidate( + indicator = indicator, + start = start, + end = cursor, + text = token.substring(indicator.length), + ) + } + + private data class MentionCandidate( + val indicator: String, + val start: Int, + val end: Int, + val text: String, + ) +} diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMAutoLinkDetector.h b/packages/react-native-enriched-markdown/ios/input/ENRMAutoLinkDetector.h index beecbbc9a..fd601ac24 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMAutoLinkDetector.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMAutoLinkDetector.h @@ -1,6 +1,7 @@ #pragma once #import "ENRMFormattingStore.h" +#import "ENRMLinkCoordinator.h" #import "ENRMLinkRegexConfig.h" #import "ENRMTextDetector.h" #import @@ -11,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN typedef void (^ENRMAutoLinkCallback)(NSString *text, NSString *url, NSRange range); -@interface ENRMAutoLinkDetector : NSObject +@interface ENRMAutoLinkDetector : NSObject - (instancetype)initWithTextStorage:(NSTextStorage *)textStorage formattingStore:(ENRMFormattingStore *)store diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.h b/packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.h new file mode 100644 index 000000000..2dc229a65 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.h @@ -0,0 +1,37 @@ +#pragma once + +#import "ENRMBlockRange.h" +#import "ENRMFormattingRange.h" +#import + +@class ENRMFormattingStore; +@class ENRMBlockStore; +@class ENRMInputFormatter; +@protocol ENRMDetectorPipelineTransientRanges; + +NS_ASSUME_NONNULL_BEGIN + +@protocol ENRMTransientRangeProvider +- (NSArray *)allTransientFormattingRanges; +@end + +@interface ENRMClipboardCoordinator : NSObject + +- (instancetype)initWithFormattingStore:(ENRMFormattingStore *)formattingStore + blockStore:(ENRMBlockStore *)blockStore + transientRangeProvider:(id)transientRangeProvider + formatter:(ENRMInputFormatter *)formatter; + +- (NSArray *)allRangesIncludingTransient; + +- (NSString *)serializeText:(NSString *)text + ranges:(NSArray *)ranges + blockRanges:(NSArray *)blockRanges; + +- (NSString *)serializeFullDocument:(NSString *)plainText; + +- (nullable NSString *)serializeSelectedRange:(NSRange)selection inText:(NSString *)fullText; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.mm b/packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.mm new file mode 100644 index 000000000..d3440fe2d --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMClipboardCoordinator.mm @@ -0,0 +1,97 @@ +#import "ENRMClipboardCoordinator.h" +#import "ENRMBlockStore.h" +#import "ENRMFormattingStore.h" +#import "ENRMInputFormatter.h" +#import "ENRMMarkdownSerializer.h" +#import "styles/ENRMBlockHandler.h" + +@implementation ENRMClipboardCoordinator { + ENRMFormattingStore *_formattingStore; + ENRMBlockStore *_blockStore; + id _transientRangeProvider; + ENRMInputFormatter *_formatter; +} + +- (instancetype)initWithFormattingStore:(ENRMFormattingStore *)formattingStore + blockStore:(ENRMBlockStore *)blockStore + transientRangeProvider:(id)transientRangeProvider + formatter:(ENRMInputFormatter *)formatter +{ + if (self = [super init]) { + _formattingStore = formattingStore; + _blockStore = blockStore; + _transientRangeProvider = transientRangeProvider; + _formatter = formatter; + } + return self; +} + +- (NSArray *)allRangesIncludingTransient +{ + NSArray *transient = [_transientRangeProvider allTransientFormattingRanges]; + if (transient.count == 0) { + return _formattingStore.allRanges; + } + NSMutableArray *merged = [_formattingStore.allRanges mutableCopy]; + [merged addObjectsFromArray:transient]; + return merged; +} + +- (NSString *)serializeText:(NSString *)text + ranges:(NSArray *)ranges + blockRanges:(NSArray *)blockRanges +{ + ENRMInputFormatter *formatter = _formatter; + return [ENRMMarkdownSerializer serializePlainText:text + ranges:ranges + blockRanges:blockRanges + blockPrefixProvider:^NSString *(ENRMBlockRange *blockRange) { + id handler = [formatter handlerForBlockType:blockRange.type]; + return [handler markdownLinePrefixForBlockRange:blockRange]; + }]; +} + +- (NSString *)serializeFullDocument:(NSString *)plainText +{ + return [self serializeText:plainText ranges:[self allRangesIncludingTransient] blockRanges:_blockStore.allRanges]; +} + +- (nullable NSString *)serializeSelectedRange:(NSRange)selection inText:(NSString *)fullText +{ + if (selection.length == 0) { + return nil; + } + + NSString *selectedText = [fullText substringWithRange:selection]; + NSUInteger selEnd = NSMaxRange(selection); + + NSMutableArray *clippedRanges = [NSMutableArray array]; + for (ENRMFormattingRange *range in [self allRangesIncludingTransient]) { + NSUInteger rangeStart = range.range.location; + NSUInteger rangeEnd = NSMaxRange(range.range); + if (rangeEnd <= selection.location || rangeStart >= selEnd) { + continue; + } + NSUInteger clippedStart = MAX(rangeStart, selection.location); + NSUInteger clippedEnd = MIN(rangeEnd, selEnd); + NSRange shifted = NSMakeRange(clippedStart - selection.location, clippedEnd - clippedStart); + [clippedRanges addObject:[ENRMFormattingRange rangeWithType:range.type range:shifted url:range.url]]; + } + + NSMutableArray *clippedBlockRanges = [NSMutableArray array]; + for (ENRMBlockRange *blockRange in _blockStore.allRanges) { + NSUInteger rangeStart = blockRange.range.location; + NSUInteger rangeEnd = NSMaxRange(blockRange.range); + if (rangeEnd <= selection.location || rangeStart >= selEnd) { + continue; + } + NSUInteger clippedStart = MAX(rangeStart, selection.location); + NSUInteger clippedEnd = MIN(rangeEnd, selEnd); + NSRange shifted = NSMakeRange(clippedStart - selection.location, clippedEnd - clippedStart); + [clippedBlockRanges addObject:[ENRMBlockRange rangeWithType:blockRange.type range:shifted level:blockRange.level]]; + } + + return [self serializeText:selectedText ranges:clippedRanges blockRanges:clippedBlockRanges]; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMDetectorPipeline.h b/packages/react-native-enriched-markdown/ios/input/ENRMDetectorPipeline.h index 8721bc520..e60e712b5 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMDetectorPipeline.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMDetectorPipeline.h @@ -1,5 +1,6 @@ #pragma once +#import "ENRMClipboardCoordinator.h" #import "ENRMFormattingRange.h" #import "ENRMTextDetector.h" #import @@ -8,7 +9,7 @@ NS_ASSUME_NONNULL_BEGIN /// Coordinates a set of ENRMTextDetector instances. The input view calls /// this pipeline instead of individual detectors. -@interface ENRMDetectorPipeline : NSObject +@interface ENRMDetectorPipeline : NSObject - (void)addDetector:(id)detector; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.h b/packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.h new file mode 100644 index 000000000..62c3f598d --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.h @@ -0,0 +1,41 @@ +#pragma once + +#import "ENRMFormattingRange.h" +#import "ENRMFormattingStore.h" +#import + +@class AutoLinkDetector; + +NS_ASSUME_NONNULL_BEGIN + +@protocol ENRMAutoLinkDetecting +- (void)clearAutoLinkInRange:(NSRange)range; +@end + +@interface ENRMLinkCoordinator : NSObject + +- (instancetype)initWithFormattingStore:(ENRMFormattingStore *)formattingStore + autoLinkDetector:(id)autoLinkDetector; + +- (NSString *)sanitizeURL:(NSString *)url; +- (nullable ENRMFormattingRange *)linkAtPosition:(NSUInteger)position; + +/// Sets a link on the selection range, or updates an existing link's URL at the cursor. +/// Returns YES if a mutation occurred. +- (BOOL)setLinkURL:(NSString *)url atCursor:(NSUInteger)cursor selection:(NSRange)selection; + +/// Adds a new link range (sanitizes the URL, clears auto-links). +- (void)addLinkWithURL:(NSString *)url start:(NSUInteger)start end:(NSUInteger)end; + +/// Adds a link range with an already-sanitized URL (no auto-link clearing). +- (void)addLinkDirectWithURL:(NSString *)url start:(NSUInteger)start end:(NSUInteger)end; + +/// Removes the link at the given position. Returns YES if a link was found and removed. +- (BOOL)removeLinkAtPosition:(NSUInteger)position; + +/// Returns the link range containing `position - 1`, for atomic link deletion. +- (nullable ENRMFormattingRange *)linkRangeForDeletionAtPosition:(NSUInteger)position; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.mm b/packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.mm new file mode 100644 index 000000000..61df3c17c --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMLinkCoordinator.mm @@ -0,0 +1,89 @@ +#import "ENRMLinkCoordinator.h" + +@implementation ENRMLinkCoordinator { + ENRMFormattingStore *_formattingStore; + id _autoLinkDetector; +} + +- (instancetype)initWithFormattingStore:(ENRMFormattingStore *)formattingStore + autoLinkDetector:(id)autoLinkDetector +{ + if (self = [super init]) { + _formattingStore = formattingStore; + _autoLinkDetector = autoLinkDetector; + } + return self; +} + +- (NSString *)sanitizeURL:(NSString *)url +{ + NSString *result = [url stringByReplacingOccurrencesOfString:@"(" withString:@"%28"]; + return [result stringByReplacingOccurrencesOfString:@")" withString:@"%29"]; +} + +- (nullable ENRMFormattingRange *)linkAtPosition:(NSUInteger)position +{ + return [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:position]; +} + +- (BOOL)setLinkURL:(NSString *)url atCursor:(NSUInteger)cursor selection:(NSRange)selection +{ + ENRMFormattingRange *activeLink = [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:cursor]; + + if (activeLink != nil) { + activeLink.url = url; + [_autoLinkDetector clearAutoLinkInRange:activeLink.range]; + return YES; + } + + if (selection.length > 0) { + ENRMFormattingRange *linkRange = [ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink range:selection url:url]; + [_formattingStore addRange:linkRange]; + [_autoLinkDetector clearAutoLinkInRange:selection]; + return YES; + } + + return NO; +} + +- (void)addLinkWithURL:(NSString *)url start:(NSUInteger)start end:(NSUInteger)end +{ + if (start >= end) { + return; + } + NSRange range = NSMakeRange(start, end - start); + [_autoLinkDetector clearAutoLinkInRange:range]; + [_formattingStore addRange:[ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink + range:range + url:[self sanitizeURL:url]]]; +} + +- (void)addLinkDirectWithURL:(NSString *)url start:(NSUInteger)start end:(NSUInteger)end +{ + if (start >= end) { + return; + } + [_formattingStore addRange:[ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink + range:NSMakeRange(start, end - start) + url:url]]; +} + +- (BOOL)removeLinkAtPosition:(NSUInteger)position +{ + ENRMFormattingRange *activeLink = [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:position]; + if (activeLink == nil) { + return NO; + } + [_formattingStore removeRange:activeLink]; + return YES; +} + +- (nullable ENRMFormattingRange *)linkRangeForDeletionAtPosition:(NSUInteger)position +{ + if (position == 0) { + return nil; + } + return [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:position - 1]; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.h b/packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.h new file mode 100644 index 000000000..481f1628c --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.h @@ -0,0 +1,45 @@ +#pragma once + +#import "ENRMFormattingStore.h" +#import "ENRMInputMentionCandidate.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, ENRMMentionEventType) { + ENRMMentionEventStart, + ENRMMentionEventChange, + ENRMMentionEventEnd, +}; + +@interface ENRMMentionEvent : NSObject +@property (nonatomic, assign) ENRMMentionEventType type; +@property (nonatomic, copy) NSString *indicator; +@property (nonatomic, copy, nullable) NSString *text; ++ (instancetype)startWithIndicator:(NSString *)indicator; ++ (instancetype)changeWithIndicator:(NSString *)indicator text:(NSString *)text; ++ (instancetype)endWithIndicator:(NSString *)indicator; +@end + +@interface ENRMMentionCoordinator : NSObject + +@property (nonatomic, readonly, nullable) NSString *activeIndicator; +@property (nonatomic, readonly) NSRange activeRange; +@property (nonatomic, readonly, copy) NSString *activeText; +@property (nonatomic, readonly) BOOL isActive; + +- (instancetype)initWithFormattingStore:(ENRMFormattingStore *)formattingStore; + +- (BOOL)containsIndicator:(NSString *)indicator; +- (NSArray *)setIndicators:(NSArray *)indicators; + +/// Re-evaluates the active mention. Returns events for the view to dispatch. +- (NSArray *)updateWithText:(nullable NSString *)plainText selectedRange:(NSRange)selectedRange; + +- (NSArray *)clearWithIndicatorOverride:(nullable NSString *)indicatorOverride; + +- (nullable ENRMInputMentionCandidate *)detectCandidateInText:(NSString *)plainText atCursor:(NSUInteger)cursor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.mm b/packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.mm new file mode 100644 index 000000000..99303495a --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMentionCoordinator.mm @@ -0,0 +1,167 @@ +#import "ENRMMentionCoordinator.h" +#import "ENRMWordsUtils.h" + +@implementation ENRMMentionEvent + ++ (instancetype)startWithIndicator:(NSString *)indicator +{ + ENRMMentionEvent *e = [[ENRMMentionEvent alloc] init]; + e.type = ENRMMentionEventStart; + e.indicator = indicator; + return e; +} + ++ (instancetype)changeWithIndicator:(NSString *)indicator text:(NSString *)text +{ + ENRMMentionEvent *e = [[ENRMMentionEvent alloc] init]; + e.type = ENRMMentionEventChange; + e.indicator = indicator; + e.text = text; + return e; +} + ++ (instancetype)endWithIndicator:(NSString *)indicator +{ + ENRMMentionEvent *e = [[ENRMMentionEvent alloc] init]; + e.type = ENRMMentionEventEnd; + e.indicator = indicator; + return e; +} + +@end + +@implementation ENRMMentionCoordinator { + ENRMFormattingStore *_formattingStore; + NSArray *_indicators; + NSString *_activeIndicator; + NSRange _activeRange; + NSString *_activeText; +} + +- (instancetype)initWithFormattingStore:(ENRMFormattingStore *)formattingStore +{ + if (self = [super init]) { + _formattingStore = formattingStore; + _indicators = @[]; + _activeRange = NSMakeRange(NSNotFound, 0); + _activeText = @""; + } + return self; +} + +- (NSString *)activeIndicator +{ + return _activeIndicator; +} + +- (NSRange)activeRange +{ + return _activeRange; +} + +- (NSString *)activeText +{ + return _activeText; +} + +- (BOOL)isActive +{ + return _activeIndicator != nil; +} + +- (BOOL)containsIndicator:(NSString *)indicator +{ + return [_indicators containsObject:indicator]; +} + +- (NSArray *)setIndicators:(NSArray *)indicators +{ + if ([indicators isEqualToArray:_indicators]) { + return @[]; + } + _indicators = [indicators copy]; + NSMutableArray *events = [NSMutableArray array]; + if (_activeIndicator != nil && ![_indicators containsObject:_activeIndicator]) { + [events addObjectsFromArray:[self clearWithIndicatorOverride:_activeIndicator]]; + } + return events; +} + +- (NSArray *)updateWithText:(nullable NSString *)plainText selectedRange:(NSRange)selectedRange +{ + if (plainText == nil || selectedRange.length != 0) { + return [self clearWithIndicatorOverride:nil]; + } + NSUInteger cursor = selectedRange.location; + if (cursor > plainText.length) { + return [self clearWithIndicatorOverride:nil]; + } + + ENRMInputMentionCandidate *candidate = [self detectCandidateInText:plainText atCursor:cursor]; + if (candidate == nil) { + return [self clearWithIndicatorOverride:nil]; + } + + NSMutableArray *events = [NSMutableArray array]; + + if (_activeIndicator == nil || ![_activeIndicator isEqualToString:candidate.indicator] || + _activeRange.location != candidate.start) { + if (_activeIndicator != nil) { + [events addObject:[ENRMMentionEvent endWithIndicator:_activeIndicator]]; + } + _activeIndicator = candidate.indicator; + [events addObject:[ENRMMentionEvent startWithIndicator:candidate.indicator]]; + } + + _activeRange = NSMakeRange(candidate.start, candidate.end - candidate.start); + + if (![_activeText isEqualToString:candidate.text]) { + _activeText = candidate.text; + [events addObject:[ENRMMentionEvent changeWithIndicator:candidate.indicator text:candidate.text]]; + } + + return events; +} + +- (NSArray *)clearWithIndicatorOverride:(nullable NSString *)indicatorOverride +{ + NSString *indicator = indicatorOverride ?: _activeIndicator; + _activeIndicator = nil; + _activeRange = NSMakeRange(NSNotFound, 0); + _activeText = @""; + if (indicator.length > 0) { + return @[ [ENRMMentionEvent endWithIndicator:indicator] ]; + } + return @[]; +} + +- (nullable ENRMInputMentionCandidate *)detectCandidateInText:(NSString *)plainText atCursor:(NSUInteger)cursor +{ + if (_indicators.count == 0) { + return nil; + } + + NSUInteger start = [ENRMWordsUtils tokenStartInText:plainText beforePosition:cursor]; + NSString *token = [plainText substringWithRange:NSMakeRange(start, cursor - start)]; + NSString *matchedIndicator = nil; + for (NSString *indicator in _indicators) { + if ([token hasPrefix:indicator]) { + matchedIndicator = indicator; + break; + } + } + if (matchedIndicator == nil) { + return nil; + } + + if ([_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:start] != nil) { + return nil; + } + + return [ENRMInputMentionCandidate candidateWithIndicator:matchedIndicator + start:start + end:cursor + text:[token substringFromIndex:matchedIndicator.length]]; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index f91e2e0b8..4fb422921 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -4,6 +4,7 @@ #import "ENRMBlockEditCoordinator.h" #import "ENRMBlockHandler.h" #import "ENRMBlockStore.h" +#import "ENRMClipboardCoordinator.h" #import "ENRMDetectorPipeline.h" #import "ENRMEditPipeline.h" #import "ENRMEditSession.h" @@ -12,15 +13,14 @@ #import "ENRMInputFormatter.h" #import "ENRMInputLayoutManager.h" #import "ENRMInputLinkPrompt.h" -#import "ENRMInputMentionCandidate.h" #import "ENRMInputParser.h" #import "ENRMInputTextView.h" +#import "ENRMLinkCoordinator.h" #import "ENRMLinkRegexConfig.h" -#import "ENRMMarkdownSerializer.h" +#import "ENRMMentionCoordinator.h" #import "ENRMStyleHandler.h" #import "ENRMStyleMergingConfig.h" #import "ENRMUIKit.h" -#import "ENRMWordsUtils.h" #import "EnrichedMarkdownTextInput+Internal.h" #import "InputStylePropsUtils.h" #import "ParagraphStyleUtils.h" @@ -106,15 +106,13 @@ @implementation EnrichedMarkdownTextInput { NSArray *_contextMenuItemTexts; NSArray *_contextMenuItemIcons; - NSArray *_mentionIndicators; - NSString *_activeMentionIndicator; - NSRange _activeMentionRange; - NSString *_activeMentionText; - ENRMAutoLinkDetector *_autoLinkDetector; ENRMDetectorPipeline *_detectorPipeline; ENRMEditPipeline *_editPipeline; ENRMBlockEditCoordinator *_blockCoordinator; + ENRMMentionCoordinator *_mentionCoordinator; + ENRMLinkCoordinator *_linkCoordinator; + ENRMClipboardCoordinator *_clipboardCoordinator; ENRMWritingDirectionMode _writingDirectionMode; NSWritingDirection _resolvedLayoutDirection; @@ -164,9 +162,7 @@ - (instancetype)initWithFrame:(CGRect)frame _pendingStyleRemovals = [NSMutableSet set]; _lastTextLength = 0; _lastSelectedRange = NSMakeRange(0, 0); - _mentionIndicators = @[]; - _activeMentionRange = NSMakeRange(NSNotFound, 0); - _activeMentionText = @""; + _mentionCoordinator = [[ENRMMentionCoordinator alloc] initWithFormattingStore:_formattingStore]; _writingDirectionMode = ENRMWritingDirectionModeFirstStrong; _resolvedLayoutDirection = @@ -186,6 +182,12 @@ - (instancetype)initWithFrame:(CGRect)frame detectorPipeline:_detectorPipeline host:self]; _blockCoordinator = [[ENRMBlockEditCoordinator alloc] initWithBlockStore:_blockStore]; + _linkCoordinator = [[ENRMLinkCoordinator alloc] initWithFormattingStore:_formattingStore + autoLinkDetector:_autoLinkDetector]; + _clipboardCoordinator = [[ENRMClipboardCoordinator alloc] initWithFormattingStore:_formattingStore + blockStore:_blockStore + transientRangeProvider:_detectorPipeline + formatter:_formatter]; } return self; } @@ -484,11 +486,8 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [indicators addObject:value]; } } - _mentionIndicators = [indicators copy]; - if (_activeMentionIndicator != nil && ![_mentionIndicators containsObject:_activeMentionIndicator]) { - [self clearActiveMention:_activeMentionIndicator]; - } - [self updateActiveMention]; + [self dispatchMentionEvents:[_mentionCoordinator setIndicators:indicators]]; + [self dispatchMentionUpdate]; } BOOL styleChanged = applyInputStyleProps(_formatterStyle, newViewProps, oldViewProps); @@ -1273,21 +1272,9 @@ - (void)clearListParagraphStyleFromTypingAttributes - (void)setLink:(NSString *)url { NSRange selection = _textView.selectedRange; - NSUInteger cursor = selection.location; - - ENRMFormattingRange *activeLink = [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:cursor]; - - if (activeLink != nil) { - activeLink.url = url; - [_autoLinkDetector clearAutoLinkInRange:activeLink.range]; - } else if (selection.length > 0) { - ENRMFormattingRange *linkRange = [ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink range:selection url:url]; - [_formattingStore addRange:linkRange]; - [_autoLinkDetector clearAutoLinkInRange:selection]; - } else { + if (![_linkCoordinator setLinkURL:url atCursor:selection.location selection:selection]) { return; } - [self applyFormatting]; [self emitFormattingChanged]; } @@ -1298,34 +1285,30 @@ - (void)insertLink:(NSString *)text url:(NSString *)url NSRange linkRange = NSMakeRange(0, displayText.length); ENRMFormattingRange *range = [ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink range:linkRange - url:[self sanitizeLinkURL:url]]; + url:[_linkCoordinator sanitizeURL:url]]; [self replaceSelectedTextWith:displayText formattingRanges:@[ range ]]; } -- (NSString *)sanitizeLinkURL:(NSString *)url -{ - NSString *result = [url stringByReplacingOccurrencesOfString:@"(" withString:@"%28"]; - return [result stringByReplacingOccurrencesOfString:@")" withString:@"%29"]; -} - - (void)startMention:(NSString *)indicator { - if (indicator.length == 0 || ![_mentionIndicators containsObject:indicator]) { + if (indicator.length == 0 || ![_mentionCoordinator containsIndicator:indicator]) { return; } [self replaceSelectedTextWith:indicator formattingRanges:@[]]; - [self updateActiveMention]; + [self dispatchMentionUpdate]; } - (void)insertMention:(NSString *)displayText url:(NSString *)url { - if (displayText.length == 0 || _activeMentionIndicator == nil || _activeMentionRange.location == NSNotFound) { + if (displayText.length == 0 || !_mentionCoordinator.isActive || + _mentionCoordinator.activeRange.location == NSNotFound) { return; } NSString *plainText = ENRMGetPlainText(_textView); - NSUInteger rangeEnd = NSMaxRange(_activeMentionRange); + NSRange activeRange = _mentionCoordinator.activeRange; + NSUInteger rangeEnd = NSMaxRange(activeRange); if (rangeEnd > plainText.length) { return; } @@ -1335,33 +1318,27 @@ - (void)insertMention:(NSString *)displayText url:(NSString *)url NSString *replacement = nextCharIsWhitespace ? displayText : [displayText stringByAppendingString:@" "]; ENRMFormattingRange *linkRange = [ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink range:NSMakeRange(0, displayText.length) - url:[self sanitizeLinkURL:url]]; - NSString *indicator = _activeMentionIndicator; - NSRange mentionRange = _activeMentionRange; + url:[_linkCoordinator sanitizeURL:url]]; + NSString *indicator = _mentionCoordinator.activeIndicator; - [self clearActiveMention:indicator]; - [self replaceTextInRange:mentionRange withText:replacement formattingRanges:@[ linkRange ]]; - _textView.selectedRange = NSMakeRange(mentionRange.location + replacement.length, 0); + [self dispatchMentionEvents:[_mentionCoordinator clearWithIndicatorOverride:indicator]]; + [self replaceTextInRange:activeRange withText:replacement formattingRanges:@[ linkRange ]]; + _textView.selectedRange = NSMakeRange(activeRange.location + replacement.length, 0); [self emitOnChangeSelection]; } - (void)removeLink { - NSUInteger cursor = _textView.selectedRange.location; - ENRMFormattingRange *activeLink = [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:cursor]; - if (activeLink == nil) { + if (![_linkCoordinator removeLinkAtPosition:_textView.selectedRange.location]) { return; } - - [_formattingStore removeRange:activeLink]; [self applyFormatting]; [self emitFormattingChanged]; } - (void)showLinkPrompt { - NSUInteger cursor = _textView.selectedRange.location; - ENRMFormattingRange *activeLink = [_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:cursor]; + ENRMFormattingRange *activeLink = [_linkCoordinator linkAtPosition:_textView.selectedRange.location]; NSString *existingURL = activeLink != nil ? activeLink.url : nil; __weak EnrichedMarkdownTextInput *weakSelf = self; @@ -1376,62 +1353,12 @@ - (NSString *)serializeText:(NSString *)text ranges:(NSArray *)ranges blockRanges:(NSArray *)blockRanges { - // The provider runs synchronously inside the serializer call, so capturing - // the formatter directly is safe — no escaping closure, no retain cycle. - ENRMInputFormatter *formatter = _formatter; - return [ENRMMarkdownSerializer serializePlainText:text - ranges:ranges - blockRanges:blockRanges - blockPrefixProvider:^NSString *(ENRMBlockRange *blockRange) { - id handler = [formatter handlerForBlockType:blockRange.type]; - return [handler markdownLinePrefixForBlockRange:blockRange]; - }]; + return [_clipboardCoordinator serializeText:text ranges:ranges blockRanges:blockRanges]; } - (nullable NSString *)markdownForSelectedRange { - NSRange selection = _textView.selectedRange; - if (selection.length == 0) { - return nil; - } - - NSString *fullText = ENRMGetPlainText(_textView); - NSString *selectedText = [fullText substringWithRange:selection]; - NSUInteger selEnd = NSMaxRange(selection); - - NSMutableArray *clippedRanges = [NSMutableArray array]; - for (ENRMFormattingRange *range in [self allRangesIncludingTransient]) { - NSUInteger rangeStart = range.range.location; - NSUInteger rangeEnd = NSMaxRange(range.range); - - if (rangeEnd <= selection.location || rangeStart >= selEnd) { - continue; - } - - NSUInteger clippedStart = MAX(rangeStart, selection.location); - NSUInteger clippedEnd = MIN(rangeEnd, selEnd); - NSRange shifted = NSMakeRange(clippedStart - selection.location, clippedEnd - clippedStart); - - [clippedRanges addObject:[ENRMFormattingRange rangeWithType:range.type range:shifted url:range.url]]; - } - - NSMutableArray *clippedBlockRanges = [NSMutableArray array]; - for (ENRMBlockRange *blockRange in _blockStore.allRanges) { - NSUInteger rangeStart = blockRange.range.location; - NSUInteger rangeEnd = NSMaxRange(blockRange.range); - - if (rangeEnd <= selection.location || rangeStart >= selEnd) { - continue; - } - - NSUInteger clippedStart = MAX(rangeStart, selection.location); - NSUInteger clippedEnd = MIN(rangeEnd, selEnd); - NSRange shifted = NSMakeRange(clippedStart - selection.location, clippedEnd - clippedStart); - - [clippedBlockRanges addObject:[ENRMBlockRange rangeWithType:blockRange.type range:shifted level:blockRange.level]]; - } - - return [self serializeText:selectedText ranges:clippedRanges blockRanges:clippedBlockRanges]; + return [_clipboardCoordinator serializeSelectedRange:_textView.selectedRange inText:ENRMGetPlainText(_textView)]; } - (void)copyToClipboard @@ -1440,9 +1367,7 @@ - (void)copyToClipboard if (plainText.length == 0) { return; } - NSString *markdown = [self serializeText:plainText - ranges:[self allRangesIncludingTransient] - blockRanges:_blockStore.allRanges]; + NSString *markdown = [_clipboardCoordinator serializeFullDocument:plainText]; NSMutableDictionary *items = [NSMutableDictionary dictionary]; items[kUTIPlainText] = plainText; if (markdown.length > 0) { @@ -1457,9 +1382,7 @@ - (void)requestMarkdown:(NSInteger)requestId if (emitter == nullptr) { return; } - NSString *markdown = [self serializeText:ENRMGetPlainText(_textView) - ranges:[self allRangesIncludingTransient] - blockRanges:_blockStore.allRanges]; + NSString *markdown = [_clipboardCoordinator serializeFullDocument:ENRMGetPlainText(_textView)]; emitter->onRequestMarkdownResult({ .requestId = static_cast(requestId), .markdown = std::string([markdown UTF8String] ?: ""), @@ -1590,89 +1513,39 @@ - (void)emitFormattingChanged - (NSArray *)allRangesIncludingTransient { - NSArray *transient = [_detectorPipeline allTransientFormattingRanges]; - if (transient.count == 0) { - return _formattingStore.allRanges; - } - NSMutableArray *merged = [_formattingStore.allRanges mutableCopy]; - [merged addObjectsFromArray:transient]; - return merged; + return [_clipboardCoordinator allRangesIncludingTransient]; } -- (void)clearActiveMention:(nullable NSString *)indicatorOverride +- (void)updateActiveMention { - NSString *indicator = indicatorOverride ?: _activeMentionIndicator; - _activeMentionIndicator = nil; - _activeMentionRange = NSMakeRange(NSNotFound, 0); - _activeMentionText = @""; - - if (indicator.length > 0) { - [self emitOnEndMention:indicator]; - } + [self dispatchMentionUpdate]; } -- (nullable ENRMInputMentionCandidate *)mentionCandidateAtCursor +- (void)clearActiveMention:(nullable NSString *)indicatorOverride { - NSRange selectedRange = _textView.selectedRange; - if (_mentionIndicators.count == 0 || selectedRange.length != 0) { - return nil; - } - - NSString *plainText = ENRMGetPlainText(_textView); - NSUInteger cursor = selectedRange.location; - if (cursor > plainText.length) { - return nil; - } - - NSUInteger start = [ENRMWordsUtils tokenStartInText:plainText beforePosition:cursor]; - NSString *token = [plainText substringWithRange:NSMakeRange(start, cursor - start)]; - NSString *matchedIndicator = nil; - for (NSString *indicator in _mentionIndicators) { - if ([token hasPrefix:indicator]) { - matchedIndicator = indicator; - break; - } - } - if (matchedIndicator == nil) { - return nil; - } - - if ([_formattingStore rangeOfType:ENRMInputStyleTypeLink containingPosition:start] != nil) { - return nil; - } - - return [ENRMInputMentionCandidate candidateWithIndicator:matchedIndicator - start:start - end:cursor - text:[token substringFromIndex:matchedIndicator.length]]; + [self dispatchMentionEvents:[_mentionCoordinator clearWithIndicatorOverride:indicatorOverride]]; } -- (void)updateActiveMention +- (void)dispatchMentionUpdate { - ENRMInputMentionCandidate *candidate = [self mentionCandidateAtCursor]; - if (candidate == nil) { - [self clearActiveMention:nil]; - return; - } - - NSString *indicator = candidate.indicator; - NSUInteger start = candidate.start; - NSUInteger end = candidate.end; - NSString *query = candidate.text; - - if (_activeMentionIndicator == nil || ![_activeMentionIndicator isEqualToString:indicator] || - _activeMentionRange.location != start) { - if (_activeMentionIndicator != nil) { - [self emitOnEndMention:_activeMentionIndicator]; + NSString *plainText = ENRMGetPlainText(_textView); + [self dispatchMentionEvents:[_mentionCoordinator updateWithText:plainText selectedRange:_textView.selectedRange]]; +} + +- (void)dispatchMentionEvents:(NSArray *)events +{ + for (ENRMMentionEvent *event in events) { + switch (event.type) { + case ENRMMentionEventStart: + [self emitOnStartMention:event.indicator]; + break; + case ENRMMentionEventChange: + [self emitOnChangeMentionWithIndicator:event.indicator text:event.text]; + break; + case ENRMMentionEventEnd: + [self emitOnEndMention:event.indicator]; + break; } - _activeMentionIndicator = indicator; - [self emitOnStartMention:indicator]; - } - _activeMentionRange = NSMakeRange(start, end - start); - - if (![_activeMentionText isEqualToString:query]) { - _activeMentionText = query; - [self emitOnChangeMentionWithIndicator:indicator text:query]; } } @@ -1691,8 +1564,7 @@ - (BOOL)deleteLinkForReplacementRange:(NSRange)range replacementText:(NSString * return NO; } - ENRMFormattingRange *linkRange = [_formattingStore rangeOfType:ENRMInputStyleTypeLink - containingPosition:lookupPosition]; + ENRMFormattingRange *linkRange = [_linkCoordinator linkAtPosition:lookupPosition]; if (linkRange == nil) { return NO; }