fix(richtext): preserve blank lines between aligned paragraphs; share span merging
Code-review fixes: an all-empty paragraph group (a blank line isolated by an alignment split) emitted <p></p>, which the parser collapses — it now emits one <br> per line so blank lines round-trip. The identical span-merge helper that existed in both the parser and RichTextEditing is now a single shared mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop their never-used default font resolver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,3 +102,20 @@ internal fun lineMarker(line: String): String? = when {
|
||||
line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX
|
||||
else -> ORDERED_PREFIX.find(line)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges spans with the exact same style value that touch or overlap, yielding the canonical
|
||||
* maximal-run form. Shared by the HTML parser and the editing ops so the two can never drift.
|
||||
*/
|
||||
internal fun mergeSameValueSpans(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) {
|
||||
val i = merged.indexOfLast { it.style == span.style && span.start <= it.end }
|
||||
if (i >= 0) {
|
||||
merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end))
|
||||
} else {
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ object RichTextEditing {
|
||||
val updated = if (isFullyStyled(sameKind.filter { it.style == style }, start, end)) {
|
||||
cleared
|
||||
} else {
|
||||
mergeSameValue(cleared + RichSpan(start, end, style))
|
||||
mergeSameValueSpans(cleared + RichSpan(start, end, style))
|
||||
}
|
||||
return content.copy(spans = (otherKinds + updated).sortedBy { it.start })
|
||||
}
|
||||
@@ -160,20 +160,6 @@ private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<Ric
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges spans with the exact same style value that touch or overlap. */
|
||||
private fun mergeSameValue(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) {
|
||||
val i = merged.indexOfLast { it.style == span.style && span.start <= it.end }
|
||||
if (i >= 0) {
|
||||
merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end))
|
||||
} else {
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- block marker helpers ---
|
||||
|
||||
private val ORDERED = Regex("^\\d+\\. ")
|
||||
|
||||
@@ -122,12 +122,17 @@ private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<L
|
||||
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
val align = alignFor(content, lines[from])
|
||||
sb.append(openBlockTag("p", align))
|
||||
val inner = StringBuilder()
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH && alignFor(content, lines[i]) == align) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
if (i > from) inner.append("<br>")
|
||||
inner.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
// A group of only empty lines would otherwise vanish on parse (the closing tag's newline is a
|
||||
// no-op at a line start), so it emits one <br> per line to keep the blank lines round-trippable.
|
||||
val allEmpty = (from until i).all { lines[it].contentStart >= lines[it].contentEnd }
|
||||
sb.append(if (allEmpty) "<br>".repeat(i - from) else inner)
|
||||
sb.append("</p>")
|
||||
return i
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ internal class RichTextHtmlParser(private val html: String) {
|
||||
val len = out.length
|
||||
return RichTextContent(
|
||||
text = out,
|
||||
spans = mergedSpans(spans.mapNotNull { it.clampedTo(len) }),
|
||||
spans = mergeSameValueSpans(spans.mapNotNull { it.clampedTo(len) }),
|
||||
links = links.mapNotNull { it.clampedTo(len) },
|
||||
alignments = mergedAlignments(alignments.mapNotNull { it.clampedTo(len) }),
|
||||
images = images.filter { it.end <= len },
|
||||
@@ -242,20 +242,6 @@ private fun RichAlignment.clampedTo(len: Int): RichAlignment? {
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
/** Merges same-style spans that touch or overlap, yielding the canonical maximal-run form. */
|
||||
private fun mergedSpans(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) {
|
||||
val i = merged.indexOfLast { it.style == span.style && span.start <= it.end }
|
||||
if (i >= 0) {
|
||||
merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end))
|
||||
} else {
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Merges same-alignment runs on adjacent lines (ranges separated by exactly the newline). */
|
||||
private fun mergedAlignments(alignments: List<RichAlignment>): List<RichAlignment> {
|
||||
val merged = ArrayList<RichAlignment>()
|
||||
|
||||
@@ -300,7 +300,7 @@ private fun applyBlock(
|
||||
value: TextFieldValue,
|
||||
marker: BlockMarker,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextFieldValue {
|
||||
val result = RichTextEditing.toggleBlock(
|
||||
value.annotatedString.toRichContent(),
|
||||
@@ -318,7 +318,7 @@ private fun applyLink(
|
||||
value: TextFieldValue,
|
||||
url: String,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextFieldValue {
|
||||
val updated = RichTextEditing.applyLink(
|
||||
value.annotatedString.toRichContent(),
|
||||
|
||||
@@ -187,6 +187,20 @@ class RichTextHtmlTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank line between differently aligned paragraphs survives round-trip`() {
|
||||
val content = RichTextContent(
|
||||
text = "a\n\nb",
|
||||
alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(3, 4, RichAlign.CENTER)),
|
||||
)
|
||||
// The empty middle group must emit an explicit <br> or the blank line vanishes on parse.
|
||||
assertEquals(
|
||||
"<p style=\"text-align:center\">a</p><p><br></p><p style=\"text-align:center\">b</p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser accepts start and end alignment synonyms`() {
|
||||
val restored = RichTextHtml.fromHtml("<p style=\"text-align:start\">a</p><p style=\"text-align:end\">b</p>")
|
||||
|
||||
Reference in New Issue
Block a user