Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ class EnrichedMarkdownTextInputView(
private var headingOverrideBaseSizePx: Float? = null
private var savedHintTextColors: ColorStateList? = null

private var listItemSpacingPx = 0

init {
setupDetectorPipeline()
prepareComponent()
Expand Down Expand Up @@ -338,16 +340,16 @@ class EnrichedMarkdownTextInputView(
}

/**
* Drops heading ranges no longer anchored at a line start (e.g. Backspace merged
* Drops anchored blocks 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)
Expand All @@ -365,7 +367,7 @@ class EnrichedMarkdownTextInputView(

/**
* Adjusts both [formattingStore] and [blockStore] for a text edit, then prunes
* orphaned heading anchors and normalizes block ranges to line bounds. Every
* 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:`.
*/
Expand All @@ -376,7 +378,7 @@ class EnrichedMarkdownTextInputView(
) {
formattingStore.adjustForEdit(editStart, deletedLength, insertedLength)
blockStore.adjustForEdit(editStart, deletedLength, insertedLength)
pruneOrphanedHeadingAnchors()
pruneOrphanedAnchors()
text?.let { blockStore.normalizeToLineBounds(it) }
}

Expand Down Expand Up @@ -845,6 +847,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<FormattingRange> {
val editable = text ?: return formattingStore.allRanges
val transientRanges = detectorPipeline.allTransientFormattingRanges(editable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.swmansion.enriched.markdown.input.formatting

import com.swmansion.enriched.markdown.input.model.BlockRange
import com.swmansion.enriched.markdown.input.model.BlockType
import com.swmansion.enriched.markdown.input.model.MAX_LIST_DEPTH
import java.util.Collections

/**
Expand All @@ -24,6 +25,7 @@ class BlockStore {
fun setRanges(newRanges: List<BlockRange>) {
ranges.clear()
ranges.addAll(newRanges.sortedBy { it.start })
recomputeListMetadata()
}

fun clearAll() {
Expand All @@ -44,9 +46,9 @@ class BlockStore {
) {
val (start, end) = paragraphBounds(paragraphStart, paragraphEnd, text)
removeBlocksOverlapping(start, end)
// A heading on an empty line is kept as a zero-length anchor (see adjustForEdit);
// other blocks need real content, so an empty line yields no block.
if (end < start || (end == start && type !in BlockType.HEADINGS)) return
// An anchored block (heading, list item) on an empty line is kept as a zero-length
// anchor (see adjustForEdit); other blocks need real content.
if (end < start || (end == start && type !in BlockType.ANCHORED)) return

val block = BlockRange(type, start, end, level)
ranges.add(sortedInsertionIndex(ranges, start), block)
Expand All @@ -67,10 +69,10 @@ class BlockStore {

/**
* Shifts/clips block ranges to follow a text edit (see [RangeEditAdjustment]),
* with heading persistence layered on top: a heading deleted exactly to its
* end collapses to a zero-length anchor at the edit location (its line
* survives), and existing anchors shift/keep/drop with their line. The view's
* prune/normalize pass reconciles anchors against the final text.
* with anchored-block (heading / list item) persistence layered on top: a block
* deleted exactly to its end collapses to a zero-length anchor at the edit
* location (its line survives), and existing anchors shift/keep/drop with their
* line. The view's prune/normalize pass reconciles anchors against the final text.
*/
fun adjustForEdit(
editLocation: Int,
Expand All @@ -82,14 +84,14 @@ class BlockStore {
val deleteEnd = editLocation + deletedLength
val delta = insertedLength - deletedLength

val anchors = ranges.filter { it.length == 0 && it.type in BlockType.HEADINGS }
val anchors = ranges.filter { it.length == 0 && it.type in BlockType.ANCHORED }
ranges.removeAll { it.length == 0 }

// At most one range can end exactly at deleteEnd, so this restores at most
// one collapsed heading.
// one collapsed block.
val collapsed =
ranges.firstOrNull {
it.type in BlockType.HEADINGS && it.start >= editLocation && it.end == deleteEnd
it.type in BlockType.ANCHORED && it.start >= editLocation && it.end == deleteEnd
}

RangeEditAdjustment.adjustForEdit(ranges, editLocation, deletedLength, insertedLength)
Expand Down Expand Up @@ -121,9 +123,11 @@ class BlockStore {
/**
* Snaps every stored range to the line bounds of its start position.
* Absorbs edge-typed chars, clips split ranges to first line, drops
* duplicates. On an empty line a heading persists as a zero-length anchor;
* any other collapsed range is dropped. Call after [adjustForEdit] once
* [text] is final. Idempotent.
* duplicates. On an empty line an anchored block (heading, list item)
* persists as a zero-length anchor; any other collapsed range is dropped.
* List depths are clamped so an item nests at most one level under the
* previous adjacent item (CommonMark cannot represent orphan nesting).
* Call after [adjustForEdit] once [text] is final. Idempotent.
*/
fun normalizeToLineBounds(text: CharSequence) {
if (ranges.isEmpty()) return
Expand All @@ -134,14 +138,54 @@ 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
}
range.start = lineStart
range.end = lineEnd
previousEnd = lineEnd
}

recomputeListMetadata()
}

/**
* Clamps list depths to valid ancestry (an item nests at most one level under
* the previous adjacent list item — CommonMark cannot represent orphan nesting)
* and renumbers ordered items among their adjacent same-depth, same-type run.
*/
private fun recomputeListMetadata() {
var prevEnd = -2
var prevDepth = -1
val counters = IntArray(MAX_LIST_DEPTH + 2)
val counterTypes = arrayOfNulls<BlockType>(MAX_LIST_DEPTH + 2)
for (range in ranges) {
if (range.type !in BlockType.LIST_ITEMS) {
prevDepth = -1
continue
}
val adjacent = prevDepth >= 0 && range.start == prevEnd + 1
if (!adjacent) {
counters.fill(0)
counterTypes.fill(null)
}
val maxDepth = if (adjacent) prevDepth + 1 else 0
if (range.level > maxDepth) range.level = maxDepth
val depth = range.level
for (i in depth + 1..MAX_LIST_DEPTH + 1) {
counters[i] = 0
counterTypes[i] = null
}
if (counterTypes[depth] != range.type) {
counters[depth] = 0
counterTypes[depth] = range.type
}
counters[depth]++
range.ordinal = counters[depth]
prevEnd = range.end
prevDepth = range.level
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import com.swmansion.enriched.markdown.input.model.BlockType
import com.swmansion.enriched.markdown.input.model.FormattingRange
import com.swmansion.enriched.markdown.input.model.InputFormatterStyle
import com.swmansion.enriched.markdown.input.model.StyleType
import com.swmansion.enriched.markdown.input.spans.InputListItemSpacingSpan
import com.swmansion.enriched.markdown.input.styles.BlockHandler
import com.swmansion.enriched.markdown.input.styles.BoldStyleHandler
import com.swmansion.enriched.markdown.input.styles.HeadingBlockHandler
import com.swmansion.enriched.markdown.input.styles.ItalicStyleHandler
import com.swmansion.enriched.markdown.input.styles.LinkStyleHandler
import com.swmansion.enriched.markdown.input.styles.OrderedListBlockHandler
import com.swmansion.enriched.markdown.input.styles.SpoilerStyleHandler
import com.swmansion.enriched.markdown.input.styles.StrikethroughStyleHandler
import com.swmansion.enriched.markdown.input.styles.StyleHandler
import com.swmansion.enriched.markdown.input.styles.UnderlineStyleHandler
import com.swmansion.enriched.markdown.input.styles.UnorderedListBlockHandler

/**
* Marker interface so we only remove spans we created, leaving
Expand All @@ -37,11 +40,15 @@ class InputFormatter {
/**
* Block handlers, keyed by block type. A single [HeadingBlockHandler] serves all
* six heading levels — it reads the level from the [BlockRange] — so it is mapped
* under every `HEADING_n` key.
* under every `HEADING_n` key. One [UnorderedListBlockHandler] serves every list
* depth (depth lives on the range), so it is mapped under the single list key.
*/
val blockHandlers: Map<BlockType, BlockHandler> =
HeadingBlockHandler().let { heading ->
BlockType.HEADINGS.associateWith { heading }
buildMap {
val heading = HeadingBlockHandler()
for (type in BlockType.HEADINGS) put(type, heading)
put(BlockType.UNORDERED_LIST_ITEM, UnorderedListBlockHandler())
put(BlockType.ORDERED_LIST_ITEM, OrderedListBlockHandler())
}

fun handlerForBlock(type: BlockType): BlockHandler? = blockHandlers[type]
Expand Down Expand Up @@ -170,23 +177,32 @@ class InputFormatter {
}

for (range in blockRanges) {
val isHeadingAnchor = range.length == 0 && range.type in BlockType.HEADINGS
if (!isHeadingAnchor && (range.start >= range.end || range.start < 0 || range.end > spannable.length)) continue
if (isHeadingAnchor && (range.start < 0 || range.start > spannable.length)) continue
if (isHeadingAnchor) {
val isAnchor = range.length == 0 && range.type in BlockType.ANCHORED
if (!isAnchor && (range.start >= range.end || range.start < 0 || range.end > spannable.length)) continue
if (isAnchor && (range.start < 0 || range.start > spannable.length)) continue
if (isAnchor) {
if (range.start < start || range.start > end) continue
} else if (range.end <= start || range.start >= end) {
continue
}
val handler = blockHandlers[range.type] ?: continue

// Extend a zero-length heading anchor to cover the next character (usually
// '\n') so the Layout gives the empty line heading metrics. At the very end
// of text the span stays zero-length; the view-level paint override handles
// Extend a zero-length anchor to cover the next character (usually '\n') so
// the Layout gives the empty line the block's metrics. At the very end of
// text the span stays zero-length; the view-level paint override handles
// cursor height for that edge case.
val spanEnd = if (isHeadingAnchor && range.start < spannable.length) range.start + 1 else range.end
val flags = if (isHeadingAnchor) Spannable.SPAN_INCLUSIVE_INCLUSIVE else Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
val anchorEnd = if (isAnchor && range.start < spannable.length) range.start + 1 else range.end
val flags = if (isAnchor) Spannable.SPAN_INCLUSIVE_INCLUSIVE else Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
for (span in handler.createSpans(range, currentStyle)) {
// A LineHeightSpan (list-item spacing) must cover only the item's first
// character so it spaces just the first visual line, not wrapped lines.
val spanEnd =
if (span is InputListItemSpacingSpan) {
(range.start + 1).coerceAtMost(range.end).coerceAtMost(spannable.length)
} else {
anchorEnd
}
if (span is InputListItemSpacingSpan && spanEnd <= range.start) continue
spannable.setSpan(span, range.start, spanEnd, flags)
}
}
Expand Down
Loading
Loading