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 + } + +}