From 6ea77043fc1dc807e030237c51e835da34a4ac60 Mon Sep 17 00:00:00 2001 From: MohamedRejeb Date: Sun, 10 Nov 2024 13:34:20 +0100 Subject: [PATCH] Trim paragraphs on converting to and from Markdown --- .../mohamedrejeb/richeditor/model/RichSpan.kt | 91 +++++++- .../richeditor/paragraph/RichParagraph.kt | 41 ++++ .../markdown/RichTextStateMarkdownParser.kt | 24 +- .../model/RichParagraphStyleTest.kt | 60 ----- .../model/RichParagraphTest.kt | 215 ++++++++++++++++++ .../{RichSpanStyleTest.kt => RichSpanTest.kt} | 0 .../RichTextStateMarkdownParserDecodeTest.kt | 22 ++ .../RichTextStateMarkdownParserEncodeTest.kt | 27 +++ 8 files changed, 412 insertions(+), 68 deletions(-) delete mode 100644 richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphStyleTest.kt create mode 100644 richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphTest.kt rename richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/{RichSpanStyleTest.kt => RichSpanTest.kt} (100%) diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt index 9c53ef42..06135f7e 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt @@ -9,6 +9,7 @@ import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi import com.mohamedrejeb.richeditor.paragraph.RichParagraph import com.mohamedrejeb.richeditor.utils.customMerge import com.mohamedrejeb.richeditor.utils.isSpecifiedFieldsEquals +import kotlin.collections.indices /** * A rich span is a part of a rich paragraph. @@ -232,6 +233,94 @@ internal class RichSpan( return null } + /** + * Trim the start of the rich span + * + * @return True if the rich span is empty after trimming, false otherwise + */ + internal fun trimStart(): Boolean { + if (richSpanStyle is RichSpanStyle.Image) + return false + + if (isBlank()) { + text = "" + children.clear() + return true + } + + text = text.trimStart() + + if (text.isNotEmpty()) + return false + + var isEmpty = true + val toRemoveIndices = mutableListOf() + + for (i in children.indices) { + val richSpan = children[i] + + val isChildEmpty = richSpan.trimStart() + + if (isChildEmpty) { + // Remove the child if it's empty + toRemoveIndices.add(i) + } else { + isEmpty = false + break + } + } + + toRemoveIndices.fastForEachReversed { + children.removeAt(it) + } + + return isEmpty + } + + internal fun trimEnd(): Boolean { + val isImage = richSpanStyle is RichSpanStyle.Image + + if (isImage) + return false + + val isChildrenBlank = isChildrenBlank() && !isImage + + if (text.isBlank() && isChildrenBlank) { + text = "" + children.clear() + return true + } + + if (isChildrenBlank) { + children.clear() + text = text.trimEnd() + return false + } + + var isEmpty = true + val toRemoveIndices = mutableListOf() + + for (i in children.indices.reversed()) { + val richSpan = children[i] + + val isChildEmpty = richSpan.trimEnd() + + if (isChildEmpty) { + // Remove the child if it's empty + toRemoveIndices.add(i) + } else { + isEmpty = false + break + } + } + + toRemoveIndices.fastForEach { + children.removeAt(it) + } + + return isEmpty + } + /** * Get the last non-empty child * @@ -497,6 +586,6 @@ internal class RichSpan( ) override fun toString(): String { - return "richSpan(text='$text', textRange=$textRange, fullTextRange=$fullTextRange), richSpanStyle=$richSpanStyle)" + return "richSpan(text='$text', textRange=$textRange, fullTextRange=$fullTextRange, fontSize=${spanStyle.fontSize}, fontWeight=${spanStyle.fontWeight}, richSpanStyle=$richSpanStyle)" } } \ No newline at end of file diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt index 466a48ea..997058d5 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt @@ -178,6 +178,47 @@ internal class RichParagraph( return firstChild } + /** + * Trim the rich paragraph + */ + fun trim() { + val isEmpty = trimStart() + if (!isEmpty) + trimEnd() + } + + /** + * Trim the start of the rich paragraph + * + * @return True if the rich paragraph is empty after trimming, false otherwise + */ + fun trimStart(): Boolean { + children.fastForEach { richSpan -> + val isEmpty = richSpan.trimStart() + + if (!isEmpty) + return false + } + + return true + } + + /** + * Trim the end of the rich paragraph + * + * @return True if the rich paragraph is empty after trimming, false otherwise + */ + fun trimEnd(): Boolean { + children.fastForEachReversed { richSpan -> + val isEmpty = richSpan.trimEnd() + + if (!isEmpty) + return false + } + + return true + } + /** * Update the paragraph of the children recursively * diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/markdown/RichTextStateMarkdownParser.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/markdown/RichTextStateMarkdownParser.kt index 49724b32..c4199ac4 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/markdown/RichTextStateMarkdownParser.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/markdown/RichTextStateMarkdownParser.kt @@ -305,6 +305,8 @@ internal object RichTextStateMarkdownParser : RichTextStateParser { var lastBrParagraphIndex = -1 richParagraphList.forEachIndexed { i, paragraph -> + paragraph.trim() + val isEmpty = paragraph.isEmpty() val isBr = i in brParagraphIndices @@ -341,16 +343,20 @@ internal object RichTextStateMarkdownParser : RichTextStateParser { // Append paragraph start text builder.appendParagraphStartText(richParagraph) + var isHeading = false + richParagraph.getFirstNonEmptyChild()?.let { firstNonEmptyChild -> if (firstNonEmptyChild.text.isNotEmpty()) { // Append markdown line start text - builder.append(getMarkdownLineStartTextFromFirstRichSpan(firstNonEmptyChild)) + val lineStartText = getMarkdownLineStartTextFromFirstRichSpan(firstNonEmptyChild) + builder.append(lineStartText) + isHeading = lineStartText.startsWith('#') } } // Append paragraph children richParagraph.children.fastForEach { richSpan -> - builder.append(decodeRichSpanToMarkdown(richSpan)) + builder.append(decodeRichSpanToMarkdown(richSpan, isHeading)) } // Append line break if needed @@ -371,7 +377,10 @@ internal object RichTextStateMarkdownParser : RichTextStateParser { } @OptIn(ExperimentalRichTextApi::class) - private fun decodeRichSpanToMarkdown(richSpan: RichSpan): String { + private fun decodeRichSpanToMarkdown( + richSpan: RichSpan, + isHeading: Boolean, + ): String { val stringBuilder = StringBuilder() // Check if span is empty @@ -384,7 +393,8 @@ internal object RichTextStateMarkdownParser : RichTextStateParser { val markdownOpen = mutableListOf() val markdownClose = mutableListOf() - if ((richSpan.spanStyle.fontWeight?.weight ?: 400) > 400) { + // Ignore adding bold `**` for heading since it's already bold + if ((richSpan.spanStyle.fontWeight?.weight ?: 400) > 400 && !isHeading) { markdownOpen += "**" markdownClose += "**" } @@ -405,7 +415,7 @@ internal object RichTextStateMarkdownParser : RichTextStateParser { } // Append markdown open - if (!isBlank) + if (!isBlank && markdownOpen.isNotEmpty()) stringBuilder.append(markdownOpen.joinToString(separator = "")) // Apply rich span style to markdown @@ -416,11 +426,11 @@ internal object RichTextStateMarkdownParser : RichTextStateParser { // Append children richSpan.children.fastForEach { child -> - stringBuilder.append(decodeRichSpanToMarkdown(child)) + stringBuilder.append(decodeRichSpanToMarkdown(child, isHeading)) } // Append markdown close - if (!isBlank) + if (!isBlank && markdownClose.isNotEmpty()) stringBuilder.append(markdownClose.reversed().joinToString(separator = "")) return stringBuilder.toString() diff --git a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphStyleTest.kt b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphStyleTest.kt deleted file mode 100644 index 4667d2d7..00000000 --- a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphStyleTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.mohamedrejeb.richeditor.model - -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.ui.text.TextRange -import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi -import com.mohamedrejeb.richeditor.paragraph.RichParagraph -import kotlin.test.Test -import kotlin.test.assertEquals - -class RichParagraphStyleTest { - private val paragraph = RichParagraph(key = 0) - @OptIn(ExperimentalRichTextApi::class) - private val richSpanLists get() = listOf( - RichSpan( - key = 0, - paragraph = paragraph, - text = "012", - textRange = TextRange(0, 3), - children = mutableStateListOf( - RichSpan( - key = 10, - paragraph = paragraph, - text = "345", - textRange = TextRange(3, 6), - ), - RichSpan( - key = 11, - paragraph = paragraph, - text = "6", - textRange = TextRange(6, 7), - ), - ) - ), - RichSpan( - key = 1, - paragraph = paragraph, - text = "78", - textRange = TextRange(7, 9), - ) - ) - private val richParagraph = RichParagraph(key = 0,) - - @Test - fun testRemoveTextRange() { - richParagraph.children.clear() - richParagraph.children.addAll(richSpanLists) - assertEquals( - null, - richParagraph.removeTextRange(TextRange(0, 20), 0) - ) - - richParagraph.children.clear() - richParagraph.children.addAll(richSpanLists) - assertEquals( - 1, - richParagraph.removeTextRange(TextRange(0, 8), 0)?.children?.size - ) - } - -} \ No newline at end of file diff --git a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphTest.kt b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphTest.kt new file mode 100644 index 00000000..4249ad7f --- /dev/null +++ b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichParagraphTest.kt @@ -0,0 +1,215 @@ +package com.mohamedrejeb.richeditor.model + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.text.TextRange +import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi +import com.mohamedrejeb.richeditor.paragraph.RichParagraph +import kotlin.test.Test +import kotlin.test.assertEquals + +class RichParagraphTest { + private val paragraph = RichParagraph(key = 0) + + @OptIn(ExperimentalRichTextApi::class) + private val richSpanLists + get() = listOf( + RichSpan( + key = 0, + paragraph = paragraph, + text = "012", + textRange = TextRange(0, 3), + children = mutableStateListOf( + RichSpan( + key = 10, + paragraph = paragraph, + text = "345", + textRange = TextRange(3, 6), + ), + RichSpan( + key = 11, + paragraph = paragraph, + text = "6", + textRange = TextRange(6, 7), + ), + ) + ), + RichSpan( + key = 1, + paragraph = paragraph, + text = "78", + textRange = TextRange(7, 9), + ) + ) + private val richParagraph = RichParagraph(key = 0) + + @Test + fun testRemoveTextRange() { + richParagraph.children.clear() + richParagraph.children.addAll(richSpanLists) + assertEquals( + null, + richParagraph.removeTextRange(TextRange(0, 20), 0) + ) + + richParagraph.children.clear() + richParagraph.children.addAll(richSpanLists) + assertEquals( + 1, + richParagraph.removeTextRange(TextRange(0, 8), 0)?.children?.size + ) + } + + @OptIn(ExperimentalRichTextApi::class) + @Test + fun testTrimStart() { + val paragraph = RichParagraph(key = 0) + val richSpanLists = listOf( + RichSpan( + key = 0, + paragraph = paragraph, + text = " ", + textRange = TextRange(0, 3), + children = mutableStateListOf( + RichSpan( + key = 10, + paragraph = paragraph, + text = " 345", + textRange = TextRange(3, 6), + ), + RichSpan( + key = 11, + paragraph = paragraph, + text = "6", + textRange = TextRange(6, 7), + ), + ) + ), + RichSpan( + key = 1, + paragraph = paragraph, + text = "78", + textRange = TextRange(7, 9), + ) + ) + paragraph.children.addAll(richSpanLists) + + paragraph.trimStart() + + val firstChild = paragraph.children[0] + val secondChild = paragraph.children[1] + + assertEquals("", firstChild.text) + assertEquals("78", secondChild.text) + + val firstGrandChild = firstChild.children[0] + val secondGrandChild = firstChild.children[1] + + assertEquals("345", firstGrandChild.text) + assertEquals("6", secondGrandChild.text) + } + + @OptIn(ExperimentalRichTextApi::class) + @Test + fun testTrimEnd() { + val paragraph = RichParagraph(key = 0) + val richSpanLists = listOf( + RichSpan( + key = 0, + paragraph = paragraph, + text = " 012", + children = mutableStateListOf( + RichSpan( + key = 10, + paragraph = paragraph, + text = " 345", + ), + RichSpan( + key = 11, + paragraph = paragraph, + text = "6 ", + ), + RichSpan( + key = 12, + paragraph = paragraph, + text = " ", + ), + ) + ), + RichSpan( + key = 1, + paragraph = paragraph, + text = " ", + ) + ) + paragraph.children.addAll(richSpanLists) + + paragraph.trimEnd() + + val firstChild = paragraph.children[0] + val secondChild = paragraph.children[1] + + assertEquals(2, firstChild.children.size) + + assertEquals(" 012", firstChild.text) + assertEquals("", secondChild.text) + + val firstGrandChild = firstChild.children[0] + val secondGrandChild = firstChild.children[1] + + assertEquals(" 345", firstGrandChild.text) + assertEquals("6", secondGrandChild.text) + } + + @OptIn(ExperimentalRichTextApi::class) + @Test + fun testTrim() { + val paragraph = RichParagraph(key = 0) + val richSpanLists = listOf( + RichSpan( + key = 0, + paragraph = paragraph, + text = " ", + children = mutableStateListOf( + RichSpan( + key = 10, + paragraph = paragraph, + text = " 345", + ), + RichSpan( + key = 11, + paragraph = paragraph, + text = "6 ", + ), + RichSpan( + key = 12, + paragraph = paragraph, + text = " ", + ), + ) + ), + RichSpan( + key = 1, + paragraph = paragraph, + text = " ", + ) + ) + paragraph.children.addAll(richSpanLists) + + paragraph.trim() + + val firstChild = paragraph.children[0] + val secondChild = paragraph.children[1] + + assertEquals(2, firstChild.children.size) + + assertEquals("", firstChild.text) + assertEquals("", secondChild.text) + + val firstGrandChild = firstChild.children[0] + val secondGrandChild = firstChild.children[1] + + assertEquals("345", firstGrandChild.text) + assertEquals("6", secondGrandChild.text) + } + +} \ No newline at end of file diff --git a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichSpanStyleTest.kt b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichSpanTest.kt similarity index 100% rename from richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichSpanStyleTest.kt rename to richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichSpanTest.kt diff --git a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserDecodeTest.kt b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserDecodeTest.kt index 8955f91e..ac742691 100644 --- a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserDecodeTest.kt +++ b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserDecodeTest.kt @@ -355,4 +355,26 @@ class RichTextStateMarkdownParserDecodeTest { ) } + @Test + fun testDecodeTitles() { + val markdown = """ + # Prompt + ## Emphasis + """.trimIndent() + + val state = RichTextState() + + state.setMarkdown(markdown) + + state.printParagraphs() + + assertEquals( + """ + # Prompt + ## Emphasis + """.trimIndent(), + state.toMarkdown() + ) + } + } \ No newline at end of file diff --git a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserEncodeTest.kt b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserEncodeTest.kt index bc45917e..38d84f35 100644 --- a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserEncodeTest.kt +++ b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/parser/markdown/RichTextStateMarkdownParserEncodeTest.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.text.style.TextDecoration import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi import com.mohamedrejeb.richeditor.model.RichSpanStyle import com.mohamedrejeb.richeditor.model.RichTextState +import com.mohamedrejeb.richeditor.parser.utils.H1SpanStyle +import com.mohamedrejeb.richeditor.parser.utils.H2SpanStyle import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -402,4 +404,29 @@ class RichTextStateMarkdownParserEncodeTest { assertEquals(state.richParagraphList.size, 2) } + @Test + fun testEncodeTitles() { + val markdown = """ + # Prompt + ## Emphasis + """.trimIndent() + + val state = RichTextState() + + state.setMarkdown(markdown) + + state.printParagraphs() + + assertEquals(2, state.richParagraphList.size) + + val firstParagraph = state.richParagraphList[0] + + assertEquals(H1SpanStyle, firstParagraph.getFirstNonEmptyChild()!!.spanStyle) + + val secondParagraph = state.richParagraphList[1] + assertEquals(H2SpanStyle, secondParagraph.getFirstNonEmptyChild()!!.spanStyle) + + assertEquals("Prompt\nEmphasis", state.toText()) + } + } \ No newline at end of file