From 5f96b2a69911efde9239639d9faa5e25d0c526e8 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 26 Jul 2026 00:24:36 +0200 Subject: [PATCH] refactor(input): extract BlockEditCoordinator (iOS & Android) Phase 3 of the input architecture refactor: moves block-editing operations (toggle block type, toggle list, change depth) and block query helpers (blockAtPosition, listBlockAtPosition, headingLevel) into a dedicated BlockEditCoordinator on each platform. The view retains the keyboard interception (handleListKey), UI effects (formatting, typing attributes, empty bullet marker, anchor sync, cursor sizing), and event emission. The coordinator owns pure block-store mutations and query logic, accepting text + selection as parameters and returning results the view acts on. ~250 lines removed from the two views, ~350 lines in two new files. Co-authored-by: Cursor --- .../input/EnrichedMarkdownTextInputView.kt | 160 +++----------- .../input/editing/BlockEditCoordinator.kt | 196 ++++++++++++++++++ .../ios/input/ENRMBlockEditCoordinator.h | 71 +++++++ .../ios/input/ENRMBlockEditCoordinator.mm | 195 +++++++++++++++++ .../ios/input/EnrichedMarkdownTextInput.mm | 136 ++---------- 5 files changed, 509 insertions(+), 249 deletions(-) create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/BlockEditCoordinator.kt create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.h create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.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 287d4925..0a1e7a63 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 @@ -26,6 +26,7 @@ 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.EditContext import com.swmansion.enriched.markdown.input.editing.EditPhase import com.swmansion.enriched.markdown.input.editing.EditPipeline @@ -35,7 +36,6 @@ import com.swmansion.enriched.markdown.input.editing.InputConnectionWrapper import com.swmansion.enriched.markdown.input.editing.MarkdownEditableFactory import com.swmansion.enriched.markdown.input.editing.MarkdownTextWatcher import com.swmansion.enriched.markdown.input.editing.ZWSP -import com.swmansion.enriched.markdown.input.editing.isLineBreak import com.swmansion.enriched.markdown.input.formatting.BlockStore import com.swmansion.enriched.markdown.input.formatting.FormattingStore import com.swmansion.enriched.markdown.input.formatting.InputFormatter @@ -47,7 +47,6 @@ import com.swmansion.enriched.markdown.input.model.BlockRange import com.swmansion.enriched.markdown.input.model.BlockType import com.swmansion.enriched.markdown.input.model.FormattingRange import com.swmansion.enriched.markdown.input.model.InputFormatterStyle -import com.swmansion.enriched.markdown.input.model.MAX_LIST_DEPTH import com.swmansion.enriched.markdown.input.model.StyleType import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils @@ -65,6 +64,7 @@ class EnrichedMarkdownTextInputView( val pendingStyleRemovals = mutableSetOf() val editSession = EditSession() + val blockCoordinator = BlockEditCoordinator(blockStore) private var lastProcessedText: String = "" private var preEditSelectionStart = 0 @@ -238,11 +238,9 @@ class EnrichedMarkdownTextInputView( KeyEvent.KEYCODE_DEL -> { if (selectionStart == selectionEnd) { val editable = text ?: return false - val ls = lineStartOf(editable, selectionStart) - val le = lineEndOf(editable, selectionStart) + val ls = blockCoordinator.lineStartOf(editable, selectionStart) + val le = blockCoordinator.lineEndOf(editable, selectionStart) val content = editable.subSequence(ls, le).toString() - // At the item's start, or on an empty/ZWSP-anchored item (the caret sits - // after the anchor, not at the line start). if (selectionStart == ls || content.isEmpty() || content == ZWSP.toString()) { if (depth > 0) outdentList() else toggleListType(listBlock.type) return true @@ -506,42 +504,23 @@ class EnrichedMarkdownTextInputView( } } - /** The list block owning the caret's paragraph, or null. */ - private fun listBlockAtCursor(): BlockRange? = blockOnParagraphAt(selectionStart)?.takeIf { it.type in BlockType.LIST_ITEMS } + private fun listBlockAtCursor(): BlockRange? { + val editable = text ?: return null + return blockCoordinator.listBlockAtPosition(editable, selectionStart) + } - /** - * List state of the cursor's paragraph for [type]: whether it is such an item and, - * if so, its 0-based nesting depth. The orchestrator side of the - * `onChangeState.unorderedList` / `orderedList` payloads. - */ fun listStateAtCursor(type: BlockType): Pair { - val block = listBlockAtCursor() ?: return false to 0 - return if (block.type == type) true to block.level else false to 0 + val editable = text ?: return false to 0 + return blockCoordinator.listStateAtPosition(editable, selectionStart, type) } fun toggleUnorderedList() = toggleListType(BlockType.UNORDERED_LIST_ITEM) fun toggleOrderedList() = toggleListType(BlockType.ORDERED_LIST_ITEM) - /** - * Toggles a list of [type] on the cursor's paragraph(s): turns the touched lines - * into depth-0 items (an item of the other list type is replaced keeping its - * depth), or clears them back to plain paragraphs when the cursor's line already - * carries [type]. - */ private fun toggleListType(type: BlockType) { val editable = text ?: return - val turningOff = listBlockAtCursor()?.type == type - if (turningOff) { - forEachSelectedLine { ls, le -> blockStore.removeBlock(ls, le, editable) } - } else { - forEachSelectedLine { ls, le -> - // Switching unordered <-> ordered keeps each line's nesting depth. - val existing = listBlockStartingAt(ls) - blockStore.setBlock(type, existing?.level ?: 0, ls, le, editable) - } - } - blockStore.normalizeToLineBounds(editable) + blockCoordinator.toggleList(editable, type, selectionStart, selectionStart, selectionEnd) applyFormattingAndEmit() syncEmptyListAnchor() } @@ -553,44 +532,13 @@ class EnrichedMarkdownTextInputView( fun outdentList() = changeListDepthBy(-1) private fun changeListDepthBy(delta: Int) { - val cursorBlock = listBlockAtCursor() - if (cursorBlock == null) { - // Indent on a plain paragraph starts a bullet list; headings/outdent are ignored. - if (delta > 0 && blockOnParagraphAt(selectionStart) == null) toggleUnorderedList() - return - } - if (delta < 0 && cursorBlock.level == 0) { - toggleListType(cursorBlock.type) - return - } val editable = text ?: return - forEachSelectedLine { ls, le -> - val block = listBlockStartingAt(ls) - if (block != null) { - val newDepth = (block.level + delta).coerceIn(0, MAX_LIST_DEPTH) - blockStore.setBlock(block.type, newDepth, ls, le, editable) - } - } - blockStore.normalizeToLineBounds(editable) + val result = blockCoordinator.changeDepth(editable, selectionStart, selectionStart, selectionEnd, delta) + if (result == BlockEditCoordinator.DepthChangeResult.NO_OP) return applyFormattingAndEmit() syncEmptyListAnchor() } - /** Runs [action] with the `[lineStart, lineEnd)` content bounds of each line the selection touches. */ - private inline fun forEachSelectedLine(action: (lineStart: Int, lineEnd: Int) -> Unit) { - val editable = text ?: return - val selEnd = selectionEnd.coerceIn(0, editable.length) - var cursor = selectionStart.coerceIn(0, editable.length) - while (cursor > 0 && !editable[cursor - 1].isLineBreak()) cursor-- - while (cursor <= editable.length) { - var le = cursor - while (le < editable.length && !editable[le].isLineBreak()) le++ - action(cursor, le) - if (le >= selEnd) break - cursor = le + 1 - } - } - /** * Keeps an empty bullet line anchored by a ZWSP so its marker draws and the caret * indents (a [android.text.style.LeadingMarginSpan] doesn't indent an empty @@ -632,10 +580,10 @@ class EnrichedMarkdownTextInputView( var i = editable.length - 1 while (i >= 0) { if (editable[i] == ZWSP) { - val ls = lineStartOf(editable, i) - val le = lineEndOf(editable, i) + val ls = blockCoordinator.lineStartOf(editable, i) + val le = blockCoordinator.lineEndOf(editable, i) val onlyZwsp = le - ls == 1 && editable[ls] == ZWSP - val isEmptyListLine = onlyZwsp && listBlockStartingAt(ls) != null + val isEmptyListLine = onlyZwsp && blockCoordinator.listBlockAtLineStart(ls) != null if (!isEmptyListLine) { runAsATransaction { editable.delete(i, i + 1) } blockStore.adjustForEdit(i, 1, 0) @@ -649,12 +597,11 @@ class EnrichedMarkdownTextInputView( zwspAnchorCount = keptAnchors } - // Anchor the caret's line if it is an empty list item with no ZWSP yet. val caret = selectionStart if (selectionStart == selectionEnd) { - val ls = lineStartOf(editable, caret) - val le = lineEndOf(editable, caret) - val block = listBlockStartingAt(ls) + val ls = blockCoordinator.lineStartOf(editable, caret) + val le = blockCoordinator.lineEndOf(editable, caret) + val block = blockCoordinator.listBlockAtLineStart(ls) if (block != null && le == ls) { runAsATransaction { editable.insert(ls, ZWSP.toString()) } blockStore.adjustForEdit(ls, 0, 1) @@ -696,24 +643,6 @@ class EnrichedMarkdownTextInputView( syncHintVisibility() } - private fun lineStartOf( - editable: CharSequence, - pos: Int, - ): Int { - var s = pos.coerceIn(0, editable.length) - while (s > 0 && !editable[s - 1].isLineBreak()) s-- - return s - } - - private fun lineEndOf( - editable: CharSequence, - pos: Int, - ): Int { - var e = pos.coerceIn(0, editable.length) - while (e < editable.length && !editable[e].isLineBreak()) e++ - return e - } - // Copies the whole input as markdown without disturbing the current selection, // tagged so paste restores formatting and block ranges — mirrors iOS, which // stores markdown under its custom pasteboard type. @@ -836,69 +765,28 @@ class EnrichedMarkdownTextInputView( toggleBlockType(blockType, level) } - /** - * Block counterpart to [toggleInlineStyle]: sets [type] on the paragraph(s) the - * selection touches, or clears it back to a plain paragraph when already active. - */ private fun toggleBlockType( type: BlockType, level: Int, ) { val editable = text ?: return - val selStart = selectionStart.coerceIn(0, editable.length) val selEnd = selectionEnd.coerceIn(selStart, editable.length) - - val existing = blockOnParagraphAt(selStart) - val isActive = existing != null && existing.type == type && existing.level == level - - if (isActive) { - blockStore.removeBlock(selStart, selEnd, editable) - } else { - // Blocks are single-paragraph: set one range per line the selection - // touches, not one range spanning them all — otherwise the next edit's - // line normalization would clip the block to its first line. - var lineStart = selStart - while (lineStart > 0 && !editable[lineStart - 1].isLineBreak()) lineStart-- - while (lineStart <= selEnd) { - var lineEnd = lineStart - while (lineEnd < editable.length && !editable[lineEnd].isLineBreak()) lineEnd++ - blockStore.setBlock(type, level, lineStart, lineEnd, editable) - lineStart = lineEnd + 1 - } - } - - blockStore.normalizeToLineBounds(editable) + blockCoordinator.toggleBlock(editable, type, level, selStart, selEnd) applyFormattingAndEmit() syncCursorSizeWithBlock() } - /** - * The block owning [pos]'s paragraph, or null. Matched by line start, not - * containment, so line-end carets and zero-length heading anchors register. - */ private fun blockOnParagraphAt(pos: Int): BlockRange? { val editable = text ?: return null - val cursor = pos.coerceIn(0, editable.length) - var lineStart = cursor - while (lineStart > 0 && !editable[lineStart - 1].isLineBreak()) lineStart-- - return blockStore.blockStartingAt(lineStart) + return blockCoordinator.blockAtPosition(editable, pos) } - /** - * The list-item block whose paragraph starts exactly at [lineStart], or null. - * Line-start counterpart to [listBlockAtCursor] for the per-line loops - * (toggle / indent / anchor sync) that already hold a resolved line start. - * Routes through [BlockStore.blockStartingAt]'s O(log n) lookup instead of the - * linear `allRanges.firstOrNull { it.start == ls }` scans these sites repeated. - */ - private fun listBlockStartingAt(lineStart: Int): BlockRange? = - blockStore.blockStartingAt(lineStart)?.takeIf { it.type in BlockType.LIST_ITEMS } + private fun listBlockStartingAt(lineStart: Int): BlockRange? = blockCoordinator.listBlockAtLineStart(lineStart) - /** Heading level (1-6) of the cursor's paragraph, or 0 when it is a plain paragraph. */ fun headingLevelAtCursor(): Int { - val block = blockOnParagraphAt(selectionStart) ?: return 0 - return if (block.type in BlockType.HEADINGS) block.level else 0 + val editable = text ?: return 0 + return blockCoordinator.headingLevelAtPosition(editable, selectionStart) } /** diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/BlockEditCoordinator.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/BlockEditCoordinator.kt new file mode 100644 index 00000000..e227c0b2 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/editing/BlockEditCoordinator.kt @@ -0,0 +1,196 @@ +package com.swmansion.enriched.markdown.input.editing + +import android.text.Editable +import com.swmansion.enriched.markdown.input.formatting.BlockStore +import com.swmansion.enriched.markdown.input.model.BlockRange +import com.swmansion.enriched.markdown.input.model.BlockType +import com.swmansion.enriched.markdown.input.model.MAX_LIST_DEPTH + +/** + * Encapsulates block-editing operations: toggling block types, adjusting + * list depth, and querying the block at a given position. The view delegates + * here and applies UI-level consequences (formatting, anchor sync, cursor + * size, typing attributes) based on the returned result. + * + * All mutating methods normalize stores to line bounds before returning, so + * the caller can re-stamp block spans immediately. + */ +class BlockEditCoordinator( + private val blockStore: BlockStore, +) { + // ── Queries ────────────────────────────────────────────────────────── + + fun blockAtPosition( + text: CharSequence, + pos: Int, + ): BlockRange? { + val lineStart = lineStartOf(text, pos) + return blockStore.blockStartingAt(lineStart) + } + + fun listBlockAtPosition( + text: CharSequence, + pos: Int, + ): BlockRange? = blockAtPosition(text, pos)?.takeIf { it.type in BlockType.LIST_ITEMS } + + fun listBlockAtLineStart(lineStart: Int): BlockRange? = blockStore.blockStartingAt(lineStart)?.takeIf { it.type in BlockType.LIST_ITEMS } + + fun headingLevelAtPosition( + text: CharSequence, + pos: Int, + ): Int { + val block = blockAtPosition(text, pos) ?: return 0 + return if (block.type in BlockType.HEADINGS) block.level else 0 + } + + fun listStateAtPosition( + text: CharSequence, + pos: Int, + type: BlockType, + ): Pair { + val block = listBlockAtPosition(text, pos) ?: return false to 0 + return if (block.type == type) true to block.level else false to 0 + } + + // ── Mutations ──────────────────────────────────────────────────────── + + /** + * Toggles [type] at [level] on the paragraphs the selection touches. + * Returns true when the block was already active (i.e. it was turned off). + */ + fun toggleBlock( + text: Editable, + type: BlockType, + level: Int, + selStart: Int, + selEnd: Int, + ): Boolean { + val existing = blockAtPosition(text, selStart) + val wasActive = existing != null && existing.type == type && existing.level == level + + if (wasActive) { + blockStore.removeBlock(selStart, selEnd, text) + } else { + forEachLine(text, selStart, selEnd) { ls, le -> + blockStore.setBlock(type, level, ls, le, text) + } + } + blockStore.normalizeToLineBounds(text) + return wasActive + } + + /** + * Toggles a list of [type] on the selection: turns lines into depth-0 + * items (switching list type keeps each line's depth) or clears them back + * to plain paragraphs when the cursor line already carries [type]. + * Returns true when the list was turned off. + */ + fun toggleList( + text: Editable, + type: BlockType, + cursorPos: Int, + selStart: Int, + selEnd: Int, + ): Boolean { + val turningOff = listBlockAtPosition(text, cursorPos)?.type == type + if (turningOff) { + forEachLine(text, selStart, selEnd) { ls, le -> + blockStore.removeBlock(ls, le, text) + } + } else { + forEachLine(text, selStart, selEnd) { ls, le -> + val existing = listBlockAtLineStart(ls) + blockStore.setBlock(type, existing?.level ?: 0, ls, le, text) + } + } + blockStore.normalizeToLineBounds(text) + return turningOff + } + + enum class DepthChangeResult { + /** Depth was adjusted on the existing list item(s). */ + CHANGED, + + /** No list existed; a new unordered list was started (delta > 0 only). */ + STARTED_LIST, + + /** Outdented at depth 0 — the list marker was removed. */ + EXITED_LIST, + + /** Nothing happened (e.g. outdent on a heading or plain paragraph). */ + NO_OP, + } + + /** + * Adjusts list depth by [delta] on the selected lines. When the cursor + * is on a plain paragraph and delta > 0, starts a new unordered list. + * When outdenting at depth 0, removes the list marker. + * + * Returns the action taken so the view can apply the appropriate UI effects. + */ + fun changeDepth( + text: Editable, + cursorPos: Int, + selStart: Int, + selEnd: Int, + delta: Int, + ): DepthChangeResult { + val cursorBlock = listBlockAtPosition(text, cursorPos) + if (cursorBlock == null) { + if (delta > 0 && blockAtPosition(text, cursorPos) == null) { + toggleList(text, BlockType.UNORDERED_LIST_ITEM, cursorPos, selStart, selEnd) + return DepthChangeResult.STARTED_LIST + } + return DepthChangeResult.NO_OP + } + if (delta < 0 && cursorBlock.level == 0) { + toggleList(text, cursorBlock.type, cursorPos, selStart, selEnd) + return DepthChangeResult.EXITED_LIST + } + + forEachLine(text, selStart, selEnd) { ls, _ -> + val block = listBlockAtLineStart(ls) + if (block != null) { + val newDepth = (block.level + delta).coerceIn(0, MAX_LIST_DEPTH) + blockStore.setBlock(block.type, newDepth, ls, ls, text) + } + } + blockStore.normalizeToLineBounds(text) + return DepthChangeResult.CHANGED + } + + // ── Line utilities ─────────────────────────────────────────────────── + + fun lineStartOf( + text: CharSequence, + pos: Int, + ): Int { + var s = pos.coerceIn(0, text.length) + while (s > 0 && !text[s - 1].isLineBreak()) s-- + return s + } + + fun lineEndOf( + text: CharSequence, + pos: Int, + ): Int { + var e = pos.coerceIn(0, text.length) + while (e < text.length && !text[e].isLineBreak()) e++ + return e + } + + private inline fun forEachLine( + text: CharSequence, + selStart: Int, + selEnd: Int, + action: (lineStart: Int, lineEnd: Int) -> Unit, + ) { + var cursor = lineStartOf(text, selStart) + while (cursor <= text.length) { + val le = lineEndOf(text, cursor) + action(cursor, le) + if (le >= selEnd) break + cursor = le + 1 + } + } +} diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.h b/packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.h new file mode 100644 index 00000000..d7c31af0 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.h @@ -0,0 +1,71 @@ +#pragma once + +#import "ENRMInputBlockType.h" +#import + +@class ENRMBlockStore; +@class ENRMBlockRange; + +NS_ASSUME_NONNULL_BEGIN + +/// Result of a depth-change operation. +typedef NS_ENUM(NSInteger, ENRMDepthChangeResult) { + ENRMDepthChangeResultChanged, + ENRMDepthChangeResultStartedList, + ENRMDepthChangeResultExitedList, + ENRMDepthChangeResultNoOp, +}; + +/// Encapsulates block-editing operations: toggling block types, adjusting list +/// depth, and querying the block at a given position. The view delegates here +/// and applies UI-level consequences (formatting, anchor sync, typing +/// attributes) based on the returned result. +/// +/// All mutating methods normalize stores to line bounds before returning, so the +/// caller can re-stamp block spans immediately. +@interface ENRMBlockEditCoordinator : NSObject + +- (instancetype)initWithBlockStore:(ENRMBlockStore *)blockStore; + +// ── Queries ────────────────────────────────────────────────────────── + +/// The block owning the paragraph at `position`, or nil. +- (nullable ENRMBlockRange *)blockAtPosition:(NSUInteger)position inText:(NSString *)text; + +/// The list-item block at `position`, or nil if the paragraph is not a list item. +- (nullable ENRMBlockRange *)listBlockAtPosition:(NSUInteger)position inText:(NSString *)text; + +/// Heading level (1-6) at `position`, or 0 for non-heading paragraphs. +- (NSInteger)headingLevelAtPosition:(NSUInteger)position inText:(NSString *)text; + +/// Whether the paragraph at `position` is a list item of `type`, and its depth. +- (BOOL)listStateOfType:(ENRMInputBlockType)type + atPosition:(NSUInteger)position + inText:(NSString *)text + depth:(nullable NSInteger *)outDepth; + +// ── Mutations ──────────────────────────────────────────────────────── + +/// Toggles `type` at `level` on the paragraphs the selection touches. +/// Returns YES when the block was already active (i.e. it was turned off). +- (BOOL)toggleBlockType:(ENRMInputBlockType)type + level:(NSInteger)level + selectionRange:(NSRange)selection + inText:(NSString *)text; + +/// Toggles a list of `type` on the selection. +/// Returns YES when the list was turned off. +- (BOOL)toggleListType:(ENRMInputBlockType)type + cursorPosition:(NSUInteger)cursorPos + selectionRange:(NSRange)selection + inText:(NSString *)text; + +/// Adjusts list depth by `delta` on the selected lines. +- (ENRMDepthChangeResult)changeDepthBy:(NSInteger)delta + cursorPosition:(NSUInteger)cursorPos + selectionRange:(NSRange)selection + inText:(NSString *)text; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.mm new file mode 100644 index 00000000..38c235fa --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockEditCoordinator.mm @@ -0,0 +1,195 @@ +#import "ENRMBlockEditCoordinator.h" +#import "ENRMBlockRange.h" +#import "ENRMBlockStore.h" + +@implementation ENRMBlockEditCoordinator { + ENRMBlockStore *_blockStore; +} + +- (instancetype)initWithBlockStore:(ENRMBlockStore *)blockStore +{ + self = [super init]; + if (self) { + _blockStore = blockStore; + } + return self; +} + +// ── Queries ────────────────────────────────────────────────────────── + +- (nullable ENRMBlockRange *)blockAtPosition:(NSUInteger)position inText:(NSString *)text +{ + if (position > text.length) { + return nil; + } + NSRange paragraph = [text paragraphRangeForRange:NSMakeRange(position, 0)]; + return [_blockStore blockStartingAtLocation:paragraph.location]; +} + +- (nullable ENRMBlockRange *)listBlockAtPosition:(NSUInteger)position inText:(NSString *)text +{ + ENRMBlockRange *block = [self blockAtPosition:position inText:text]; + return (block != nil && ENRMBlockTypeIsListItem(block.type)) ? block : nil; +} + +- (NSInteger)headingLevelAtPosition:(NSUInteger)position inText:(NSString *)text +{ + ENRMBlockRange *block = [self blockAtPosition:position inText:text]; + return block != nil ? ENRMHeadingLevelForBlockType(block.type) : 0; +} + +- (BOOL)listStateOfType:(ENRMInputBlockType)type + atPosition:(NSUInteger)position + inText:(NSString *)text + depth:(nullable NSInteger *)outDepth +{ + ENRMBlockRange *block = [self listBlockAtPosition:position inText:text]; + BOOL match = block != nil && block.type == type; + if (outDepth) { + *outDepth = match ? block.level : 0; + } + return match; +} + +// ── Mutations ──────────────────────────────────────────────────────── + +- (BOOL)toggleBlockType:(ENRMInputBlockType)type + level:(NSInteger)level + selectionRange:(NSRange)selection + inText:(NSString *)text +{ + NSRange paragraphRange = [text paragraphRangeForRange:selection]; + + ENRMBlockRange *current = [_blockStore blockStartingAtLocation:paragraphRange.location]; + BOOL alreadyActive = current != nil && current.type == type; + + if (alreadyActive) { + [_blockStore removeBlockInParagraphRange:paragraphRange inText:text]; + } else if (paragraphRange.length == 0) { + NSInteger resolvedLevel = level; + if (ENRMBlockTypeIsListItem(type) && current != nil && ENRMBlockTypeIsListItem(current.type)) { + resolvedLevel = current.level; + } + [_blockStore setBlockType:type level:resolvedLevel forParagraphRange:paragraphRange inText:text]; + } else { + [text + enumerateSubstringsInRange:paragraphRange + options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + NSInteger paragraphLevel = level; + if (ENRMBlockTypeIsListItem(type)) { + ENRMBlockRange *existing = [self listBlockAtPosition:substringRange.location inText:text]; + if (existing != nil) { + paragraphLevel = existing.level; + } + } + [self->_blockStore setBlockType:type + level:paragraphLevel + forParagraphRange:substringRange + inText:text]; + }]; + } + + [_blockStore normalizeToLineBoundsInText:text]; + return alreadyActive; +} + +- (BOOL)toggleListType:(ENRMInputBlockType)type + cursorPosition:(NSUInteger)cursorPos + selectionRange:(NSRange)selection + inText:(NSString *)text +{ + ENRMBlockRange *cursorBlock = [self listBlockAtPosition:cursorPos inText:text]; + BOOL turningOff = cursorBlock != nil && cursorBlock.type == type; + NSRange paragraphRange = [text paragraphRangeForRange:selection]; + + if (turningOff) { + if (paragraphRange.length == 0) { + [_blockStore removeBlockInParagraphRange:paragraphRange inText:text]; + } else { + [text enumerateSubstringsInRange:paragraphRange + options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, + BOOL *stop) { + [self->_blockStore removeBlockInParagraphRange:substringRange inText:text]; + }]; + } + } else { + if (paragraphRange.length == 0) { + NSInteger existingLevel = 0; + ENRMBlockRange *existing = [self listBlockAtPosition:paragraphRange.location inText:text]; + if (existing != nil) { + existingLevel = existing.level; + } + [_blockStore setBlockType:type level:existingLevel forParagraphRange:paragraphRange inText:text]; + } else { + [text enumerateSubstringsInRange:paragraphRange + options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, + BOOL *stop) { + NSInteger existingLevel = 0; + ENRMBlockRange *existing = [self listBlockAtPosition:substringRange.location inText:text]; + if (existing != nil) { + existingLevel = existing.level; + } + [self->_blockStore setBlockType:type + level:existingLevel + forParagraphRange:substringRange + inText:text]; + }]; + } + } + + [_blockStore normalizeToLineBoundsInText:text]; + return turningOff; +} + +- (ENRMDepthChangeResult)changeDepthBy:(NSInteger)delta + cursorPosition:(NSUInteger)cursorPos + selectionRange:(NSRange)selection + inText:(NSString *)text +{ + ENRMBlockRange *cursorBlock = [self listBlockAtPosition:cursorPos inText:text]; + if (cursorBlock == nil) { + if (delta > 0 && [self headingLevelAtPosition:cursorPos inText:text] == 0) { + [self toggleListType:ENRMInputBlockTypeUnorderedListItem + cursorPosition:cursorPos + selectionRange:selection + inText:text]; + return ENRMDepthChangeResultStartedList; + } + return ENRMDepthChangeResultNoOp; + } + + if (delta < 0 && cursorBlock.level == 0) { + [self toggleListType:cursorBlock.type cursorPosition:cursorPos selectionRange:selection inText:text]; + return ENRMDepthChangeResultExitedList; + } + + NSRange paragraphRange = [text paragraphRangeForRange:selection]; + + if (paragraphRange.length == 0) { + NSInteger newDepth = MIN(MAX(cursorBlock.level + delta, (NSInteger)0), kENRMMaxListDepth); + [_blockStore setBlockType:cursorBlock.type level:newDepth forParagraphRange:paragraphRange inText:text]; + } else { + [text + enumerateSubstringsInRange:paragraphRange + options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + ENRMBlockRange *block = [self listBlockAtPosition:substringRange.location inText:text]; + if (block == nil) { + return; + } + NSInteger newDepth = MIN(MAX(block.level + delta, (NSInteger)0), kENRMMaxListDepth); + [self->_blockStore setBlockType:block.type + level:newDepth + forParagraphRange:substringRange + inText:text]; + }]; + } + + [_blockStore normalizeToLineBoundsInText:text]; + return ENRMDepthChangeResultChanged; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index eb5df7ec..eadee877 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -1,6 +1,7 @@ #import "EnrichedMarkdownTextInput.h" #import "ContextMenuUtils.h" #import "ENRMAutoLinkDetector.h" +#import "ENRMBlockEditCoordinator.h" #import "ENRMBlockHandler.h" #import "ENRMBlockStore.h" #import "ENRMDetectorPipeline.h" @@ -113,6 +114,7 @@ @implementation EnrichedMarkdownTextInput { ENRMAutoLinkDetector *_autoLinkDetector; ENRMDetectorPipeline *_detectorPipeline; ENRMEditPipeline *_editPipeline; + ENRMBlockEditCoordinator *_blockCoordinator; ENRMWritingDirectionMode _writingDirectionMode; NSWritingDirection _resolvedLayoutDirection; @@ -183,6 +185,7 @@ - (instancetype)initWithFrame:(CGRect)frame formatter:_formatter detectorPipeline:_detectorPipeline host:self]; + _blockCoordinator = [[ENRMBlockEditCoordinator alloc] initWithBlockStore:_blockStore]; } return self; } @@ -935,25 +938,19 @@ - (void)toggleHeading:(NSInteger)level [self toggleBlockType:ENRMBlockTypeForHeadingLevel(level) level:level]; } -/// Block counterpart to toggleInlineStyle:: sets the block on the paragraph(s) -/// the selection touches, or clears it back to a plain paragraph when already -/// active. Syncs typing attributes so the toggled line renders immediately. - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level { NSString *text = ENRMGetPlainText(_textView); - NSRange paragraphRange = [text paragraphRangeForRange:_textView.selectedRange]; - - // Match by line start, not containment: an empty block line carries a - // zero-length anchor that a containment check would miss. - ENRMBlockRange *current = [_blockStore blockStartingAtLocation:paragraphRange.location]; - BOOL alreadyActive = current != nil && current.type == type; + BOOL alreadyActive = [_blockCoordinator toggleBlockType:type + level:level + selectionRange:_textView.selectedRange + inText:text]; if (alreadyActive) { - [_blockStore removeBlockInParagraphRange:paragraphRange inText:text]; // Strip the stored block paragraph style directly: an empty item's line has no // stamped block-marker attribute (zero-length ranges are never stamped), so the // applyFormatting reset below — which is keyed off that marker — cannot reach it. - // The manual strip de-indents the line to a plain paragraph regardless. + NSRange paragraphRange = [text paragraphRangeForRange:_textView.selectedRange]; NSTextStorage *storage = _textView.textStorage; NSRange clamped = NSIntersectionRange(paragraphRange, NSMakeRange(0, storage.length)); if (clamped.length > 0) { @@ -961,43 +958,12 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level [storage removeAttribute:NSParagraphStyleAttributeName range:clamped]; [storage endEditing]; } - } else if (paragraphRange.length == 0) { - // Switching unordered <-> ordered keeps the item's nesting depth. - NSInteger resolvedLevel = level; - if (ENRMBlockTypeIsListItem(type) && current != nil && ENRMBlockTypeIsListItem(current.type)) { - resolvedLevel = current.level; - } - [_blockStore setBlockType:type level:resolvedLevel forParagraphRange:paragraphRange inText:text]; - } else { - // Blocks are single-paragraph: set one range per paragraph the selection - // touches, not one range spanning them all — otherwise the next edit's - // line normalization would clip the block to its first line. - [text - enumerateSubstringsInRange:paragraphRange - options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired - usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { - // Switching unordered <-> ordered keeps each line's nesting depth. - NSInteger paragraphLevel = level; - if (ENRMBlockTypeIsListItem(type)) { - ENRMBlockRange *existing = [self listBlockForParagraphAtPosition:substringRange.location]; - if (existing != nil) { - paragraphLevel = existing.level; - } - } - [self->_blockStore setBlockType:type - level:paragraphLevel - forParagraphRange:substringRange - inText:text]; - }]; } - [_blockStore normalizeToLineBoundsInText:text]; [self applyFormatting]; [self syncTypingAttributesWithCursorBlock]; [self updatePlaceholderVisibility]; if (alreadyActive) { - // The just-cleared line is a plain paragraph now; drop any list indent the - // typing attributes carried so the next typed character isn't indented. [self clearListParagraphStyleFromTypingAttributes]; } [self updateEmptyBulletMarker]; @@ -1024,61 +990,22 @@ - (void)outdentList [self changeListDepthBy:-1]; } -/// Adjusts the nesting depth of the cursor's list item by `delta`, clamped to -/// [0, kENRMMaxListDepth] and preserving the item's list type. QoL on the -/// boundaries: indent on a non-list paragraph starts a depth-0 bullet (ignored -/// on headings), and outdent past depth 0 removes the marker. - (void)changeListDepthBy:(NSInteger)delta { - ENRMBlockRange *listBlock = [self listBlockForCursorParagraph]; - if (listBlock == nil) { - if (delta > 0 && [self headingLevelForCursorParagraph] == 0) { - [self toggleUnorderedList]; - } - return; - } - - if (delta < 0 && listBlock.level == 0) { - [self toggleBlockType:listBlock.type level:0]; - return; - } - NSString *text = ENRMGetPlainText(_textView); - NSRange paragraphRange = [text paragraphRangeForRange:_textView.selectedRange]; - - // Adjust every selected paragraph that is a list item, each clamped from its - // own depth (mirrors Android's forEachSelectedLine and toggleBlockType's - // paragraph enumeration — one range per line, so normalize can't clip the - // block to the selection's first line). - if (paragraphRange.length == 0) { - NSInteger newDepth = MIN(MAX(listBlock.level + delta, (NSInteger)0), kENRMMaxListDepth); - [_blockStore setBlockType:listBlock.type level:newDepth forParagraphRange:paragraphRange inText:text]; - } else { - [text - enumerateSubstringsInRange:paragraphRange - options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired - usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { - ENRMBlockRange *block = [self listBlockForParagraphAtPosition:substringRange.location]; - if (block == nil) { - return; - } - NSInteger newDepth = MIN(MAX(block.level + delta, (NSInteger)0), kENRMMaxListDepth); - [self->_blockStore setBlockType:block.type - level:newDepth - forParagraphRange:substringRange - inText:text]; - }]; + ENRMDepthChangeResult result = [_blockCoordinator changeDepthBy:delta + cursorPosition:_textView.selectedRange.location + selectionRange:_textView.selectedRange + inText:text]; + if (result == ENRMDepthChangeResultNoOp) { + return; } - - [_blockStore normalizeToLineBoundsInText:text]; [self applyFormatting]; [self syncTypingAttributesWithCursorBlock]; [self updateEmptyBulletMarker]; [self emitFormattingChanged]; } -/// The list block owning the caret's paragraph (matched by line start, so empty -/// anchors and line-end carets register), or nil. - (nullable ENRMBlockRange *)listBlockForCursorParagraph { return [self listBlockForParagraphAtPosition:_textView.selectedRange.location]; @@ -1086,33 +1013,20 @@ - (nullable ENRMBlockRange *)listBlockForCursorParagraph - (nullable ENRMBlockRange *)listBlockForParagraphAtPosition:(NSUInteger)position { - ENRMBlockRange *block = [self blockForParagraphAtPosition:position]; - return (block != nil && ENRMBlockTypeIsListItem(block.type)) ? block : nil; + return [_blockCoordinator listBlockAtPosition:position inText:_textView.textStorage.string]; } -/// The block (of any type) whose line starts at the paragraph containing -/// `position`, or nil. Single choke point over the store's O(log n) lookup — -/// callers that want only a list item or only a heading filter the result. - (nullable ENRMBlockRange *)blockForParagraphAtPosition:(NSUInteger)position { - NSString *text = _textView.textStorage.string; - if (position > text.length) { - return nil; - } - NSRange paragraph = [text paragraphRangeForRange:NSMakeRange(position, 0)]; - return [_blockStore blockStartingAtLocation:paragraph.location]; + return [_blockCoordinator blockAtPosition:position inText:_textView.textStorage.string]; } -/// Whether the caret's paragraph is a list item of `type`, writing its depth -/// into `outDepth` when non-NULL. Mirrors headingLevelForCursorParagraph. - (BOOL)listStateOfType:(ENRMInputBlockType)type forCursorParagraphDepth:(nullable NSInteger *)outDepth { - ENRMBlockRange *block = [self listBlockForCursorParagraph]; - BOOL match = block != nil && block.type == type; - if (outDepth) { - *outDepth = match ? block.level : 0; - } - return match; + return [_blockCoordinator listStateOfType:type + atPosition:_textView.selectedRange.location + inText:_textView.textStorage.string + depth:outDepth]; } /// Records the block kind/level of the line being edited before the change, so a @@ -1213,11 +1127,9 @@ - (BOOL)handleBackspaceAtDocumentStart return YES; } -/// listBlockForCursorParagraph for an arbitrary position — the caret hasn't -/// moved yet when a replacement is intercepted. - (BOOL)listStateForParagraphAtPosition:(NSUInteger)position depth:(NSInteger *)outDepth { - ENRMBlockRange *block = [self listBlockForParagraphAtPosition:position]; + ENRMBlockRange *block = [_blockCoordinator listBlockAtPosition:position inText:_textView.textStorage.string]; if (block != nil) { if (outDepth) { *outDepth = block.level; @@ -1309,12 +1221,10 @@ - (BOOL)emptyListLineIsRTL } } -/// Heading level (1-6, or 0) of the caret's paragraph, matched by line start so -/// line-end carets and empty-line anchors register (mirrors inline isActive). - (NSInteger)headingLevelForCursorParagraph { - ENRMBlockRange *block = [self blockForParagraphAtPosition:_textView.selectedRange.location]; - return block != nil ? ENRMHeadingLevelForBlockType(block.type) : 0; + return [_blockCoordinator headingLevelAtPosition:_textView.selectedRange.location + inText:_textView.textStorage.string]; } /// Syncs typing attributes so text typed at the caret on a block-styled line