From da132151fffefa93790698850b6e3492a62ac838 Mon Sep 17 00:00:00 2001 From: Sean Holbert Date: Sun, 5 Jul 2026 08:13:54 -0700 Subject: [PATCH 1/5] feat(input): nested unordered lists on the block pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UNORDERED_LIST_ITEM block type on both platforms; 0-based nesting depth rides in the block range's level. Parser derives depth from AST/UL nesting (never leading spaces); serializer emits "- " with two spaces per depth. - toggleUnorderedList / indentList / outdentList commands; Tab and Backspace at item start indent/outdent from the keyboard; Backspace at depth 0 removes the bullet. - Enter continues a non-empty item as a sibling at the same depth and exits the list on an empty item — handler-driven via continuesOnNewline. - Empty-line persistence generalizes the heading anchor machinery: BlockType.ANCHORED / ENRMBlockTypePersistsWhenEmpty replace the heading-only checks in BlockStore, prune and normalize passes. - Android draws the bullet via InputBulletSpan (LeadingMarginSpan) with a ZWSP anchor on empty items; iOS reserves the indent via paragraph style and draws the marker in the layout manager, with an explicit empty-line marker for anchor-only lines. - onChangeState and the context-menu styleState gain unorderedList: { isActive, depth }; listItemSpacing prop spaces items. - Example toolbar gains bullet/indent/outdent buttons; docs and a maestro flow cover the feature. Co-Authored-By: Claude Fable 5 --- .../block_elements/unordered_list_test.yaml | 43 ++ .../screenshots/ios/unordered_list_input.png | Bin 0 -> 18573 bytes .../src/components/FormattingToolbar.tsx | 24 ++ docs/API_REFERENCE.md | 23 +- docs/INPUT.md | 23 +- .../input/EnrichedMarkdownTextInputManager.kt | 25 +- .../input/EnrichedMarkdownTextInputView.kt | 398 ++++++++++++++++- .../input/events/OnChangeStateEvent.kt | 9 + .../events/OnContextMenuItemPressEvent.kt | 9 + .../markdown/input/formatting/BlockStore.kt | 28 +- .../input/formatting/InputFormatter.kt | 38 +- .../markdown/input/formatting/InputParser.kt | 48 ++- .../input/formatting/MarkdownSerializer.kt | 16 +- .../input/layout/InputEventEmitter.kt | 13 +- .../markdown/input/model/BlockType.kt | 10 + .../input/model/InputFormatterStyle.kt | 4 + .../markdown/input/spans/InputBulletSpan.kt | 82 ++++ .../input/spans/InputListItemSpacingSpan.kt | 28 ++ .../markdown/input/styles/BlockHandler.kt | 7 + .../input/styles/UnorderedListBlockHandler.kt | 34 ++ .../ios/input/ENRMBlockStore.mm | 29 +- .../ios/input/ENRMInputBlockType.h | 22 + .../ios/input/ENRMInputFormatter.h | 5 + .../ios/input/ENRMInputFormatter.mm | 3 + .../ios/input/ENRMInputLayoutManager.h | 26 ++ .../ios/input/ENRMInputLayoutManager.mm | 182 ++++++++ .../ios/input/ENRMInputParser.mm | 69 ++- .../ios/input/ENRMInputTextView.mm | 50 ++- .../ios/input/ENRMMarkdownSerializer.mm | 2 +- .../EnrichedMarkdownTextInput+Internal.h | 4 + .../ios/input/EnrichedMarkdownTextInput.mm | 408 +++++++++++++++++- .../input/EnrichedMarkdownTextInput.mm.tmp | 0 .../ios/input/InputStylePropsUtils.h | 5 + .../ios/input/styles/ENRMBlockHandler.h | 6 + .../styles/ENRMUnorderedListBlockHandler.h | 15 + .../styles/ENRMUnorderedListBlockHandler.mm | 51 +++ .../src/EnrichedMarkdownTextInput.tsx | 28 +- ...nrichedMarkdownTextInputNativeComponent.ts | 16 + 38 files changed, 1681 insertions(+), 102 deletions(-) create mode 100644 .maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml create mode 100644 .maestro/enrichedMarkdownInput/screenshots/ios/unordered_list_input.png create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt create mode 100644 packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm.tmp create mode 100644 packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h create mode 100644 packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm diff --git a/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml new file mode 100644 index 000000000..7979ab525 --- /dev/null +++ b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml @@ -0,0 +1,43 @@ +appId: swmansion.enriched.markdown.example +tags: + - input + - smoke + - unordered-list + - block +--- +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +# Start a bullet list, then type two items separated by Return. Return on a +# non-empty item must continue the list (new bullet at the same depth); the +# second item is then indented one level via the toolbar. +- tapOn: + id: focus-button +- waitForAnimationToEnd + +- tapOn: + id: toolbar-unordered-list +- waitForAnimationToEnd + +- inputText: 'First item' +- waitForAnimationToEnd + +- pressKey: Enter +- waitForAnimationToEnd + +- inputText: 'Second item' +- waitForAnimationToEnd + +- tapOn: + id: toolbar-indent +- waitForAnimationToEnd + +- tapOn: + id: blur-button + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'unordered_list_input' diff --git a/.maestro/enrichedMarkdownInput/screenshots/ios/unordered_list_input.png b/.maestro/enrichedMarkdownInput/screenshots/ios/unordered_list_input.png new file mode 100644 index 0000000000000000000000000000000000000000..17f99ed9fccc785d83d199d22fe8a1c19ed3b64d GIT binary patch literal 18573 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 ))} + inputRef.current?.toggleUnorderedList()} + testID="toolbar-unordered-list" + > + + + inputRef.current?.outdentList()} + testID="toolbar-outdent" + > + + + inputRef.current?.indentList()} + testID="toolbar-indent" + > + + ` or set `I18nManager.forceRTL(true)`. - **Mixed paragraphs while typing** — newly inserted characters in an empty paragraph briefly inherit the previous paragraph's direction; the first-strong pass corrects this on the next input event. -- **Code blocks, tables, blockquotes, lists** are not supported in the input (it's a flat inline-formatting surface). For those, use [`EnrichedMarkdownText`](TEXT.md). +- **Code blocks, tables, blockquotes** are not supported in the input. Headings and bullet lists are; other block elements require [`EnrichedMarkdownText`](TEXT.md). See [RTL Support](RTL.md) for the full per-element behavior on the rendered output side. diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt index 020fb0753..b51624a45 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt @@ -120,7 +120,7 @@ class EnrichedMarkdownTextInputManager : view: EnrichedMarkdownTextInputView?, value: String?, ) { - view?.hint = value + view?.setUserHint(value) } @ReactProp(name = "placeholderTextColor", customType = "Color") @@ -198,13 +198,20 @@ class EnrichedMarkdownTextInputManager : if (view == null || value == null) return val style = MarkdownStyleParser.parse(value) - view.setAutoLinkStyle(style) - val changed = view.formatter.updateStyle(style) + val changed = view.setMarkdownStyleFromProps(style) if (changed) { view.applyFormatting() } } + @ReactProp(name = "listItemSpacing", defaultInt = 0) + override fun setListItemSpacing( + view: EnrichedMarkdownTextInputView?, + value: Int, + ) { + view?.setListItemSpacingFromProps(value.toFloat()) + } + @ReactProp(name = "color", customType = "Color") override fun setColor( view: EnrichedMarkdownTextInputView?, @@ -429,6 +436,18 @@ class EnrichedMarkdownTextInputManager : view?.toggleHeading(level) } + override fun toggleUnorderedList(view: EnrichedMarkdownTextInputView?) { + view?.toggleUnorderedList() + } + + override fun indentList(view: EnrichedMarkdownTextInputView?) { + view?.indentList() + } + + override fun outdentList(view: EnrichedMarkdownTextInputView?) { + view?.outdentList() + } + override fun setLink( view: EnrichedMarkdownTextInputView?, url: String?, 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 b9363edf0..f32160c3b 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 @@ -41,11 +41,17 @@ 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 import kotlin.math.ceil +// Zero-width space: anchors an empty bullet line so the marker draws and the caret +// indents (Android won't apply a LeadingMarginSpan's indent to an empty paragraph). +// Stripped during serialization, so it never reaches the Markdown output. +private const val ZWSP = '\u200B' + private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r' || this == '\u0085' || this == '\u2028' || this == '\u2029' class EnrichedMarkdownTextInputView( @@ -101,6 +107,15 @@ class EnrichedMarkdownTextInputView( private var headingOverrideBaseSizePx: Float? = null private var savedHintTextColors: ColorStateList? = null + // Guards re-entrancy while the empty-list-line ZWSP anchor is managed (its + // insert/delete + setSelection would otherwise loop back through the callbacks). + private var isManagingAnchor = false + + // The consumer-set placeholder, hidden while a bullet is drawn on an empty editor. + private var userHint: CharSequence? = null + + private var listItemSpacingPx = 0 + init { setupDetectorPipeline() prepareComponent() @@ -175,9 +190,47 @@ class EnrichedMarkdownTextInputView( if (keyCode == KeyEvent.KEYCODE_DEL && deleteLinkBeforeCursor()) { return true } + if (handleListKey(keyCode, event)) { + return true + } return super.onKeyDown(keyCode, event) } + /** + * Hardware-keyboard list editing: Tab indents the current item, Shift+Tab outdents, + * and Backspace at the start of an item (or on an empty/ZWSP-anchored item) outdents, + * then un-lists at depth 0. Only fires on a list line; returns true when handled. + */ + private fun handleListKey( + keyCode: Int, + event: KeyEvent?, + ): Boolean { + val (isList, depth) = unorderedListStateAtCursor() + if (!isList) return false + when (keyCode) { + KeyEvent.KEYCODE_TAB -> { + if (event?.isShiftPressed == true) outdentList() else indentList() + return true + } + + KeyEvent.KEYCODE_DEL -> { + if (selectionStart == selectionEnd) { + val editable = text ?: return false + val ls = lineStartOf(editable, selectionStart) + val le = 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 toggleUnorderedList() + return true + } + } + } + } + return false + } + // Prevents TextView from deferring its internal layout when a Fabric // state-update (height change) triggers requestLayout(). Without this // override the deferred relayout causes a visible flicker of styled spans. @@ -257,9 +310,30 @@ class EnrichedMarkdownTextInputView( isProcessingTextChange = true try { - adjustStoresForEdit(editStart, deletedLength, insertedLength) + formattingStore.adjustForEdit(editStart, deletedLength, insertedLength) + blockStore.adjustForEdit(editStart, deletedLength, insertedLength) + pruneOrphanedAnchors() + handleNewlineBlockContinuation(editStart, deletedLength, insertedLength) + text?.let { blockStore.normalizeToLineBounds(it) } applyPendingStyles(editStart, insertedLength) - applyFormattingScopedToEdit(editStart, insertedLength) + // Settle the empty-bullet ZWSP anchor (insert/strip the char and re-snap ranges) + // BEFORE stamping spans, so block formatting runs exactly once over the final + // text/ranges — otherwise a pre-ZWSP anchor span and the post-ZWSP span would + // both land on the empty line (the "double bullet" bug). + val anchorChanged = syncEmptyListAnchor(restamp = false) + // A newline insert/delete (list continuation/exit) or a ZWSP anchor change can + // move spans across lines, where a per-line scoped re-stamp would miss a stale + // bullet span. Re-stamp the whole document in those cases; scope to the edited + // line for ordinary typing to keep per-keystroke work bounded. + val touchedNewline = + anchorChanged || + editTouchedNewline(editStart, deletedLength, insertedLength, currentText) + applyInlineFormatting() + if (touchedNewline) { + text?.let { formatter.applyBlockFormatting(it, blockStore.allRanges) } + } else { + applyBlockFormattingScopedToEdit(editStart, insertedLength) + } val editable = text if (editable != null) { @@ -274,7 +348,9 @@ class EnrichedMarkdownTextInputView( eventEmitter.emitCaretRectChangeIfNeeded() isTextChanging = false didTextChangeRecently = true - lastProcessedText = currentText + // Record the post-pass text (block continuation / ZWSP sync may have mutated it), + // so the next change detects equality correctly and doesn't reprocess. + lastProcessedText = text?.toString() ?: currentText } finally { isProcessingTextChange = false } @@ -302,6 +378,12 @@ class EnrichedMarkdownTextInputView( } } + if (!isTextChanging && !isProcessingTextChange) { + // The caret moving on/off an empty bullet line toggles the ZWSP anchor and the + // placeholder visibility; skip during a text-change pass (handled there). + syncEmptyListAnchor() + } + eventEmitter.emitSelection(selStart, selEnd) updateActiveMention() eventEmitter.emitState() @@ -338,22 +420,64 @@ class EnrichedMarkdownTextInputView( } /** - * Drops heading ranges no longer anchored at a line start (e.g. Backspace merged - * their line into the previous one). Must run BEFORE [BlockStore.normalizeToLineBounds] - * so a merged range is judged on its unsnapped anchor and can't grow over the line - * it merged into. + * Drops anchored blocks (headings, list items) no longer anchored at a line start + * (e.g. Backspace merged their line into the previous one). Must run BEFORE + * [BlockStore.normalizeToLineBounds] so a merged range is judged on its unsnapped + * anchor and can't grow over the line it merged into. */ - private fun pruneOrphanedHeadingAnchors() { + private fun pruneOrphanedAnchors() { val editable = text ?: return val orphans = blockStore.allRanges.filter { range -> - range.type in BlockType.HEADINGS && !isAtLineStart(editable, range.start) + range.type in BlockType.ANCHORED && !isAtLineStart(editable, range.start) } for (orphan in orphans) { blockStore.removeBlock(orphan.start, orphan.start, editable) } } + /** + * After a newline insertion, continues a block whose handler reports + * [com.swmansion.enriched.markdown.input.styles.BlockHandler.continuesOnNewline] + * (a list item) onto the new line as a sibling at the same depth, or exits the + * block when the emptied item gets a second Enter. + */ + private fun handleNewlineBlockContinuation( + editStart: Int, + deletedLength: Int, + insertedLength: Int, + ) { + val editable = text ?: return + if (deletedLength != 0 || insertedLength <= 0) return + val insertedEnd = (editStart + insertedLength).coerceAtMost(editable.length) + val insertedNewline = (editStart until insertedEnd).any { editable[it] == '\n' } + if (!insertedNewline) return + + // The line the Enter was pressed on ends at the inserted newline. + val prevLineEnd = editStart + var prevLineStart = prevLineEnd + while (prevLineStart > 0 && editable[prevLineStart - 1] != '\n') prevLineStart-- + + val prevBlock = blockStore.allRanges.firstOrNull { it.start == prevLineStart } ?: return + val handler = formatter.handlerForBlock(prevBlock.type) ?: return + if (!handler.continuesOnNewline) return + + val prevContentLength = (prevLineStart until prevLineEnd).count { editable[it] != ZWSP } + if (prevContentLength == 0) { + // Exit: clear the block AND delete the just-inserted newline so the empty + // item collapses in place instead of leaving an extra indented blank line. + blockStore.removeBlock(prevLineStart, prevLineEnd, editable) + val newlineEnd = (editStart + insertedLength).coerceAtMost(editable.length) + runAsATransaction { editable.delete(editStart, newlineEnd) } + blockStore.adjustForEdit(editStart, insertedLength, 0) + setSelection(prevLineStart.coerceAtMost(editable.length)) + return + } + + val newLineStart = (editStart + insertedLength).coerceAtMost(editable.length) + blockStore.setBlock(prevBlock.type, prevBlock.level, newLineStart, newLineStart, editable) + } + /** True when [pos] is the first character of a line (document start or just after a line break). */ private fun isAtLineStart( editable: CharSequence, @@ -365,9 +489,9 @@ class EnrichedMarkdownTextInputView( /** * Adjusts both [formattingStore] and [blockStore] for a text edit, then prunes - * orphaned heading anchors and normalizes block ranges to line bounds. Every - * code path that mutates the text buffer must call this so block ranges stay in - * sync — mirrors iOS's `replaceTextInRange:withText:formattingRanges:blockRanges:`. + * orphaned anchors and normalizes block ranges to line bounds. Every code path + * that mutates the text buffer must call this so block ranges stay in sync — + * mirrors iOS's `replaceTextInRange:withText:formattingRanges:blockRanges:`. */ private fun adjustStoresForEdit( editStart: Int, @@ -376,7 +500,7 @@ class EnrichedMarkdownTextInputView( ) { formattingStore.adjustForEdit(editStart, deletedLength, insertedLength) blockStore.adjustForEdit(editStart, deletedLength, insertedLength) - pruneOrphanedHeadingAnchors() + pruneOrphanedAnchors() text?.let { blockStore.normalizeToLineBounds(it) } } @@ -386,24 +510,29 @@ class EnrichedMarkdownTextInputView( formatter.applyBlockFormatting(editable, blockStore.allRanges) } + /** Re-applies inline (character) formatting across the whole document. */ + private fun applyInlineFormatting() { + val editable = text ?: return + formatter.applyFormatting(editable, formattingStore.allRanges) + } + /** - * Re-applies inline formatting across the document, but re-normalizes block spans - * only on the paragraph(s) touched by an edit at `[editStart, editStart + insertedLength)`. - * Heading sizing is paragraph-scoped, so re-stamping only the edited line keeps - * per-keystroke work bounded instead of re-spanning the whole document. + * Re-stamps block spans only on the paragraph(s) touched by an edit at + * `[editStart, editStart + insertedLength)`. A block span covers its whole line, so + * re-stamping only the edited line keeps per-keystroke work bounded instead of + * re-spanning the whole document. Safe only for edits that stay within a line; a + * newline-crossing edit must re-stamp the whole document (see [onAfterTextChanged]). */ - private fun applyFormattingScopedToEdit( + private fun applyBlockFormattingScopedToEdit( editStart: Int, insertedLength: Int, ) { val editable = text ?: return - formatter.applyFormatting(editable, formattingStore.allRanges) - val length = editable.length val rawStart = editStart.coerceIn(0, length) val rawEnd = (editStart + insertedLength).coerceIn(rawStart, length) - // Expand the edit span to whole-line bounds: a heading span covers its line, so + // Expand the edit span to whole-line bounds: a block span covers its line, so // re-stamping must cover every line the edit touched, edge-to-edge. var lineStart = rawStart while (lineStart > 0 && editable[lineStart - 1] != '\n') lineStart-- @@ -413,6 +542,29 @@ class EnrichedMarkdownTextInputView( formatter.applyBlockFormatting(editable, blockStore.allRanges, lineStart, lineEnd) } + /** + * True when the edit inserted or deleted a line break (so block spans may need to + * move across lines). Checks the inserted run in the current text and the deleted run + * in the pre-edit text. + */ + private fun editTouchedNewline( + editStart: Int, + deletedLength: Int, + insertedLength: Int, + preEditText: String, + ): Boolean { + val editable = text + if (editable != null && insertedLength > 0) { + val end = (editStart + insertedLength).coerceAtMost(editable.length) + if ((editStart until end).any { editable[it].isLineBreak() }) return true + } + if (deletedLength > 0) { + val end = (editStart + deletedLength).coerceAtMost(preEditText.length) + if ((editStart until end).any { preEditText[it].isLineBreak() }) return true + } + return false + } + private fun applyFormattingAndEmit() { applyFormatting() forceScrollToSelection() @@ -493,6 +645,181 @@ class EnrichedMarkdownTextInputView( } } + /** + * Bullet-list state of the cursor's paragraph: whether it is a list item and, if so, + * its 0-based nesting depth. Mirrors [headingLevelAtCursor] — the orchestrator side + * of the `onChangeState.unorderedList` payload. + */ + fun unorderedListStateAtCursor(): Pair { + val block = blockOnParagraphAt(selectionStart) ?: return false to 0 + return if (block.type == BlockType.UNORDERED_LIST_ITEM) true to block.level else false to 0 + } + + /** + * Toggles a bullet list on the cursor's paragraph(s): turns the touched lines into + * depth-0 list items, or clears the list back to plain paragraphs when the cursor's + * line is already a list item. Mirrors [toggleHeading]; reuses [setListBlockOnLines]. + */ + fun toggleUnorderedList() { + val editable = text ?: return + val turningOff = unorderedListStateAtCursor().first + if (turningOff) { + forEachSelectedLine { ls, le -> blockStore.removeBlock(ls, le, editable) } + } else { + setListBlockOnLines(0) + } + applyFormattingAndEmit() + syncEmptyListAnchor() + } + + /** Increases the nesting depth of the selected list item(s). QoL: indenting a plain paragraph starts a list. */ + fun indentList() = changeListDepthBy(1) + + /** Decreases the nesting depth; outdenting at depth 0 removes the list marker. */ + fun outdentList() = changeListDepthBy(-1) + + private fun changeListDepthBy(delta: Int) { + val (isList, depth) = unorderedListStateAtCursor() + if (!isList) { + // Indent on a plain paragraph starts a bullet list; headings/outdent are ignored. + if (delta > 0 && blockOnParagraphAt(selectionStart) == null) toggleUnorderedList() + return + } + if (delta < 0 && depth == 0) { + toggleUnorderedList() + return + } + val editable = text ?: return + forEachSelectedLine { ls, le -> + val block = blockStore.allRanges.firstOrNull { it.start == ls && it.type == BlockType.UNORDERED_LIST_ITEM } + if (block != null) { + val newDepth = (block.level + delta).coerceIn(0, MAX_LIST_DEPTH) + blockStore.setBlock(BlockType.UNORDERED_LIST_ITEM, newDepth, ls, le, editable) + } + } + applyFormattingAndEmit() + syncEmptyListAnchor() + } + + /** Sets a list block at [depth] on every line the selection touches. */ + private fun setListBlockOnLines(depth: Int) { + val editable = text ?: return + forEachSelectedLine { ls, le -> + blockStore.setBlock(BlockType.UNORDERED_LIST_ITEM, depth, ls, le, editable) + } + } + + /** 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 + * paragraph). Inserts the ZWSP on the caret's empty list line, strips stale ones. + * + * @param restamp re-apply block formatting here (selection/command paths); the + * text-change pass passes false and stamps once afterwards. + * @return true if an anchor was inserted or stripped (text/ranges mutated). + */ + private fun syncEmptyListAnchor(restamp: Boolean = true): Boolean { + if (isManagingAnchor) return false + val editable = text ?: return false + isManagingAnchor = true + var anchorChanged = false + try { + // Strip every stale ZWSP first (a line that gained content or stopped being a list). + var i = editable.length - 1 + while (i >= 0) { + if (editable[i] == ZWSP) { + val ls = lineStartOf(editable, i) + val le = lineEndOf(editable, i) + val onlyZwsp = le - ls == 1 && editable[ls] == ZWSP + val isEmptyListLine = onlyZwsp && blockStore.allRanges.any { it.start == ls && it.type == BlockType.UNORDERED_LIST_ITEM } + if (!isEmptyListLine) { + runAsATransaction { editable.delete(i, i + 1) } + blockStore.adjustForEdit(i, 1, 0) + anchorChanged = true + } + } + i-- + } + + // 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 = blockStore.allRanges.firstOrNull { it.start == ls && it.type == BlockType.UNORDERED_LIST_ITEM } + if (block != null && le == ls) { + runAsATransaction { editable.insert(ls, ZWSP.toString()) } + blockStore.adjustForEdit(ls, 0, 1) + blockStore.normalizeToLineBounds(editable) + setSelection(ls + 1) + anchorChanged = true + } + } + + if (anchorChanged) { + // Re-snap any range left zero-length by a strip so the next stamp is exact. + blockStore.normalizeToLineBounds(editable) + if (restamp) applyFormatting() + lastProcessedText = editable.toString() + if (emitMarkdown) eventEmitter.emitChangeMarkdown() + } + syncHintVisibility() + return anchorChanged + } finally { + isManagingAnchor = false + } + } + + /** + * The hint shows only on a truly empty editor with no block range — a bullet's + * ZWSP anchor counts as content, so the hint never overlaps a marker. Mirrors iOS. + */ + private fun syncHintVisibility() { + val content = text + val hasRealText = content != null && content.any { it != ZWSP } + val hasBlock = blockStore.allRanges.isNotEmpty() + val target: CharSequence? = if (hasRealText || hasBlock) "" else userHint + if (hint != target) super.setHint(target) + } + + fun setUserHint(value: CharSequence?) { + userHint = value + 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. @@ -845,6 +1172,35 @@ class EnrichedMarkdownTextInputView( autoLinkDetector.style = style } + // The markdownStyle prop, kept so density + listItemSpacing (sourced outside the + // prop) can be folded into the formatter style whenever any of them changes. + private var baseStyle: InputFormatterStyle? = null + + /** + * Applies the parsed `markdownStyle`, folding in the display density and the current + * `listItemSpacing` so block handlers can build density-correct, spacing-aware spans. + * Returns true if the effective style changed (caller re-applies formatting). + */ + fun setMarkdownStyleFromProps(style: InputFormatterStyle): Boolean { + baseStyle = style + setAutoLinkStyle(style) + return applyComposedStyle() + } + + private fun applyComposedStyle(): Boolean { + val base = baseStyle ?: return false + val composed = base.copy(displayDensity = resources.displayMetrics.density, listItemSpacingPx = listItemSpacingPx) + return formatter.updateStyle(composed) + } + + /** Sets the vertical spacing (dp) above each list item, re-stamping list spans. */ + fun setListItemSpacingFromProps(spacingDp: Float) { + val px = if (spacingDp > 0f) PixelUtil.toPixelFromDIP(spacingDp).toInt() else 0 + if (px == listItemSpacingPx) return + listItemSpacingPx = px + if (applyComposedStyle()) applyFormatting() + } + fun allFormattingRangesForSerialization(): List { val editable = text ?: return formattingStore.allRanges val transientRanges = detectorPipeline.allTransientFormattingRanges(editable) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnChangeStateEvent.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnChangeStateEvent.kt index 4ebdfcc46..7e7f601e3 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnChangeStateEvent.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnChangeStateEvent.kt @@ -14,6 +14,8 @@ class OnChangeStateEvent( private val isSpoiler: Boolean, private val isLink: Boolean, private val headingLevel: Int, + private val isUnorderedList: Boolean, + private val unorderedListDepth: Int, ) : Event(surfaceId, viewId) { override fun getEventName(): String = EVENT_NAME @@ -50,6 +52,13 @@ class OnChangeStateEvent( putInt("level", headingLevel) }, ) + putMap( + "unorderedList", + Arguments.createMap().apply { + putBoolean("isActive", isUnorderedList) + putInt("depth", unorderedListDepth) + }, + ) } companion object { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnContextMenuItemPressEvent.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnContextMenuItemPressEvent.kt index fac8f07dc..33a2d9583 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnContextMenuItemPressEvent.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnContextMenuItemPressEvent.kt @@ -18,6 +18,8 @@ class OnContextMenuItemPressEvent( private val isSpoiler: Boolean, private val isLink: Boolean, private val headingLevel: Int, + private val isUnorderedList: Boolean, + private val unorderedListDepth: Int, ) : Event(surfaceId, viewId) { override fun getEventName(): String = EVENT_NAME @@ -45,6 +47,13 @@ class OnContextMenuItemPressEvent( putInt("level", headingLevel) }, ) + putMap( + "unorderedList", + Arguments.createMap().apply { + putBoolean("isActive", isUnorderedList) + putInt("depth", unorderedListDepth) + }, + ) }, ) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt index 2f3b9fa28..56b7d627e 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt @@ -44,9 +44,9 @@ class BlockStore { ) { val (start, end) = paragraphBounds(paragraphStart, paragraphEnd, text) removeBlocksOverlapping(start, end) - // A heading on an empty line is kept as a zero-length anchor (see adjustForEdit); - // other blocks need real content, so an empty line yields no block. - if (end < start || (end == start && type !in BlockType.HEADINGS)) return + // An anchored block (heading, list item) on an empty line is kept as a zero-length + // anchor (see adjustForEdit); other blocks need real content. + if (end < start || (end == start && type !in BlockType.ANCHORED)) return val block = BlockRange(type, start, end, level) ranges.add(sortedInsertionIndex(ranges, start), block) @@ -67,10 +67,10 @@ class BlockStore { /** * Shifts/clips block ranges to follow a text edit (see [RangeEditAdjustment]), - * with heading persistence layered on top: a heading deleted exactly to its - * end collapses to a zero-length anchor at the edit location (its line - * survives), and existing anchors shift/keep/drop with their line. The view's - * prune/normalize pass reconciles anchors against the final text. + * with anchored-block (heading / list item) persistence layered on top: a block + * deleted exactly to its end collapses to a zero-length anchor at the edit + * location (its line survives), and existing anchors shift/keep/drop with their + * line. The view's prune/normalize pass reconciles anchors against the final text. */ fun adjustForEdit( editLocation: Int, @@ -82,14 +82,14 @@ class BlockStore { val deleteEnd = editLocation + deletedLength val delta = insertedLength - deletedLength - val anchors = ranges.filter { it.length == 0 && it.type in BlockType.HEADINGS } + val anchors = ranges.filter { it.length == 0 && it.type in BlockType.ANCHORED } ranges.removeAll { it.length == 0 } // At most one range can end exactly at deleteEnd, so this restores at most - // one collapsed heading. + // one collapsed block. val collapsed = ranges.firstOrNull { - it.type in BlockType.HEADINGS && it.start >= editLocation && it.end == deleteEnd + it.type in BlockType.ANCHORED && it.start >= editLocation && it.end == deleteEnd } RangeEditAdjustment.adjustForEdit(ranges, editLocation, deletedLength, insertedLength) @@ -121,9 +121,9 @@ class BlockStore { /** * Snaps every stored range to the line bounds of its start position. * Absorbs edge-typed chars, clips split ranges to first line, drops - * duplicates. On an empty line a heading persists as a zero-length anchor; - * any other collapsed range is dropped. Call after [adjustForEdit] once - * [text] is final. Idempotent. + * duplicates. On an empty line an anchored block (heading, list item) + * persists as a zero-length anchor; any other collapsed range is dropped. + * Call after [adjustForEdit] once [text] is final. Idempotent. */ fun normalizeToLineBounds(text: CharSequence) { if (ranges.isEmpty()) return @@ -134,7 +134,7 @@ class BlockStore { val range = iterator.next() val (lineStart, lineEnd) = paragraphBounds(range.start, range.start, text) val isEmptyLine = lineEnd == lineStart - if ((isEmptyLine && range.type !in BlockType.HEADINGS) || lineStart <= previousEnd) { + if ((isEmptyLine && range.type !in BlockType.ANCHORED) || lineStart <= previousEnd) { iterator.remove() continue } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt index 474f80fc2..ccdb500d2 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt @@ -7,6 +7,7 @@ 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.StyleType +import com.swmansion.enriched.markdown.input.spans.InputListItemSpacingSpan import com.swmansion.enriched.markdown.input.styles.BlockHandler import com.swmansion.enriched.markdown.input.styles.BoldStyleHandler import com.swmansion.enriched.markdown.input.styles.HeadingBlockHandler @@ -16,6 +17,7 @@ import com.swmansion.enriched.markdown.input.styles.SpoilerStyleHandler import com.swmansion.enriched.markdown.input.styles.StrikethroughStyleHandler import com.swmansion.enriched.markdown.input.styles.StyleHandler import com.swmansion.enriched.markdown.input.styles.UnderlineStyleHandler +import com.swmansion.enriched.markdown.input.styles.UnorderedListBlockHandler /** * Marker interface so we only remove spans we created, leaving @@ -37,11 +39,14 @@ class InputFormatter { /** * Block handlers, keyed by block type. A single [HeadingBlockHandler] serves all * six heading levels — it reads the level from the [BlockRange] — so it is mapped - * under every `HEADING_n` key. + * under every `HEADING_n` key. One [UnorderedListBlockHandler] serves every list + * depth (depth lives on the range), so it is mapped under the single list key. */ val blockHandlers: Map = - HeadingBlockHandler().let { heading -> - BlockType.HEADINGS.associateWith { heading } + buildMap { + val heading = HeadingBlockHandler() + for (type in BlockType.HEADINGS) put(type, heading) + put(BlockType.UNORDERED_LIST_ITEM, UnorderedListBlockHandler()) } fun handlerForBlock(type: BlockType): BlockHandler? = blockHandlers[type] @@ -170,23 +175,32 @@ class InputFormatter { } for (range in blockRanges) { - val isHeadingAnchor = range.length == 0 && range.type in BlockType.HEADINGS - if (!isHeadingAnchor && (range.start >= range.end || range.start < 0 || range.end > spannable.length)) continue - if (isHeadingAnchor && (range.start < 0 || range.start > spannable.length)) continue - if (isHeadingAnchor) { + val isAnchor = range.length == 0 && range.type in BlockType.ANCHORED + if (!isAnchor && (range.start >= range.end || range.start < 0 || range.end > spannable.length)) continue + if (isAnchor && (range.start < 0 || range.start > spannable.length)) continue + if (isAnchor) { if (range.start < start || range.start > end) continue } else if (range.end <= start || range.start >= end) { continue } val handler = blockHandlers[range.type] ?: continue - // Extend a zero-length heading anchor to cover the next character (usually - // '\n') so the Layout gives the empty line heading metrics. At the very end - // of text the span stays zero-length; the view-level paint override handles + // Extend a zero-length anchor to cover the next character (usually '\n') so + // the Layout gives the empty line the block's metrics. At the very end of + // text the span stays zero-length; the view-level paint override handles // cursor height for that edge case. - val spanEnd = if (isHeadingAnchor && range.start < spannable.length) range.start + 1 else range.end - val flags = if (isHeadingAnchor) Spannable.SPAN_INCLUSIVE_INCLUSIVE else Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + val anchorEnd = if (isAnchor && range.start < spannable.length) range.start + 1 else range.end + val flags = if (isAnchor) Spannable.SPAN_INCLUSIVE_INCLUSIVE else Spannable.SPAN_EXCLUSIVE_EXCLUSIVE for (span in handler.createSpans(range, currentStyle)) { + // A LineHeightSpan (list-item spacing) must cover only the item's first + // character so it spaces just the first visual line, not wrapped lines. + val spanEnd = + if (span is InputListItemSpacingSpan) { + (range.start + 1).coerceAtMost(range.end).coerceAtMost(spannable.length) + } else { + anchorEnd + } + if (span is InputListItemSpacingSpan && spanEnd <= range.start) continue spannable.setSpan(span, range.start, spanEnd, flags) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt index 33888ac8a..4d2cfc781 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt @@ -3,6 +3,7 @@ package com.swmansion.enriched.markdown.input.formatting 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.MAX_LIST_DEPTH import com.swmansion.enriched.markdown.input.model.StyleType import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.parser.MarkdownASTNode.NodeType @@ -43,7 +44,7 @@ object InputParser { .toList(), ) - walkNode(ast, plainText, ranges, blockRanges, ArrayDeque(), blankRuns) + walkNode(ast, plainText, ranges, blockRanges, ArrayDeque(), blankRuns, 0) return ParseResult(plainText.toString(), ranges, blockRanges) } @@ -55,6 +56,7 @@ object InputParser { blockRanges: MutableList, activeStyles: ArrayDeque, blankRuns: ArrayDeque, + listDepth: Int, ) { val styleType = nodeTypeToStyleType(node.type) @@ -63,6 +65,18 @@ object InputParser { activeStyles.addLast(ActiveStyle(styleType, plainText.length, url)) } + // A list node increments the nesting depth of its items, so a list item's depth + // is (enclosing list nodes − 1) — matching the iOS depth derivation. Depth comes + // from this AST nesting, never from counting leading spaces. + val childListDepth = if (node.type == NodeType.UnorderedList) listDepth + 1 else listDepth + + // Each list item starts on its own line; md4c emits no separator between sibling + // items or before a nested sublist, so insert the line break ourselves. + if (node.type == NodeType.ListItem && plainText.isNotEmpty() && !plainText.endsWith("\n")) { + plainText.append("\n") + } + val itemStart = if (node.type == NodeType.ListItem) plainText.length else -1 + // Block-level node: record where its text content begins so we can build a // BlockRange on the way back out. PARAGRAPH is the implicit default and is // dropped below, so it produces no stored block range. @@ -77,11 +91,26 @@ object InputParser { } for ((index, child) in node.children.withIndex()) { - // Keep the source's blank lines between top-level blocks (md4c drops them, iOS keeps them). - if (index > 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) { + // Keep the source's blank lines between genuinely top-level blocks (md4c drops + // them, iOS keeps them). Inside a list, items are separated by a single newline + // (inserted above), not blank lines. + if (index > 0 && listDepth == 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) { plainText.append("\n".repeat(blankRuns.removeFirstOrNull() ?: 2)) } - walkNode(child, plainText, ranges, blockRanges, activeStyles, blankRuns) + walkNode(child, plainText, ranges, blockRanges, activeStyles, blankRuns, childListDepth) + } + + // A list item owns its own first line; a nested sublist lives on later lines, so + // the item's range ends at the first newline after its start. + if (itemStart >= 0) { + var lineEnd = plainText.indexOf('\n', itemStart) + if (lineEnd < 0) lineEnd = plainText.length + if (lineEnd > itemStart) { + // The item sees listDepth already incremented by its enclosing list node, so + // its 0-based depth is listDepth − 1 (enclosing list nodes − 1, per iOS). + val depth = (listDepth - 1).coerceIn(0, MAX_LIST_DEPTH) + blockRanges.add(BlockRange(BlockType.UNORDERED_LIST_ITEM, itemStart, lineEnd, depth)) + } } if (styleType != null) { @@ -93,8 +122,9 @@ object InputParser { } // Emit the block range for handler-claimed block types only; PARAGRAPH (the - // implicit default) yields nothing, so PR1 produces an empty block list. - if (blockType != null && blockType != BlockType.PARAGRAPH) { + // implicit default) yields nothing. List items are emitted above with their own + // line bounds, so they are excluded here. + if (blockType != null && blockType != BlockType.PARAGRAPH && blockType != BlockType.UNORDERED_LIST_ITEM) { val end = plainText.length if (end > blockStartPosition) { blockRanges.add(BlockRange(blockType, blockStartPosition, end, blockLevel)) @@ -125,7 +155,13 @@ object InputParser { ): BlockType? = when (nodeType) { NodeType.Paragraph -> BlockType.PARAGRAPH + NodeType.Heading -> BlockType.forHeadingLevel(level) ?: BlockType.PARAGRAPH + + // List items are recognized here but emitted with their own per-line bounds and + // AST-derived nesting depth in the walk above, not via the generic emission. + NodeType.ListItem -> BlockType.UNORDERED_LIST_ITEM + else -> null } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt index fd3b8c8be..15698f106 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt @@ -8,12 +8,17 @@ import com.swmansion.enriched.markdown.input.model.StyleType object MarkdownSerializer { private const val TAG = "MarkdownSerializer" + // Zero-width space used by the editor to anchor an empty bullet line; it is an + // internal editing aid and must never appear in serialized markdown. + private const val ZWSP = "\u200B" + /** * Block-aware serialization: serializes inline styles exactly as the inline-only * overload, then prepends each line's block prefix. [blockPrefixProvider] is * asked, per block range, for the markdown line marker (e.g. `"# "`, `"- "`); * returning `""` leaves the line unprefixed. With empty [blockRanges] the output - * is identical to the inline-only overload. + * is identical to the inline-only overload. Any ZWSP empty-line anchor is stripped + * so an empty bullet still serializes to a bare `"- "` rather than `"- ​"`. */ fun serialize( text: String, @@ -22,7 +27,7 @@ object MarkdownSerializer { blockPrefixProvider: (BlockRange) -> String, ): String { val inlineMarkdown = serialize(text, ranges) - if (blockRanges.isEmpty()) return inlineMarkdown + if (blockRanges.isEmpty()) return inlineMarkdown.replace(ZWSP, "") // Block prefixes attach per line. Inline serialization only inserts inline // delimiters (never newlines), so the serialized output has the same line @@ -37,14 +42,15 @@ object MarkdownSerializer { // not crash the host app over lost block prefixes. if (plainLines.size != markdownLines.size) { Log.e(TAG, "Block serialization line-count invariant violated: plain=${plainLines.size} markdown=${markdownLines.size}") - return inlineMarkdown + return inlineMarkdown.replace(ZWSP, "") } + // Plain-text character offset at the start of each line. val lineStartOffsets = IntArray(plainLines.size) var runningOffset = 0 for (i in plainLines.indices) { lineStartOffsets[i] = runningOffset - runningOffset += plainLines[i].length + 1 + runningOffset += plainLines[i].length + 1 // +1 for the '\n' separator } for (blockRange in blockRanges) { @@ -62,7 +68,7 @@ object MarkdownSerializer { } } - return markdownLines.joinToString("\n") + return markdownLines.joinToString("\n").replace(ZWSP, "") } fun serialize( diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt index 2d36f59b6..4ca6d0ff6 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt @@ -27,6 +27,7 @@ class InputEventEmitter( ) { private var prevState: Map = emptyMap() private var prevHeadingLevel: Int = 0 + private var prevUnorderedList: Pair = false to 0 private var prevCaretRect: CaretRect? = null fun emitChangeText() { @@ -52,10 +53,12 @@ class InputEventEmitter( isStyleEffectivelyActive(style, pos) } val headingLevel = view.headingLevelAtCursor() + val unorderedList = view.unorderedListStateAtCursor() - if (current == prevState && headingLevel == prevHeadingLevel) return + if (current == prevState && headingLevel == prevHeadingLevel && unorderedList == prevUnorderedList) return prevState = current prevHeadingLevel = headingLevel + prevUnorderedList = unorderedList dispatch( OnChangeStateEvent( @@ -68,6 +71,8 @@ class InputEventEmitter( current[StyleType.SPOILER] ?: false, current[StyleType.LINK] ?: false, headingLevel, + unorderedList.first, + unorderedList.second, ), ) } @@ -159,6 +164,7 @@ class InputEventEmitter( isStyleEffectivelyActive(type, selectionStart) } + val contextMenuListState = view.unorderedListStateAtCursor() dispatch( OnContextMenuItemPressEvent( surfaceId(), @@ -174,6 +180,8 @@ class InputEventEmitter( isSpoiler = isActive(StyleType.SPOILER), isLink = isActive(StyleType.LINK), headingLevel = view.headingLevelAtCursor(), + isUnorderedList = contextMenuListState.first, + unorderedListDepth = contextMenuListState.second, ), ) } @@ -190,6 +198,9 @@ class InputEventEmitter( private fun serializeToMarkdown(): String { val plainText = view.text?.toString() ?: "" + // Each block resolves its markdown line prefix through its registered handler. + // With no block handlers registered the provider returns "" for every block + // and output equals the inline-only serialization. return MarkdownSerializer.serialize( plainText, view.allFormattingRangesForSerialization(), diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt index 6e0bccaa1..f7a186b67 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt @@ -18,6 +18,7 @@ enum class BlockType { HEADING_4, HEADING_5, HEADING_6, + UNORDERED_LIST_ITEM, ; companion object { @@ -25,6 +26,12 @@ enum class BlockType { val HEADINGS: List = listOf(HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6) + /** + * Block types whose emptied line persists as a zero-length anchor (an emptied + * heading stays a heading, an emptied bullet keeps its marker) until toggled off. + */ + val ANCHORED: Set = HEADINGS.toSet() + UNORDERED_LIST_ITEM + /** * Maps an H-level (1-6) to its [BlockType], or null when out of range. Used by * the parser to turn an AST heading node's `level` attribute into a block type. @@ -32,3 +39,6 @@ enum class BlockType { fun forHeadingLevel(level: Int): BlockType? = HEADINGS.getOrNull(level - 1) } } + +/** Maximum bullet-list nesting depth (0-based), so indent can't run away unbounded. */ +const val MAX_LIST_DEPTH = 5 diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt index bc1cc6b34..6124e0616 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt @@ -11,6 +11,10 @@ data class InputFormatterStyle( val spoilerBackgroundColor: Int, /** Per-level heading styling, indexed 0..5 for H1..H6. Always length 6. */ val headings: List, + /** Display density (px per dp) so block handlers can build density-correct spans without a Context. */ + val displayDensity: Float = 1f, + /** Extra vertical spacing (px) above each bullet item, from the `listItemSpacing` prop. */ + val listItemSpacingPx: Int = 0, ) { /** * Resolves the heading style for an H-level (1-6), clamping out-of-range levels diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt new file mode 100644 index 000000000..f17d898ce --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt @@ -0,0 +1,82 @@ +package com.swmansion.enriched.markdown.input.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import com.swmansion.enriched.markdown.input.formatting.MarkdownSpan + +/** + * Bullet list item span: reserves a per-depth leading margin and draws the marker + * glyph (dot / ring / square, cycling by `depth % 3`) on the item's first line. + * Mirrors the readonly renderer's bullet. Tagged [MarkdownSpan] for formatter + * cleanup. + */ +class InputBulletSpan( + val depth: Int, + density: Float, +) : LeadingMarginSpan, + MarkdownSpan { + private val indentPerDepth = INDENT_PER_DEPTH_DP * density + private val markerWidth = MARKER_WIDTH_DP * density + + override fun getLeadingMargin(first: Boolean): Int = (depth * indentPerDepth + markerWidth).toInt() + + override fun drawLeadingMargin( + canvas: Canvas, + paint: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence?, + start: Int, + end: Int, + first: Boolean, + layout: Layout?, + ) { + if (!first) return + // Wrapped continuation lines also report first=true; draw only on the visual + // line where this span actually begins. + if (text is Spanned && text.getSpanStart(this) != start) return + + // Bullet size tracks the text size (~30%), with a small floor so it stays visible. + val size = (paint.textSize * BULLET_SIZE_RATIO).coerceAtLeast(MIN_BULLET_PX) + val radius = size / 2f + val centerX = x + (depth * indentPerDepth + markerWidth * 0.5f) * dir + val fm = paint.fontMetrics + val centerY = baseline + (fm.ascent + fm.descent) / 2f + + val originalStyle = paint.style + val originalStrokeWidth = paint.strokeWidth + when (depth % 3) { + 0 -> { + paint.style = Paint.Style.FILL + canvas.drawCircle(centerX, centerY, radius, paint) + } + + 1 -> { + paint.style = Paint.Style.STROKE + paint.strokeWidth = (size * RING_STROKE_RATIO).coerceAtLeast(1f) + canvas.drawCircle(centerX, centerY, radius - paint.strokeWidth / 2f, paint) + } + + else -> { + paint.style = Paint.Style.FILL + canvas.drawRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius, paint) + } + } + paint.style = originalStyle + paint.strokeWidth = originalStrokeWidth + } + + companion object { + private const val INDENT_PER_DEPTH_DP = 18f + private const val MARKER_WIDTH_DP = 18f + private const val BULLET_SIZE_RATIO = 0.3f + private const val MIN_BULLET_PX = 4f + private const val RING_STROKE_RATIO = 0.15f + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt new file mode 100644 index 000000000..7d729753e --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt @@ -0,0 +1,28 @@ +package com.swmansion.enriched.markdown.input.spans + +import android.graphics.Paint +import android.text.style.LineHeightSpan +import com.swmansion.enriched.markdown.input.formatting.MarkdownSpan + +/** + * Adds [spacingPx] of vertical space above a bullet item (counterpart to iOS + * `paragraphSpacingBefore`). Must span only the item's first character so wrapped + * continuation lines aren't spaced. Tagged [MarkdownSpan] for formatter cleanup. + */ +class InputListItemSpacingSpan( + val spacingPx: Int, +) : LineHeightSpan, + MarkdownSpan { + override fun chooseHeight( + text: CharSequence?, + start: Int, + end: Int, + spanstartv: Int, + lineHeight: Int, + fm: Paint.FontMetricsInt?, + ) { + if (fm == null || spacingPx <= 0) return + fm.ascent -= spacingPx + fm.top -= spacingPx + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/BlockHandler.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/BlockHandler.kt index f9b855715..94f985f07 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/BlockHandler.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/BlockHandler.kt @@ -24,6 +24,13 @@ import com.swmansion.enriched.markdown.input.model.InputFormatterStyle interface BlockHandler { val blockType: BlockType + /** + * Whether Enter continues the block onto the next line (a list item) rather + * than ending it (a heading). Keeps Enter behavior handler-driven. + */ + val continuesOnNewline: Boolean + get() = false + /** * Spans to apply over the block's line range. Returned spans are tagged with * [com.swmansion.enriched.markdown.input.formatting.MarkdownSpan] so the diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt new file mode 100644 index 000000000..aaf1b8917 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt @@ -0,0 +1,34 @@ +package com.swmansion.enriched.markdown.input.styles + +import com.swmansion.enriched.markdown.input.model.BlockRange +import com.swmansion.enriched.markdown.input.model.BlockType +import com.swmansion.enriched.markdown.input.model.InputFormatterStyle +import com.swmansion.enriched.markdown.input.spans.InputBulletSpan +import com.swmansion.enriched.markdown.input.spans.InputListItemSpacingSpan + +/** + * Block handler for unordered (bullet) list items. One instance serves every + * nesting depth — the 0-based depth rides in [BlockRange.level] and drives the + * indent, marker glyph, and serialized indentation. + */ +class UnorderedListBlockHandler : BlockHandler { + override val blockType: BlockType = BlockType.UNORDERED_LIST_ITEM + + override val continuesOnNewline: Boolean = true + + override fun createSpans( + blockRange: BlockRange, + style: InputFormatterStyle, + ): List { + val spans = mutableListOf(InputBulletSpan(blockRange.level, style.displayDensity)) + if (style.listItemSpacingPx > 0) { + spans.add(InputListItemSpacingSpan(style.listItemSpacingPx)) + } + return spans + } + + override fun spanClasses(): List> = listOf(InputBulletSpan::class.java, InputListItemSpacingSpan::class.java) + + /** `"- "` for a top-level item, indented two spaces per nesting depth (mirrors iOS). */ + override fun markdownLinePrefix(blockRange: BlockRange): String = " ".repeat(blockRange.level.coerceAtLeast(0)) + "- " +} diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm index e8fab8d4e..dfce1646c 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm @@ -94,7 +94,7 @@ - (void)setBlockType:(ENRMInputBlockType)type paragraphRange = ENRMTrimLineTerminators(paragraphRange, text); - if (paragraphRange.length == 0 && ENRMHeadingLevelForBlockType(type) == 0) { + if (paragraphRange.length == 0 && !ENRMBlockTypePersistsWhenEmpty(type)) { return; } @@ -122,13 +122,13 @@ - (void)adjustForEditAtLocation:(NSUInteger)editLocation for (NSUInteger idx = 0; idx < _ranges.count; idx++) { ENRMBlockRange *blockRange = _ranges[idx]; - BOOL isHeading = ENRMHeadingLevelForBlockType(blockRange.type) > 0; + BOOL persists = ENRMBlockTypePersistsWhenEmpty(blockRange.type); - // Zero-length heading anchors don't follow the shared adjustment: one at - // the edit location stays put (normalize grows it over the typed text), - // one past the edit shifts with it, one inside the deletion is dropped. + // Zero-length anchors don't follow the shared adjustment: one at the edit + // location stays put (normalize grows it over the typed text), one past + // the edit shifts with it, one inside the deletion is dropped. if (blockRange.range.length == 0) { - if (!isHeading) { + if (!persists) { [indexesToRemove addIndex:idx]; } else if (blockRange.range.location >= deleteEnd && blockRange.range.location > editLocation) { blockRange.range = NSMakeRange(blockRange.range.location - deletedLength + insertedLength, 0); @@ -140,10 +140,10 @@ - (void)adjustForEditAtLocation:(NSUInteger)editLocation ENRMAdjustedRange adjusted = ENRMAdjustRangeForEdit(blockRange.range, editLocation, deletedLength, insertedLength); if (adjusted.shouldRemove) { - // A heading deleted exactly to its end collapses to a zero-length anchor - // (the line's newline survived, so the line stays a heading); a deletion - // running past its end removed the line, so drop the heading with it. - if (isHeading && NSMaxRange(blockRange.range) == deleteEnd && blockRange.range.location >= editLocation) { + // A persisting block deleted exactly to its end collapses to a zero-length + // anchor (the line's newline survived, so the line stays the block); a + // deletion running past its end removed the line, so drop the block with it. + if (persists && NSMaxRange(blockRange.range) == deleteEnd && blockRange.range.location >= editLocation) { blockRange.range = NSMakeRange(editLocation, 0); } else { [indexesToRemove addIndex:idx]; @@ -155,10 +155,12 @@ - (void)adjustForEditAtLocation:(NSUInteger)editLocation ENRMRemoveIndexesInReverse(_ranges, indexesToRemove); + // Prune zero-length ranges, but keep zero-length persisting blocks: they anchor + // an emptied-but-still-present heading/bullet line (see the collapse rule above). NSMutableIndexSet *emptyIndexes = [NSMutableIndexSet indexSet]; for (NSUInteger idx = 0; idx < _ranges.count; idx++) { ENRMBlockRange *range = _ranges[idx]; - if (range.range.length == 0 && ENRMHeadingLevelForBlockType(range.type) == 0) { + if (range.range.length == 0 && !ENRMBlockTypePersistsWhenEmpty(range.type)) { [emptyIndexes addIndex:idx]; } } @@ -182,8 +184,9 @@ - (void)normalizeToLineBoundsInText:(NSString *)text lineRange = ENRMTrimLineTerminators(lineRange, text); - BOOL isHeading = ENRMHeadingLevelForBlockType(blockRange.type) > 0; - if ((lineRange.length == 0 && !isHeading) || (NSInteger)lineRange.location <= previousEnd) { + BOOL emptyLine = lineRange.length == 0; + if ((emptyLine && !ENRMBlockTypePersistsWhenEmpty(blockRange.type)) || + (NSInteger)lineRange.location <= previousEnd) { [indexesToRemove addIndex:idx]; continue; } diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h index 592ea187a..b1cd2e27e 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h @@ -19,8 +19,21 @@ typedef NS_ENUM(NSInteger, ENRMInputBlockType) { ENRMInputBlockTypeHeading4 = 4, ENRMInputBlockTypeHeading5 = 5, ENRMInputBlockTypeHeading6 = 6, + ENRMInputBlockTypeUnorderedListItem = 7, }; +/// Maximum bullet-list nesting depth (0-based). Depth is carried as the block's +/// level payload (ENRMBlockRange.level), clamped into [0, kENRMMaxListDepth]. +static const NSInteger kENRMMaxListDepth = 5; + +/// Bullet-list layout metrics (points). Shared by the list block handler (which +/// reserves the head-indent column via the paragraph style) and the layout +/// manager (which draws the marker glyph into that column). Text for a list item +/// at depth d starts at (d + 1) * kENRMListIndentPerDepth from the leading edge; +/// the marker is centered kENRMListBulletGap before the text. +static const CGFloat kENRMListIndentPerDepth = 18.0; +static const CGFloat kENRMListBulletGap = 9.0; + /// Maps a heading level (1-6) to its ENRMInputBlockType. Levels outside 1-6 are /// clamped into range. The contiguous Heading1..Heading6 enum values make this a /// simple offset from the Heading1 base. @@ -44,6 +57,15 @@ static inline NSInteger ENRMHeadingLevelForBlockType(ENRMInputBlockType type) return 0; } +/// Whether an emptied line of this type keeps a zero-length anchor (an emptied +/// heading stays a heading, an emptied bullet keeps its marker) instead of +/// reverting to a plain paragraph. +static inline BOOL ENRMBlockTypePersistsWhenEmpty(ENRMInputBlockType type) +{ + return (type >= ENRMInputBlockTypeHeading1 && type <= ENRMInputBlockTypeHeading6) || + type == ENRMInputBlockTypeUnorderedListItem; +} + /// NSAttributedString attribute carrying the ENRMInputBlockType (boxed NSNumber) /// of the paragraph a character belongs to. Set by ENRMInputFormatter on the /// paragraphs a block claims; the next block pass uses it to find and strip the diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h index ecee2f933..a70aba1b4 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h @@ -41,6 +41,11 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, nullable) RCTUIColor *spoilerColor; @property (nonatomic, strong, nullable) RCTUIColor *spoilerBackgroundColor; +/// Vertical spacing (points) added above each list item via the paragraph +/// style's paragraphSpacingBefore, so bullets read as separate rows. Configured +/// from the `listItemSpacing` prop; defaults to 0 (items pack tightly). +@property (nonatomic, assign) CGFloat listItemSpacing; + /// Per-level heading config, indexed by level 1-6. A nil/0 entry means the level /// uses a built-in default derived from the base font. Configured from the /// `markdownStyle.h1..h6` props. diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm index ad66ac48b..10517104f 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm @@ -8,6 +8,7 @@ #import "ENRMStrikethroughStyleHandler.h" #import "ENRMStyleHandler.h" #import "ENRMUnderlineStyleHandler.h" +#import "ENRMUnorderedListBlockHandler.h" #import "FontUtils.h" @implementation ENRMInputLinkVariantStyle @@ -52,6 +53,7 @@ - (id)copyWithZone:(NSZone *)zone copy.linkVariants = [_linkVariants copy]; copy.spoilerColor = _spoilerColor; copy.spoilerBackgroundColor = _spoilerBackgroundColor; + copy.listItemSpacing = _listItemSpacing; for (NSInteger level = 1; level <= 6; level++) { [copy setHeadingFontSize:_headingFontSizes[level] forLevel:level]; [copy setHeadingFontWeight:_headingFontWeights[level] forLevel:level]; @@ -191,6 +193,7 @@ - (instancetype)init for (NSInteger level = 1; level <= 6; level++) { blockMap[@(ENRMBlockTypeForHeadingLevel(level))] = headingHandler; } + blockMap[@(ENRMInputBlockTypeUnorderedListItem)] = [[ENRMUnorderedListBlockHandler alloc] init]; _blockHandlers = [blockMap copy]; } return self; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h index 56324f072..b9f47cdef 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h @@ -4,8 +4,34 @@ NS_ASSUME_NONNULL_BEGIN +/// Layout manager that draws the bullet marker for unordered-list lines into the +/// head-indent column the list block handler reserves. Non-empty list lines are +/// found by scanning the block-type/level attributes the formatter stamps; an +/// empty list line (a just-toggled or just-continued item with no characters to +/// anchor a marker to) is flagged explicitly by the orchestrator via the +/// emptyBullet* properties below. @interface ENRMInputLayoutManager : NSLayoutManager +/// Depth of the bullet to draw on an otherwise-empty list line. Negative disables +/// the empty-line marker. +@property (nonatomic, assign) NSInteger emptyBulletDepth; + +/// Character location of the empty list line's paragraph (start of the line). +@property (nonatomic, assign) NSUInteger emptyBulletLocation; + +/// Font/color for an empty list line's marker (no character to read them from). +@property (nonatomic, strong, nullable) UIFont *emptyBulletFont; +@property (nonatomic, strong, nullable) UIColor *emptyBulletColor; + +/// Leading spacing (points) applied above list items, so the empty-line marker's +/// baseline can account for the spacing that pushes the text down in the fragment. +@property (nonatomic, assign) CGFloat listItemSpacing; + +/// Draws the marker for a wholly empty editor. `drawGlyphsForGlyphRange:` is +/// never called when there are zero glyphs, so the text view drives this from its +/// own `drawRect:`. No-op unless an empty list line is flagged. +- (void)drawEmptyEditorBulletWithInset:(UIEdgeInsets)inset; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm index 5c46a8132..a1dbfc1b1 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm @@ -1,5 +1,187 @@ #import "ENRMInputLayoutManager.h" +#import "ENRMInputBlockType.h" +#import @implementation ENRMInputLayoutManager +- (instancetype)init +{ + if (self = [super init]) { + _emptyBulletDepth = -1; + } + return self; +} + +/// Draws the depth-styled bullet (filled dot, ring, then square — cycling every +/// three levels) centered at (markerX, centerY). The glyph is sized at ~30% of +/// the text point size so it reads as a marker, not a character. +- (void)drawBulletAtX:(CGFloat)markerX + centerY:(CGFloat)centerY + depth:(NSInteger)depth + font:(UIFont *)font + color:(UIColor *)color +{ + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (!ctx || isnan(markerX) || isnan(centerY)) { + return; + } + CGFloat size = MAX(4.0, font.pointSize * 0.30); + CGRect bulletRect = CGRectMake(markerX - size / 2.0, centerY - size / 2.0, size, size); + CGContextSaveGState(ctx); + switch (((depth % 3) + 3) % 3) { + case 0: + [color setFill]; + CGContextFillEllipseInRect(ctx, bulletRect); + break; + case 1: { + CGFloat lineWidth = MAX(1.0, size * 0.15); + [color setStroke]; + CGContextSetLineWidth(ctx, lineWidth); + CGContextStrokeEllipseInRect(ctx, CGRectInset(bulletRect, lineWidth / 2.0, lineWidth / 2.0)); + break; + } + default: + [color setFill]; + CGContextFillRect(ctx, bulletRect); + break; + } + CGContextRestoreGState(ctx); +} + +- (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin +{ + [super drawGlyphsForGlyphRange:glyphsToShow atPoint:origin]; + + NSTextStorage *storage = self.textStorage; + NSString *string = storage.string; + NSMutableSet *drawnParagraphs = [NSMutableSet set]; + + [self enumerateLineFragmentsForGlyphRange:glyphsToShow + usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *container, + NSRange glyphRange, BOOL *stop) { + NSRange charRange = [self characterRangeForGlyphRange:glyphRange + actualGlyphRange:NULL]; + if (charRange.location == NSNotFound) { + return; + } + + NSRange paraRange = (charRange.location < string.length) + ? [string paragraphRangeForRange:charRange] + : NSMakeRange(charRange.location, 0); + // Wrapped continuation fragments share their line's paragraph; only the + // first fragment of a list line draws a marker. + if ([drawnParagraphs containsObject:@(paraRange.location)]) { + return; + } + + BOOL isAttributedListLine = NO; + NSInteger depth = 0; + if (charRange.location < storage.length) { + NSNumber *type = [storage attribute:ENRMBlockTypeAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + if (type && type.integerValue == ENRMInputBlockTypeUnorderedListItem && + charRange.location == paraRange.location) { + isAttributedListLine = YES; + NSNumber *depthValue = [storage attribute:ENRMBlockLevelAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + depth = depthValue ? depthValue.integerValue : 0; + } + } + + // An empty list line has no character carrying the attribute, so the + // orchestrator points us at it explicitly. + BOOL isEmptyListLine = self.emptyBulletDepth >= 0 && + charRange.location == self.emptyBulletLocation && + !isAttributedListLine; + if (isEmptyListLine) { + depth = self.emptyBulletDepth; + } + + if (!isAttributedListLine && !isEmptyListLine) { + return; + } + [drawnParagraphs addObject:@(paraRange.location)]; + + UIFont *font = nil; + UIColor *color = nil; + // An empty list line has no character carrying the font/color, so + // use the values the orchestrator supplied for the empty marker. + if (isEmptyListLine) { + font = self.emptyBulletFont; + color = self.emptyBulletColor; + } + if (!font && charRange.location < storage.length) { + font = [storage attribute:NSFontAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + } + if (!color && charRange.location < storage.length) { + color = [storage attribute:NSForegroundColorAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + } + if (!font) { + font = [UIFont systemFontOfSize:16]; + } + if (!color) { + color = [UIColor labelColor]; + } + + // An empty line's only glyph is a newline, whose location sits at the + // line's bottom rather than a text baseline — using it would draw the + // marker too low. Derive the baseline from the font ascender (plus the + // leading paragraph spacing, which pushes the text down within the + // fragment) so an empty list line's bullet lands exactly where the + // first typed glyph's bullet will. + CGFloat baselineOffset = isEmptyListLine + ? self.listItemSpacing + font.ascender + : [self locationForGlyphAtIndex:glyphRange.location].y; + CGFloat baselineY = origin.y + rect.origin.y + baselineOffset; + CGFloat markerX = origin.x + usedRect.origin.x - kENRMListBulletGap; + CGFloat centerY = baselineY - (font.xHeight + font.capHeight) / 4.0; + [self drawBulletAtX:markerX centerY:centerY depth:depth font:font color:color]; + }]; + + // The trailing empty line (including a wholly empty editor) has no glyph + // fragment — the enumeration above skips it — so draw its marker via the extra + // line fragment when the orchestrator has flagged it as an empty list line. + if (self.emptyBulletDepth >= 0 && self.emptyBulletLocation >= storage.length && + self.extraLineFragmentTextContainer != nil) { + UIFont *font = self.emptyBulletFont ?: [UIFont systemFontOfSize:16]; + UIColor *color = self.emptyBulletColor ?: [UIColor labelColor]; + CGRect used = self.extraLineFragmentUsedRect; + CGFloat markerX = origin.x + used.origin.x - kENRMListBulletGap; + // Use the same optical center as the in-text marker (baseline minus half the + // cap/x-height), not the geometric line-box center (font.lineHeight / 2): the + // two diverge by a font-dependent amount, so a geometric center would shift + // the empty-line bullet relative to where the first typed glyph's bullet lands + // (visible with fonts whose ascender/descender are asymmetric). + CGFloat baselineY = origin.y + used.origin.y + font.ascender; + CGFloat centerY = baselineY - (font.xHeight + font.capHeight) / 4.0; + [self drawBulletAtX:markerX centerY:centerY depth:self.emptyBulletDepth font:font color:color]; + } +} + +- (void)drawEmptyEditorBulletWithInset:(UIEdgeInsets)inset +{ + if (self.emptyBulletDepth < 0) { + return; + } + UIFont *font = self.emptyBulletFont ?: [UIFont systemFontOfSize:16]; + UIColor *color = self.emptyBulletColor ?: [UIColor labelColor]; + // Text for a depth-d item starts at (d + 1) * kENRMListIndentPerDepth from the + // leading edge (see the list block handler); the marker sits a gap before that. + CGFloat headIndent = (self.emptyBulletDepth + 1) * kENRMListIndentPerDepth; + CGFloat markerX = inset.left + headIndent - kENRMListBulletGap; + // Optical center (baseline minus half the cap/x-height) to match the in-text + // marker, rather than the geometric line-box center; see the extra-line-fragment + // path. TextKit doesn't apply paragraphSpacingBefore to the first paragraph, so + // no spacing offset here. + CGFloat baselineY = inset.top + font.ascender; + CGFloat centerY = baselineY - (font.xHeight + font.capHeight) / 4.0; + [self drawBulletAtX:markerX centerY:centerY depth:self.emptyBulletDepth font:font color:color]; +} + @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm index f7dc03c36..2e70ac97d 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm @@ -105,6 +105,11 @@ static NSInteger resolveBlockLevel(MD_BLOCKTYPE blockType, void *detail) std::vector openBlockStack; std::vector resolvedBlocks; size_t lastTextEnd = 0; + // Unordered-list nesting depth: incremented on each MD_BLOCK_UL we enter, + // decremented on leave. A list item's paragraph (MD_BLOCK_P with listDepth > 0) + // becomes an UnorderedListItem block at depth listDepth-1. md4c carries no + // per-item depth, so it's derived from how many UL ancestors are open. + NSInteger listDepth = 0; }; static std::vector buildByteToUTF16Map(const char *utf8, size_t byteLength) @@ -171,12 +176,31 @@ static size_t closingDelimiterEndByte(const InlineSpanInfo &span, const char *ut // discarded later when building results. static int onEnterBlock(MD_BLOCKTYPE blockType, void *detail, void *userdata) { + auto *context = static_cast(userdata); + + // Bullet-list nesting is tracked by depth, not stored as its own block: the + // container UL only bumps the counter that its items read. + if (blockType == MD_BLOCK_UL) { + context->listDepth++; + return 0; + } + + // Tag the item itself, not its inner paragraph: md4c emits MD_BLOCK_P inside + // items only for *loose* lists, so a tight list has no paragraph to tag. The + // item's range is clipped back to its own first line when building results. + if (blockType == MD_BLOCK_LI) { + BlockInfo blockInfo; + blockInfo.type = ENRMInputBlockTypeUnorderedListItem; + blockInfo.level = context->listDepth > 0 ? context->listDepth - 1 : 0; + context->openBlockStack.push_back(blockInfo); + return 0; + } + ENRMInputBlockType mappedType; if (!isSupportedBlock(blockType, mappedType)) { return 0; } - auto *context = static_cast(userdata); BlockInfo blockInfo; blockInfo.level = resolveBlockLevel(blockType, detail); // Headings share one md4c block type but split into six ENRMInputBlockTypes by @@ -188,12 +212,30 @@ static int onEnterBlock(MD_BLOCKTYPE blockType, void *detail, void *userdata) static int onLeaveBlock(MD_BLOCKTYPE blockType, void *, void *userdata) { + auto *context = static_cast(userdata); + + if (blockType == MD_BLOCK_UL) { + if (context->listDepth > 0) { + context->listDepth--; + } + return 0; + } + + // List items are tagged in onEnterBlock (not via isSupportedBlock); resolve + // them here the same way as supported blocks. + if (blockType == MD_BLOCK_LI) { + if (!context->openBlockStack.empty()) { + context->resolvedBlocks.push_back(context->openBlockStack.back()); + context->openBlockStack.pop_back(); + } + return 0; + } + ENRMInputBlockType mappedType; if (!isSupportedBlock(blockType, mappedType)) { return 0; } - auto *context = static_cast(userdata); if (context->openBlockStack.empty()) { return 0; } @@ -363,10 +405,9 @@ static bool runMd4cParse(NSString *markdown, ParseContext &context) // Builds block-level ranges (raw-markdown UTF-16 coords) from the same // completed parse, mirroring styledRangesFromContext for inline spans. // Paragraph blocks (the implicit default) are omitted — only blocks a handler -// claims are returned. In PR1 only MD_BLOCK_P is mapped, so this returns @[]; -// a heading handler's block type lights it up. +// claims are returned. static NSArray *blockRangesFromContext(const ParseContext &context, - const std::vector &byteMap) + const std::vector &byteMap, NSString *markdown) { NSMutableArray *results = [NSMutableArray arrayWithCapacity:context.resolvedBlocks.size()]; @@ -385,6 +426,22 @@ static bool runMd4cParse(NSString *markdown, ParseContext &context) continue; } + // A list item accumulates all text in its subtree, so a parent item's range + // runs through its nested sublist. Clip each item to its own first line so + // nested items keep their own (deeper) depth rather than being overwritten + // by the parent's range. Input list items are single-line. + if (blockInfo.type == ENRMInputBlockTypeUnorderedListItem && contentEnd <= markdown.length) { + NSRange newline = [markdown rangeOfString:@"\n" + options:0 + range:NSMakeRange(contentStart, contentEnd - contentStart)]; + if (newline.location != NSNotFound) { + contentEnd = newline.location; + } + if (contentEnd <= contentStart) { + continue; + } + } + [results addObject:[ENRMBlockRange rangeWithType:blockInfo.type range:NSMakeRange(contentStart, contentEnd - contentStart) level:blockInfo.level]]; @@ -431,7 +488,7 @@ - (ENRMParseResult *)parseToPlainTextAndRanges:(NSString *)markdown if (runMd4cParse(markdown, context)) { auto byteMap = buildByteToUTF16Map(context.buffer, context.bufferLength); styledRanges = styledRangesFromContext(context, byteMap); - rawBlockRanges = blockRangesFromContext(context, byteMap); + rawBlockRanges = blockRangesFromContext(context, byteMap, markdown); } NSUInteger rawLength = markdown.length; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm index 55e6a5a0e..2d848218e 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm @@ -1,9 +1,8 @@ #import "ENRMInputTextView.h" +#import "ENRMInputLayoutManager.h" +#import "EnrichedMarkdownTextInput+Internal.h" #import "EnrichedMarkdownTextInput.h" #import "PasteboardUtils.h" -#if TARGET_OS_OSX -#import "EnrichedMarkdownTextInput+Internal.h" -#endif NSString *const kENRMMarkdownPasteboardType = @"com.swmansion.enriched-markdown.markdown"; @@ -76,6 +75,51 @@ - (void)layoutSubviews } } +- (void)deleteBackward +{ + // Backspace at the very start of the document doesn't fire the text-change + // delegate (nothing precedes the caret), so removing/outdenting the first + // line's list marker has to be handled here. + if (self.markdownTextInput != nil && [self.markdownTextInput handleBackspaceAtDocumentStart]) { + return; + } + [super deleteBackward]; +} + +/// Hardware-keyboard Tab / Shift+Tab indent and outdent the current list item. +/// UIKeyCommand only fires for an attached keyboard; on-screen Tab/Backspace go +/// through the text-change delegate (see handleListKeyForReplacementRange:). +- (NSArray *)keyCommands +{ + return @[ + [UIKeyCommand keyCommandWithInput:@"\t" modifierFlags:0 action:@selector(enrmIndentList:)], + [UIKeyCommand keyCommandWithInput:@"\t" modifierFlags:UIKeyModifierShift action:@selector(enrmOutdentList:)], + ]; +} + +- (void)enrmIndentList:(UIKeyCommand *)command +{ + [self.markdownTextInput indentList]; +} + +- (void)enrmOutdentList:(UIKeyCommand *)command +{ + [self.markdownTextInput outdentList]; +} + +- (void)drawRect:(CGRect)rect +{ + [super drawRect:rect]; + // A wholly empty editor has no glyphs, so the layout manager's + // drawGlyphsForGlyphRange: never runs — draw the just-toggled list marker here. + if (self.text.length == 0) { + NSLayoutManager *layoutManager = self.layoutManager; + if ([layoutManager isKindOfClass:[ENRMInputLayoutManager class]]) { + [(ENRMInputLayoutManager *)layoutManager drawEmptyEditorBulletWithInset:self.textContainerInset]; + } + } +} + @end #else // TARGET_OS_OSX diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm index 3d40530e4..89f4d5193 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm @@ -254,7 +254,7 @@ + (NSString *)serializePlainText:(NSString *)text NSUInteger runningOffset = 0; for (NSString *line in plainLines) { [lineStartOffsets addObject:@(runningOffset)]; - runningOffset += line.length + 1; + runningOffset += line.length + 1; // +1 for the '\n' separator } for (ENRMBlockRange *blockRange in blockRanges) { diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h index 1ef8a31a7..05e34f21b 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h @@ -38,6 +38,10 @@ typedef struct { - (void)toggleSpoiler; - (void)toggleInlineStyle:(ENRMInputStyleType)type; - (void)toggleHeading:(NSInteger)level; +- (void)toggleUnorderedList; +- (void)indentList; +- (void)outdentList; +- (BOOL)handleBackspaceAtDocumentStart; - (void)showLinkPrompt; - (BOOL)isEffectiveStyleActive:(ENRMInputStyleType)type atPosition:(NSUInteger)position; diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 14eb8fe06..d04a7c7e6 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -78,8 +78,17 @@ @implementation EnrichedMarkdownTextInput { struct { BOOL bold, italic, underline, strikethrough, spoiler, link, initialized; NSInteger headingLevel; + BOOL unorderedList; + NSInteger unorderedListDepth; } _prevState; + // Block type/level of the line being edited, captured before a text change so + // a Return that continues a list (or an autocorrect/paste that replaces the + // line) can restore the right block on the resulting line(s). + ENRMInputBlockType _preEditBlockType; + NSInteger _preEditBlockLevel; + BOOL _preEditParagraphWasEmpty; + std::optional _prevCaretRect; #if TARGET_OS_OSX @@ -568,7 +577,12 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection - (void)updatePlaceholderVisibility { - _placeholderLabel.hidden = (ENRMGetPlainText(_textView).length > 0); + // Hide the placeholder once there's any content OR any block has been started + // (e.g. an empty bullet/heading line with only a zero-length anchor): the block + // marker or indent would otherwise overlap the placeholder text. + BOOL hasText = ENRMGetPlainText(_textView).length > 0; + BOOL hasBlock = _blockStore.allRanges.count > 0; + _placeholderLabel.hidden = hasText || hasBlock; } #pragma mark - Markdown import @@ -608,7 +622,7 @@ - (void)replaceTextInRange:(NSRange)selection NSString *plainText = ENRMGetPlainText(_textView); [_formattingStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; [_blockStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; - [self pruneOrphanedHeadingBlocks]; + [self pruneOrphanedBlockAnchors]; [_blockStore normalizeToLineBoundsInText:plainText]; for (ENRMFormattingRange *range in ranges) { @@ -871,7 +885,7 @@ - (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 heading line carries a + // Match by line start, not containment: an empty block line carries a // zero-length anchor that a containment check would miss. ENRMBlockRange *current = nil; for (ENRMBlockRange *blockRange in _blockStore.allRanges) { @@ -884,6 +898,15 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level if (alreadyActive) { [_blockStore removeBlockInParagraphRange:paragraphRange inText:text]; + // Strip the stored block paragraph style so a removed bullet de-indents to a + // plain paragraph (applyFormatting re-stamps base style below). + NSTextStorage *storage = _textView.textStorage; + NSRange clamped = NSIntersectionRange(paragraphRange, NSMakeRange(0, storage.length)); + if (clamped.length > 0) { + [storage beginEditing]; + [storage removeAttribute:NSParagraphStyleAttributeName range:clamped]; + [storage endEditing]; + } } else if (paragraphRange.length == 0) { [_blockStore setBlockType:type level:level forParagraphRange:paragraphRange inText:text]; } else { @@ -903,9 +926,298 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level [self applyFormatting]; [self syncTypingAttributesWithCursorBlock]; + 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]; + [self emitFormattingChanged]; +} + +- (void)toggleUnorderedList +{ + [self toggleBlockType:ENRMInputBlockTypeUnorderedListItem level:0]; +} + +- (void)indentList +{ + [self changeListDepthBy:1]; +} + +- (void)outdentList +{ + [self changeListDepthBy:-1]; +} + +/// Adjusts the nesting depth of the cursor's bullet item by `delta`, clamped to +/// [0, kENRMMaxListDepth]. 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 (back to a plain paragraph). +- (void)changeListDepthBy:(NSInteger)delta +{ + NSInteger currentDepth; + if (![self unorderedListStateForCursorParagraph:¤tDepth]) { + if (delta > 0 && [self headingLevelForCursorParagraph] == 0) { + [self toggleUnorderedList]; + } + return; + } + + if (delta < 0 && currentDepth == 0) { + [self toggleUnorderedList]; + return; + } + + NSInteger newDepth = MIN(MAX(currentDepth + delta, (NSInteger)0), kENRMMaxListDepth); + if (newDepth == currentDepth) { + return; + } + + NSString *text = ENRMGetPlainText(_textView); + NSRange paragraphRange = [text paragraphRangeForRange:_textView.selectedRange]; + [_blockStore setBlockType:ENRMInputBlockTypeUnorderedListItem + level:newDepth + forParagraphRange:paragraphRange + inText:text]; + + [self applyFormatting]; + [self syncTypingAttributesWithCursorBlock]; + [self updateEmptyBulletMarker]; [self emitFormattingChanged]; } +/// Whether the caret's paragraph is a bullet item, writing its depth into +/// `outDepth` when non-NULL. Mirrors headingLevelForCursorParagraph. +- (BOOL)unorderedListStateForCursorParagraph:(nullable NSInteger *)outDepth +{ + NSString *text = _textView.textStorage.string; + NSRange paragraph = [text paragraphRangeForRange:_textView.selectedRange]; + for (ENRMBlockRange *block in _blockStore.allRanges) { + if (block.type == ENRMInputBlockTypeUnorderedListItem && block.range.location == paragraph.location) { + if (outDepth) { + *outDepth = block.level; + } + return YES; + } + } + if (outDepth) { + *outDepth = 0; + } + return NO; +} + +/// Records the block kind/level of the line being edited before the change, so a +/// Return that continues a list can recover the previous item's depth and an +/// in-line replacement (autocorrect/paste) that wipes the line can heal it. +- (void)capturePreEditBlockForRange:(NSRange)range +{ + _preEditBlockType = ENRMInputBlockTypeParagraph; + _preEditBlockLevel = 0; + + NSString *text = _textView.textStorage.string; + NSRange paragraph = [text paragraphRangeForRange:range]; + for (ENRMBlockRange *block in _blockStore.allRanges) { + if (block.range.location == paragraph.location) { + _preEditBlockType = block.type; + _preEditBlockLevel = block.level; + return; + } + } +} + +/// Whether the caret's paragraph had no glyph content at the start of the edit +/// (an empty line). Used to decide whether Return continues or exits the list. +- (BOOL)preEditParagraphWasEmpty:(NSRange)range +{ + NSString *text = _textView.textStorage.string; + if (text.length == 0) { + return YES; + } + NSRange paragraph = [text paragraphRangeForRange:range]; + if (paragraph.length == 0) { + return YES; + } + return [[text substringWithRange:paragraph] isEqualToString:@"\n"]; +} + +/// Intercepts Tab (indent) and Backspace at a bullet item's start (outdent, then +/// un-list at depth 0). Returns YES when handled, suppressing the default edit. +/// Keyed off the caret's own paragraph (NSMaxRange(range)), not range.location — +/// a backspace at a line start targets the previous line's newline, and using the +/// caret's line lets the next Backspace after un-listing fall through to a merge. +- (BOOL)handleListKeyForReplacementRange:(NSRange)range replacementText:(NSString *)text +{ + NSUInteger caret = NSMaxRange(range); + NSInteger depth; + if (![self listStateForParagraphAtPosition:caret depth:&depth]) { + return NO; + } + + // Tab indents the current item. + if ([text isEqualToString:@"\t"]) { + [self indentList]; + return YES; + } + + // Backspace at the very start of the caret's own item: outdent, or remove the + // marker entirely once at depth 0 (de-indenting the line to a plain paragraph). + if (text.length == 0 && range.length == 1) { + NSString *plainText = ENRMGetPlainText(_textView); + NSRange paragraphRange = [plainText paragraphRangeForRange:NSMakeRange(caret, 0)]; + BOOL atItemStart = caret == paragraphRange.location; + if (atItemStart) { + if (depth > 0) { + [self outdentList]; + } else { + [self toggleUnorderedList]; + } + return YES; + } + } + + return NO; +} + +/// Backspace at the document start (caret at 0) never fires the text-change +/// delegate — nothing precedes the caret — so the first line's bullet has to be +/// outdented/removed here. Returns YES when handled. +- (BOOL)handleBackspaceAtDocumentStart +{ + NSRange selection = _textView.selectedRange; + if (selection.location != 0 || selection.length != 0) { + return NO; + } + NSInteger depth; + if (![self unorderedListStateForCursorParagraph:&depth]) { + return NO; + } + if (depth > 0) { + [self outdentList]; + } else { + [self toggleUnorderedList]; + } + return YES; +} + +/// unorderedListStateForCursorParagraph: for an arbitrary position — the caret +/// hasn't moved yet when a replacement is intercepted. +- (BOOL)listStateForParagraphAtPosition:(NSUInteger)position depth:(NSInteger *)outDepth +{ + NSString *text = _textView.textStorage.string; + if (position > text.length) { + return NO; + } + NSRange paragraph = [text paragraphRangeForRange:NSMakeRange(position, 0)]; + for (ENRMBlockRange *block in _blockStore.allRanges) { + if (block.type == ENRMInputBlockTypeUnorderedListItem && block.range.location == paragraph.location) { + if (outDepth) { + *outDepth = block.level; + } + return YES; + } + } + return NO; +} + +/// Reconciles blocks after Return: a non-empty item continues as a new item at +/// the same depth, an empty item exits the list (both lines revert to plain +/// paragraphs). Whether a type continues is the handler's continuesOnNewline. +- (void)reconcileBlockContinuationAfterNewlineAt:(NSUInteger)newlineLocation previousItemWasEmpty:(BOOL)previousWasEmpty +{ + id handler = [_formatter handlerForBlockType:_preEditBlockType]; + if (!handler || ![handler respondsToSelector:@selector(continuesOnNewline)] || !handler.continuesOnNewline) { + return; + } + + NSString *text = ENRMGetPlainText(_textView); + NSUInteger newLineLocation = newlineLocation + 1; + if (newLineLocation > text.length) { + return; + } + NSRange originalParagraph = [text paragraphRangeForRange:NSMakeRange(newlineLocation, 0)]; + NSRange newParagraph = [text paragraphRangeForRange:NSMakeRange(newLineLocation, 0)]; + + if (previousWasEmpty) { + // Exit: drop the block from both lines and strip their stored list paragraph + // style so they render without leftover indent. + [_blockStore removeBlockInParagraphRange:originalParagraph inText:text]; + [_blockStore removeBlockInParagraphRange:newParagraph inText:text]; + + NSTextStorage *storage = _textView.textStorage; + [storage beginEditing]; + for (NSRange paragraph : {originalParagraph, newParagraph}) { + NSRange clamped = NSIntersectionRange(paragraph, NSMakeRange(0, storage.length)); + if (clamped.length > 0) { + [storage removeAttribute:NSParagraphStyleAttributeName range:clamped]; + } + } + [storage endEditing]; + return; + } + + // Keep the source line its block at its level, and start the new line as a + // fresh block of the same type/level (e.g. a sibling bullet at the same depth). + [_blockStore setBlockType:_preEditBlockType level:_preEditBlockLevel forParagraphRange:originalParagraph inText:text]; + [_blockStore setBlockType:_preEditBlockType level:_preEditBlockLevel forParagraphRange:newParagraph inText:text]; +} + +/// Drives the layout manager's empty-line bullet: an empty bullet line has no +/// character to anchor the marker to, so the manager is told its location/depth +/// explicitly; cleared when the caret isn't on an empty bullet line. +- (void)updateEmptyBulletMarker +{ + NSString *text = ENRMGetPlainText(_textView); + NSRange selection = _textView.selectedRange; + BOOL show = NO; + NSUInteger location = 0; + NSInteger depth = 0; + + NSInteger cursorDepth; + if (selection.length == 0 && [self unorderedListStateForCursorParagraph:&cursorDepth]) { + NSRange paragraphRange = text.length == 0 ? NSMakeRange(0, 0) : [text paragraphRangeForRange:selection]; + NSString *paragraphText = text.length == 0 ? @"" : [text substringWithRange:paragraphRange]; + BOOL empty = paragraphText.length == 0 || [paragraphText isEqualToString:@"\n"]; + if (empty) { + show = YES; + location = paragraphRange.location; + depth = cursorDepth; + + // A mid-document empty bullet line has no paragraph style and lays out + // flush left; stamp the list style so caret and marker indent immediately. + // (A trailing empty line uses the extra line fragment instead.) + if (paragraphRange.length > 0) { + NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; + CGFloat indent = (depth + 1) * kENRMListIndentPerDepth; + paragraph.firstLineHeadIndent = indent; + paragraph.headIndent = indent; + paragraph.paragraphSpacingBefore = _formatterStyle.listItemSpacing; + NSTextStorage *storage = _textView.textStorage; + [storage beginEditing]; + [storage addAttribute:NSParagraphStyleAttributeName value:paragraph range:paragraphRange]; + [storage endEditing]; + } + } + } + + _layoutManager.emptyBulletDepth = show ? depth : -1; + _layoutManager.emptyBulletLocation = location; + _layoutManager.emptyBulletFont = _formatterStyle.baseFont; + _layoutManager.emptyBulletColor = _formatterStyle.baseTextColor; + _layoutManager.listItemSpacing = _formatterStyle.listItemSpacing; + + // An empty editor never runs the formatter (it early-returns at length 0), so + // the trailing/extra line fragment the marker draws into isn't laid out yet — + // force it so the bullet appears before the first keystroke. + if (show && text.length == 0) { + [_layoutManager ensureLayoutForTextContainer:_textView.textContainer]; + } + ENRMSetNeedsDisplay(_textView); + + // The empty-editor bullet would otherwise overlap the placeholder. + [self updatePlaceholderVisibility]; +} + /// 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 @@ -950,18 +1262,47 @@ - (void)syncTypingAttributesWithCursorBlock attrs[NSFontAttributeName] = font; RCTUIColor *headingColor = headingLevel >= 1 ? [_formatterStyle headingColorForLevel:headingLevel] : nil; attrs[NSForegroundColorAttributeName] = headingColor ?: _formatterStyle.baseTextColor; + + // On a bullet line, carry the list paragraph style in the typing attributes so + // the caret sits at the marker indent and the bullet draws before the first + // keystroke. Headings need no paragraph style here, so clear it. + NSInteger listDepth; + if ([self unorderedListStateForCursorParagraph:&listDepth]) { + NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; + CGFloat indent = (listDepth + 1) * kENRMListIndentPerDepth; + paragraph.firstLineHeadIndent = indent; + paragraph.headIndent = indent; + paragraph.paragraphSpacingBefore = _formatterStyle.listItemSpacing; + attrs[NSParagraphStyleAttributeName] = paragraph; + } else { + [attrs removeObjectForKey:NSParagraphStyleAttributeName]; + } + + _textView.typingAttributes = attrs; +} + +/// Drops any paragraph style from the caret's typing attributes so a list indent +/// left over from a just-exited bullet doesn't indent the next typed character. +- (void)clearListParagraphStyleFromTypingAttributes +{ + if (_textView.typingAttributes[NSParagraphStyleAttributeName] == nil) { + return; + } + NSMutableDictionary *attrs = [_textView.typingAttributes mutableCopy]; + [attrs removeObjectForKey:NSParagraphStyleAttributeName]; _textView.typingAttributes = attrs; } -/// Reverts to a plain paragraph any heading no longer anchored at a line start -/// (e.g. Backspace merged its line into the previous one). Must run BEFORE -/// normalizeToLineBoundsInText: so a merged range is judged on its unsnapped -/// anchor and can't grow over the line it merged into. Mirrors Android. -- (void)pruneOrphanedHeadingBlocks +/// Reverts to a plain paragraph any anchored block (heading, bullet item) no +/// longer anchored at a line start (e.g. Backspace merged its line into the +/// previous one). Must run BEFORE normalizeToLineBoundsInText: so a merged range +/// is judged on its unsnapped anchor and can't grow over the line it merged +/// into. Mirrors Android. +- (void)pruneOrphanedBlockAnchors { NSString *text = _textView.textStorage.string; for (ENRMBlockRange *block in _blockStore.allRanges) { - if (ENRMHeadingLevelForBlockType(block.type) == 0) { + if (!ENRMBlockTypePersistsWhenEmpty(block.type)) { continue; } NSUInteger anchor = MIN(block.range.location, text.length); @@ -1078,6 +1419,8 @@ - (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 @@ -1459,9 +1802,13 @@ - (void)emitOnChangeState NSInteger headingLevel = [self headingLevelForCursorParagraph]; + NSInteger listDepth = 0; + BOOL unorderedListActive = [self unorderedListStateForCursorParagraph:&listDepth]; + if (_prevState.initialized && _prevState.bold == boldActive && _prevState.italic == italicActive && _prevState.underline == underlineActive && _prevState.strikethrough == strikethroughActive && - _prevState.spoiler == spoilerActive && _prevState.link == linkActive && _prevState.headingLevel == headingLevel) { + _prevState.spoiler == spoilerActive && _prevState.link == linkActive && _prevState.headingLevel == headingLevel && + _prevState.unorderedList == unorderedListActive && _prevState.unorderedListDepth == listDepth) { return; } @@ -1472,6 +1819,8 @@ - (void)emitOnChangeState _prevState.spoiler = spoilerActive; _prevState.link = linkActive; _prevState.headingLevel = headingLevel; + _prevState.unorderedList = unorderedListActive; + _prevState.unorderedListDepth = listDepth; _prevState.initialized = YES; emitter->onChangeState({ @@ -1482,6 +1831,7 @@ - (void)emitOnChangeState .spoiler = {.isActive = spoilerActive}, .link = {.isActive = linkActive}, .heading = {.isActive = headingLevel > 0, .level = static_cast(headingLevel)}, + .unorderedList = {.isActive = unorderedListActive, .depth = static_cast(listDepth)}, }); } @@ -1553,6 +1903,8 @@ - (void)emitContextMenuItemPress:(NSString *)itemText BOOL spoilerActive = isActive(ENRMInputStyleTypeSpoiler); BOOL linkActive = isActive(ENRMInputStyleTypeLink); NSInteger headingLevel = [self headingLevelForCursorParagraph]; + NSInteger listDepth = 0; + BOOL unorderedListActive = [self unorderedListStateForCursorParagraph:&listDepth]; eventEmitter->onContextMenuItemPress({ .itemText = std::string(itemText.UTF8String), @@ -1568,6 +1920,7 @@ - (void)emitContextMenuItemPress:(NSString *)itemText .spoiler = {.isActive = spoilerActive}, .link = {.isActive = linkActive}, .heading = {.isActive = headingLevel > 0, .level = static_cast(headingLevel)}, + .unorderedList = {.isActive = unorderedListActive, .depth = static_cast(listDepth)}, }, }); } @@ -1670,7 +2023,7 @@ - (void)handleTextChanged [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; [_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; - [self pruneOrphanedHeadingBlocks]; + [self pruneOrphanedBlockAnchors]; [_blockStore normalizeToLineBoundsInText:ENRMGetPlainText(_textView)]; if (insertedLength > 0) { @@ -1704,13 +2057,22 @@ - (void)handleTextChanged for (NSNumber *styleNum in _pendingStyleRemovals) { [_formattingStore removeType:(ENRMInputStyleType)styleNum.integerValue inRange:insertedRange]; } + + // A newline-only insertion is Return: ask the previous line's block handler + // whether to continue the block (e.g. a sibling bullet) or end it. (Pasted/ + // typed glyph content keeps the block the store already grew; pasted markdown + // lists arrive via replaceTextInRange:.) + if (!insertedHasGlyphContent && insertedLength == 1) { + [self reconcileBlockContinuationAfterNewlineAt:editLocation previousItemWasEmpty:_preEditParagraphWasEmpty]; + } } _lastTextLength = newLength; #if !TARGET_OS_OSX if (newLength == 0) { - if ([self headingLevelForCursorParagraph] > 0) { + NSInteger emptyDocDepth; + if ([self headingLevelForCursorParagraph] > 0 || [self unorderedListStateForCursorParagraph:&emptyDocDepth]) { [self syncTypingAttributesWithCursorBlock]; } else { [self resetBaseTypingAttributes]; @@ -1728,6 +2090,18 @@ - (void)handleTextChanged [self syncTypingAttributesWithCursorBlock]; } + // Keep the caret's typing attributes aligned with a bullet line so the next + // character continues the marker (UIKit drops custom typing attributes after + // the first insertion, and a continued/empty item needs the list font+indent). + // A Return that EXITED the list leaves the prior list paragraph style lingering + // in the typing attributes, which would indent the new plain line — clear it. + NSInteger cursorListDepth; + if ([self unorderedListStateForCursorParagraph:&cursorListDepth]) { + [self syncTypingAttributesWithCursorBlock]; + } else if (_textView.typingAttributes[NSParagraphStyleAttributeName] != nil) { + [self clearListParagraphStyleFromTypingAttributes]; + } + NSUInteger clampedEditLocation = MIN(editLocation, newLength); NSUInteger clampedInsertedLength = MIN(insertedLength, newLength - clampedEditLocation); [_detectorPipeline processTextChange:ENRMGetPlainText(_textView) @@ -1741,6 +2115,7 @@ - (void)handleTextChanged [self emitCaretRectChangeIfNeeded]; [self requestHeightUpdate]; [self scheduleRelayoutIfNeeded]; + [self updateEmptyBulletMarker]; } #pragma mark - Text view delegate @@ -1785,7 +2160,8 @@ - (void)manageSelectionBasedChanges NSString *paragraphText = [text substringWithRange:paragraphRange]; BOOL isEmpty = paragraphText.length == 0 || [paragraphText isEqualToString:@"\n"]; if (isEmpty) { - if ([self headingLevelForCursorParagraph] > 0) { + NSInteger emptyLineDepth; + if ([self headingLevelForCursorParagraph] > 0 || [self unorderedListStateForCursorParagraph:&emptyLineDepth]) { [self syncTypingAttributesWithCursorBlock]; } else { NSMutableDictionary *attrs = [NSMutableDictionary dictionary]; @@ -1803,7 +2179,12 @@ - (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range r if ([self deleteLinkForReplacementRange:range replacementText:text]) { return NO; } + if ([self handleListKeyForReplacementRange:range replacementText:text]) { + return NO; + } _preEditSelectedRange = _lastSelectedRange; + [self capturePreEditBlockForRange:range]; + _preEditParagraphWasEmpty = [self preEditParagraphWasEmpty:range]; _isTextChanging = YES; [self stripLinkTypingAttributes]; return YES; @@ -1867,6 +2248,7 @@ - (void)textViewDidChangeSelection:(UITextView *)textView [self updateActiveMention]; [self emitOnChangeState]; [self emitCaretRectChangeIfNeeded]; + [self updateEmptyBulletMarker]; } #else diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm.tmp b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm.tmp new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h b/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h index 0dba41d9c..c6170996e 100644 --- a/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h @@ -169,6 +169,11 @@ BOOL applyInputStyleProps(ENRMInputFormatterStyle *style, const InputProps &newP changed = YES; } + if (newProps.listItemSpacing != oldProps.listItemSpacing) { + style.listItemSpacing = newProps.listItemSpacing; + changed = YES; + } + changed |= applyHeadingLevelProps(style, 1, newProps.markdownStyle.h1, oldProps.markdownStyle.h1); changed |= applyHeadingLevelProps(style, 2, newProps.markdownStyle.h2, oldProps.markdownStyle.h2); changed |= applyHeadingLevelProps(style, 3, newProps.markdownStyle.h3, oldProps.markdownStyle.h3); diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMBlockHandler.h b/packages/react-native-enriched-markdown/ios/input/styles/ENRMBlockHandler.h index ce90267c7..1a6e81b77 100644 --- a/packages/react-native-enriched-markdown/ios/input/styles/ENRMBlockHandler.h +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMBlockHandler.h @@ -35,6 +35,12 @@ NS_ASSUME_NONNULL_BEGIN /// no prefix. Owning the marker here replaces a central serializer switch. - (NSString *)markdownLinePrefixForBlockRange:(ENRMBlockRange *)blockRange; +@optional + +/// Whether Return continues the block onto the next line (a list item) rather +/// than ending it (a heading). Defaults to NO when unimplemented. +@property (nonatomic, readonly) BOOL continuesOnNewline; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h new file mode 100644 index 000000000..7f184b4e2 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h @@ -0,0 +1,15 @@ +#pragma once + +#import "ENRMBlockHandler.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Block handler for unordered (bullet) list items. Reserves the head-indent +/// column for the marker via the paragraph style (the layout manager draws the +/// glyph into it) and serializes to a `- ` line prefix indented two spaces per +/// nesting level. Nesting depth is carried in ENRMBlockRange.level. Continues on +/// Return (a new item at the same depth) unlike single-line heading blocks. +@interface ENRMUnorderedListBlockHandler : NSObject +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm new file mode 100644 index 000000000..bc8b39d10 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm @@ -0,0 +1,51 @@ +#import "ENRMUnorderedListBlockHandler.h" +#import "ENRMInputBlockType.h" + +@implementation ENRMUnorderedListBlockHandler + +- (ENRMInputBlockType)blockType +{ + return ENRMInputBlockTypeUnorderedListItem; +} + +- (BOOL)continuesOnNewline +{ + return YES; +} + +- (void)applyAttributesToParagraphStyle:(NSMutableParagraphStyle *)paragraphStyle + attributes:(NSMutableDictionary *)attributes + blockRange:(ENRMBlockRange *)blockRange + style:(ENRMInputFormatterStyle *)style +{ + NSInteger depth = blockRange.level; + if (depth < 0) { + depth = 0; + } else if (depth > kENRMMaxListDepth) { + depth = kENRMMaxListDepth; + } + + // Reserve a marker column per nesting level. Both the first (marker) line and + // wrapped continuation lines align to the same text inset so wrapped text + // hangs under the text, not under the bullet. + CGFloat indent = (depth + 1) * kENRMListIndentPerDepth; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + paragraphStyle.paragraphSpacingBefore = style.listItemSpacing; +} + +- (NSString *)markdownLinePrefixForBlockRange:(ENRMBlockRange *)blockRange +{ + NSInteger depth = blockRange.level; + if (depth < 0) { + depth = 0; + } else if (depth > kENRMMaxListDepth) { + depth = kENRMMaxListDepth; + } + // Two spaces of indent per nesting level, then the bullet marker, e.g. + // " - " for a depth-2 item. + NSString *indent = [@"" stringByPaddingToLength:depth * 2 withString:@" " startingAtIndex:0]; + return [indent stringByAppendingString:@"- "]; +} + +@end diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx index de09ff680..ef59cb210 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx @@ -97,6 +97,7 @@ export interface StyleState { spoiler: { isActive: boolean }; link: { isActive: boolean }; heading: { isActive: boolean; level: HeadingLevel }; + unorderedList: { isActive: boolean; depth: number }; } export interface ContextMenuItem { @@ -131,6 +132,9 @@ export interface EnrichedMarkdownTextInputInstance { toggleStrikethrough: () => void; toggleSpoiler: () => void; toggleHeading: (level: HeadingLevel) => void; + toggleUnorderedList: () => void; + indentList: () => void; + outdentList: () => void; setLink: (url: string) => void; insertLink: (text: string, url: string) => void; insertMention: (displayText: string, url: string) => void; @@ -242,6 +246,12 @@ export interface EnrichedMarkdownTextInputProps extends Omit< * @platform ios */ writingDirection?: 'auto' | 'ltr' | 'rtl' | 'first-strong'; + /** + * Vertical spacing (points) above each bullet list item so items read as + * separate rows. iOS uses `paragraphSpacingBefore`; Android a `LineHeightSpan`. + * @default 0 + */ + listItemSpacing?: number; } type PendingRequest = { @@ -289,6 +299,7 @@ export const EnrichedMarkdownTextInput = ({ formatMenuConfig, linkRegex: _linkRegex, writingDirection = 'first-strong', + listItemSpacing = 0, ...rest }: EnrichedMarkdownTextInputProps) => { const nativeRef = useRef(null); @@ -428,8 +439,16 @@ export const EnrichedMarkdownTextInput = ({ const handleChangeState = useCallback( (e: NativeSyntheticEvent) => { - const { bold, italic, underline, strikethrough, spoiler, link, heading } = - e.nativeEvent; + const { + bold, + italic, + underline, + strikethrough, + spoiler, + link, + heading, + unorderedList, + } = e.nativeEvent; onChangeState?.({ bold, italic, @@ -438,6 +457,7 @@ export const EnrichedMarkdownTextInput = ({ spoiler, link, heading: { ...heading, level: toHeadingLevel(heading.level) }, + unorderedList, }); }, [onChangeState] @@ -550,6 +570,9 @@ export const EnrichedMarkdownTextInput = ({ toggleStrikethrough: () => Commands.toggleStrikethrough(commandRef), toggleSpoiler: () => Commands.toggleSpoiler(commandRef), toggleHeading: (level) => Commands.toggleHeading(commandRef, level), + toggleUnorderedList: () => Commands.toggleUnorderedList(commandRef), + indentList: () => Commands.indentList(commandRef), + outdentList: () => Commands.outdentList(commandRef), setLink: (url) => Commands.setLink(commandRef, url), insertLink: (text, url) => Commands.insertLink(commandRef, text, url), insertMention: (displayText, url) => @@ -590,6 +613,7 @@ export const EnrichedMarkdownTextInput = ({ multiline={multiline} cursorColor={cursorColor} selectionColor={selectionColor} + listItemSpacing={listItemSpacing} isOnChangeMarkdownSet={onChangeMarkdown !== undefined} onChangeText={handleChangeText as NativeProps['onChangeText']} onChangeMarkdown={handleChangeMarkdown as NativeProps['onChangeMarkdown']} diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts index 38dbbaed7..52d1bd95f 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts @@ -74,6 +74,7 @@ export interface OnChangeStateEvent { spoiler: { isActive: boolean }; link: { isActive: boolean }; heading: { isActive: boolean; level: CodegenTypes.Int32 }; + unorderedList: { isActive: boolean; depth: CodegenTypes.Int32 }; } export interface OnRequestMarkdownResultEvent { @@ -164,6 +165,7 @@ export interface OnContextMenuItemPressEvent { spoiler: { isActive: boolean }; link: { isActive: boolean }; heading: { isActive: boolean; level: CodegenTypes.Int32 }; + unorderedList: { isActive: boolean; depth: CodegenTypes.Int32 }; }; } @@ -272,6 +274,14 @@ export interface NativeProps extends ViewProps { */ writingDirection?: CodegenTypes.WithDefault; + /** + * Vertical spacing (points) added above each bullet list item so items read as + * separate rows. iOS applies it via paragraphSpacingBefore; Android via a + * LineHeightSpan. + * @default 0 + */ + listItemSpacing?: CodegenTypes.WithDefault; + // Events onChangeText?: CodegenTypes.DirectEventHandler; onChangeMarkdown?: CodegenTypes.DirectEventHandler; @@ -312,6 +322,9 @@ interface NativeCommands { viewRef: React.ElementRef, level: CodegenTypes.Int32 ) => void; + toggleUnorderedList: (viewRef: React.ElementRef) => void; + indentList: (viewRef: React.ElementRef) => void; + outdentList: (viewRef: React.ElementRef) => void; setLink: (viewRef: React.ElementRef, url: string) => void; insertLink: ( viewRef: React.ElementRef, @@ -351,6 +364,9 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'toggleStrikethrough', 'toggleSpoiler', 'toggleHeading', + 'toggleUnorderedList', + 'indentList', + 'outdentList', 'setLink', 'insertLink', 'insertMention', From 8c756317ea13aa1df93ea82586b6048142f518fc Mon Sep 17 00:00:00 2001 From: Sean Holbert Date: Sun, 5 Jul 2026 08:14:45 -0700 Subject: [PATCH 2/5] chore: drop stray editor temp file Co-Authored-By: Claude Fable 5 --- .../ios/input/EnrichedMarkdownTextInput.mm.tmp | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm.tmp diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm.tmp b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm.tmp deleted file mode 100644 index e69de29bb..000000000 From ed0253e29655410afbfb655fb2694cd3895697c5 Mon Sep 17 00:00:00 2001 From: Sean Holbert Date: Sun, 5 Jul 2026 08:26:41 -0700 Subject: [PATCH 3/5] test(e2e): scroll the toolbar before tapping list buttons The list buttons sit past H1-H6 in the horizontally scrolling toolbar and are offscreen on phone-width viewports, so the flow failed before reaching them. Co-Authored-By: Claude Fable 5 --- .../basic/block_elements/unordered_list_test.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml index 7979ab525..fc52461e7 100644 --- a/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml +++ b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml @@ -17,6 +17,18 @@ tags: id: focus-button - waitForAnimationToEnd +# The list buttons sit past H1-H6 in the horizontally scrolling toolbar and are +# offscreen on phone-width viewports; swipe the toolbar left to reveal them. +- swipe: + from: + id: toolbar-bold + direction: LEFT +- swipe: + from: + id: toolbar-h2 + direction: LEFT +- waitForAnimationToEnd + - tapOn: id: toolbar-unordered-list - waitForAnimationToEnd From 9ed20375aa6690cbea0836b9d32706f31031a716 Mon Sep 17 00:00:00 2001 From: Sean Holbert Date: Sun, 5 Jul 2026 09:36:32 -0700 Subject: [PATCH 4/5] fix(input): clamp list depths to valid ancestry; don't serialize empty-item markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two round-trip bugs found in manual testing: - Editor allowed depth ancestry markdown can't represent (an item indented more than one level past the previous item, or a leading indented item). CommonMark derives nesting structurally, so md4c flattened those on re-parse. The stores now clamp each item to at most previous adjacent item's depth + 1 (0 with no item above) during line normalization, and block commands normalize after mutating, so every editor state serializes losslessly. Matches Notion/Docs indent behavior. - An empty list item serialized as a marker-only line (" - "), which md4c re-parses as a setext-heading underline for the previous line — the prior item rendered bold in previews. Serializers now emit empty list lines bare (empty "# " headings are valid ATX and keep their prefix). Co-Authored-By: Claude Fable 5 --- .../input/EnrichedMarkdownTextInputView.kt | 3 +++ .../markdown/input/formatting/BlockStore.kt | 16 ++++++++++++++++ .../input/formatting/MarkdownSerializer.kt | 6 ++++++ .../ios/input/ENRMBlockStore.h | 6 ++++-- .../ios/input/ENRMBlockStore.mm | 18 ++++++++++++++++++ .../ios/input/ENRMMarkdownSerializer.mm | 7 +++++++ .../ios/input/EnrichedMarkdownTextInput.mm | 2 ++ 7 files changed, 56 insertions(+), 2 deletions(-) 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 f32160c3b..a7320689b 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 @@ -668,6 +668,7 @@ class EnrichedMarkdownTextInputView( } else { setListBlockOnLines(0) } + blockStore.normalizeToLineBounds(editable) applyFormattingAndEmit() syncEmptyListAnchor() } @@ -697,6 +698,7 @@ class EnrichedMarkdownTextInputView( blockStore.setBlock(BlockType.UNORDERED_LIST_ITEM, newDepth, ls, le, editable) } } + blockStore.normalizeToLineBounds(editable) applyFormattingAndEmit() syncEmptyListAnchor() } @@ -972,6 +974,7 @@ class EnrichedMarkdownTextInputView( } } + blockStore.normalizeToLineBounds(editable) applyFormattingAndEmit() syncCursorSizeWithBlock() } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt index 56b7d627e..eae53ffb1 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt @@ -123,6 +123,8 @@ class BlockStore { * Absorbs edge-typed chars, clips split ranges to first line, drops * duplicates. On an empty line an anchored block (heading, list item) * persists as a zero-length anchor; any other collapsed range is dropped. + * List depths are clamped so an item nests at most one level under the + * previous adjacent item (CommonMark cannot represent orphan nesting). * Call after [adjustForEdit] once [text] is final. Idempotent. */ fun normalizeToLineBounds(text: CharSequence) { @@ -142,6 +144,20 @@ class BlockStore { range.end = lineEnd previousEnd = lineEnd } + + clampListDepths() + } + + private fun clampListDepths() { + var prevListEnd = -2 + var prevListDepth = -1 + for (range in ranges) { + if (range.type != BlockType.UNORDERED_LIST_ITEM) continue + val maxDepth = if (range.start == prevListEnd + 1) prevListDepth + 1 else 0 + if (range.level > maxDepth) range.level = maxDepth + prevListEnd = range.end + prevListDepth = range.level + } } /** diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt index 15698f106..52ba88e9e 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.markdown.input.formatting import android.util.Log 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.StyleType @@ -58,11 +59,16 @@ object MarkdownSerializer { if (prefix.isEmpty()) continue val isZeroLength = blockRange.length == 0 + val isListItem = blockRange.type == BlockType.UNORDERED_LIST_ITEM for (lineIndex in plainLines.indices) { val lineStart = lineStartOffsets[lineIndex] val lineEnd = lineStart + plainLines[lineIndex].length val overlaps = if (isZeroLength) lineStart == blockRange.start else lineEnd >= blockRange.start && lineStart < blockRange.end if (overlaps) { + // A marker-only list line ("- " with no content) re-parses as a setext + // underline for the previous line; emit an empty list line bare. An + // empty "# " heading is valid ATX and keeps its prefix. + if (isListItem && plainLines[lineIndex].replace(ZWSP, "").isEmpty()) continue markdownLines[lineIndex] = prefix + markdownLines[lineIndex] } } diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h index 80f25e2f5..668e0ddae 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h @@ -39,8 +39,10 @@ NS_ASSUME_NONNULL_BEGIN /// Snaps every stored range to the line bounds of its start position. /// Absorbs edge-typed chars, clips split ranges to first line, drops /// duplicates. On an empty line a heading persists as a zero-length anchor; -/// any other collapsed range is dropped. Call after adjustForEditAtLocation: -/// once text is final. Idempotent. +/// any other collapsed range is dropped. List depths are clamped so an item +/// nests at most one level under the previous adjacent item (CommonMark cannot +/// represent orphan nesting). Call after adjustForEditAtLocation: once text is +/// final. Idempotent. - (void)normalizeToLineBoundsInText:(NSString *)text; @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm index dfce1646c..d61b0c759 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm @@ -196,6 +196,24 @@ - (void)normalizeToLineBoundsInText:(NSString *)text } ENRMRemoveIndexesInReverse(_ranges, indexesToRemove); + [self clampListDepths]; +} + +- (void)clampListDepths +{ + NSInteger prevListEnd = -2; + NSInteger prevListDepth = -1; + for (ENRMBlockRange *blockRange in _ranges) { + if (blockRange.type != ENRMInputBlockTypeUnorderedListItem) { + continue; + } + NSInteger maxDepth = ((NSInteger)blockRange.range.location == prevListEnd + 1) ? prevListDepth + 1 : 0; + if (blockRange.level > maxDepth) { + blockRange.level = maxDepth; + } + prevListEnd = (NSInteger)NSMaxRange(blockRange.range); + prevListDepth = blockRange.level; + } } @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm index 89f4d5193..1ab72fee2 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm @@ -266,12 +266,19 @@ + (NSString *)serializePlainText:(NSString *)text NSUInteger blockStart = blockRange.range.location; NSUInteger blockEnd = NSMaxRange(blockRange.range); BOOL isZeroLength = blockRange.range.length == 0; + BOOL isListItem = blockRange.type == ENRMInputBlockTypeUnorderedListItem; for (NSUInteger lineIndex = 0; lineIndex < plainLines.count; lineIndex++) { NSUInteger lineStart = lineStartOffsets[lineIndex].unsignedIntegerValue; NSUInteger lineEnd = lineStart + plainLines[lineIndex].length; BOOL overlaps = isZeroLength ? (lineStart == blockStart) : (lineEnd >= blockStart && lineStart < blockEnd); if (overlaps) { + // A marker-only list line ("- " with no content) re-parses as a setext + // underline for the previous line; emit an empty list line bare. An + // empty "# " heading is valid ATX and keeps its prefix. + if (isListItem && plainLines[lineIndex].length == 0) { + continue; + } markdownLines[lineIndex] = [prefix stringByAppendingString:markdownLines[lineIndex]]; } } diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index d04a7c7e6..7fc344d69 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -924,6 +924,7 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level }]; } + [_blockStore normalizeToLineBoundsInText:text]; [self applyFormatting]; [self syncTypingAttributesWithCursorBlock]; if (alreadyActive) { @@ -981,6 +982,7 @@ - (void)changeListDepthBy:(NSInteger)delta forParagraphRange:paragraphRange inText:text]; + [_blockStore normalizeToLineBoundsInText:text]; [self applyFormatting]; [self syncTypingAttributesWithCursorBlock]; [self updateEmptyBulletMarker]; From 9652b174750b8434ef129e8b659105d515bf1e43 Mon Sep 17 00:00:00 2001 From: Sean Holbert Date: Sun, 5 Jul 2026 09:46:04 -0700 Subject: [PATCH 5/5] feat(input): parse external plain-text paste as markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal copy/paste already round-trips through the tagged clipboard, but an external plain-text clip pasted literally — "- 1" stayed raw text. Route the plain-text paste fallback through pasteMarkdown on both platforms so pasted syntax formats (bullets, headings, inline styles); syntax-free text is unchanged. Android's "Paste as plain text" menu item keeps the literal default as an escape hatch. Co-Authored-By: Claude Fable 5 --- .../markdown/input/EnrichedMarkdownTextInputView.kt | 13 +++++++++++++ .../ios/input/ENRMInputTextView.mm | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) 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 a7320689b..b03cb0508 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 @@ -849,6 +849,13 @@ class EnrichedMarkdownTextInputView( pasteMarkdown(markdown) return true } + // External plain text is treated as markdown so pasted syntax ("- ", "#", + // "**") formats instead of landing literal; syntax-free text is unchanged. + // "Paste as plain text" (pasteAsPlainText) keeps the literal default. + plainTextFromClipboard()?.let { plainText -> + pasteMarkdown(plainText) + return true + } } if (id == android.R.id.copy || id == android.R.id.cut) { val selStart = selectionStart @@ -901,6 +908,12 @@ class EnrichedMarkdownTextInputView( } } + private fun plainTextFromClipboard(): String? { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return null + val item = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0) ?: return null + return item.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() } + } + /** * Replaces the selection with parsed markdown, importing its inline formatting * and block ranges (headings etc.) into the stores — mirrors iOS pasteMarkdown. diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm index 2d848218e..a60d58922 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm @@ -50,8 +50,12 @@ - (void)paste:(id)sender return; } + // External plain text is treated as markdown so pasted syntax ("- ", "#", + // "**") formats instead of landing literal; syntax-free text is unchanged. NSString *plainText = pasteboard.string; - if (plainText.length > 0) { + if (plainText.length > 0 && self.markdownTextInput != nil) { + [self.markdownTextInput pasteMarkdown:plainText]; + } else if (plainText.length > 0) { [self replaceRange:self.selectedTextRange withText:plainText]; } } @@ -159,8 +163,12 @@ - (void)paste:(id)sender return; } + // External plain text is treated as markdown so pasted syntax ("- ", "#", + // "**") formats instead of landing literal; syntax-free text is unchanged. NSString *plainText = [pasteboard stringForType:NSPasteboardTypeString]; - if (plainText.length > 0) { + if (plainText.length > 0 && self.markdownTextInput != nil) { + [self.markdownTextInput pasteMarkdown:plainText]; + } else if (plainText.length > 0) { [self insertText:plainText replacementRange:self.selectedRange]; } }