Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
39ec075
Created the HeadingParagraphStyle enum class to facilitate the conver…
DevinDuricka Apr 4, 2025
b9810b6
It looks like the paragraph style was essential for the spacing to li…
DevinDuricka Apr 5, 2025
77ec8ca
The "toggle" follows the Google Docs behavior where the cursor paragr…
DevinDuricka Apr 7, 2025
9b19188
Renamed the toggleHeaderParagraphStyle to setHeadingParagraphStyle an…
DevinDuricka Apr 8, 2025
b82b649
Simplified the getMarkdownLineStateTextFromFirstRichSpan function to …
DevinDuricka Apr 8, 2025
26a2645
Added a space after the pound sign as the markdown element is used to…
DevinDuricka Apr 17, 2025
419e45f
Switching out the H1-H6 spanstyles with the Typography versions. Stil…
DevinDuricka Apr 17, 2025
5bbc7fb
Removed the redundant isHeader because the RichSpan can now identify …
DevinDuricka Apr 17, 2025
3783ca1
Added a function to expose the ParagraphStyle. Also added a setOf for…
DevinDuricka Apr 17, 2025
7c4ae44
Following the pattern of the ElementsSpanStyle, I created the Element…
DevinDuricka Apr 17, 2025
932a6ee
I realized I want to add the applicable ParagraphStyle anytime it exi…
DevinDuricka Apr 17, 2025
e7bfaa9
Added getHeadingParagraphStyle which retrieves the [HeadingParagraphS…
DevinDuricka Apr 22, 2025
fea87f2
Added the text for the html tag
DevinDuricka Apr 22, 2025
d8b7d27
Added the a function to determine the HeadingParagraphStyle based off…
DevinDuricka Apr 22, 2025
2c69950
There was an issue where the heading tags weren't being added and it …
DevinDuricka Apr 22, 2025
b780728
The paragraphTagName was only "ul", "ol", or "p", which means that th…
DevinDuricka Apr 22, 2025
f88fb89
Heading paragraph styles inherit custom ParagraphStyle from the Typog…
DevinDuricka Apr 22, 2025
a7db8c1
If the heading type is normal, follow the previous behavior of encodi…
DevinDuricka Apr 22, 2025
acec8d2
If the heading type is normal, follow the previous behavior of encodi…
DevinDuricka Apr 22, 2025
ff9a6a2
This was causing the paragraph style from heading tags to be applied …
DevinDuricka Apr 22, 2025
6dadf73
Forgot to pass in the HeadingParagraphStyle, so the ParagraphStyle st…
DevinDuricka Apr 22, 2025
2025037
Moved the HeadingParagraphStyle to the model package
DevinDuricka Apr 22, 2025
9051acd
Added comments to explain the setHeadingParagraphStyle function along…
DevinDuricka Apr 23, 2025
2a67a63
Added a SpanStyle and ParagraphStyle diff that will remove the other …
DevinDuricka Apr 24, 2025
2a932df
Moved the setHeadingStyle, addHeadingStyle and removeHeadingStyle to …
DevinDuricka Apr 24, 2025
6635377
Moved the setHeadingStyle, addHeadingStyle and removeHeadingStyle to …
DevinDuricka Apr 24, 2025
0dada4c
unmerge wasn't working correctly. diff should actually remove the hea…
DevinDuricka Apr 24, 2025
93ec453
Added various tests that will test the functionality of setting the H…
DevinDuricka Apr 24, 2025
21ad790
Removed setting the fontweight from the heading typography, as it's j…
DevinDuricka Apr 24, 2025
72e572d
Renamed HeadingParagraphStyle.kt to HeadingStyle.kt as it probably ma…
DevinDuricka Apr 24, 2025
4e2e8e9
Forgot to make the textStyle public, which if you want to use the ass…
DevinDuricka Apr 24, 2025
10eedb3
Added multiline comments for the HeadingStyle
DevinDuricka Apr 24, 2025
27ff437
Added a few unit tests for the HeadingStyle enum class
DevinDuricka Apr 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <a href="https://developer.android.com/develop/ui/compose/designsystems/material2-material3#typography">Material 3 Typography Mapping</a>
*/
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 <a href="https://www.w3schools.com/html/html_headings.asp">HTML headings</a>
*/
internal val headingTags = setOf("h1", "h2", "h3", "h4", "h5", "h6")

/**
* Markdown heading nodes.
*
* @see <a href="https://www.w3schools.com/html/html_headings.asp">Markdown headings</a>
*/
internal val markdownHeadingNodes = setOf(
MarkdownElementTypes.ATX_1,
MarkdownElementTypes.ATX_2,
MarkdownElementTypes.ATX_3,
MarkdownElementTypes.ATX_4,
MarkdownElementTypes.ATX_5,
MarkdownElementTypes.ATX_6,
)

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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--
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()) {
Expand Down
Loading