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..fc52461e7
--- /dev/null
+++ b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/unordered_list_test.yaml
@@ -0,0 +1,55 @@
+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
+
+# 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
+
+- 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 000000000..17f99ed9f
Binary files /dev/null and b/.maestro/enrichedMarkdownInput/screenshots/ios/unordered_list_input.png differ
diff --git a/apps/react-native-example/src/components/FormattingToolbar.tsx b/apps/react-native-example/src/components/FormattingToolbar.tsx
index 3238681c6..4d72ae52e 100644
--- a/apps/react-native-example/src/components/FormattingToolbar.tsx
+++ b/apps/react-native-example/src/components/FormattingToolbar.tsx
@@ -148,6 +148,40 @@ export function FormattingToolbar({
{`H${level}`}
))}
+ inputRef.current?.toggleUnorderedList()}
+ testID="toolbar-unordered-list"
+ >
+ •
+
+ inputRef.current?.toggleOrderedList()}
+ testID="toolbar-ordered-list"
+ >
+ 1.
+
+ 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..f4d49f2f0 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,22 @@ class EnrichedMarkdownTextInputManager :
view?.toggleHeading(level)
}
+ override fun toggleUnorderedList(view: EnrichedMarkdownTextInputView?) {
+ view?.toggleUnorderedList()
+ }
+
+ override fun toggleOrderedList(view: EnrichedMarkdownTextInputView?) {
+ view?.toggleOrderedList()
+ }
+
+ 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..bad21b0eb 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 listBlock = listBlockAtCursor() ?: return false
+ val depth = listBlock.level
+ 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 toggleListType(listBlock.type)
+ 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,193 @@ class EnrichedMarkdownTextInputView(
}
}
+ /** The list block owning the caret's paragraph, or null. */
+ private fun listBlockAtCursor(): BlockRange? = blockOnParagraphAt(selectionStart)?.takeIf { it.type in BlockType.LIST_ITEMS }
+
+ /**
+ * List state of the cursor's paragraph for [type]: whether it is such an item and,
+ * if so, its 0-based nesting depth. The orchestrator side of the
+ * `onChangeState.unorderedList` / `orderedList` payloads.
+ */
+ fun listStateAtCursor(type: BlockType): Pair {
+ val block = listBlockAtCursor() ?: return false to 0
+ return if (block.type == type) true to block.level else false to 0
+ }
+
+ fun toggleUnorderedList() = toggleListType(BlockType.UNORDERED_LIST_ITEM)
+
+ fun toggleOrderedList() = toggleListType(BlockType.ORDERED_LIST_ITEM)
+
+ /**
+ * Toggles a list of [type] on the cursor's paragraph(s): turns the touched lines
+ * into depth-0 items (replacing an item of the other list type), or clears them
+ * back to plain paragraphs when the cursor's line already carries [type].
+ */
+ private fun toggleListType(type: BlockType) {
+ val editable = text ?: return
+ val turningOff = listBlockAtCursor()?.type == type
+ if (turningOff) {
+ forEachSelectedLine { ls, le -> blockStore.removeBlock(ls, le, editable) }
+ } else {
+ setListBlockOnLines(type, 0)
+ }
+ blockStore.normalizeToLineBounds(editable)
+ 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 cursorBlock = listBlockAtCursor()
+ if (cursorBlock == null) {
+ // Indent on a plain paragraph starts a bullet list; headings/outdent are ignored.
+ if (delta > 0 && blockOnParagraphAt(selectionStart) == null) toggleUnorderedList()
+ return
+ }
+ if (delta < 0 && cursorBlock.level == 0) {
+ toggleListType(cursorBlock.type)
+ return
+ }
+ val editable = text ?: return
+ forEachSelectedLine { ls, le ->
+ val block = blockStore.allRanges.firstOrNull { it.start == ls && it.type in BlockType.LIST_ITEMS }
+ if (block != null) {
+ val newDepth = (block.level + delta).coerceIn(0, MAX_LIST_DEPTH)
+ blockStore.setBlock(block.type, newDepth, ls, le, editable)
+ }
+ }
+ blockStore.normalizeToLineBounds(editable)
+ applyFormattingAndEmit()
+ syncEmptyListAnchor()
+ }
+
+ /** Sets a [type] list block at [depth] on every line the selection touches. */
+ private fun setListBlockOnLines(
+ type: BlockType,
+ depth: Int,
+ ) {
+ val editable = text ?: return
+ forEachSelectedLine { ls, le ->
+ blockStore.setBlock(type, 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 in BlockType.LIST_ITEMS }
+ 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 in BlockType.LIST_ITEMS }
+ 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.
@@ -520,6 +859,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
@@ -572,6 +918,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.
@@ -645,6 +997,7 @@ class EnrichedMarkdownTextInputView(
}
}
+ blockStore.normalizeToLineBounds(editable)
applyFormattingAndEmit()
syncCursorSizeWithBlock()
}
@@ -845,6 +1198,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..da74e833a 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,10 @@ class OnChangeStateEvent(
private val isSpoiler: Boolean,
private val isLink: Boolean,
private val headingLevel: Int,
+ private val isUnorderedList: Boolean,
+ private val unorderedListDepth: Int,
+ private val isOrderedList: Boolean,
+ private val orderedListDepth: Int,
) : Event(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
@@ -50,6 +54,20 @@ class OnChangeStateEvent(
putInt("level", headingLevel)
},
)
+ putMap(
+ "unorderedList",
+ Arguments.createMap().apply {
+ putBoolean("isActive", isUnorderedList)
+ putInt("depth", unorderedListDepth)
+ },
+ )
+ putMap(
+ "orderedList",
+ Arguments.createMap().apply {
+ putBoolean("isActive", isOrderedList)
+ putInt("depth", orderedListDepth)
+ },
+ )
}
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..e0f8550d8 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,10 @@ class OnContextMenuItemPressEvent(
private val isSpoiler: Boolean,
private val isLink: Boolean,
private val headingLevel: Int,
+ private val isUnorderedList: Boolean,
+ private val unorderedListDepth: Int,
+ private val isOrderedList: Boolean,
+ private val orderedListDepth: Int,
) : Event(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
@@ -45,6 +49,20 @@ class OnContextMenuItemPressEvent(
putInt("level", headingLevel)
},
)
+ putMap(
+ "unorderedList",
+ Arguments.createMap().apply {
+ putBoolean("isActive", isUnorderedList)
+ putInt("depth", unorderedListDepth)
+ },
+ )
+ putMap(
+ "orderedList",
+ Arguments.createMap().apply {
+ putBoolean("isActive", isOrderedList)
+ putInt("depth", orderedListDepth)
+ },
+ )
},
)
}
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..47641fc76 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
@@ -2,6 +2,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.MAX_LIST_DEPTH
import java.util.Collections
/**
@@ -24,6 +25,7 @@ class BlockStore {
fun setRanges(newRanges: List) {
ranges.clear()
ranges.addAll(newRanges.sortedBy { it.start })
+ recomputeListMetadata()
}
fun clearAll() {
@@ -44,9 +46,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 +69,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 +84,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 +123,11 @@ 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.
+ * 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) {
if (ranges.isEmpty()) return
@@ -134,7 +138,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
}
@@ -142,6 +146,46 @@ class BlockStore {
range.end = lineEnd
previousEnd = lineEnd
}
+
+ recomputeListMetadata()
+ }
+
+ /**
+ * Clamps list depths to valid ancestry (an item nests at most one level under
+ * the previous adjacent list item — CommonMark cannot represent orphan nesting)
+ * and renumbers ordered items among their adjacent same-depth, same-type run.
+ */
+ private fun recomputeListMetadata() {
+ var prevEnd = -2
+ var prevDepth = -1
+ val counters = IntArray(MAX_LIST_DEPTH + 2)
+ val counterTypes = arrayOfNulls(MAX_LIST_DEPTH + 2)
+ for (range in ranges) {
+ if (range.type !in BlockType.LIST_ITEMS) {
+ prevDepth = -1
+ continue
+ }
+ val adjacent = prevDepth >= 0 && range.start == prevEnd + 1
+ if (!adjacent) {
+ counters.fill(0)
+ counterTypes.fill(null)
+ }
+ val maxDepth = if (adjacent) prevDepth + 1 else 0
+ if (range.level > maxDepth) range.level = maxDepth
+ val depth = range.level
+ for (i in depth + 1..MAX_LIST_DEPTH + 1) {
+ counters[i] = 0
+ counterTypes[i] = null
+ }
+ if (counterTypes[depth] != range.type) {
+ counters[depth] = 0
+ counterTypes[depth] = range.type
+ }
+ counters[depth]++
+ range.ordinal = counters[depth]
+ prevEnd = range.end
+ prevDepth = range.level
+ }
}
/**
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..a9cea3bff 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,15 +7,18 @@ 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
import com.swmansion.enriched.markdown.input.styles.ItalicStyleHandler
import com.swmansion.enriched.markdown.input.styles.LinkStyleHandler
+import com.swmansion.enriched.markdown.input.styles.OrderedListBlockHandler
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 +40,15 @@ 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())
+ put(BlockType.ORDERED_LIST_ITEM, OrderedListBlockHandler())
}
fun handlerForBlock(type: BlockType): BlockHandler? = blockHandlers[type]
@@ -170,23 +177,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..2c31fe0b6 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, false)
return ParseResult(plainText.toString(), ranges, blockRanges)
}
@@ -55,6 +56,8 @@ object InputParser {
blockRanges: MutableList,
activeStyles: ArrayDeque,
blankRuns: ArrayDeque,
+ listDepth: Int,
+ orderedContainer: Boolean,
) {
val styleType = nodeTypeToStyleType(node.type)
@@ -63,6 +66,20 @@ 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 isListContainer = node.type == NodeType.UnorderedList || node.type == NodeType.OrderedList
+ val childListDepth = if (isListContainer) listDepth + 1 else listDepth
+ val childOrdered = if (isListContainer) node.type == NodeType.OrderedList else orderedContainer
+
+ // 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 +94,27 @@ 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, childOrdered)
+ }
+
+ // 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)
+ val itemType = if (orderedContainer) BlockType.ORDERED_LIST_ITEM else BlockType.UNORDERED_LIST_ITEM
+ blockRanges.add(BlockRange(itemType, itemStart, lineEnd, depth))
+ }
}
if (styleType != null) {
@@ -93,8 +126,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 !in BlockType.LIST_ITEMS) {
val end = plainText.length
if (end > blockStartPosition) {
blockRanges.add(BlockRange(blockType, blockStartPosition, end, blockLevel))
@@ -125,7 +159,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..843ec2a8c 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,18 +2,24 @@ 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
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 +28,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 +43,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) {
@@ -52,17 +59,22 @@ object MarkdownSerializer {
if (prefix.isEmpty()) continue
val isZeroLength = blockRange.length == 0
+ val isListItem = blockRange.type in BlockType.LIST_ITEMS
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]
}
}
}
- 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..14482b362 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
@@ -19,6 +19,7 @@ import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEven
import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
import com.swmansion.enriched.markdown.input.formatting.MarkdownSerializer
+import com.swmansion.enriched.markdown.input.model.BlockType
import com.swmansion.enriched.markdown.input.model.CaretRect
import com.swmansion.enriched.markdown.input.model.StyleType
@@ -27,6 +28,8 @@ class InputEventEmitter(
) {
private var prevState: Map = emptyMap()
private var prevHeadingLevel: Int = 0
+ private var prevUnorderedList: Pair = false to 0
+ private var prevOrderedList: Pair = false to 0
private var prevCaretRect: CaretRect? = null
fun emitChangeText() {
@@ -52,10 +55,18 @@ class InputEventEmitter(
isStyleEffectivelyActive(style, pos)
}
val headingLevel = view.headingLevelAtCursor()
+ val unorderedList = view.listStateAtCursor(BlockType.UNORDERED_LIST_ITEM)
+ val orderedList = view.listStateAtCursor(BlockType.ORDERED_LIST_ITEM)
- if (current == prevState && headingLevel == prevHeadingLevel) return
+ if (current == prevState && headingLevel == prevHeadingLevel && unorderedList == prevUnorderedList &&
+ orderedList == prevOrderedList
+ ) {
+ return
+ }
prevState = current
prevHeadingLevel = headingLevel
+ prevUnorderedList = unorderedList
+ prevOrderedList = orderedList
dispatch(
OnChangeStateEvent(
@@ -68,6 +79,10 @@ class InputEventEmitter(
current[StyleType.SPOILER] ?: false,
current[StyleType.LINK] ?: false,
headingLevel,
+ unorderedList.first,
+ unorderedList.second,
+ orderedList.first,
+ orderedList.second,
),
)
}
@@ -159,6 +174,8 @@ class InputEventEmitter(
isStyleEffectivelyActive(type, selectionStart)
}
+ val contextMenuListState = view.listStateAtCursor(BlockType.UNORDERED_LIST_ITEM)
+ val contextMenuOrderedState = view.listStateAtCursor(BlockType.ORDERED_LIST_ITEM)
dispatch(
OnContextMenuItemPressEvent(
surfaceId(),
@@ -174,6 +191,10 @@ class InputEventEmitter(
isSpoiler = isActive(StyleType.SPOILER),
isLink = isActive(StyleType.LINK),
headingLevel = view.headingLevelAtCursor(),
+ isUnorderedList = contextMenuListState.first,
+ unorderedListDepth = contextMenuListState.second,
+ isOrderedList = contextMenuOrderedState.first,
+ orderedListDepth = contextMenuOrderedState.second,
),
)
}
@@ -190,6 +211,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/BlockRange.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt
index 3627d3441..5a10bdbec 100644
--- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt
+++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt
@@ -12,13 +12,16 @@ package com.swmansion.enriched.markdown.input.model
* @property start inclusive start offset, in plain-text characters.
* @property end exclusive end offset, in plain-text characters.
* @property level generic integer payload, 0 by default. Headings use it for the
- * H-level (1-6); list items will use it for nesting depth.
+ * H-level (1-6); list items use it for nesting depth.
+ * @property ordinal 1-based position of an ordered list item among its adjacent
+ * siblings at the same depth; recomputed by the store's list-metadata pass.
*/
class BlockRange(
val type: BlockType,
override var start: Int,
override var end: Int,
var level: Int = 0,
+ var ordinal: Int = 1,
) : MutableRangeBounds {
val length: Int get() = end - start
}
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..476dace70 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,8 @@ enum class BlockType {
HEADING_4,
HEADING_5,
HEADING_6,
+ UNORDERED_LIST_ITEM,
+ ORDERED_LIST_ITEM,
;
companion object {
@@ -25,6 +27,15 @@ enum class BlockType {
val HEADINGS: List =
listOf(HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6)
+ /** List-item block types; nesting depth rides in the range's level payload. */
+ val LIST_ITEMS: Set = setOf(UNORDERED_LIST_ITEM, ORDERED_LIST_ITEM)
+
+ /**
+ * Block types whose emptied line persists as a zero-length anchor (an emptied
+ * heading stays a heading, an emptied list item keeps its marker) until toggled off.
+ */
+ val ANCHORED: Set = HEADINGS.toSet() + LIST_ITEMS
+
/**
* 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 +43,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/spans/InputOrderedListMarkerSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputOrderedListMarkerSpan.kt
new file mode 100644
index 000000000..f40399403
--- /dev/null
+++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputOrderedListMarkerSpan.kt
@@ -0,0 +1,61 @@
+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
+
+/**
+ * Ordered list item span: reserves the same per-depth leading margin as
+ * [InputBulletSpan] and draws the item's number ("3.") right-aligned before the
+ * text column. The ordinal comes from the block store's list-metadata pass.
+ * Tagged [MarkdownSpan] for formatter cleanup.
+ */
+class InputOrderedListMarkerSpan(
+ val depth: Int,
+ private val ordinal: Int,
+ density: Float,
+) : LeadingMarginSpan,
+ MarkdownSpan {
+ private val indentPerDepth = INDENT_PER_DEPTH_DP * density
+ private val markerWidth = MARKER_WIDTH_DP * density
+ private val markerGap = MARKER_GAP_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
+
+ val label = "$ordinal."
+ val originalAlign = paint.textAlign
+ paint.textAlign = Paint.Align.RIGHT
+ val markerRight = x + (depth * indentPerDepth + markerWidth - markerGap) * dir
+ canvas.drawText(label, markerRight, baseline.toFloat(), paint)
+ paint.textAlign = originalAlign
+ }
+
+ companion object {
+ // Geometry mirrors InputBulletSpan so ordered and bullet items align.
+ private const val INDENT_PER_DEPTH_DP = 18f
+ private const val MARKER_WIDTH_DP = 18f
+ private const val MARKER_GAP_DP = 4f
+ }
+}
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/OrderedListBlockHandler.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/OrderedListBlockHandler.kt
new file mode 100644
index 000000000..0d82a5d3e
--- /dev/null
+++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/OrderedListBlockHandler.kt
@@ -0,0 +1,35 @@
+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.InputListItemSpacingSpan
+import com.swmansion.enriched.markdown.input.spans.InputOrderedListMarkerSpan
+
+/**
+ * Block handler for ordered list items. One instance serves every nesting depth —
+ * the 0-based depth rides in [BlockRange.level] and the displayed number in
+ * [BlockRange.ordinal], both maintained by the block store.
+ */
+class OrderedListBlockHandler : BlockHandler {
+ override val blockType: BlockType = BlockType.ORDERED_LIST_ITEM
+
+ override val continuesOnNewline: Boolean = true
+
+ override fun createSpans(
+ blockRange: BlockRange,
+ style: InputFormatterStyle,
+ ): List {
+ val spans = mutableListOf(InputOrderedListMarkerSpan(blockRange.level, blockRange.ordinal, style.displayDensity))
+ if (style.listItemSpacingPx > 0) {
+ spans.add(InputListItemSpacingSpan(style.listItemSpacingPx))
+ }
+ return spans
+ }
+
+ override fun spanClasses(): List> = listOf(InputOrderedListMarkerSpan::class.java, InputListItemSpacingSpan::class.java)
+
+ /** `"1. "` numbering from the range's ordinal, indented three spaces per nesting depth. */
+ override fun markdownLinePrefix(blockRange: BlockRange): String =
+ " ".repeat(blockRange.level.coerceAtLeast(0)) + "${blockRange.ordinal}. "
+}
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..1eb513f11
--- /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 three spaces per nesting depth (wide enough for ordered markers). */
+ override fun markdownLinePrefix(blockRange: BlockRange): String = " ".repeat(blockRange.level.coerceAtLeast(0)) + "- "
+}
diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h
index aad15f330..6dc010fea 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h
@@ -13,9 +13,13 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) NSRange range;
/// Generic integer payload for the block. 0 by default. Headings use it for the
-/// H-level (1-6); list items will use it for nesting depth.
+/// H-level (1-6); list items use it for nesting depth.
@property (nonatomic, assign) NSInteger level;
+/// 1-based position of an ordered item among its adjacent same-depth siblings;
+/// recomputed by the block store's list-metadata pass.
+@property (nonatomic, assign) NSInteger ordinal;
+
+ (instancetype)rangeWithType:(ENRMInputBlockType)type range:(NSRange)range;
+ (instancetype)rangeWithType:(ENRMInputBlockType)type range:(NSRange)range level:(NSInteger)level;
diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm
index 1992f46cb..8d8569e02 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm
@@ -13,6 +13,7 @@ + (instancetype)rangeWithType:(ENRMInputBlockType)type range:(NSRange)range leve
blockRange.type = type;
blockRange.range = range;
blockRange.level = level;
+ blockRange.ordinal = 1;
return blockRange;
}
@@ -22,6 +23,7 @@ - (id)copyWithZone:(NSZone *)zone
copy.type = _type;
copy.range = _range;
copy.level = _level;
+ copy.ordinal = _ordinal;
return copy;
}
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 e8fab8d4e..3e6b022b9 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm
@@ -45,6 +45,7 @@ - (void)setRanges:(NSArray *)ranges
return NSOrderedDescending;
return NSOrderedSame;
}] mutableCopy];
+ [self recomputeListMetadata];
}
- (void)clearAll
@@ -94,7 +95,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 +123,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 +141,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 +156,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 +185,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;
}
@@ -193,6 +197,49 @@ - (void)normalizeToLineBoundsInText:(NSString *)text
}
ENRMRemoveIndexesInReverse(_ranges, indexesToRemove);
+ [self recomputeListMetadata];
+}
+
+/// Clamps list depths to valid ancestry (an item nests at most one level under
+/// the previous adjacent list item — CommonMark cannot represent orphan nesting)
+/// and renumbers ordered items among their adjacent same-depth, same-type run.
+- (void)recomputeListMetadata
+{
+ NSInteger prevEnd = -2;
+ NSInteger prevDepth = -1;
+ NSInteger counters[kENRMMaxListDepth + 2];
+ ENRMInputBlockType counterTypes[kENRMMaxListDepth + 2];
+ memset(counters, 0, sizeof(counters));
+ memset(counterTypes, 0, sizeof(counterTypes));
+
+ for (ENRMBlockRange *blockRange in _ranges) {
+ if (!ENRMBlockTypeIsListItem(blockRange.type)) {
+ prevDepth = -1;
+ continue;
+ }
+ BOOL adjacent = prevDepth >= 0 && (NSInteger)blockRange.range.location == prevEnd + 1;
+ if (!adjacent) {
+ memset(counters, 0, sizeof(counters));
+ memset(counterTypes, 0, sizeof(counterTypes));
+ }
+ NSInteger maxDepth = adjacent ? prevDepth + 1 : 0;
+ if (blockRange.level > maxDepth) {
+ blockRange.level = maxDepth;
+ }
+ NSInteger depth = blockRange.level;
+ for (NSInteger i = depth + 1; i <= kENRMMaxListDepth + 1; i++) {
+ counters[i] = 0;
+ counterTypes[i] = (ENRMInputBlockType)0;
+ }
+ if (counterTypes[depth] != blockRange.type) {
+ counters[depth] = 0;
+ counterTypes[depth] = blockRange.type;
+ }
+ counters[depth]++;
+ blockRange.ordinal = counters[depth];
+ prevEnd = (NSInteger)NSMaxRange(blockRange.range);
+ prevDepth = blockRange.level;
+ }
}
@end
diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h
index 592ea187a..092da49e0 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h
@@ -19,8 +19,28 @@ typedef NS_ENUM(NSInteger, ENRMInputBlockType) {
ENRMInputBlockTypeHeading4 = 4,
ENRMInputBlockTypeHeading5 = 5,
ENRMInputBlockTypeHeading6 = 6,
+ ENRMInputBlockTypeUnorderedListItem = 7,
+ ENRMInputBlockTypeOrderedListItem = 8,
};
+/// Whether the type is a list item (nesting depth rides in the range's level).
+static inline BOOL ENRMBlockTypeIsListItem(ENRMInputBlockType type)
+{
+ return type == ENRMInputBlockTypeUnorderedListItem || type == ENRMInputBlockTypeOrderedListItem;
+}
+
+/// 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 +64,14 @@ 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) || ENRMBlockTypeIsListItem(type);
+}
+
/// 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
@@ -55,4 +83,9 @@ extern NSAttributedStringKey const ENRMBlockTypeAttributeName;
/// Applied and stripped alongside ENRMBlockTypeAttributeName.
extern NSAttributedStringKey const ENRMBlockLevelAttributeName;
+/// NSAttributedString attribute carrying an ordered item's 1-based ordinal
+/// (boxed NSNumber) so the layout manager can draw its number. Applied and
+/// stripped alongside ENRMBlockTypeAttributeName.
+extern NSAttributedStringKey const ENRMBlockOrdinalAttributeName;
+
NS_ASSUME_NONNULL_END
diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm
index 1136ff4b7..60d1645b8 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm
@@ -2,3 +2,4 @@
NSAttributedStringKey const ENRMBlockTypeAttributeName = @"ENRMBlockType";
NSAttributedStringKey const ENRMBlockLevelAttributeName = @"ENRMBlockLevel";
+NSAttributedStringKey const ENRMBlockOrdinalAttributeName = @"ENRMBlockOrdinal";
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..5c515a745 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm
@@ -4,10 +4,12 @@
#import "ENRMHeadingBlockHandler.h"
#import "ENRMItalicStyleHandler.h"
#import "ENRMLinkStyleHandler.h"
+#import "ENRMOrderedListBlockHandler.h"
#import "ENRMSpoilerStyleHandler.h"
#import "ENRMStrikethroughStyleHandler.h"
#import "ENRMStyleHandler.h"
#import "ENRMUnderlineStyleHandler.h"
+#import "ENRMUnorderedListBlockHandler.h"
#import "FontUtils.h"
@implementation ENRMInputLinkVariantStyle
@@ -52,6 +54,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 +194,8 @@ - (instancetype)init
for (NSInteger level = 1; level <= 6; level++) {
blockMap[@(ENRMBlockTypeForHeadingLevel(level))] = headingHandler;
}
+ blockMap[@(ENRMInputBlockTypeUnorderedListItem)] = [[ENRMUnorderedListBlockHandler alloc] init];
+ blockMap[@(ENRMInputBlockTypeOrderedListItem)] = [[ENRMOrderedListBlockHandler alloc] init];
_blockHandlers = [blockMap copy];
}
return self;
@@ -363,6 +368,7 @@ - (void)applyBlockRanges:(NSArray *)blockRanges
NSRange range = rangeValue.rangeValue;
[textStorage removeAttribute:ENRMBlockTypeAttributeName range:range];
[textStorage removeAttribute:ENRMBlockLevelAttributeName range:range];
+ [textStorage removeAttribute:ENRMBlockOrdinalAttributeName range:range];
[textStorage removeAttribute:NSParagraphStyleAttributeName range:range];
}
@@ -409,6 +415,7 @@ - (void)applyBlockRanges:(NSArray *)blockRanges
attributes[NSParagraphStyleAttributeName] = paragraphStyle;
attributes[ENRMBlockTypeAttributeName] = @(blockRange.type);
attributes[ENRMBlockLevelAttributeName] = @(blockRange.level);
+ attributes[ENRMBlockOrdinalAttributeName] = @(blockRange.ordinal);
// For a zero-length heading anchor on an empty line, stamp onto the line
// terminator so the paragraph style (heading font size) takes effect.
diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h
index 56324f072..392cf823a 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h
@@ -4,8 +4,39 @@
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 marker to draw on an otherwise-empty list line. Negative disables
+/// the empty-line marker.
+@property (nonatomic, assign) NSInteger emptyBulletDepth;
+
+/// Whether the empty list line is an ordered item (draws its number instead of a
+/// bullet glyph), and the 1-based number to draw.
+@property (nonatomic, assign) BOOL emptyBulletOrdered;
+@property (nonatomic, assign) NSInteger emptyBulletOrdinal;
+
+/// 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..915f5cd7a 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm
@@ -1,5 +1,236 @@
#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);
+}
+
+/// Draws an ordered item's number ("3.") right-aligned so the dot sits a small
+/// pad before the text column, sharing the bullet's baseline math.
+- (void)drawOrderedMarkerEndingAtX:(CGFloat)markerRight
+ baselineY:(CGFloat)baselineY
+ ordinal:(NSInteger)ordinal
+ font:(UIFont *)font
+ color:(UIColor *)color
+{
+ NSString *label = [NSString stringWithFormat:@"%ld.", (long)MAX(ordinal, (NSInteger)1)];
+ NSDictionary *attrs = @{NSFontAttributeName : font, NSForegroundColorAttributeName : color};
+ CGSize size = [label sizeWithAttributes:attrs];
+ [label drawAtPoint:CGPointMake(markerRight - size.width, baselineY - font.ascender) withAttributes:attrs];
+}
+
+- (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;
+ BOOL isOrdered = NO;
+ NSInteger depth = 0;
+ NSInteger ordinal = 1;
+ if (charRange.location < storage.length) {
+ NSNumber *type = [storage attribute:ENRMBlockTypeAttributeName
+ atIndex:charRange.location
+ effectiveRange:NULL];
+ if (type && ENRMBlockTypeIsListItem((ENRMInputBlockType)type.integerValue) &&
+ charRange.location == paraRange.location) {
+ isAttributedListLine = YES;
+ isOrdered = type.integerValue == ENRMInputBlockTypeOrderedListItem;
+ NSNumber *depthValue = [storage attribute:ENRMBlockLevelAttributeName
+ atIndex:charRange.location
+ effectiveRange:NULL];
+ depth = depthValue ? depthValue.integerValue : 0;
+ NSNumber *ordinalValue = [storage attribute:ENRMBlockOrdinalAttributeName
+ atIndex:charRange.location
+ effectiveRange:NULL];
+ ordinal = ordinalValue ? ordinalValue.integerValue : 1;
+ }
+ }
+
+ // 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;
+ isOrdered = self.emptyBulletOrdered;
+ ordinal = self.emptyBulletOrdinal;
+ }
+
+ 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;
+ if (isOrdered) {
+ CGFloat markerRight = origin.x + usedRect.origin.x - kENRMListBulletGap / 2.0;
+ [self drawOrderedMarkerEndingAtX:markerRight
+ baselineY:baselineY
+ ordinal:ordinal
+ font:font
+ color:color];
+ } else {
+ 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;
+ CGFloat markerRight = origin.x + used.origin.x - kENRMListBulletGap / 2.0;
+ // 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;
+ if (self.emptyBulletOrdered) {
+ [self drawOrderedMarkerEndingAtX:markerRight
+ baselineY:baselineY
+ ordinal:self.emptyBulletOrdinal
+ font:font
+ color:color];
+ } else {
+ 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;
+ if (self.emptyBulletOrdered) {
+ [self drawOrderedMarkerEndingAtX:(markerX + kENRMListBulletGap / 2.0)
+ baselineY:baselineY
+ ordinal:self.emptyBulletOrdinal
+ font:font
+ color:color];
+ return;
+ }
+ 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..47fa7095b 100644
--- a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm
+++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm
@@ -105,6 +105,10 @@ static NSInteger resolveBlockLevel(MD_BLOCKTYPE blockType, void *detail)
std::vector openBlockStack;
std::vector resolvedBlocks;
size_t lastTextEnd = 0;
+ // Open list containers, innermost last (true = ordered). An item's depth is
+ // the stack size - 1 and its type the innermost container's; md4c carries no
+ // per-item depth, so both derive from the open UL/OL ancestors.
+ std::vector listContainerStack;
};
static std::vector buildByteToUTF16Map(const char *utf8, size_t byteLength)
@@ -171,12 +175,32 @@ 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);
+
+ // List nesting is tracked by the container stack, not stored as its own
+ // block: the UL/OL container only frames the items that read it.
+ if (blockType == MD_BLOCK_UL || blockType == MD_BLOCK_OL) {
+ context->listContainerStack.push_back(blockType == MD_BLOCK_OL);
+ 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;
+ BOOL ordered = !context->listContainerStack.empty() && context->listContainerStack.back();
+ blockInfo.type = ordered ? ENRMInputBlockTypeOrderedListItem : ENRMInputBlockTypeUnorderedListItem;
+ blockInfo.level = context->listContainerStack.empty() ? 0 : (NSInteger)context->listContainerStack.size() - 1;
+ 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 || blockType == MD_BLOCK_OL) {
+ if (!context->listContainerStack.empty()) {
+ context->listContainerStack.pop_back();
+ }
+ 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 (ENRMBlockTypeIsListItem(blockInfo.type) && 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..a60d58922 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";
@@ -51,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];
}
}
@@ -76,6 +79,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
@@ -115,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];
}
}
diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm
index 3d40530e4..3eed9c8a8 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) {
@@ -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 = ENRMBlockTypeIsListItem(blockRange.type);
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+Internal.h b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput+Internal.h
index 1ef8a31a7..7890dda31 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,11 @@ typedef struct {
- (void)toggleSpoiler;
- (void)toggleInlineStyle:(ENRMInputStyleType)type;
- (void)toggleHeading:(NSInteger)level;
+- (void)toggleUnorderedList;
+- (void)toggleOrderedList;
+- (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..f2b7526da 100644
--- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm
+++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm
@@ -72,14 +72,26 @@ @implementation EnrichedMarkdownTextInput {
ENRMPlaceholderLabel *_placeholderLabel;
NSUInteger _lastTextLength;
+ NSUInteger _lastLineCount;
NSRange _lastSelectedRange;
NSRange _preEditSelectedRange;
struct {
BOOL bold, italic, underline, strikethrough, spoiler, link, initialized;
NSInteger headingLevel;
+ BOOL unorderedList;
+ NSInteger unorderedListDepth;
+ BOOL orderedList;
+ NSInteger orderedListDepth;
} _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 +580,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 +625,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 +888,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 +901,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 {
@@ -901,11 +927,320 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level
}];
}
+ [_blockStore normalizeToLineBoundsInText:text];
[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)toggleOrderedList
+{
+ [self toggleBlockType:ENRMInputBlockTypeOrderedListItem level:0];
+}
+
+- (void)indentList
+{
+ [self changeListDepthBy:1];
+}
+
+- (void)outdentList
+{
+ [self changeListDepthBy:-1];
+}
+
+/// Adjusts the nesting depth of the cursor's list item by `delta`, clamped to
+/// [0, kENRMMaxListDepth] and preserving the item's list type. QoL on the
+/// boundaries: indent on a non-list paragraph starts a depth-0 bullet (ignored
+/// on headings), and outdent past depth 0 removes the marker.
+- (void)changeListDepthBy:(NSInteger)delta
+{
+ ENRMBlockRange *listBlock = [self listBlockForCursorParagraph];
+ if (listBlock == nil) {
+ if (delta > 0 && [self headingLevelForCursorParagraph] == 0) {
+ [self toggleUnorderedList];
+ }
+ return;
+ }
+
+ if (delta < 0 && listBlock.level == 0) {
+ [self toggleBlockType:listBlock.type level:0];
+ return;
+ }
+
+ NSInteger newDepth = MIN(MAX(listBlock.level + delta, (NSInteger)0), kENRMMaxListDepth);
+ if (newDepth == listBlock.level) {
+ return;
+ }
+
+ NSString *text = ENRMGetPlainText(_textView);
+ NSRange paragraphRange = [text paragraphRangeForRange:_textView.selectedRange];
+ [_blockStore setBlockType:listBlock.type level:newDepth forParagraphRange:paragraphRange inText:text];
+
+ [_blockStore normalizeToLineBoundsInText:text];
+ [self applyFormatting];
+ [self syncTypingAttributesWithCursorBlock];
+ [self updateEmptyBulletMarker];
+ [self emitFormattingChanged];
+}
+
+/// The list block owning the caret's paragraph (matched by line start, so empty
+/// anchors and line-end carets register), or nil.
+- (nullable ENRMBlockRange *)listBlockForCursorParagraph
+{
+ return [self listBlockForParagraphAtPosition:_textView.selectedRange.location];
+}
+
+- (nullable ENRMBlockRange *)listBlockForParagraphAtPosition:(NSUInteger)position
+{
+ NSString *text = _textView.textStorage.string;
+ if (position > text.length) {
+ return nil;
+ }
+ NSRange paragraph = [text paragraphRangeForRange:NSMakeRange(position, 0)];
+ for (ENRMBlockRange *block in _blockStore.allRanges) {
+ if (ENRMBlockTypeIsListItem(block.type) && block.range.location == paragraph.location) {
+ return block;
+ }
+ }
+ return nil;
+}
+
+/// Whether the caret's paragraph is a list item of `type`, writing its depth
+/// into `outDepth` when non-NULL. Mirrors headingLevelForCursorParagraph.
+- (BOOL)listStateOfType:(ENRMInputBlockType)type forCursorParagraphDepth:(nullable NSInteger *)outDepth
+{
+ ENRMBlockRange *block = [self listBlockForCursorParagraph];
+ BOOL match = block != nil && block.type == type;
+ if (outDepth) {
+ *outDepth = match ? block.level : 0;
+ }
+ return match;
+}
+
+/// 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 {
+ ENRMBlockRange *listBlock = [self listBlockForParagraphAtPosition:caret];
+ [self toggleBlockType:listBlock != nil ? listBlock.type : ENRMInputBlockTypeUnorderedListItem level:0];
+ }
+ 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;
+ }
+ ENRMBlockRange *listBlock = [self listBlockForCursorParagraph];
+ if (listBlock == nil) {
+ return NO;
+ }
+ if (listBlock.level > 0) {
+ [self outdentList];
+ } else {
+ [self toggleBlockType:listBlock.type level:0];
+ }
+ return YES;
+}
+
+/// listBlockForCursorParagraph for an arbitrary position — the caret hasn't
+/// moved yet when a replacement is intercepted.
+- (BOOL)listStateForParagraphAtPosition:(NSUInteger)position depth:(NSInteger *)outDepth
+{
+ ENRMBlockRange *block = [self listBlockForParagraphAtPosition:position];
+ if (block != nil) {
+ 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;
+
+ BOOL ordered = NO;
+ NSInteger ordinal = 1;
+ ENRMBlockRange *cursorListBlock = selection.length == 0 ? [self listBlockForCursorParagraph] : nil;
+ if (cursorListBlock != nil) {
+ NSInteger cursorDepth = cursorListBlock.level;
+ ordered = cursorListBlock.type == ENRMInputBlockTypeOrderedListItem;
+ ordinal = cursorListBlock.ordinal;
+ 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.emptyBulletOrdered = ordered;
+ _layoutManager.emptyBulletOrdinal = ordinal;
+ _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 +1285,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.
+ ENRMBlockRange *typingListBlock = [self listBlockForCursorParagraph];
+ if (typingListBlock != nil) {
+ NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
+ CGFloat indent = (typingListBlock.level + 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 +1442,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 +1825,19 @@ - (void)emitOnChangeState
NSInteger headingLevel = [self headingLevelForCursorParagraph];
+ NSInteger listDepth = 0;
+ ENRMBlockRange *emitListBlock = [self listBlockForCursorParagraph];
+ BOOL unorderedListActive = emitListBlock != nil && emitListBlock.type == ENRMInputBlockTypeUnorderedListItem;
+ BOOL orderedListActive = emitListBlock != nil && emitListBlock.type == ENRMInputBlockTypeOrderedListItem;
+ if (emitListBlock != nil) {
+ listDepth = emitListBlock.level;
+ }
+
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 &&
+ _prevState.orderedList == orderedListActive && _prevState.orderedListDepth == listDepth) {
return;
}
@@ -1472,6 +1848,10 @@ - (void)emitOnChangeState
_prevState.spoiler = spoilerActive;
_prevState.link = linkActive;
_prevState.headingLevel = headingLevel;
+ _prevState.unorderedList = unorderedListActive;
+ _prevState.unorderedListDepth = listDepth;
+ _prevState.orderedList = orderedListActive;
+ _prevState.orderedListDepth = listDepth;
_prevState.initialized = YES;
emitter->onChangeState({
@@ -1482,6 +1862,9 @@ - (void)emitOnChangeState
.spoiler = {.isActive = spoilerActive},
.link = {.isActive = linkActive},
.heading = {.isActive = headingLevel > 0, .level = static_cast(headingLevel)},
+ .unorderedList = {.isActive = unorderedListActive,
+ .depth = static_cast(unorderedListActive ? listDepth : 0)},
+ .orderedList = {.isActive = orderedListActive, .depth = static_cast(orderedListActive ? listDepth : 0)},
});
}
@@ -1553,6 +1936,13 @@ - (void)emitContextMenuItemPress:(NSString *)itemText
BOOL spoilerActive = isActive(ENRMInputStyleTypeSpoiler);
BOOL linkActive = isActive(ENRMInputStyleTypeLink);
NSInteger headingLevel = [self headingLevelForCursorParagraph];
+ NSInteger listDepth = 0;
+ ENRMBlockRange *emitListBlock = [self listBlockForCursorParagraph];
+ BOOL unorderedListActive = emitListBlock != nil && emitListBlock.type == ENRMInputBlockTypeUnorderedListItem;
+ BOOL orderedListActive = emitListBlock != nil && emitListBlock.type == ENRMInputBlockTypeOrderedListItem;
+ if (emitListBlock != nil) {
+ listDepth = emitListBlock.level;
+ }
eventEmitter->onContextMenuItemPress({
.itemText = std::string(itemText.UTF8String),
@@ -1568,6 +1958,10 @@ - (void)emitContextMenuItemPress:(NSString *)itemText
.spoiler = {.isActive = spoilerActive},
.link = {.isActive = linkActive},
.heading = {.isActive = headingLevel > 0, .level = static_cast(headingLevel)},
+ .unorderedList = {.isActive = unorderedListActive,
+ .depth = static_cast(unorderedListActive ? listDepth : 0)},
+ .orderedList = {.isActive = orderedListActive,
+ .depth = static_cast(orderedListActive ? listDepth : 0)},
},
});
}
@@ -1670,7 +2064,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 +2098,21 @@ - (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) {
+ if ([self headingLevelForCursorParagraph] > 0 || [self listBlockForCursorParagraph] != nil) {
[self syncTypingAttributesWithCursorBlock];
} else {
[self resetBaseTypingAttributes];
@@ -1718,7 +2120,22 @@ - (void)handleTextChanged
}
#endif
- [self applyFormattingScopedToEditAtLocation:editLocation insertedLength:insertedLength];
+ // An edit that adds or removes lines can shift the depth clamp and ordered
+ // numbering of items far below the edited line; re-stamp the whole document
+ // in that case. Same-line typing keeps the scoped per-keystroke path.
+ NSString *postEditText = ENRMGetPlainText(_textView);
+ NSUInteger lineCount = 1;
+ for (NSUInteger i = 0; i < postEditText.length; i++) {
+ if ([postEditText characterAtIndex:i] == '\n') {
+ lineCount++;
+ }
+ }
+ if (lineCount != _lastLineCount) {
+ _lastLineCount = lineCount;
+ [self applyFormatting];
+ } else {
+ [self applyFormattingScopedToEditAtLocation:editLocation insertedLength:insertedLength];
+ }
// Sync typing attributes after every edit. The selection-change callback
// is either suppressed (_isTextChanging) or guarded by the grace period,
@@ -1728,6 +2145,17 @@ - (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.
+ if ([self listBlockForCursorParagraph] != nil) {
+ [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 +2169,7 @@ - (void)handleTextChanged
[self emitCaretRectChangeIfNeeded];
[self requestHeightUpdate];
[self scheduleRelayoutIfNeeded];
+ [self updateEmptyBulletMarker];
}
#pragma mark - Text view delegate
@@ -1785,7 +2214,7 @@ - (void)manageSelectionBasedChanges
NSString *paragraphText = [text substringWithRange:paragraphRange];
BOOL isEmpty = paragraphText.length == 0 || [paragraphText isEqualToString:@"\n"];
if (isEmpty) {
- if ([self headingLevelForCursorParagraph] > 0) {
+ if ([self headingLevelForCursorParagraph] > 0 || [self listBlockForCursorParagraph] != nil) {
[self syncTypingAttributesWithCursorBlock];
} else {
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
@@ -1803,7 +2232,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 +2301,7 @@ - (void)textViewDidChangeSelection:(UITextView *)textView
[self updateActiveMention];
[self emitOnChangeState];
[self emitCaretRectChangeIfNeeded];
+ [self updateEmptyBulletMarker];
}
#else
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/ENRMOrderedListBlockHandler.h b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.h
new file mode 100644
index 000000000..daf7d2ea4
--- /dev/null
+++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#import "ENRMBlockHandler.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Block handler for ordered list items. Reserves the same head-indent column as
+/// the bullet handler (the layout manager draws the number into it) and
+/// serializes to an `N. ` line prefix indented three spaces per nesting level.
+/// Depth rides in ENRMBlockRange.level, the number in ENRMBlockRange.ordinal.
+@interface ENRMOrderedListBlockHandler : NSObject
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.mm b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.mm
new file mode 100644
index 000000000..fd3eac2cb
--- /dev/null
+++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.mm
@@ -0,0 +1,48 @@
+#import "ENRMOrderedListBlockHandler.h"
+#import "ENRMInputBlockType.h"
+
+@implementation ENRMOrderedListBlockHandler
+
+- (ENRMInputBlockType)blockType
+{
+ return ENRMInputBlockTypeOrderedListItem;
+}
+
+- (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;
+ }
+
+ // Same indent column as the bullet handler so mixed lists align; wrapped
+ // lines hang under the text, not under the number.
+ 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;
+ }
+ NSString *indent = [@"" stringByPaddingToLength:depth * 3 withString:@" " startingAtIndex:0];
+ return [indent stringByAppendingFormat:@"%ld. ", (long)blockRange.ordinal];
+}
+
+@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..73639e2d6
--- /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;
+ }
+ // Three spaces of indent per nesting level (wide enough that ordered markers
+ // nest under bullets and vice versa), then the bullet marker.
+ NSString *indent = [@"" stringByPaddingToLength:depth * 3 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..c6f5ee1c7 100644
--- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx
+++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx
@@ -97,6 +97,8 @@ export interface StyleState {
spoiler: { isActive: boolean };
link: { isActive: boolean };
heading: { isActive: boolean; level: HeadingLevel };
+ unorderedList: { isActive: boolean; depth: number };
+ orderedList: { isActive: boolean; depth: number };
}
export interface ContextMenuItem {
@@ -131,6 +133,10 @@ export interface EnrichedMarkdownTextInputInstance {
toggleStrikethrough: () => void;
toggleSpoiler: () => void;
toggleHeading: (level: HeadingLevel) => void;
+ toggleUnorderedList: () => void;
+ toggleOrderedList: () => void;
+ indentList: () => void;
+ outdentList: () => void;
setLink: (url: string) => void;
insertLink: (text: string, url: string) => void;
insertMention: (displayText: string, url: string) => void;
@@ -242,6 +248,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 +301,7 @@ export const EnrichedMarkdownTextInput = ({
formatMenuConfig,
linkRegex: _linkRegex,
writingDirection = 'first-strong',
+ listItemSpacing = 0,
...rest
}: EnrichedMarkdownTextInputProps) => {
const nativeRef = useRef(null);
@@ -428,8 +441,17 @@ 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,
+ orderedList,
+ } = e.nativeEvent;
onChangeState?.({
bold,
italic,
@@ -438,6 +460,8 @@ export const EnrichedMarkdownTextInput = ({
spoiler,
link,
heading: { ...heading, level: toHeadingLevel(heading.level) },
+ unorderedList,
+ orderedList,
});
},
[onChangeState]
@@ -550,6 +574,10 @@ export const EnrichedMarkdownTextInput = ({
toggleStrikethrough: () => Commands.toggleStrikethrough(commandRef),
toggleSpoiler: () => Commands.toggleSpoiler(commandRef),
toggleHeading: (level) => Commands.toggleHeading(commandRef, level),
+ toggleUnorderedList: () => Commands.toggleUnorderedList(commandRef),
+ toggleOrderedList: () => Commands.toggleOrderedList(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 +618,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..d5f73168d 100644
--- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts
+++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts
@@ -74,6 +74,8 @@ export interface OnChangeStateEvent {
spoiler: { isActive: boolean };
link: { isActive: boolean };
heading: { isActive: boolean; level: CodegenTypes.Int32 };
+ unorderedList: { isActive: boolean; depth: CodegenTypes.Int32 };
+ orderedList: { isActive: boolean; depth: CodegenTypes.Int32 };
}
export interface OnRequestMarkdownResultEvent {
@@ -164,6 +166,8 @@ export interface OnContextMenuItemPressEvent {
spoiler: { isActive: boolean };
link: { isActive: boolean };
heading: { isActive: boolean; level: CodegenTypes.Int32 };
+ unorderedList: { isActive: boolean; depth: CodegenTypes.Int32 };
+ orderedList: { isActive: boolean; depth: CodegenTypes.Int32 };
};
}
@@ -272,6 +276,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 +324,10 @@ interface NativeCommands {
viewRef: React.ElementRef,
level: CodegenTypes.Int32
) => void;
+ toggleUnorderedList: (viewRef: React.ElementRef) => void;
+ toggleOrderedList: (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 +367,10 @@ export const Commands: NativeCommands = codegenNativeCommands({
'toggleStrikethrough',
'toggleSpoiler',
'toggleHeading',
+ 'toggleUnorderedList',
+ 'toggleOrderedList',
+ 'indentList',
+ 'outdentList',
'setLink',
'insertLink',
'insertMention',