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