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