diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/HeadingStyle.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/HeadingStyle.kt
new file mode 100644
index 00000000..2e45a3b8
--- /dev/null
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/HeadingStyle.kt
@@ -0,0 +1,202 @@
+package com.mohamedrejeb.richeditor.model
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.ParagraphStyle
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import org.intellij.markdown.MarkdownElementTypes
+
+/**
+ * Represents the different heading levels (H1 to H6) and a normal paragraph style
+ * that can be applied to a paragraph in the Rich Editor.
+ *
+ * Each heading level is associated with a specific Markdown element (e.g., "# ", "## ")
+ * and HTML tag (e.g., "h1", "h2").
+ *
+ * These styles are typically applied to an entire paragraph, influencing its appearance
+ * and semantic meaning in both the editor and when converted to formats like Markdown or HTML.
+ */
+public enum class HeadingStyle(
+ public val markdownElement: String,
+ public val htmlTag: String? = null,
+) {
+ /**
+ * Represents a standard, non-heading paragraph.
+ */
+ Normal(""),
+
+ /**
+ * Represents a Heading Level 1.
+ */
+ H1("# ", "h1"),
+
+ /**
+ * Represents a Heading Level 2.
+ */
+ H2("## ", "h2"),
+
+ /**
+ * Represents a Heading Level 3.
+ */
+ H3("### ", "h3"),
+
+ /**
+ * Represents a Heading Level 4.
+ */
+ H4("#### ", "h4"),
+
+ /**
+ * Represents a Heading Level 5.
+ */
+ H5("##### ", "h5"),
+
+ /**
+ * Represents a Heading Level 6.
+ */
+ H6("###### ", "h6");
+
+ // Using Material 3 Typography for default heading styles
+ // Instantiation here allows use to use Typography without a composable
+ private val typography = Typography()
+
+ /**
+ * Retrieves the base [SpanStyle] associated with this heading level.
+ *
+ * This function converts the [TextStyle] obtained from [getTextStyle] to a [SpanStyle].
+ *
+ * Setting [FontWeight] to `null` here prevents the base heading's font weight
+ * ([FontWeight.Normal] in typography for each heading) from interfering with user-applied font weights
+ * like [FontWeight.Bold] when identifying or diffing styles.
+ *
+ * @return The base [SpanStyle] for this heading level, with [FontWeight] set to `null`.
+ */
+ public fun getSpanStyle(): SpanStyle {
+ return this.getTextStyle().toSpanStyle().copy(fontWeight = null)
+ }
+
+ /**
+ * Retrieves the base [ParagraphStyle] associated with this heading level.
+ *
+ * This function converts the [TextStyle] obtained from [getTextStyle] to a [ParagraphStyle].
+ * This style includes paragraph-level properties like line height, text alignment, etc.,
+ * as defined by the Material 3 Typography for the corresponding text style.
+ *
+ * @return The base [ParagraphStyle] for this heading level.
+ */
+ public fun getParagraphStyle() : ParagraphStyle {
+ return this.getTextStyle().toParagraphStyle()
+ }
+
+ /**
+ * Retrieves the base [TextStyle] associated with this heading level from the
+ * Material 3 Typography.
+ *
+ * This maps each heading level (H1-H6) to a specific Material 3 display or
+ * headline text style. [Normal] maps to [TextStyle.Default].
+ *
+ * @return The base [TextStyle] for this heading level.
+ * @see Material 3 Typography Mapping
+ */
+ public fun getTextStyle() : TextStyle {
+ return when (this) {
+ Normal -> TextStyle.Default
+ H1 -> typography.displayLarge
+ H2 -> typography.displayMedium
+ H3 -> typography.displaySmall
+ H4 -> typography.headlineMedium
+ H5 -> typography.headlineSmall
+ H6 -> typography.titleLarge
+ }
+ }
+
+ public companion object {
+ /**
+ * Identifies the [HeadingStyle] based on a given [SpanStyle].
+ *
+ * This function compares the provided [spanStyle] with the base [SpanStyle]
+ * of each heading level defined in [HeadingStyle.getTextStyle].
+ * It primarily matches based on properties like font size, font family,
+ * and letter spacing, as these are strong indicators of a heading style
+ * derived from typography.
+ *
+ * Special handling for [FontWeight.Normal]: If a heading's base style has
+ * [FontWeight.Normal] (which is common in typography but explicitly set to
+ * `null` by [getSpanStyle]), this property is effectively ignored during
+ * comparison. This allows user-applied non-normal font weights (like Bold)
+ * to coexist with the identified heading style without preventing a match.
+ *
+ * @param spanStyle The [SpanStyle] to compare against heading styles.
+ * @return The matching [HeadingStyle], or [HeadingStyle.Normal] if no match is found.
+ */
+ public fun fromSpanStyle(spanStyle: SpanStyle): HeadingStyle {
+ return entries.find {
+ val entrySpanStyle = it.getSpanStyle()
+ entrySpanStyle.fontSize == spanStyle.fontSize
+ // Ignore fontWeight comparison because getSpanStyle makes it null
+ && entrySpanStyle.fontFamily == spanStyle.fontFamily
+ && entrySpanStyle.letterSpacing == spanStyle.letterSpacing
+ } ?: Normal
+ }
+
+ /**
+ * Identifies the [HeadingStyle] based on the [SpanStyle] of a given [RichSpan].
+ *
+ * This function is a convenience wrapper around [fromSpanStyle], extracting the
+ * [SpanStyle] from the provided [richSpan] and passing it to [fromSpanStyle]
+ * for comparison against heading styles.
+ *
+ * Special handling for [FontWeight.Normal] is inherited from [fromSpanStyle].
+ *
+ * @param richSpan The [RichSpan] whose style is compared against heading styles.
+ * @return The matching [HeadingStyle], or [HeadingStyle.Normal] if no match is found.
+ */
+ internal fun fromRichSpan(richSpanStyle: RichSpan): HeadingStyle {
+ return fromSpanStyle(richSpanStyle.spanStyle)
+ }
+
+ /**
+ * Identifies the [HeadingStyle] based on a given [ParagraphStyle].
+ *
+ * This function compares the provided [paragraphStyle] with the base [ParagraphStyle]
+ * of each heading level defined in [HeadingStyle.getTextStyle].
+ * It primarily matches based on properties like line height, text alignment,
+ * text direction, line break, and hyphens, as these are strong indicators
+ * of a paragraph style derived from typography.
+ *
+ * @param paragraphStyle The [ParagraphStyle] to compare against heading styles.
+ * @return The matching [HeadingStyle], or [HeadingStyle.Normal] if no match is found.
+ */
+ public fun fromParagraphStyle(paragraphStyle: ParagraphStyle): HeadingStyle {
+ return entries.find {
+ val entryParagraphStyle = it.getParagraphStyle()
+ entryParagraphStyle.lineHeight == paragraphStyle.lineHeight
+ && entryParagraphStyle.textAlign == paragraphStyle.textAlign
+ && entryParagraphStyle.textDirection == paragraphStyle.textDirection
+ && entryParagraphStyle.lineBreak == paragraphStyle.lineBreak
+ && entryParagraphStyle.hyphens == paragraphStyle.hyphens
+ } ?: Normal
+ }
+
+ /**
+ * HTML heading tags.
+ *
+ * @see HTML headings
+ */
+ internal val headingTags = setOf("h1", "h2", "h3", "h4", "h5", "h6")
+
+ /**
+ * Markdown heading nodes.
+ *
+ * @see Markdown headings
+ */
+ internal val markdownHeadingNodes = setOf(
+ MarkdownElementTypes.ATX_1,
+ MarkdownElementTypes.ATX_2,
+ MarkdownElementTypes.ATX_3,
+ MarkdownElementTypes.ATX_4,
+ MarkdownElementTypes.ATX_5,
+ MarkdownElementTypes.ATX_6,
+ )
+
+ }
+}
diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt
index 107a571d..a47bafa6 100644
--- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt
@@ -585,7 +585,31 @@ public class RichTextState internal constructor(
removeSpanStyle(currentSpanStyle, textRange)
}
- // RichSpanStyle
+ /**
+ * Sets the heading style for the currently selected paragraphs.
+ *
+ * This function applies the specified [headerParagraphStyle] to all paragraphs
+ * that are fully or partially within the current [selection].
+ *
+ * If the specified style is [HeadingStyle.Normal], any existing heading
+ * style (H1-H6) is removed from the selected paragraphs. Otherwise, the specified
+ * heading style is applied, replacing any previous heading style on those paragraphs.
+ * Heading styles are applied to the entire paragraph, if the selection is collapsed -
+ * consistent with common rich text editor behavior. If the selection is not collapsed,
+ * heading styles will be applied to each paragraph in the selection.
+ */
+ public fun setHeadingStyle(headerParagraphStyle: HeadingStyle) {
+ val paragraphs = getRichParagraphListByTextRange(selection)
+ if (paragraphs.isEmpty()) return
+
+ paragraphs.forEach { paragraph ->
+ paragraph.setHeadingStyle(headerParagraphStyle)
+ }
+
+ updateAnnotatedString()
+ updateCurrentSpanStyle()
+ updateCurrentParagraphStyle()
+ }
/**
* Add a link to the text field.
@@ -2283,8 +2307,8 @@ public class RichTextState internal constructor(
newType = DefaultParagraph(),
textFieldValue = tempTextFieldValue,
)
- newParagraphFirstRichSpan.spanStyle = SpanStyle()
- newParagraphFirstRichSpan.richSpanStyle = RichSpanStyle.Default
+ newParagraphFirstRichSpan?.spanStyle = SpanStyle()
+ newParagraphFirstRichSpan?.richSpanStyle = RichSpanStyle.Default
// Ignore adding the new paragraph
index--
@@ -2293,14 +2317,14 @@ public class RichTextState internal constructor(
(!config.preserveStyleOnEmptyLine || richSpan.paragraph.isEmpty()) &&
isSelectionAtNewRichSpan
) {
- newParagraphFirstRichSpan.spanStyle = SpanStyle()
- newParagraphFirstRichSpan.richSpanStyle = RichSpanStyle.Default
+ newParagraphFirstRichSpan?.spanStyle = SpanStyle()
+ newParagraphFirstRichSpan?.richSpanStyle = RichSpanStyle.Default
} else if (
config.preserveStyleOnEmptyLine &&
isSelectionAtNewRichSpan
) {
- newParagraphFirstRichSpan.spanStyle = currentSpanStyle
- newParagraphFirstRichSpan.richSpanStyle = currentRichSpanStyle
+ newParagraphFirstRichSpan?.spanStyle = currentSpanStyle
+ newParagraphFirstRichSpan?.richSpanStyle = currentRichSpanStyle
}
}
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 b20883eb..b717a081 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
@@ -7,11 +7,14 @@ import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastForEachReversed
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
+import com.mohamedrejeb.richeditor.model.HeadingStyle
import com.mohamedrejeb.richeditor.model.RichSpan
import com.mohamedrejeb.richeditor.paragraph.type.DefaultParagraph
import com.mohamedrejeb.richeditor.paragraph.type.ParagraphType
import com.mohamedrejeb.richeditor.paragraph.type.ParagraphType.Companion.startText
import com.mohamedrejeb.richeditor.ui.test.getRichTextStyleTreeRepresentation
+import com.mohamedrejeb.richeditor.utils.customMerge
+import com.mohamedrejeb.richeditor.utils.unmerge
internal class RichParagraph(
val key: Int = 0,
@@ -199,6 +202,84 @@ internal class RichParagraph(
return firstChild?.spanStyle
}
+ /**
+ * Retrieves the [HeadingStyle] applied to this paragraph.
+ *
+ * In Rich Text editors like Google Docs, heading styles (H1-H6) are
+ * applied to the entire paragraph. This function reflects that behavior
+ * by checking all child [RichSpan]s for a non-default [HeadingStyle].
+ * If any child [RichSpan] has a heading style (other than [HeadingStyle.Normal]),
+ * this function returns that heading style, indicating that the entire paragraph is styled as a heading.
+ */
+ fun getHeadingStyle() : HeadingStyle {
+ children.fastForEach { richSpan ->
+ val childHeadingParagraphStyle = HeadingStyle.fromRichSpan(richSpan)
+ if (childHeadingParagraphStyle != HeadingStyle.Normal){
+ return childHeadingParagraphStyle
+ }
+ }
+ return HeadingStyle.Normal
+ }
+
+ /**
+ * Sets the heading style for this paragraph.
+ *
+ * This function applies the specified [headerParagraphStyle] to the entire paragraph.
+ *
+ * If the specified style is [HeadingStyle.Normal], any existing heading
+ * style (H1-H6) is removed from the paragraph. Otherwise, the specified
+ * heading style is applied, replacing any previous heading style on this paragraph.
+ *
+ * Heading styles are applied to the entire paragraph, consistent with common rich text editor
+ behavior.
+ */
+ fun setHeadingStyle(headerParagraphStyle: HeadingStyle) {
+ val spanStyle = headerParagraphStyle.getSpanStyle()
+ val paragraphStyle = headerParagraphStyle.getParagraphStyle()
+
+ // Remove any existing heading styles first
+ HeadingStyle.entries.forEach {
+ removeHeadingStyle(it.getSpanStyle(), it.getParagraphStyle())
+ }
+
+ // Apply the new heading style if it's not Normal
+ if (headerParagraphStyle != HeadingStyle.Normal) {
+ addHeadingStyle(spanStyle, paragraphStyle)
+ }
+ }
+
+ /**
+ * Internal helper function to apply a given header [SpanStyle] and [ParagraphStyle]
+ * to this paragraph.
+ *
+ * This function is used by [setHeadingStyle] after determining which
+ * style to set.
+ * Note: This function only adds the styles and does not handle removing existing
+ * heading styles from the paragraph.
+ */
+ private fun addHeadingStyle(spanStyle: SpanStyle, paragraphStyle: ParagraphStyle) {
+ children.forEach { richSpan ->
+ richSpan.spanStyle = richSpan.spanStyle.customMerge(spanStyle)
+ }
+ this.paragraphStyle = this.paragraphStyle.merge(paragraphStyle)
+ }
+
+ /**
+ * Internal helper function to remove a given header [SpanStyle] and [ParagraphStyle]
+ * from this paragraph.
+ *
+ * This function is used by [setHeadingStyle] to clear any existing heading
+ * styles before applying a new one, or to remove a specific heading style when
+ * setting the paragraph style back to [HeadingStyle.Normal].
+ */
+ private fun removeHeadingStyle(spanStyle: SpanStyle, paragraphStyle: ParagraphStyle) {
+ children.forEach { richSpan ->
+ richSpan.spanStyle = richSpan.spanStyle.unmerge(spanStyle) // Unmerge using toSpanStyle
+ }
+ this.paragraphStyle = this.paragraphStyle.unmerge(paragraphStyle) // Unmerge ParagraphStyle
+ }
+
+
fun getFirstNonEmptyChild(offset: Int = -1): RichSpan? {
children.fastForEach { richSpan ->
if (richSpan.text.isNotEmpty()) {
diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParser.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParser.kt
index f3473fd2..fd25f1f3 100644
--- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParser.kt
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParser.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastForEachReversed
import com.mohamedrejeb.richeditor.paragraph.type.ConfigurableListLevel
+import com.mohamedrejeb.richeditor.utils.diff
internal object RichTextStateHtmlParser : RichTextStateParser {
@@ -94,6 +95,7 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
val cssStyleMap = attributes["style"]?.let { CssEncoder.parseCssStyle(it) } ?: emptyMap()
val cssSpanStyle = CssEncoder.parseCssStyleMapToSpanStyle(cssStyleMap)
val tagSpanStyle = htmlElementsSpanStyleEncodeMap[name]
+ val tagParagraphStyle = htmlElementsParagraphStyleEncodeMap[name]
val currentRichParagraph = richParagraphList.lastOrNull()
val isCurrentRichParagraphBlank = currentRichParagraph?.isBlank() == true
@@ -133,6 +135,11 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
newRichParagraph.paragraphStyle = newRichParagraph.paragraphStyle.merge(cssParagraphStyle)
newRichParagraph.type = paragraphType
+ // Apply paragraph style (if applicable)
+ tagParagraphStyle?.let {
+ newRichParagraph.paragraphStyle = newRichParagraph.paragraphStyle.merge(it)
+ }
+
if (!isCurrentRichParagraphBlank) {
stringBuilder.append(' ')
@@ -208,11 +215,9 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
if (isCurrentTagBlockElement && !isCurrentRichParagraphBlank) {
stringBuilder.append(' ')
- val newParagraph =
- if (richParagraphList.isEmpty())
- RichParagraph()
- else
- RichParagraph(paragraphStyle = richParagraphList.last().paragraphStyle)
+ //TODO - This was causing the paragraph style from heading tags to be applied to
+ // subsequent paragraphs. Verify that this isn't crucial (all the tests still pass)
+ val newParagraph = RichParagraph()
richParagraphList.add(newParagraph)
@@ -273,7 +278,7 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
richTextState.richParagraphList.fastForEachIndexed { index, richParagraph ->
val richParagraphType = richParagraph.type
val isParagraphEmpty = richParagraph.isEmpty()
- val paragraphGroupTagName = decodeHtmlElementFromRichParagraphType(richParagraph.type)
+ val paragraphGroupTagName = decodeHtmlElementFromRichParagraph(richParagraph)
val paragraphLevel =
if (richParagraphType is ConfigurableListLevel)
@@ -383,10 +388,25 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
// Create paragraph tag name
val paragraphTagName =
if (paragraphGroupTagName == "ol" || paragraphGroupTagName == "ul") "li"
- else "p"
+ else paragraphGroupTagName
// Create paragraph css
- val paragraphCssMap = CssDecoder.decodeParagraphStyleToCssStyleMap(richParagraph.paragraphStyle)
+ val paragraphCssMap =
+ /*
+ Heading paragraph styles inherit custom ParagraphStyle from the Typography class.
+ This will allow us to remove any inherited ParagraphStyle properties, but keep the user added ones.
+ to tags will allow the browser to apply the default heading styles.
+ If the paragraphTagName isn't a h1-h6 tag, it will revert to the old behavior of applying whatever paragraphstyle is present.
+ */
+ if (paragraphTagName in HeadingStyle.headingTags) {
+ val headingType = HeadingStyle.fromParagraphStyle(richParagraph.paragraphStyle)
+ val baseParagraphStyle = headingType.getParagraphStyle()
+ val diffParagraphStyle = richParagraph.paragraphStyle.diff(baseParagraphStyle)
+ CssDecoder.decodeParagraphStyleToCssStyleMap(diffParagraphStyle)
+ } else {
+ CssDecoder.decodeParagraphStyleToCssStyleMap(richParagraph.paragraphStyle)
+ }
+
val paragraphCss = CssDecoder.decodeCssStyleMap(paragraphCssMap)
// Append paragraph opening tag
@@ -396,7 +416,7 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
// Append paragraph children
richParagraph.children.fastForEach { richSpan ->
- builder.append(decodeRichSpanToHtml(richSpan))
+ builder.append(decodeRichSpanToHtml(richSpan, headingType = HeadingStyle.fromRichSpan(richSpan)))
}
// Append paragraph closing tag
@@ -420,7 +440,11 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
}
@OptIn(ExperimentalRichTextApi::class)
- private fun decodeRichSpanToHtml(richSpan: RichSpan, parentFormattingTags: List = emptyList()): String {
+ private fun decodeRichSpanToHtml(
+ richSpan: RichSpan,
+ parentFormattingTags: List = emptyList(),
+ headingType: HeadingStyle = HeadingStyle.Normal,
+ ): String {
val stringBuilder = StringBuilder()
// Check if span is empty
@@ -438,7 +462,16 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
}
// Convert span style to CSS string
- val htmlStyleFormat = CssDecoder.decodeSpanStyleToHtmlStylingFormat(richSpan.spanStyle)
+ val htmlStyleFormat =
+ /**
+ * If the heading type is normal, follow the previous behavior of encoding the SpanStyle to the
+ * Css span style. If it is a heading paragraph style, remove the Heading-specific [SpanStyle] features via
+ * [diff] but retain the non-heading associated [SpanStyle] properties.
+ */
+ if (headingType == HeadingStyle.Normal)
+ CssDecoder.decodeSpanStyleToHtmlStylingFormat(richSpan.spanStyle)
+ else
+ CssDecoder.decodeSpanStyleToHtmlStylingFormat(richSpan.spanStyle.diff(headingType.getSpanStyle()))
val spanCss = CssDecoder.decodeCssStyleMap(htmlStyleFormat.cssStyleMap)
val htmlTags = htmlStyleFormat.htmlTags.filter { it !in parentFormattingTags }
@@ -553,15 +586,16 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
}
/**
- * Decodes HTML elements from [ParagraphType].
+ * Decodes HTML elements from [RichParagraph].
*/
- private fun decodeHtmlElementFromRichParagraphType(
- richParagraphType: ParagraphType,
+ private fun decodeHtmlElementFromRichParagraph(
+ richParagraph: RichParagraph,
): String {
- return when (richParagraphType) {
+ val paragraphType = richParagraph.type
+ return when (paragraphType) {
is UnorderedList -> "ul"
is OrderedList -> "ol"
- else -> "p"
+ else -> richParagraph.getHeadingStyle().htmlTag ?: "p"
}
}
@@ -570,6 +604,11 @@ internal object RichTextStateHtmlParser : RichTextStateParser {
/**
* Encodes HTML elements to [SpanStyle].
*
+ * Some HTML elements have both an associated SpanStyle and ParagraphStyle.
+ * Ensure both the [SpanStyle] (via [htmlElementsSpanStyleEncodeMap] - if applicable) and
+ * [androidx.compose.ui.text.ParagraphStyle] (via [htmlElementsParagraphStyleEncodeMap] - if applicable)
+ * are applied to the text.
+ *
* @see HTML formatting
*/
internal val htmlElementsSpanStyleEncodeMap = mapOf(
@@ -594,6 +633,23 @@ internal val htmlElementsSpanStyleEncodeMap = mapOf(
"h6" to H6SpanStyle,
)
+/**
+ * Encodes the HTML elements to [androidx.compose.ui.text.ParagraphStyle].
+ * Some HTML elements have both an associated SpanStyle and ParagraphStyle.
+ * Ensure both the [SpanStyle] (via [htmlElementsSpanStyleEncodeMap] - if applicable) and
+ * [androidx.compose.ui.text.ParagraphStyle] (via [htmlElementsParagraphStyleEncodeMap] - if applicable)
+ * are applied to the text.
+ * @see HTML formatting
+ */
+internal val htmlElementsParagraphStyleEncodeMap = mapOf(
+ "h1" to H1ParagraphStyle,
+ "h2" to H2ParagraphStyle,
+ "h3" to H3ParagraphStyle,
+ "h4" to H4ParagraphStyle,
+ "h5" to H5ParagraphStyle,
+ "h6" to H6ParagraphStyle,
+)
+
/**
* Decodes HTML elements from [SpanStyle].
*
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 8371d1e9..aff3d549 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
@@ -7,6 +7,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
+import com.mohamedrejeb.richeditor.model.HeadingStyle
import com.mohamedrejeb.richeditor.model.RichSpan
import com.mohamedrejeb.richeditor.model.RichSpanStyle
import com.mohamedrejeb.richeditor.model.RichTextState
@@ -121,6 +122,7 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
}
val tagSpanStyle = markdownElementsSpanStyleEncodeMap[node.type]
+ val tagParagraphStyle = markdownElementsParagraphStyleEncodeMap[node.type]
if (node.type in markdownBlockElements) {
val currentRichParagraph = richParagraphList.last()
@@ -142,6 +144,11 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
currentRichParagraph.type = currentRichParagraphType
}
+ // Apply paragraph style (if applicable)
+ tagParagraphStyle?.let {
+ currentRichParagraph.paragraphStyle = currentRichParagraph.paragraphStyle.merge(it)
+ }
+
val newRichSpan = RichSpan(paragraph = currentRichParagraph)
newRichSpan.spanStyle = tagSpanStyle ?: SpanStyle()
@@ -374,20 +381,17 @@ 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
val lineStartText = getMarkdownLineStartTextFromFirstRichSpan(firstNonEmptyChild)
builder.append(lineStartText)
- isHeading = lineStartText.startsWith('#')
}
}
// Append paragraph children
richParagraph.children.fastForEach { richSpan ->
- builder.append(decodeRichSpanToMarkdown(richSpan, isHeading))
+ builder.append(decodeRichSpanToMarkdown(richSpan))
}
// Append line break if needed
@@ -410,7 +414,6 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
@OptIn(ExperimentalRichTextApi::class)
private fun decodeRichSpanToMarkdown(
richSpan: RichSpan,
- isHeading: Boolean,
): String {
val stringBuilder = StringBuilder()
@@ -424,8 +427,8 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
val markdownOpen = mutableListOf()
val markdownClose = mutableListOf()
- // Ignore adding bold `**` for heading since it's already bold
- if ((richSpan.spanStyle.fontWeight?.weight ?: 400) > 400 && !isHeading) {
+ // Bold is based off fontWeight
+ if ((richSpan.spanStyle.fontWeight?.weight ?: 400) > 400) {
markdownOpen += "**"
markdownClose += "**"
}
@@ -457,7 +460,7 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
// Append children
richSpan.children.fastForEach { child ->
- stringBuilder.append(decodeRichSpanToMarkdown(child, isHeading))
+ stringBuilder.append(decodeRichSpanToMarkdown(child))
}
// Append markdown close
@@ -482,7 +485,10 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
/**
* Encodes Markdown elements to [SpanStyle].
- *
+ * Some Markdown elements have both an associated SpanStyle and ParagraphStyle.
+ * Ensure both the [SpanStyle] (via [markdownElementsSpanStyleEncodeMap] - if applicable) and
+ * [androidx.compose.ui.text.ParagraphStyle] (via [markdownElementsParagraphStyleEncodeMap] - if applicable)
+ * are applied to the text.
* @see HTML formatting
*/
private val markdownElementsSpanStyleEncodeMap = mapOf(
@@ -497,6 +503,23 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
MarkdownElementTypes.ATX_6 to H6SpanStyle,
)
+ /**
+ * Encodes the Markdown elements to [androidx.compose.ui.text.ParagraphStyle].
+ * Some Markdown elements have both an associated SpanStyle and ParagraphStyle.
+ * Ensure both the [SpanStyle] (via [markdownElementsSpanStyleEncodeMap] - if applicable) and
+ * [androidx.compose.ui.text.ParagraphStyle] (via [markdownElementsParagraphStyleEncodeMap] if applicable)
+ * are applied to the text.
+ * @see ATX Header formatting
+ */
+ private val markdownElementsParagraphStyleEncodeMap = mapOf(
+ MarkdownElementTypes.ATX_1 to H1ParagraphStyle,
+ MarkdownElementTypes.ATX_2 to H2ParagraphStyle,
+ MarkdownElementTypes.ATX_3 to H3ParagraphStyle,
+ MarkdownElementTypes.ATX_4 to H4ParagraphStyle,
+ MarkdownElementTypes.ATX_5 to H5ParagraphStyle,
+ MarkdownElementTypes.ATX_6 to H6ParagraphStyle,
+ )
+
/**
* Encodes Markdown elements to [RichSpanStyle].
*/
@@ -572,30 +595,7 @@ internal object RichTextStateMarkdownParser : RichTextStateParser {
* For example, if the first [RichSpan] spanStyle is [H1SpanStyle], the markdown line start text will be "# ".
*/
private fun getMarkdownLineStartTextFromFirstRichSpan(firstRichSpan: RichSpan): String {
- if ((firstRichSpan.spanStyle.fontWeight?.weight ?: 400) <= 400) return ""
- val fontSize = firstRichSpan.spanStyle.fontSize
-
- return if (fontSize.isEm) {
- when {
- fontSize >= H1SpanStyle.fontSize -> "# "
- fontSize >= H2SpanStyle.fontSize -> "## "
- fontSize >= H3SpanStyle.fontSize -> "### "
- fontSize >= H4SpanStyle.fontSize -> "#### "
- fontSize >= H5SpanStyle.fontSize -> "##### "
- fontSize >= H6SpanStyle.fontSize -> "###### "
- else -> ""
- }
- } else {
- when {
- fontSize.value >= H1SpanStyle.fontSize.value * 16 -> "# "
- fontSize.value >= H2SpanStyle.fontSize.value * 16 -> "## "
- fontSize.value >= H3SpanStyle.fontSize.value * 16 -> "### "
- fontSize.value >= H4SpanStyle.fontSize.value * 16 -> "#### "
- fontSize.value >= H5SpanStyle.fontSize.value * 16 -> "##### "
- fontSize.value >= H6SpanStyle.fontSize.value * 16 -> "###### "
- else -> ""
- }
- }
+ return HeadingStyle.fromRichSpan(firstRichSpan).markdownElement
}
/**
diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsParagraphStyle.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsParagraphStyle.kt
new file mode 100644
index 00000000..2cd60347
--- /dev/null
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsParagraphStyle.kt
@@ -0,0 +1,10 @@
+package com.mohamedrejeb.richeditor.parser.utils
+
+import com.mohamedrejeb.richeditor.model.HeadingStyle
+
+internal val H1ParagraphStyle = HeadingStyle.H1.getParagraphStyle()
+internal val H2ParagraphStyle = HeadingStyle.H2.getParagraphStyle()
+internal val H3ParagraphStyle = HeadingStyle.H3.getParagraphStyle()
+internal val H4ParagraphStyle = HeadingStyle.H4.getParagraphStyle()
+internal val H5ParagraphStyle = HeadingStyle.H5.getParagraphStyle()
+internal val H6ParagraphStyle = HeadingStyle.H6.getParagraphStyle()
\ No newline at end of file
diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsSpanStyle.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsSpanStyle.kt
index 33426897..d8469010 100644
--- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsSpanStyle.kt
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/parser/utils/ElementsSpanStyle.kt
@@ -7,6 +7,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.em
+import com.mohamedrejeb.richeditor.model.HeadingStyle
internal val MarkBackgroundColor = Color.Yellow
internal val SmallFontSize = 0.8f.em
@@ -19,9 +20,9 @@ internal val SubscriptSpanStyle = SpanStyle(baselineShift = BaselineShift.Subscr
internal val SuperscriptSpanStyle = SpanStyle(baselineShift = BaselineShift.Superscript)
internal val MarkSpanStyle = SpanStyle(background = MarkBackgroundColor)
internal val SmallSpanStyle = SpanStyle(fontSize = SmallFontSize)
-internal val H1SpanStyle = SpanStyle(fontSize = 2.em, fontWeight = FontWeight.Bold)
-internal val H2SpanStyle = SpanStyle(fontSize = 1.5.em, fontWeight = FontWeight.Bold)
-internal val H3SpanStyle = SpanStyle(fontSize = 1.17.em, fontWeight = FontWeight.Bold)
-internal val H4SpanStyle = SpanStyle(fontSize = 1.12.em, fontWeight = FontWeight.Bold)
-internal val H5SpanStyle = SpanStyle(fontSize = 0.83.em, fontWeight = FontWeight.Bold)
-internal val H6SpanStyle = SpanStyle(fontSize = 0.75.em, fontWeight = FontWeight.Bold)
\ No newline at end of file
+internal val H1SpanStyle = HeadingStyle.H1.getSpanStyle()
+internal val H2SpanStyle = HeadingStyle.H2.getSpanStyle()
+internal val H3SpanStyle = HeadingStyle.H3.getSpanStyle()
+internal val H4SpanStyle = HeadingStyle.H4.getSpanStyle()
+internal val H5SpanStyle = HeadingStyle.H5.getSpanStyle()
+internal val H6SpanStyle = HeadingStyle.H6.getSpanStyle()
diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ParagraphStyleExt.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ParagraphStyleExt.kt
index 79f77060..1ed2c3a9 100644
--- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ParagraphStyleExt.kt
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ParagraphStyleExt.kt
@@ -9,6 +9,37 @@ import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.isUnspecified
import com.mohamedrejeb.richeditor.paragraph.RichParagraph
+/**
+ * Creates a new [ParagraphStyle] that contains only the properties that are different
+ * between this [ParagraphStyle] and the [other] [ParagraphStyle].
+ *
+ * Properties that are the same in both styles are set to their default/unspecified values
+ * in the resulting [ParagraphStyle].
+ *
+ * This is useful for identifying the "delta" or the additional styles applied on top
+ * of a base style (e.g., finding user-added alignment on a heading style).
+ *
+ * @param other The [ParagraphStyle] to compare against.
+ * @return A new [ParagraphStyle] containing only the differing properties.
+ */
+internal fun ParagraphStyle.diff(
+ other: ParagraphStyle,
+): ParagraphStyle {
+ return ParagraphStyle(
+ textAlign = if (this.textAlign != other.textAlign) this.textAlign else TextAlign.Unspecified,
+ textDirection = if (this.textDirection != other.textDirection) this.textDirection else
+ TextDirection.Unspecified,
+ lineHeight = if (this.lineHeight != other.lineHeight) this.lineHeight else
+ androidx.compose.ui.unit.TextUnit.Unspecified,
+ textIndent = if (this.textIndent != other.textIndent) this.textIndent else null,
+ platformStyle = if (this.platformStyle != other.platformStyle) this.platformStyle else null,
+ lineHeightStyle = if (this.lineHeightStyle != other.lineHeightStyle) this.lineHeightStyle else
+ null,
+ lineBreak = if (this.lineBreak != other.lineBreak) this.lineBreak else LineBreak.Unspecified,
+ hyphens = if (this.hyphens != other.hyphens) this.hyphens else Hyphens.Unspecified,
+ )
+}
+
internal fun ParagraphStyle.unmerge(
other: ParagraphStyle?,
): ParagraphStyle {
diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/SpanStyleExt.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/SpanStyleExt.kt
index e7cb489c..598e77d9 100644
--- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/SpanStyleExt.kt
+++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/SpanStyleExt.kt
@@ -52,6 +52,44 @@ internal fun SpanStyle.customMerge(
}
}
+/**
+ * Creates a new [SpanStyle] that contains only the properties that are different
+ * between this [SpanStyle] and the [other] [SpanStyle].
+ *
+ * Properties that are the same in both styles are set to their default/unspecified values
+ * in the resulting [SpanStyle].
+ *
+ * This is useful for identifying the "delta" or the additional styles applied on top
+ * of a base style (e.g., finding user-added bold/italic on a heading style).
+ *
+ * @param other The [SpanStyle] to compare against.
+ * @return A new [SpanStyle] containing only the differing properties.
+ */
+internal fun SpanStyle.diff(
+ other: SpanStyle,
+): SpanStyle {
+ return SpanStyle(
+ color = if (this.color != other.color) this.color else Color.Unspecified,
+ fontFamily = if (this.fontFamily != other.fontFamily) this.fontFamily else null,
+ fontSize = if (this.fontSize != other.fontSize) this.fontSize else TextUnit.Unspecified,
+ fontWeight = if (this.fontWeight != other.fontWeight) this.fontWeight else null,
+ fontStyle = if (this.fontStyle != other.fontStyle) this.fontStyle else null,
+ fontSynthesis = if (this.fontSynthesis != other.fontSynthesis) this.fontSynthesis else null,
+ fontFeatureSettings = if (this.fontFeatureSettings != other.fontFeatureSettings)
+ this.fontFeatureSettings else null,
+ letterSpacing = if (this.letterSpacing != other.letterSpacing) this.letterSpacing else
+ TextUnit.Unspecified,
+ baselineShift = if (this.baselineShift != other.baselineShift) this.baselineShift else null,
+ textGeometricTransform = if (this.textGeometricTransform != other.textGeometricTransform)
+ this.textGeometricTransform else null,
+ localeList = if (this.localeList != other.localeList) this.localeList else null,
+ background = if (this.background != other.background) this.background else Color.Unspecified,
+ // For TextDecoration, we want the decorations present in 'this' but not in 'other'
+ textDecoration = other.textDecoration?.let { this.textDecoration?.minus(it) },
+ shadow = if (this.shadow != other.shadow) this.shadow else null,
+ )
+}
+
internal fun SpanStyle.unmerge(
other: SpanStyle?,
): SpanStyle {
diff --git a/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/model/HeadingStyleTest.kt b/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/model/HeadingStyleTest.kt
new file mode 100644
index 00000000..7ebcfd50
--- /dev/null
+++ b/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/model/HeadingStyleTest.kt
@@ -0,0 +1,104 @@
+package com.mohamedrejeb.richeditor.model
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.ParagraphStyle
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.sp
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class HeadingStyleTest {
+
+ private val typography = Typography()
+
+ @Test
+ fun testGetSpanStyle_fontWeightIsNull() {
+ // Verify that getSpanStyle always returns fontWeight = null
+ assertEquals(null, HeadingStyle.Normal.getSpanStyle().fontWeight)
+ assertEquals(null, HeadingStyle.H1.getSpanStyle().fontWeight)
+ assertEquals(null, HeadingStyle.H2.getSpanStyle().fontWeight)
+ assertEquals(null, HeadingStyle.H3.getSpanStyle().fontWeight)
+ assertEquals(null, HeadingStyle.H4.getSpanStyle().fontWeight)
+ assertEquals(null, HeadingStyle.H5.getSpanStyle().fontWeight)
+ assertEquals(null, HeadingStyle.H6.getSpanStyle().fontWeight)
+ }
+
+ @Test
+ fun testGetSpanStyle_matchesTypographyExceptFontWeight() {
+ // Verify other properties match typography
+ assertEquals(typography.displayLarge.toSpanStyle().copy(fontWeight = null), HeadingStyle.H1.getSpanStyle())
+ assertEquals(typography.displayMedium.toSpanStyle().copy(fontWeight = null), HeadingStyle.H2.getSpanStyle())
+ assertEquals(typography.displaySmall.toSpanStyle().copy(fontWeight = null), HeadingStyle.H3.getSpanStyle())
+ assertEquals(typography.headlineMedium.toSpanStyle().copy(fontWeight = null), HeadingStyle.H4.getSpanStyle())
+ assertEquals(typography.headlineSmall.toSpanStyle().copy(fontWeight = null), HeadingStyle.H5.getSpanStyle())
+ assertEquals(typography.titleLarge.toSpanStyle().copy(fontWeight = null), HeadingStyle.H6.getSpanStyle())
+ assertEquals(SpanStyle(), HeadingStyle.Normal.getSpanStyle()) // Normal should be default
+ }
+
+ @Test
+ fun testGetParagraphStyle_matchesTypography() {
+ // Verify paragraph styles match typography
+ assertEquals(typography.displayLarge.toParagraphStyle(), HeadingStyle.H1.getParagraphStyle())
+ assertEquals(typography.displayMedium.toParagraphStyle(), HeadingStyle.H2.getParagraphStyle())
+ assertEquals(typography.displaySmall.toParagraphStyle(), HeadingStyle.H3.getParagraphStyle())
+ assertEquals(typography.headlineMedium.toParagraphStyle(), HeadingStyle.H4.getParagraphStyle())
+ assertEquals(typography.headlineSmall.toParagraphStyle(), HeadingStyle.H5.getParagraphStyle())
+ assertEquals(typography.titleLarge.toParagraphStyle(), HeadingStyle.H6.getParagraphStyle())
+ assertEquals(ParagraphStyle(), HeadingStyle.Normal.getParagraphStyle()) // Normal should be default
+ }
+
+ @Test
+ fun testFromSpanStyle_matchesBaseHeading() {
+ // Test matching base heading styles (which have fontWeight = null from getSpanStyle)
+ assertEquals(HeadingStyle.H1, HeadingStyle.fromSpanStyle(HeadingStyle.H1.getSpanStyle()))
+ assertEquals(HeadingStyle.H2, HeadingStyle.fromSpanStyle(HeadingStyle.H2.getSpanStyle()))
+ assertEquals(HeadingStyle.H3, HeadingStyle.fromSpanStyle(HeadingStyle.H3.getSpanStyle()))
+ assertEquals(HeadingStyle.H4, HeadingStyle.fromSpanStyle(HeadingStyle.H4.getSpanStyle()))
+ assertEquals(HeadingStyle.H5, HeadingStyle.fromSpanStyle(HeadingStyle.H5.getSpanStyle()))
+ assertEquals(HeadingStyle.H6, HeadingStyle.fromSpanStyle(HeadingStyle.H6.getSpanStyle()))
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromSpanStyle(HeadingStyle.Normal.getSpanStyle()))
+ }
+
+ @Test
+ fun testFromSpanStyle_matchesBaseHeadingWithBold() {
+ // Test matching base heading styles when the input SpanStyle has FontWeight.Bold
+ // The fromSpanStyle logic should ignore the base heading's null fontWeight
+ assertEquals(HeadingStyle.H1, HeadingStyle.fromSpanStyle(HeadingStyle.H1.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ assertEquals(HeadingStyle.H2, HeadingStyle.fromSpanStyle(HeadingStyle.H2.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ assertEquals(HeadingStyle.H3, HeadingStyle.fromSpanStyle(HeadingStyle.H3.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ assertEquals(HeadingStyle.H4, HeadingStyle.fromSpanStyle(HeadingStyle.H4.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ assertEquals(HeadingStyle.H5, HeadingStyle.fromSpanStyle(HeadingStyle.H5.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ assertEquals(HeadingStyle.H6, HeadingStyle.fromSpanStyle(HeadingStyle.H6.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ // Normal paragraph with bold should still be Normal
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromSpanStyle(HeadingStyle.Normal.getSpanStyle().copy(fontWeight = FontWeight.Bold)))
+ }
+
+ @Test
+ fun testFromSpanStyle_noMatchReturnsNormal() {
+ // Test SpanStyles that don't match any heading
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromSpanStyle(SpanStyle()))
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromSpanStyle(SpanStyle(fontSize = 10.sp))) // Different size
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromSpanStyle(SpanStyle(fontWeight = FontWeight.Bold))) // Only bold
+ }
+
+ @Test
+ fun testFromParagraphStyle_matchesBaseHeading() {
+ // Test matching base paragraph styles
+ assertEquals(HeadingStyle.H1, HeadingStyle.fromParagraphStyle(HeadingStyle.H1.getParagraphStyle()))
+ assertEquals(HeadingStyle.H2, HeadingStyle.fromParagraphStyle(HeadingStyle.H2.getParagraphStyle()))
+ assertEquals(HeadingStyle.H3, HeadingStyle.fromParagraphStyle(HeadingStyle.H3.getParagraphStyle()))
+ assertEquals(HeadingStyle.H4, HeadingStyle.fromParagraphStyle(HeadingStyle.H4.getParagraphStyle()))
+ assertEquals(HeadingStyle.H5, HeadingStyle.fromParagraphStyle(HeadingStyle.H5.getParagraphStyle()))
+ assertEquals(HeadingStyle.H6, HeadingStyle.fromParagraphStyle(HeadingStyle.H6.getParagraphStyle()))
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromParagraphStyle(HeadingStyle.Normal.getParagraphStyle()))
+ }
+
+ @Test
+ fun testFromParagraphStyle_noMatchReturnsNormal() {
+ // Test ParagraphStyles that don't match any heading
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromParagraphStyle(ParagraphStyle()))
+ assertEquals(HeadingStyle.Normal, HeadingStyle.fromParagraphStyle(ParagraphStyle(textAlign = TextAlign.Center))) // Different alignment
+ }
+}
diff --git a/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserDecodeTest.kt b/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserDecodeTest.kt
index ef7ffb75..930770f6 100644
--- a/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserDecodeTest.kt
+++ b/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserDecodeTest.kt
@@ -1,6 +1,11 @@
package com.mohamedrejeb.richeditor.parser.html
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
+import com.mohamedrejeb.richeditor.model.HeadingStyle
import com.mohamedrejeb.richeditor.model.RichSpan
import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.paragraph.RichParagraph
@@ -497,4 +502,115 @@ class RichTextStateHtmlParserDecodeTest {
)
}
-}
\ No newline at end of file
+ @Test
+ fun testDecodeHeadingParagraphStyles() {
+ val state = RichTextState(
+ initialRichParagraphList = listOf(
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Normal Paragraph", paragraph = it))
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 1", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H1)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 2", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H2)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 3", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H3)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 4", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H4)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 5", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H5)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 6", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H6)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Another Normal Paragraph", paragraph = it))
+ }
+ )
+ )
+
+ val html = RichTextStateHtmlParser.decode(state)
+
+ val expectedHtml = """
+ Normal Paragraph
+ Heading 1
+ Heading 2
+ Heading 3
+ Heading 4
+ Heading 5
+ Heading 6
+ Another Normal Paragraph
+ """.trimIndent().replace("\n", "") // Remove newlines for comparison
+
+ assertEquals(expectedHtml, html.replace("\n", ""))
+ }
+
+ @Test
+ fun testDecodeHeadingParagraphStylesWithAdditionalSpanStyle() {
+ val state = RichTextState(
+ initialRichParagraphList = listOf(
+ RichParagraph(type = DefaultParagraph()).also {
+ val span = RichSpan(text = "Bold Heading 1", paragraph = it)
+ span.spanStyle = span.spanStyle.merge(SpanStyle(fontWeight = FontWeight.Bold))
+ it.children.add(span)
+ it.setHeadingStyle(HeadingStyle.H1)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ val span = RichSpan(text = "Italic Heading 2", paragraph = it)
+ span.spanStyle = span.spanStyle.merge(SpanStyle(fontStyle = FontStyle.Italic))
+ it.children.add(span)
+ it.setHeadingStyle(HeadingStyle.H2)
+ }
+ )
+ )
+
+ val html = RichTextStateHtmlParser.decode(state)
+
+ // Expected HTML should have the heading tag and the additional style in the span's style attribute
+ val expectedHtml = """
+ Bold Heading 1
+ Italic Heading 2
+ """.trimIndent().replace("\n", "")
+
+ assertEquals(expectedHtml, html.replace("\n", ""))
+ }
+
+ @Test
+ fun testSetHeadingParagraphStyleWithSelection() {
+ val state = RichTextState()
+ val initialText = "Paragraph 1\nParagraph 2\nParagraph 3"
+ state.setText(initialText)
+
+ // Select "Paragraph 2"
+ val paragraph2StartIndex = initialText.indexOf("Paragraph 2")
+ val paragraph2EndIndex = paragraph2StartIndex + "Paragraph 2".length
+ state.selection = TextRange(paragraph2StartIndex, paragraph2EndIndex)
+
+ // Apply H2 heading style
+ state.setHeadingStyle(HeadingStyle.H2)
+
+ // Verify the second paragraph is now H2
+ assertEquals(3, state.richParagraphList.size)
+ assertEquals(HeadingStyle.Normal, state.richParagraphList[0].getHeadingStyle())
+ assertEquals(HeadingStyle.H2, state.richParagraphList[1].getHeadingStyle())
+ assertEquals(HeadingStyle.Normal, state.richParagraphList[2].getHeadingStyle())
+
+ // Verify the text content is unchanged
+ assertEquals(initialText.replace("\n", " "), state.annotatedString.text)
+
+ // Decode to HTML and verify the tag
+ val html = state.toHtml()
+ val expectedHtmlPart = "Paragraph 2
"
+ assertTrue(html.contains(expectedHtmlPart), "Generated HTML should contain $expectedHtmlPart")
+ }
+}
diff --git a/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserEncodeTest.kt b/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserEncodeTest.kt
index 3be4d7c1..f2ee7969 100644
--- a/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserEncodeTest.kt
+++ b/richeditor-compose/src/commonTest/kotlin/com/mohamedrejeb/richeditor/parser/html/RichTextStateHtmlParserEncodeTest.kt
@@ -1,6 +1,9 @@
package com.mohamedrejeb.richeditor.parser.html
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
+import com.mohamedrejeb.richeditor.model.HeadingStyle
import com.mohamedrejeb.richeditor.model.RichSpanStyle
import com.mohamedrejeb.richeditor.paragraph.type.OrderedList
import com.mohamedrejeb.richeditor.paragraph.type.UnorderedList
@@ -331,4 +334,70 @@ class RichTextStateHtmlParserEncodeTest {
assertEquals("Item3", fifthItem .text)
}
-}
\ No newline at end of file
+ @Test
+ fun testEncodeHeadingParagraphStyles() {
+ val html = """
+ Heading 1
+ Some text
+ Heading 2
+ More text
+ """.trimIndent()
+
+ val state = RichTextStateHtmlParser.encode(html)
+
+ assertEquals(4, state.richParagraphList.size)
+
+ // Paragraph 0: H1
+ val p0 = state.richParagraphList[0]
+ assertEquals(HeadingStyle.H1, p0.getHeadingStyle())
+ assertEquals("Heading 1", p0.getFirstNonEmptyChild()?.text)
+ assertEquals(HeadingStyle.H1.getSpanStyle(), p0.getFirstNonEmptyChild()?.spanStyle)
+
+ // Paragraph 1: Normal
+ val p1 = state.richParagraphList[1]
+ assertEquals(HeadingStyle.Normal, p1.getHeadingStyle())
+ assertEquals("Some text", p1.getFirstNonEmptyChild()?.text)
+ assertEquals(SpanStyle(), p1.getFirstNonEmptyChild()?.spanStyle) // Default SpanStyle
+
+ // Paragraph 2: H2
+ val p2 = state.richParagraphList[2]
+ assertEquals(HeadingStyle.H2, p2.getHeadingStyle())
+ assertEquals("Heading 2", p2.getFirstNonEmptyChild()?.text)
+ assertEquals(HeadingStyle.H2.getSpanStyle(), p2.getFirstNonEmptyChild()?.spanStyle)
+
+ // Paragraph 3: Normal
+ val p3 = state.richParagraphList[3]
+ assertEquals(HeadingStyle.Normal, p3.getHeadingStyle())
+ assertEquals("More text", p3.getFirstNonEmptyChild()?.text)
+ assertEquals(SpanStyle(), p3.getFirstNonEmptyChild()?.spanStyle) // Default SpanStyle
+ }
+
+ @Test
+ fun testEncodeHeadingParagraphStylesWithInlineStyles() {
+ val html = """
+ Red Heading 1
+ Bold Heading 2
+ """.trimIndent()
+
+ val state = RichTextStateHtmlParser.encode(html)
+
+ assertEquals(2, state.richParagraphList.size)
+
+ // Paragraph 0: H1 with red color
+ val p0 = state.richParagraphList[0]
+ assertEquals(HeadingStyle.H1, p0.getHeadingStyle())
+ assertEquals("Red Heading 1", p0.getFirstNonEmptyChild()?.text)
+ // Check that the base H1 style is applied AND the red color
+ val expectedH1Style = HeadingStyle.H1.getSpanStyle().merge(SpanStyle(color = androidx.compose.ui.graphics.Color.Red))
+ assertEquals(expectedH1Style, p0.getFirstNonEmptyChild()?.spanStyle)
+
+ // Paragraph 1: H2 with bold font weight
+ val p1 = state.richParagraphList[1]
+ assertEquals(HeadingStyle.H2, p1.getHeadingStyle())
+ assertEquals("Bold Heading 2", p1.getFirstNonEmptyChild()?.text)
+ // Check that the base H2 style is applied AND the bold font weight
+ val expectedH2Style = HeadingStyle.H2.getSpanStyle().merge(SpanStyle(fontWeight = FontWeight.Bold))
+ assertEquals(expectedH2Style, p1.getFirstNonEmptyChild()?.spanStyle)
+ }
+
+}
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 a30f9676..b1a747ea 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
@@ -7,6 +7,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
+import com.mohamedrejeb.richeditor.model.HeadingStyle
import com.mohamedrejeb.richeditor.model.RichSpan
import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.paragraph.RichParagraph
@@ -510,4 +511,57 @@ class RichTextStateMarkdownParserDecodeTest {
assertEquals(expectedMarkdown, richTextState.toMarkdown())
}
-}
\ No newline at end of file
+ @Test
+ fun testDecodeHeadingParagraphStyles() {
+ val state = RichTextState(
+ initialRichParagraphList = listOf(
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Normal Paragraph", paragraph = it))
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 1", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H1)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 2", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H2)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 3", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H3)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 4", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H4)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 5", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H5)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Heading 6", paragraph = it))
+ it.setHeadingStyle(HeadingStyle.H6)
+ },
+ RichParagraph(type = DefaultParagraph()).also {
+ it.children.add(RichSpan(text = "Another Normal Paragraph", paragraph = it))
+ }
+ )
+ )
+
+ val markdown = RichTextStateMarkdownParser.decode(state)
+
+ val expectedMarkdown = """
+ Normal Paragraph
+ # Heading 1
+ ## Heading 2
+ ### Heading 3
+ #### Heading 4
+ ##### Heading 5
+ ###### Heading 6
+ Another Normal Paragraph
+ """.trimIndent()
+
+ assertEquals(expectedMarkdown, markdown)
+ }
+
+}
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 9c66790d..fec7f62f 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
@@ -7,12 +7,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
+import com.mohamedrejeb.richeditor.model.HeadingStyle
import com.mohamedrejeb.richeditor.model.RichSpanStyle
import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.paragraph.type.DefaultParagraph
import com.mohamedrejeb.richeditor.paragraph.type.OrderedList
-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
@@ -421,10 +420,16 @@ class RichTextStateMarkdownParserEncodeTest {
val firstParagraph = state.richParagraphList[0]
- assertEquals(H1SpanStyle, firstParagraph.getFirstNonEmptyChild()!!.spanStyle)
+ // Check paragraph type and heading style
+ assertEquals(HeadingStyle.H1, firstParagraph.getHeadingStyle())
+ // Check span style applied by the parser
+ assertEquals(HeadingStyle.H1.getSpanStyle(), firstParagraph.getFirstNonEmptyChild()!!.spanStyle)
+
val secondParagraph = state.richParagraphList[1]
- assertEquals(H2SpanStyle, secondParagraph.getFirstNonEmptyChild()!!.spanStyle)
+ assertEquals(HeadingStyle.H2, secondParagraph.getHeadingStyle())
+ assertEquals(HeadingStyle.H2.getSpanStyle(), secondParagraph.getFirstNonEmptyChild()!!.spanStyle)
+
assertEquals("Prompt\nEmphasis", state.toText())
}
@@ -543,4 +548,42 @@ class RichTextStateMarkdownParserEncodeTest {
assertEquals("Item4", sixthItem .text)
}
-}
\ No newline at end of file
+ @Test
+ fun testEncodeHeadingParagraphStyles() {
+ val markdown = """
+ # Heading 1
+ Some text
+ ## Heading 2
+ More text
+ """.trimIndent()
+
+ val state = RichTextStateMarkdownParser.encode(markdown)
+
+ assertEquals(4, state.richParagraphList.size)
+
+ // Paragraph 0: H1
+ val p0 = state.richParagraphList[0]
+ assertEquals(HeadingStyle.H1, p0.getHeadingStyle())
+ assertEquals("Heading 1", p0.getFirstNonEmptyChild()?.text)
+ assertEquals(HeadingStyle.H1.getSpanStyle(), p0.getFirstNonEmptyChild()?.spanStyle)
+
+ // Paragraph 1: Normal
+ val p1 = state.richParagraphList[1]
+ assertEquals(HeadingStyle.Normal, p1.getHeadingStyle())
+ assertEquals("Some text", p1.getFirstNonEmptyChild()?.text)
+ assertEquals(SpanStyle(), p1.getFirstNonEmptyChild()?.spanStyle) // Default SpanStyle
+
+ // Paragraph 2: H2
+ val p2 = state.richParagraphList[2]
+ assertEquals(HeadingStyle.H2, p2.getHeadingStyle())
+ assertEquals("Heading 2", p2.getFirstNonEmptyChild()?.text)
+ assertEquals(HeadingStyle.H2.getSpanStyle(), p2.getFirstNonEmptyChild()?.spanStyle)
+
+ // Paragraph 3: Normal
+ val p3 = state.richParagraphList[3]
+ assertEquals(HeadingStyle.Normal, p3.getHeadingStyle())
+ assertEquals("More text", p3.getFirstNonEmptyChild()?.text)
+ assertEquals(SpanStyle(), p3.getFirstNonEmptyChild()?.spanStyle) // Default SpanStyle
+ }
+
+}