Compose Rich Editor

repository·main·Indexed 23 days ago

https://github.com/mohamedrejeb/compose-rich-editor

A highly customizable rich text editor library for Jetpack Compose and Compose Multiplatform (Android, iOS, Desktop, Web). It provides a WYSIWYG experience with support for text styling, lists, links, code blocks, and triggers for mentions or hashtags. The library includes RichTextEditor for editing, ExpandableRichText for read-only views with 'See more/less' toggles, and utilities for importing and exporting content via HTML and Markdown.

Tokens
17.1K
Snippets
65
Records
77
Agent score
83%

What's inside compose-rich-editor

  1. Overview of supported styling formats

    main

    Compose Rich Editor supports the following formatting categories:

    Text Formatting (SpanStyle)

    • Bold, Italic, Underline, Strikethrough
    • Text color, Background color, Font size
    • Custom SpanStyle

    Paragraph Formatting (ParagraphStyle)

    • Text Align
    • Headings (H1..H6)
    • Custom ParagraphStyle

    Lists and Blocks

    • Ordered List
    • Unordered List
    • Code Spans
    • Hyperlinks

    Rich Content

    • Images (via pluggable ImageLoader, with Coil3 integration)
    • Mentions, hashtags, and slash commands via triggers
  2. Observe the Active Trigger Query

    main

    The state.activeTriggerQuery property is a TriggerQuery? that updates after every edit or selection change. When non-null, it provides details about the in-progress query:

    • triggerId: The ID of the active trigger.
    • query: The characters typed after the trigger character.
    • range: The TextRange that will be replaced upon commitment.
    • caretRect: The caret position in editor-local coordinates (useful for anchoring custom popups).
  3. Token Serialization (HTML and Markdown)

    main

    Tokens are designed to round-trip through HTML and Markdown while preserving their identity.

    HTML: Tokens are serialized as <span data-trigger-id="..." data-token-id="...">. Important: You must call registerTrigger(...) before loading HTML content to ensure unknown trigger IDs are rendered as tokens rather than plain text.

    Markdown: Tokens use a link-shaped format: [label](trigger:<triggerId>:<tokenId>).

    Constraint: Because : is used as a separator in Markdown, both triggerId and tokenId must not contain colons.

    [@mohamed](trigger:mention:u-mohamed)
    [#release](trigger:hashtag:release)
    [/heading](trigger:command:heading)
  4. How headings are modeled in Compose Rich Editor

    main

    The editor treats headings as a first-class paragraph field rather than just visual styles. Every paragraph is assigned a HeadingStyle (ranging from Normal to H6).

    This approach provides several benefits:

    • Structural Integrity: Heading identity is preserved during theme changes or font customizations.
    • Lossless Serialization: Round-trips through HTML and Markdown preserve the heading level even if inline styles (like italics) are applied.
    • Accurate UI: Toolbar highlighting remains correct regardless of partial inline formatting within a heading.
  5. Choose between ExpandableRichText and ExpandableBasicRichText

    main
    ComposablePackageDefault style source
    ExpandableRichTextcom.mohamedrejeb.richeditor.ui.material3LocalTextStyle, LocalContentColor, MaterialTheme.colorScheme.primary
    ExpandableBasicRichTextcom.mohamedrejeb.richeditor.uiNone: caller provides style and seeMoreStyle

    When to use which:

    • Use ExpandableRichText if your UI is already using MaterialTheme.
    • Use ExpandableBasicRichText if you do not depend on Material3 or if you need to override the SpanStyle directly.
  6. Implement Triggers (Mentions, Hashtags, Slash Commands)

    main

    Experimental feature gated by @ExperimentalRichTextApi.

    Triggers allow you to implement interactive elements like @mentions or #hashtags. A trigger activates a query mode; when a user selects a suggestion, an atomic Token is inserted. Tokens are treated as a single unit for selection and deletion and are preserved during HTML/Markdown round-tripping.

    To use triggers:

    1. Register a Trigger on the RichTextState.
    2. Use TriggerSuggestions to display a popup for user selection.
    3. Return a RichSpanStyle.Token in the onSelect callback.
    import com.mohamedrejeb.richeditor.model.trigger.Trigger
    import com.mohamedrejeb.richeditor.ui.material3.TriggerSuggestions
    
    // ... inside a Composable
    val state = rememberRichTextState()
    
    LaunchedEffect(Unit) {
        state.registerTrigger(Trigger(id = "mention", char = '@'))
        state.registerTrigger(Trigger(id = "hashtag", char = '#'))
    }
    
    Box {
        RichTextEditor(state = state)
    
        TriggerSuggestions(
            state = state,
            triggerId = "mention",
            suggestions = { query -> users.filter { it.handle.contains(query) } },
            onSelect = { user ->
                RichSpanStyle.Token(
                    triggerId = "mention",
                    id = user.id,
                    label = "@${user.handle}",
                )
            },
            item = { user -> Text(user.handle) },
        )
    }
  7. Understand v1 limitations of ExpandableRichText

    main

    ExpandableBasicRichText uses Compose's BasicText to allow the See less toggle to share a baseline with the trailing word.

    What is preserved:

    • Bold, italic, color, underline, font size, font family.
    • Hyperlinks (LinkAnnotation).
    • Inline images and inline content.

    What is NOT rendered in v1:

    • Code-span pill backgrounds.
    • List-bullet glyphs.
    • Paragraph backgrounds.
    • Code-block strokes.
    • Mention/token pointer interactions.
    • Any other paragraph-level decorations drawn by BasicRichText's overlay modifiers.

    If you require paragraph-level decoration, use BasicRichText / RichText with manual maxLines and a separate See more button below the text.

  8. Handle link clicks with a custom UriHandler

    main

    By default, the editor uses the platform's standard UriHandler to open links. To intercept link clicks and implement custom logic (e.g., opening a specific internal screen instead of a browser), provide a custom UriHandler implementation via LocalUriHandler using a CompositionLocalProvider wrapping the RichText component.

    val myUriHandler = remember {
        object : UriHandler {
            override fun openUri(uri: String) {
                // Your custom link handling logic
            }
        }
    }
    
    CompositionLocalProvider(LocalUriHandler provides myUriHandler) {
        RichText(
            state = richTextState,
            modifier = Modifier.fillMaxWidth()
        )
    }
  9. Migrate from RichTextValue to RichTextState in 1.x

    main

    When upgrading from Compose Rich Editor 0.x to 1.x, RichTextValue is deprecated. You should now use RichTextState to manage the editor's state. Instead of passing a value and an onValueChange callback to the RichTextEditor, you now initialize a state object using rememberRichTextState() and pass it to the state parameter of the RichTextEditor component.

    val richTextState = rememberRichTextState()
    
    RichTextEditor(
        state = richTextState,
    )
  10. Serialize headings to HTML and Markdown

    main

    Headings are serialized using their corresponding tags (HTML) or ATX-style prefixes (Markdown).

    • HTML: Paragraphs with a heading level use <h1> through <h6>. On import, these tags are converted back to HeadingStyle.
    • Markdown: Uses ATX-style (# through ###### ). Note that Setext-style headings (Title =====) are not supported on export.
    // HTML Serialization
    state.setMarkdown("# Title\n\nBody paragraph.")
    val html = state.toHtml()
    // <h1>Title</h1><p>Body paragraph.</p>
    
    // Markdown Serialization
    state.setHtml("<h2>Section</h2><p>Body.</p>")
    val markdown = state.toMarkdown()
    // "## Section\n\nBody."
  11. Implement Mentions and Triggers

    main

    The editor supports trigger-driven token insertion (e.g., @mentions, #hashtags, /commands). A trigger is a character that activates a query session. When a user selects a suggestion, the typed text is replaced by an atomic token that behaves as a single unit (e.g., backspacing deletes the entire token).

    Note: Trigger APIs are marked @ExperimentalRichTextApi and may change in future releases.

    @OptIn(ExperimentalRichTextApi::class)
    @Composable
    fun MentionsSample() {
        val state = rememberRichTextState()
    
        LaunchedEffect(Unit) {
            state.registerTrigger(
                Trigger(
                    id = "mention",
                    char = '@',
                    style = { SpanStyle(color = Color(0xFF0969DA), fontWeight = FontWeight.Medium) },
                )
            )
        }
    
        Box {
            OutlinedRichTextEditor(state = state)
    
            TriggerSuggestions(
                state = state,
                triggerId = "mention",
                suggestions = { query ->
                    users.filter { it.handle.contains(query, ignoreCase = true) }
                },
                onSelect = { user ->
                    RichSpanStyle.Token(
                        triggerId = "mention",
                        id = user.id,
                        label = user.handle, // must start with '@'
                    )
                },
                item = { user ->
                    Column {
                        Text(user.handle, fontWeight = FontWeight.Medium)
                        Text(user.name, style = MaterialTheme.typography.bodySmall)
                    }
                },
            )
        }
    }
  12. Keyboard shortcuts for lists

    main

    The editor supports the following keyboard shortcuts for managing list levels and exiting lists:

    • Tab: Increase list level (indent).
    • Shift + Tab: Decrease list level (outdent).
    • Enter on an empty list item: Exit the list (this behavior is configurable via richTextState.config.exitListOnEmptyItem).
    • Backspace at the start of a list item:
      • If list level > 1: Decreases the list level by 1.
      • If list level = 1: Exits the list.