From e9654e6d1bbd4daddb0cbe9671094b4a1f8ded26 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 23 Oct 2025 16:05:37 +0200 Subject: [PATCH 01/14] feat(android): implement RichTextView for markdown rendering with link handling --- android/build.gradle | 1 + .../main/java/com/richtext/RichTextView.kt | 75 +++++++-- .../java/com/richtext/RichTextViewManager.kt | 83 ++++++---- .../com/richtext/events/LinkPressEvent.kt | 23 +++ .../main/java/com/richtext/parser/Parser.kt | 39 +++++ .../java/com/richtext/renderer/Renderer.kt | 149 ++++++++++++++++++ .../java/com/richtext/theme/HeaderConfig.kt | 13 ++ .../java/com/richtext/theme/RichTextTheme.kt | 17 ++ 8 files changed, 354 insertions(+), 46 deletions(-) create mode 100644 android/src/main/java/com/richtext/events/LinkPressEvent.kt create mode 100644 android/src/main/java/com/richtext/parser/Parser.kt create mode 100644 android/src/main/java/com/richtext/renderer/Renderer.kt create mode 100644 android/src/main/java/com/richtext/theme/HeaderConfig.kt create mode 100644 android/src/main/java/com/richtext/theme/RichTextTheme.kt diff --git a/android/build.gradle b/android/build.gradle index 662514f3..c985e96d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -74,4 +74,5 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.commonmark:commonmark:0.25.1" } diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 1937a68f..25dd023d 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -1,15 +1,68 @@ package com.richtext import android.content.Context -import android.util.AttributeSet -import android.view.View - -class RichTextView : View { - constructor(context: Context?) : super(context) - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) +import android.graphics.Color +import android.text.method.LinkMovementMethod +import androidx.appcompat.widget.AppCompatTextView +import com.richtext.parser.Parser +import com.richtext.renderer.Renderer +import com.richtext.theme.RichTextTheme + +/** + * Main RichTextView component for rendering markdown content + */ +class RichTextView(context: Context) : AppCompatTextView(context) { + + private val parser = Parser() + private val renderer = Renderer() + private var theme = RichTextTheme.defaultTheme() + private var onLinkPressCallback: ((String) -> Unit)? = null + + init { + // Initialize the component with basic TextView setup + text = "RichTextView - Ready for markdown!" + textSize = 16f + setTextColor(Color.BLACK) + + // Enable link clicking + movementMethod = LinkMovementMethod.getInstance() + } + + + /** + * Set markdown content and parse it + */ + fun setMarkdownContent(markdown: String) { + try { + val document = parser.parseMarkdown(markdown) + if (document != null) { + // Render the Document to styled text + val styledText = renderer.renderDocument(document, theme, onLinkPressCallback) + setText(styledText) + } else { + text = "Error parsing markdown - Document is null" + } + } catch (e: Exception) { + text = "Error: ${e.message}" + } + } + + /** + * Update the theme + */ + fun updateTheme(newTheme: RichTextTheme) { + theme = newTheme + // Re-render if we have content + if (text.isNotEmpty()) { + val markdown = text.toString() + setMarkdownContent(markdown) + } + } + + /** + * Set callback for link press events + */ + fun setOnLinkPressCallback(callback: (String) -> Unit) { + onLinkPressCallback = callback + } } diff --git a/android/src/main/java/com/richtext/RichTextViewManager.kt b/android/src/main/java/com/richtext/RichTextViewManager.kt index 4293955e..0a9e44ae 100644 --- a/android/src/main/java/com/richtext/RichTextViewManager.kt +++ b/android/src/main/java/com/richtext/RichTextViewManager.kt @@ -1,41 +1,54 @@ package com.richtext -import android.graphics.Color -import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.react.viewmanagers.RichTextViewManagerInterface -import com.facebook.react.viewmanagers.RichTextViewManagerDelegate - -@ReactModule(name = RichTextViewManager.NAME) -class RichTextViewManager : SimpleViewManager(), - RichTextViewManagerInterface { - private val mDelegate: ViewManagerDelegate - - init { - mDelegate = RichTextViewManagerDelegate(this) - } - - override fun getDelegate(): ViewManagerDelegate? { - return mDelegate - } - - override fun getName(): String { - return NAME - } - - public override fun createViewInstance(context: ThemedReactContext): RichTextView { - return RichTextView(context) - } - - @ReactProp(name = "color") - override fun setColor(view: RichTextView?, color: String?) { - view?.setBackgroundColor(Color.parseColor(color)) - } - - companion object { - const val NAME = "RichTextView" - } +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.UIManagerHelper +import com.richtext.events.LinkPressEvent + +/** + * ViewManager for RichTextView component + */ +class RichTextViewManager : SimpleViewManager() { + + private var reactContext: ThemedReactContext? = null + + override fun getName(): String = "RichTextView" + + override fun createViewInstance(reactContext: ThemedReactContext): RichTextView { + this.reactContext = reactContext + return RichTextView(reactContext) + } + + // Basic props for testing + @ReactProp(name = "markdown") + fun setMarkdown(view: RichTextView?, markdown: String?) { + // Set up link press callback BEFORE parsing markdown + view?.setOnLinkPressCallback { url -> + // Debug logging + android.util.Log.d("RichTextViewManager", "Link pressed with URL: '$url'") + // Emit onLinkPress event to React Native using Event class + emitOnLinkPress(view, url) + } + + // Parse and render markdown AFTER callback is set + view?.setMarkdownContent(markdown ?: "No markdown content") + } + + @ReactProp(name = "fontSize", defaultFloat = 16f) + fun setFontSize(view: RichTextView?, fontSize: Float) { + view?.textSize = fontSize + } + + private fun emitOnLinkPress(view: RichTextView, url: String) { + val surfaceId = UIManagerHelper.getSurfaceId(reactContext!!) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext!!, view.id) + val event = LinkPressEvent(surfaceId, view.id, url) + + android.util.Log.d("RichTextViewManager", "Emitting event with URL: '$url'") + eventDispatcher?.dispatchEvent(event) + } } diff --git a/android/src/main/java/com/richtext/events/LinkPressEvent.kt b/android/src/main/java/com/richtext/events/LinkPressEvent.kt new file mode 100644 index 00000000..307a1079 --- /dev/null +++ b/android/src/main/java/com/richtext/events/LinkPressEvent.kt @@ -0,0 +1,23 @@ +package com.richtext.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class LinkPressEvent(surfaceId: Int, viewId: Int, private val url: String) : + Event(surfaceId, viewId) { + + override fun getEventName(): String { + return EVENT_NAME + } + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + eventData.putString("url", url) + return eventData + } + + companion object { + const val EVENT_NAME: String = "onLinkPress" + } +} diff --git a/android/src/main/java/com/richtext/parser/Parser.kt b/android/src/main/java/com/richtext/parser/Parser.kt new file mode 100644 index 00000000..85540483 --- /dev/null +++ b/android/src/main/java/com/richtext/parser/Parser.kt @@ -0,0 +1,39 @@ +package com.richtext.parser + +import android.util.Log +import org.commonmark.node.Document +import org.commonmark.parser.Parser + +/** + * Parser for converting markdown strings to CommonMark Document + * Uses CommonMark-Java parser directly + */ +class Parser { + + private val parser = Parser.builder().build() + + /** + * Parse markdown string into CommonMark Document + * @param markdown The markdown string to parse + * @return CommonMark Document or null if parsing fails + */ + fun parseMarkdown(markdown: String): Document? { + if (markdown.isBlank()) { + return null + } + + try { + val document = parser.parse(markdown) as? Document + + if (document != null) { + return document + } else { + Log.w("MarkdownParser", "Failed to cast parsed result to Document") + return null + } + } catch (e: Exception) { + Log.e("MarkdownParser", "CommonMark parsing failed: ${e.message}") + return null + } + } +} diff --git a/android/src/main/java/com/richtext/renderer/Renderer.kt b/android/src/main/java/com/richtext/renderer/Renderer.kt new file mode 100644 index 00000000..ad4f5178 --- /dev/null +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -0,0 +1,149 @@ +package com.richtext.renderer + +import android.graphics.Typeface +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan +import org.commonmark.node.* +import com.richtext.theme.RichTextTheme + +/** + * Custom URLSpan that overrides onClick to call our callback instead of opening browser + */ +class CustomURLSpan(url: String, private val onLinkPress: ((String) -> Unit)?) : URLSpan(url) { + override fun onClick(widget: android.view.View) { + if (onLinkPress != null) { + onLinkPress(url) + } else { + // Fallback to default URLSpan behavior (open browser) + super.onClick(widget) + } + } +} + +/** + * Renders CommonMark Document to styled text using SpannableString + */ +class Renderer { + + /** + * Render the CommonMark Document to styled text + */ + fun renderDocument(document: Document, theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null): SpannableString { + val builder = SpannableStringBuilder() + + renderNode(document, builder, theme, onLinkPress) + + return SpannableString(builder) + } + + /** + * Render a single CommonMark node + */ + private fun renderNode( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? = null + ) { + when (node) { + is Document -> { + // Render all children + var child = node.firstChild + while (child != null) { + renderNode(child, builder, theme, onLinkPress) + child = child.next + } + } + + is Paragraph -> { + // Render paragraph content + var child = node.firstChild + while (child != null) { + renderNode(child, builder, theme, onLinkPress) + child = child.next + } + // Add line break after paragraph + builder.append("\n") + } + + is Heading -> { + val start = builder.length + // Render heading content + var child = node.firstChild + while (child != null) { + renderNode(child, builder, theme, onLinkPress) + child = child.next + } + + // Apply heading styling if content was added + val contentLength = builder.length - start + if (contentLength > 0) { + val level = node.level + val scale = theme.headerConfig.scale + val isBold = theme.headerConfig.isBold + + // Apply bold style if needed + if (isBold) { + builder.setSpan( + StyleSpan(Typeface.BOLD), + start, + start + contentLength, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + builder.append("\n") + } + + is Text -> { + // Add text content + val text = node.literal ?: "" + builder.append(text) + } + + is Link -> { + val start = builder.length + val url = node.destination ?: "" + + // Render link content + var child = node.firstChild + while (child != null) { + renderNode(child, builder, theme, onLinkPress) + child = child.next + } + + // Apply link styling if content was added + val contentLength = builder.length - start + + if (contentLength > 0) { + builder.setSpan( + CustomURLSpan(url, onLinkPress), + start, + start + contentLength, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + // Add underline + builder.setSpan( + UnderlineSpan(), + start, + start + contentLength, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + is HardLineBreak, is SoftLineBreak -> { + builder.append("\n") + } + + else -> { + // Skip unsupported node types + android.util.Log.w("Renderer", "Skipping unsupported CommonMark node type: ${node.javaClass.simpleName}") + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/richtext/theme/HeaderConfig.kt b/android/src/main/java/com/richtext/theme/HeaderConfig.kt new file mode 100644 index 00000000..21ebf778 --- /dev/null +++ b/android/src/main/java/com/richtext/theme/HeaderConfig.kt @@ -0,0 +1,13 @@ +package com.richtext.theme + +/** + * Configuration for header styling + */ +data class HeaderConfig( + val scale: Float = 2.0f, + val isBold: Boolean = true +) { + companion object { + fun defaultConfig(): HeaderConfig = HeaderConfig() + } +} diff --git a/android/src/main/java/com/richtext/theme/RichTextTheme.kt b/android/src/main/java/com/richtext/theme/RichTextTheme.kt new file mode 100644 index 00000000..68250e1d --- /dev/null +++ b/android/src/main/java/com/richtext/theme/RichTextTheme.kt @@ -0,0 +1,17 @@ +package com.richtext.theme + +import android.graphics.Typeface +import android.graphics.Color + +/** + * Theme configuration for rich text rendering + */ +data class RichTextTheme( + val baseFont: Typeface = Typeface.DEFAULT, + val textColor: Int = Color.BLACK, + val headerConfig: HeaderConfig = HeaderConfig.defaultConfig() +) { + companion object { + fun defaultTheme(): RichTextTheme = RichTextTheme() + } +} From 85de625de81ae9e176e32dcd6e3ed1b8f200f170 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 23 Oct 2025 16:17:12 +0200 Subject: [PATCH 02/14] refactor(android): remove unnecessary comments and clean up code in RichTextView and related classes --- .../main/java/com/richtext/RichTextView.kt | 16 ----- .../java/com/richtext/RichTextViewManager.kt | 19 +----- .../main/java/com/richtext/parser/Parser.kt | 9 --- .../java/com/richtext/renderer/Renderer.kt | 58 ++++++------------- .../java/com/richtext/theme/HeaderConfig.kt | 3 - .../java/com/richtext/theme/RichTextTheme.kt | 3 - 6 files changed, 22 insertions(+), 86 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 25dd023d..f3e97eb7 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -8,9 +8,6 @@ import com.richtext.parser.Parser import com.richtext.renderer.Renderer import com.richtext.theme.RichTextTheme -/** - * Main RichTextView component for rendering markdown content - */ class RichTextView(context: Context) : AppCompatTextView(context) { private val parser = Parser() @@ -24,19 +21,13 @@ class RichTextView(context: Context) : AppCompatTextView(context) { textSize = 16f setTextColor(Color.BLACK) - // Enable link clicking movementMethod = LinkMovementMethod.getInstance() } - - /** - * Set markdown content and parse it - */ fun setMarkdownContent(markdown: String) { try { val document = parser.parseMarkdown(markdown) if (document != null) { - // Render the Document to styled text val styledText = renderer.renderDocument(document, theme, onLinkPressCallback) setText(styledText) } else { @@ -47,21 +38,14 @@ class RichTextView(context: Context) : AppCompatTextView(context) { } } - /** - * Update the theme - */ fun updateTheme(newTheme: RichTextTheme) { theme = newTheme - // Re-render if we have content if (text.isNotEmpty()) { val markdown = text.toString() setMarkdownContent(markdown) } } - /** - * Set callback for link press events - */ fun setOnLinkPressCallback(callback: (String) -> Unit) { onLinkPressCallback = callback } diff --git a/android/src/main/java/com/richtext/RichTextViewManager.kt b/android/src/main/java/com/richtext/RichTextViewManager.kt index 0a9e44ae..189f83ae 100644 --- a/android/src/main/java/com/richtext/RichTextViewManager.kt +++ b/android/src/main/java/com/richtext/RichTextViewManager.kt @@ -3,15 +3,9 @@ package com.richtext import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.WritableMap -import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.UIManagerHelper import com.richtext.events.LinkPressEvent -/** - * ViewManager for RichTextView component - */ class RichTextViewManager : SimpleViewManager() { private var reactContext: ThemedReactContext? = null @@ -23,18 +17,12 @@ class RichTextViewManager : SimpleViewManager() { return RichTextView(reactContext) } - // Basic props for testing @ReactProp(name = "markdown") fun setMarkdown(view: RichTextView?, markdown: String?) { - // Set up link press callback BEFORE parsing markdown view?.setOnLinkPressCallback { url -> - // Debug logging - android.util.Log.d("RichTextViewManager", "Link pressed with URL: '$url'") - // Emit onLinkPress event to React Native using Event class emitOnLinkPress(view, url) } - - // Parse and render markdown AFTER callback is set + view?.setMarkdownContent(markdown ?: "No markdown content") } @@ -42,13 +30,12 @@ class RichTextViewManager : SimpleViewManager() { fun setFontSize(view: RichTextView?, fontSize: Float) { view?.textSize = fontSize } - + private fun emitOnLinkPress(view: RichTextView, url: String) { val surfaceId = UIManagerHelper.getSurfaceId(reactContext!!) val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext!!, view.id) val event = LinkPressEvent(surfaceId, view.id, url) - - android.util.Log.d("RichTextViewManager", "Emitting event with URL: '$url'") + eventDispatcher?.dispatchEvent(event) } } diff --git a/android/src/main/java/com/richtext/parser/Parser.kt b/android/src/main/java/com/richtext/parser/Parser.kt index 85540483..e6505a09 100644 --- a/android/src/main/java/com/richtext/parser/Parser.kt +++ b/android/src/main/java/com/richtext/parser/Parser.kt @@ -4,19 +4,10 @@ import android.util.Log import org.commonmark.node.Document import org.commonmark.parser.Parser -/** - * Parser for converting markdown strings to CommonMark Document - * Uses CommonMark-Java parser directly - */ class Parser { private val parser = Parser.builder().build() - /** - * Parse markdown string into CommonMark Document - * @param markdown The markdown string to parse - * @return CommonMark Document or null if parsing fails - */ fun parseMarkdown(markdown: String): Document? { if (markdown.isBlank()) { return null diff --git a/android/src/main/java/com/richtext/renderer/Renderer.kt b/android/src/main/java/com/richtext/renderer/Renderer.kt index ad4f5178..4f3458a8 100644 --- a/android/src/main/java/com/richtext/renderer/Renderer.kt +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -9,83 +9,63 @@ import android.text.style.UnderlineSpan import org.commonmark.node.* import com.richtext.theme.RichTextTheme -/** - * Custom URLSpan that overrides onClick to call our callback instead of opening browser - */ class CustomURLSpan(url: String, private val onLinkPress: ((String) -> Unit)?) : URLSpan(url) { override fun onClick(widget: android.view.View) { if (onLinkPress != null) { onLinkPress(url) } else { - // Fallback to default URLSpan behavior (open browser) super.onClick(widget) } } } -/** - * Renders CommonMark Document to styled text using SpannableString - */ class Renderer { - - /** - * Render the CommonMark Document to styled text - */ fun renderDocument(document: Document, theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null): SpannableString { val builder = SpannableStringBuilder() - + renderNode(document, builder, theme, onLinkPress) - + return SpannableString(builder) } - - /** - * Render a single CommonMark node - */ + private fun renderNode( - node: Node, - builder: SpannableStringBuilder, - theme: RichTextTheme, + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null ) { when (node) { is Document -> { - // Render all children var child = node.firstChild while (child != null) { renderNode(child, builder, theme, onLinkPress) child = child.next } } - + is Paragraph -> { - // Render paragraph content var child = node.firstChild while (child != null) { renderNode(child, builder, theme, onLinkPress) child = child.next } - // Add line break after paragraph builder.append("\n") } - + is Heading -> { val start = builder.length - // Render heading content var child = node.firstChild while (child != null) { renderNode(child, builder, theme, onLinkPress) child = child.next } - - // Apply heading styling if content was added + val contentLength = builder.length - start if (contentLength > 0) { val level = node.level val scale = theme.headerConfig.scale val isBold = theme.headerConfig.isBold - - // Apply bold style if needed + if (isBold) { builder.setSpan( StyleSpan(Typeface.BOLD), @@ -97,27 +77,27 @@ class Renderer { } builder.append("\n") } - + is Text -> { // Add text content val text = node.literal ?: "" builder.append(text) } - + is Link -> { val start = builder.length val url = node.destination ?: "" - + // Render link content var child = node.firstChild while (child != null) { renderNode(child, builder, theme, onLinkPress) child = child.next } - + // Apply link styling if content was added val contentLength = builder.length - start - + if (contentLength > 0) { builder.setSpan( CustomURLSpan(url, onLinkPress), @@ -125,7 +105,7 @@ class Renderer { start + contentLength, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE ) - + // Add underline builder.setSpan( UnderlineSpan(), @@ -135,15 +115,15 @@ class Renderer { ) } } - + is HardLineBreak, is SoftLineBreak -> { builder.append("\n") } - + else -> { // Skip unsupported node types android.util.Log.w("Renderer", "Skipping unsupported CommonMark node type: ${node.javaClass.simpleName}") } } } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/richtext/theme/HeaderConfig.kt b/android/src/main/java/com/richtext/theme/HeaderConfig.kt index 21ebf778..2d2c4149 100644 --- a/android/src/main/java/com/richtext/theme/HeaderConfig.kt +++ b/android/src/main/java/com/richtext/theme/HeaderConfig.kt @@ -1,8 +1,5 @@ package com.richtext.theme -/** - * Configuration for header styling - */ data class HeaderConfig( val scale: Float = 2.0f, val isBold: Boolean = true diff --git a/android/src/main/java/com/richtext/theme/RichTextTheme.kt b/android/src/main/java/com/richtext/theme/RichTextTheme.kt index 68250e1d..01096867 100644 --- a/android/src/main/java/com/richtext/theme/RichTextTheme.kt +++ b/android/src/main/java/com/richtext/theme/RichTextTheme.kt @@ -3,9 +3,6 @@ package com.richtext.theme import android.graphics.Typeface import android.graphics.Color -/** - * Theme configuration for rich text rendering - */ data class RichTextTheme( val baseFont: Typeface = Typeface.DEFAULT, val textColor: Int = Color.BLACK, From 59890f3554afcdeaff4ad83f61f58288e6db9656 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 23 Oct 2025 16:24:22 +0200 Subject: [PATCH 03/14] feat(android): implement NodeRenderer for structured markdown rendering --- .../com/richtext/renderer/NodeRenderer.kt | 162 ++++++++++++++++++ .../java/com/richtext/renderer/Renderer.kt | 93 +--------- 2 files changed, 164 insertions(+), 91 deletions(-) create mode 100644 android/src/main/java/com/richtext/renderer/NodeRenderer.kt diff --git a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt new file mode 100644 index 00000000..8a39e0be --- /dev/null +++ b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt @@ -0,0 +1,162 @@ +package com.richtext.renderer + +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import com.richtext.theme.RichTextTheme +import org.commonmark.node.* + +interface NodeRenderer { + fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) +} + +class DocumentRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) { + val document = node as Document + var child = document.firstChild + while (child != null) { + NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + child = child.next + } + } +} + +class ParagraphRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) { + val paragraph = node as Paragraph + var child = paragraph.firstChild + while (child != null) { + NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + child = child.next + } + builder.append("\n") + } +} + +class HeadingRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) { + val heading = node as Heading + val start = builder.length + + var child = heading.firstChild + while (child != null) { + NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + child = child.next + } + + val contentLength = builder.length - start + if (contentLength > 0) { + val isBold = theme.headerConfig.isBold + if (isBold) { + builder.setSpan( + StyleSpan(Typeface.BOLD), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + builder.append("\n") + } +} + +class TextRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) { + val text = node as Text + val content = text.literal ?: "" + builder.append(content) + } +} + +class LinkRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) { + val link = node as Link + val start = builder.length + val url = link.destination ?: "" + + // Render link content + var child = link.firstChild + while (child != null) { + NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + child = child.next + } + + // Apply link styling if content was added + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + CustomURLSpan(url, onLinkPress), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + // Add underline + builder.setSpan( + UnderlineSpan(), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } +} + +class LineBreakRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + theme: RichTextTheme, + onLinkPress: ((String) -> Unit)? + ) { + builder.append("\n") + } +} + +object NodeRendererFactory { + fun getRenderer(node: Node): NodeRenderer { + return when (node) { + is Document -> DocumentRenderer() + is Paragraph -> ParagraphRenderer() + is Heading -> HeadingRenderer() + is Text -> TextRenderer() + is Link -> LinkRenderer() + is HardLineBreak, is SoftLineBreak -> LineBreakRenderer() + else -> { + android.util.Log.w("NodeRendererFactory", "No renderer found for node type: ${node.javaClass.simpleName}") + TextRenderer() // Fallback to text renderer + } + } + } +} diff --git a/android/src/main/java/com/richtext/renderer/Renderer.kt b/android/src/main/java/com/richtext/renderer/Renderer.kt index 4f3458a8..c5505d51 100644 --- a/android/src/main/java/com/richtext/renderer/Renderer.kt +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -34,96 +34,7 @@ class Renderer { theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null ) { - when (node) { - is Document -> { - var child = node.firstChild - while (child != null) { - renderNode(child, builder, theme, onLinkPress) - child = child.next - } - } - - is Paragraph -> { - var child = node.firstChild - while (child != null) { - renderNode(child, builder, theme, onLinkPress) - child = child.next - } - builder.append("\n") - } - - is Heading -> { - val start = builder.length - var child = node.firstChild - while (child != null) { - renderNode(child, builder, theme, onLinkPress) - child = child.next - } - - val contentLength = builder.length - start - if (contentLength > 0) { - val level = node.level - val scale = theme.headerConfig.scale - val isBold = theme.headerConfig.isBold - - if (isBold) { - builder.setSpan( - StyleSpan(Typeface.BOLD), - start, - start + contentLength, - SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - } - builder.append("\n") - } - - is Text -> { - // Add text content - val text = node.literal ?: "" - builder.append(text) - } - - is Link -> { - val start = builder.length - val url = node.destination ?: "" - - // Render link content - var child = node.firstChild - while (child != null) { - renderNode(child, builder, theme, onLinkPress) - child = child.next - } - - // Apply link styling if content was added - val contentLength = builder.length - start - - if (contentLength > 0) { - builder.setSpan( - CustomURLSpan(url, onLinkPress), - start, - start + contentLength, - SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE - ) - - // Add underline - builder.setSpan( - UnderlineSpan(), - start, - start + contentLength, - SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - } - - is HardLineBreak, is SoftLineBreak -> { - builder.append("\n") - } - - else -> { - // Skip unsupported node types - android.util.Log.w("Renderer", "Skipping unsupported CommonMark node type: ${node.javaClass.simpleName}") - } - } + val renderer = NodeRendererFactory.getRenderer(node) + renderer.render(node, builder, theme, onLinkPress) } } From ca093b01fcfeaffd65a88910a09415d7b6b7e5cf Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 23 Oct 2025 16:35:23 +0200 Subject: [PATCH 04/14] refactor(android): rename CustomURLSpan with CallbackURLSpan --- .../main/java/com/richtext/renderer/NodeRenderer.kt | 3 ++- .../src/main/java/com/richtext/renderer/Renderer.kt | 10 ---------- .../main/java/com/richtext/spans/CallbackURLSpan.kt | 13 +++++++++++++ 3 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 android/src/main/java/com/richtext/spans/CallbackURLSpan.kt diff --git a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt index 8a39e0be..3b6b36e9 100644 --- a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt +++ b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt @@ -5,6 +5,7 @@ import android.text.SpannableStringBuilder import android.text.style.StyleSpan import android.text.style.UnderlineSpan import com.richtext.theme.RichTextTheme +import com.richtext.spans.CallbackURLSpan import org.commonmark.node.* interface NodeRenderer { @@ -116,7 +117,7 @@ class LinkRenderer : NodeRenderer { val contentLength = builder.length - start if (contentLength > 0) { builder.setSpan( - CustomURLSpan(url, onLinkPress), + CallbackURLSpan(url, onLinkPress), start, start + contentLength, android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE diff --git a/android/src/main/java/com/richtext/renderer/Renderer.kt b/android/src/main/java/com/richtext/renderer/Renderer.kt index c5505d51..84fc9454 100644 --- a/android/src/main/java/com/richtext/renderer/Renderer.kt +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -9,16 +9,6 @@ import android.text.style.UnderlineSpan import org.commonmark.node.* import com.richtext.theme.RichTextTheme -class CustomURLSpan(url: String, private val onLinkPress: ((String) -> Unit)?) : URLSpan(url) { - override fun onClick(widget: android.view.View) { - if (onLinkPress != null) { - onLinkPress(url) - } else { - super.onClick(widget) - } - } -} - class Renderer { fun renderDocument(document: Document, theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null): SpannableString { val builder = SpannableStringBuilder() diff --git a/android/src/main/java/com/richtext/spans/CallbackURLSpan.kt b/android/src/main/java/com/richtext/spans/CallbackURLSpan.kt new file mode 100644 index 00000000..4feaf493 --- /dev/null +++ b/android/src/main/java/com/richtext/spans/CallbackURLSpan.kt @@ -0,0 +1,13 @@ +package com.richtext.spans + +import android.text.style.URLSpan + +class CallbackURLSpan(url: String, private val onLinkPress: ((String) -> Unit)?) : URLSpan(url) { + override fun onClick(widget: android.view.View) { + if (onLinkPress != null) { + onLinkPress(url) + } else { + super.onClick(widget) + } + } +} From b188a4f8641fb06d083d16bcac7efd9a90aa13e0 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 12:19:53 +0200 Subject: [PATCH 05/14] chore(android): update commonmark dependency from 0.25.1 to 0.27.0 --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index c985e96d..b6046f84 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -74,5 +74,5 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "org.commonmark:commonmark:0.25.1" + implementation "org.commonmark:commonmark:0.27.0" } From ac72297b294ea913bd3aa033978538bdc65b98df Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 12:49:31 +0200 Subject: [PATCH 06/14] feat(android): enhance RichTextView and NodeRenderer with new span implementations --- .../main/java/com/richtext/RichTextView.kt | 14 +++++ .../com/richtext/renderer/NodeRenderer.kt | 52 ++++++++++++++----- .../com/richtext/spans/CallbackURLSpan.kt | 13 ----- .../com/richtext/spans/RichTextHeadingSpan.kt | 24 +++++++++ .../com/richtext/spans/RichTextLinkSpan.kt | 27 ++++++++++ .../richtext/spans/RichTextParagraphSpan.kt | 17 ++++++ .../java/com/richtext/spans/RichTextSpans.kt | 18 +++++++ .../com/richtext/spans/RichTextTextSpan.kt | 17 ++++++ 8 files changed, 156 insertions(+), 26 deletions(-) delete mode 100644 android/src/main/java/com/richtext/spans/CallbackURLSpan.kt create mode 100644 android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt create mode 100644 android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt create mode 100644 android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt create mode 100644 android/src/main/java/com/richtext/spans/RichTextSpans.kt create mode 100644 android/src/main/java/com/richtext/spans/RichTextTextSpan.kt diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index f3e97eb7..a5ff68ca 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -49,4 +49,18 @@ class RichTextView(context: Context) : AppCompatTextView(context) { fun setOnLinkPressCallback(callback: (String) -> Unit) { onLinkPressCallback = callback } + + fun emitOnLinkPress(url: String) { + val context = this.context as? com.facebook.react.bridge.ReactContext ?: return + val surfaceId = com.facebook.react.uimanager.UIManagerHelper.getSurfaceId(context) + val dispatcher = com.facebook.react.uimanager.UIManagerHelper.getEventDispatcherForReactTag(context, id) + + dispatcher?.dispatchEvent( + com.richtext.events.LinkPressEvent( + surfaceId, + id, + url + ) + ) + } } diff --git a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt index 3b6b36e9..2c2da050 100644 --- a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt +++ b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt @@ -1,11 +1,12 @@ package com.richtext.renderer -import android.graphics.Typeface import android.text.SpannableStringBuilder -import android.text.style.StyleSpan import android.text.style.UnderlineSpan import com.richtext.theme.RichTextTheme -import com.richtext.spans.CallbackURLSpan +import com.richtext.spans.RichTextLinkSpan +import com.richtext.spans.RichTextHeadingSpan +import com.richtext.spans.RichTextParagraphSpan +import com.richtext.spans.RichTextTextSpan import org.commonmark.node.* interface NodeRenderer { @@ -41,11 +42,25 @@ class ParagraphRenderer : NodeRenderer { onLinkPress: ((String) -> Unit)? ) { val paragraph = node as Paragraph + val start = builder.length + var child = paragraph.firstChild while (child != null) { NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) child = child.next } + + // Apply paragraph span to the entire paragraph content + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + RichTextParagraphSpan(), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + builder.append("\n") } } @@ -68,15 +83,13 @@ class HeadingRenderer : NodeRenderer { val contentLength = builder.length - start if (contentLength > 0) { - val isBold = theme.headerConfig.isBold - if (isBold) { - builder.setSpan( - StyleSpan(Typeface.BOLD), - start, - start + contentLength, - android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } + // Use dedicated RichTextHeadingSpan instead of generic StyleSpan + builder.setSpan( + RichTextHeadingSpan(theme, heading.level), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) } builder.append("\n") } @@ -91,7 +104,20 @@ class TextRenderer : NodeRenderer { ) { val text = node as Text val content = text.literal ?: "" + val start = builder.length + builder.append(content) + + // Apply text span to the text content + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + RichTextTextSpan(), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } } } @@ -117,7 +143,7 @@ class LinkRenderer : NodeRenderer { val contentLength = builder.length - start if (contentLength > 0) { builder.setSpan( - CallbackURLSpan(url, onLinkPress), + RichTextLinkSpan(url, onLinkPress), start, start + contentLength, android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE diff --git a/android/src/main/java/com/richtext/spans/CallbackURLSpan.kt b/android/src/main/java/com/richtext/spans/CallbackURLSpan.kt deleted file mode 100644 index 4feaf493..00000000 --- a/android/src/main/java/com/richtext/spans/CallbackURLSpan.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.richtext.spans - -import android.text.style.URLSpan - -class CallbackURLSpan(url: String, private val onLinkPress: ((String) -> Unit)?) : URLSpan(url) { - override fun onClick(widget: android.view.View) { - if (onLinkPress != null) { - onLinkPress(url) - } else { - super.onClick(widget) - } - } -} diff --git a/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt new file mode 100644 index 00000000..03780c2a --- /dev/null +++ b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt @@ -0,0 +1,24 @@ +package com.richtext.spans + +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.MetricAffectingSpan +import com.richtext.theme.RichTextTheme + +class RichTextHeadingSpan( + private val theme: RichTextTheme, + private val level: Int +) : MetricAffectingSpan() { + + override fun updateDrawState(tp: TextPaint) { + if (theme.headerConfig.isBold) { + tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) + } + } + + override fun updateMeasureState(tp: TextPaint) { + if (theme.headerConfig.isBold) { + tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) + } + } +} diff --git a/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt b/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt new file mode 100644 index 00000000..22f101af --- /dev/null +++ b/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt @@ -0,0 +1,27 @@ +package com.richtext.spans + +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View +import com.richtext.RichTextView + +class RichTextLinkSpan( + private val url: String, + private val onLinkPress: ((String) -> Unit)? +) : ClickableSpan() { + + override fun onClick(widget: View) { + if (onLinkPress != null) { + onLinkPress(url) + } else if (widget is RichTextView) { + // Emit event directly from view (enriched pattern) + widget.emitOnLinkPress(url) + } + } + + override fun updateDrawState(textPaint: TextPaint) { + super.updateDrawState(textPaint) + textPaint.isUnderlineText = true + textPaint.color = textPaint.linkColor + } +} diff --git a/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt b/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt new file mode 100644 index 00000000..8bbc9e24 --- /dev/null +++ b/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt @@ -0,0 +1,17 @@ +package com.richtext.spans + +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +class RichTextParagraphSpan : MetricAffectingSpan() { + + override fun updateDrawState(tp: TextPaint) { + // Paragraph-specific styling can be added here + // For now, we just use default paragraph styling + } + + override fun updateMeasureState(tp: TextPaint) { + // Paragraph-specific measurement can be added here + // For now, we just use default paragraph measurement + } +} diff --git a/android/src/main/java/com/richtext/spans/RichTextSpans.kt b/android/src/main/java/com/richtext/spans/RichTextSpans.kt new file mode 100644 index 00000000..84a3bb31 --- /dev/null +++ b/android/src/main/java/com/richtext/spans/RichTextSpans.kt @@ -0,0 +1,18 @@ +package com.richtext.spans + +data class BaseSpanConfig(val clazz: Class<*>) + +object RichTextSpans { + // Currently supported styles + const val LINK = "link" + const val HEADING = "heading" + const val PARAGRAPH = "paragraph" + const val TEXT = "text" + + val supportedSpans: Map = mapOf( + LINK to BaseSpanConfig(RichTextLinkSpan::class.java), + HEADING to BaseSpanConfig(RichTextHeadingSpan::class.java), + PARAGRAPH to BaseSpanConfig(RichTextParagraphSpan::class.java), + TEXT to BaseSpanConfig(RichTextTextSpan::class.java), + ) +} diff --git a/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt b/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt new file mode 100644 index 00000000..5e3e643d --- /dev/null +++ b/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt @@ -0,0 +1,17 @@ +package com.richtext.spans + +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +class RichTextTextSpan : MetricAffectingSpan() { + + override fun updateDrawState(tp: TextPaint) { + // Text-specific styling can be added here + // For now, we just use default text styling + } + + override fun updateMeasureState(tp: TextPaint) { + // Text-specific measurement can be added here + // For now, we just use default text measurement + } +} From 4209b6057ecc0c5507106fa8f7847568c55ccc14 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 12:57:41 +0200 Subject: [PATCH 07/14] feat(android): add constructors to RichTextView for enhanced initialization --- .../main/java/com/richtext/RichTextView.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index a5ff68ca..a43f156c 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -3,24 +3,45 @@ package com.richtext import android.content.Context import android.graphics.Color import android.text.method.LinkMovementMethod +import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import com.richtext.parser.Parser import com.richtext.renderer.Renderer import com.richtext.theme.RichTextTheme -class RichTextView(context: Context) : AppCompatTextView(context) { +class RichTextView : AppCompatTextView { private val parser = Parser() private val renderer = Renderer() private var theme = RichTextTheme.defaultTheme() private var onLinkPressCallback: ((String) -> Unit)? = null + + // State management (following enriched pattern) + private var isSettingValue: Boolean = false + private var typefaceDirty = false + private var didAttachToWindow = false - init { + constructor(context: Context) : super(context) { + prepareComponent() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + prepareComponent() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + prepareComponent() + } + + private fun prepareComponent() { // Initialize the component with basic TextView setup text = "RichTextView - Ready for markdown!" textSize = 16f setTextColor(Color.BLACK) - movementMethod = LinkMovementMethod.getInstance() } From 070baffbcf33f010e891cb46136a60e73fccbe73 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 12:58:09 +0200 Subject: [PATCH 08/14] refactor(android): remove unused state management variables in RichTextView --- android/src/main/java/com/richtext/RichTextView.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index a43f156c..3f7bd811 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -16,11 +16,6 @@ class RichTextView : AppCompatTextView { private var theme = RichTextTheme.defaultTheme() private var onLinkPressCallback: ((String) -> Unit)? = null - // State management (following enriched pattern) - private var isSettingValue: Boolean = false - private var typefaceDirty = false - private var didAttachToWindow = false - constructor(context: Context) : super(context) { prepareComponent() } From 6e6634f8958d4518e7fe6de1d8eeadcd75824e17 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 13:31:00 +0200 Subject: [PATCH 09/14] feat(android): add font customization properties to RichTextView and update related components --- .../main/java/com/richtext/RichTextView.kt | 65 +++++++++++++++++++ .../java/com/richtext/RichTextViewManager.kt | 31 ++++++++- example/src/App.tsx | 2 +- src/RichTextViewNativeComponent.ts | 13 +++- 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 3f7bd811..3bf65a51 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -2,9 +2,14 @@ package com.richtext import android.content.Context import android.graphics.Color +import android.graphics.Typeface import android.text.method.LinkMovementMethod import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView +import com.facebook.react.common.ReactConstants +import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.richtext.parser.Parser import com.richtext.renderer.Renderer import com.richtext.theme.RichTextTheme @@ -16,6 +21,14 @@ class RichTextView : AppCompatTextView { private var theme = RichTextTheme.defaultTheme() private var onLinkPressCallback: ((String) -> Unit)? = null + private var typefaceDirty = false + private var didAttachToWindow = false + + var fontSize: Float? = null + private var fontFamily: String? = null + private var fontStyle: Int = ReactConstants.UNSET + private var fontWeight: Int = ReactConstants.UNSET + constructor(context: Context) : super(context) { prepareComponent() } @@ -46,6 +59,7 @@ class RichTextView : AppCompatTextView { if (document != null) { val styledText = renderer.renderDocument(document, theme, onLinkPressCallback) setText(styledText) + movementMethod = LinkMovementMethod.getInstance() } else { text = "Error parsing markdown - Document is null" } @@ -79,4 +93,55 @@ class RichTextView : AppCompatTextView { ) ) } + + fun setFontSize(size: Float) { + fontSize = size + textSize = size + typefaceDirty = true + updateTypeface() + } + + fun setFontFamily(family: String?) { + fontFamily = family + typefaceDirty = true + updateTypeface() + } + + fun setFontWeight(weight: String?) { + val parsedWeight = parseFontWeight(weight) + if (parsedWeight != fontWeight) { + fontWeight = parsedWeight + typefaceDirty = true + updateTypeface() + } + } + + fun setFontStyle(style: String?) { + val parsedStyle = parseFontStyle(style) + if (parsedStyle != fontStyle) { + fontStyle = parsedStyle + typefaceDirty = true + updateTypeface() + } + } + + fun setColor(color: Int?) { + if (color != null) { + setTextColor(color) + } + } + + fun updateTypeface() { + if (!typefaceDirty) return + typefaceDirty = false + + val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets) + setTypeface(newTypeface) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + didAttachToWindow = true + updateTypeface() + } } diff --git a/android/src/main/java/com/richtext/RichTextViewManager.kt b/android/src/main/java/com/richtext/RichTextViewManager.kt index 189f83ae..88115857 100644 --- a/android/src/main/java/com/richtext/RichTextViewManager.kt +++ b/android/src/main/java/com/richtext/RichTextViewManager.kt @@ -4,6 +4,8 @@ import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.ViewProps +import com.facebook.react.uimanager.ViewDefaults import com.richtext.events.LinkPressEvent class RichTextViewManager : SimpleViewManager() { @@ -26,9 +28,34 @@ class RichTextViewManager : SimpleViewManager() { view?.setMarkdownContent(markdown ?: "No markdown content") } - @ReactProp(name = "fontSize", defaultFloat = 16f) + @ReactProp(name = "fontSize", defaultFloat = ViewDefaults.FONT_SIZE_SP) fun setFontSize(view: RichTextView?, fontSize: Float) { - view?.textSize = fontSize + view?.setFontSize(fontSize) + } + + @ReactProp(name = "fontFamily") + fun setFontFamily(view: RichTextView?, family: String?) { + view?.setFontFamily(family) + } + + @ReactProp(name = ViewProps.COLOR, customType = "Color") + fun setColor(view: RichTextView?, color: Int?) { + view?.setColor(color) + } + + @ReactProp(name = "fontWeight") + fun setFontWeight(view: RichTextView?, weight: String?) { + view?.setFontWeight(weight) + } + + @ReactProp(name = "fontStyle") + fun setFontStyle(view: RichTextView?, style: String?) { + view?.setFontStyle(style) + } + + override fun onAfterUpdateTransaction(view: RichTextView) { + super.onAfterUpdateTransaction(view) + view.updateTypeface() } private fun emitOnLinkPress(view: RichTextView, url: String) { diff --git a/example/src/App.tsx b/example/src/App.tsx index e3671a24..47af5424 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -50,7 +50,7 @@ export default function App() { fontSize={18} fontFamily="Helvetica" headerConfig={HEADER_CONFIG} - textColor="#F54927" + color="#F54927" onLinkPress={handleLinkPress} /> diff --git a/src/RichTextViewNativeComponent.ts b/src/RichTextViewNativeComponent.ts index 42ef6f2f..8185b1fe 100644 --- a/src/RichTextViewNativeComponent.ts +++ b/src/RichTextViewNativeComponent.ts @@ -2,6 +2,7 @@ import { codegenNativeComponent, type ViewProps, type CodegenTypes, + type ColorValue, } from 'react-native'; export interface HeaderConfig { @@ -39,10 +40,20 @@ interface NativeProps extends ViewProps { * @note Takes precedence over headerConfig.isBold for boldness */ fontFamily?: string; + /** + * Font weight for all text elements. + * @example "normal", "bold", "100", "200", "300", "400", "500", "600", "700", "800", "900" + */ + fontWeight?: string; + /** + * Font style for all text elements. + * @example "normal", "italic" + */ + fontStyle?: string; /** * Text color in hex format. */ - textColor?: string; + color?: ColorValue; /** * Header configuration for scaling and boldness. */ From cc5532a66a6400be6cd2ddb182d42302c8ab855e Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 13:33:16 +0200 Subject: [PATCH 10/14] refactor(android): simplify RichTextView initialization --- android/src/main/java/com/richtext/RichTextView.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 3bf65a51..0f80c4c0 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -46,11 +46,9 @@ class RichTextView : AppCompatTextView { } private fun prepareComponent() { - // Initialize the component with basic TextView setup - text = "RichTextView - Ready for markdown!" - textSize = 16f - setTextColor(Color.BLACK) movementMethod = LinkMovementMethod.getInstance() + setPadding(0, 0, 0, 0) + setBackgroundColor(Color.TRANSPARENT) } fun setMarkdownContent(markdown: String) { From c3a5084c1b1f253f5f28db66ddea1be32f71d6ae Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 13:39:28 +0200 Subject: [PATCH 11/14] refactor(android): remove theme dependency from RichTextView and NodeRenderer --- .../src/main/java/com/richtext/RichTextView.kt | 11 +---------- .../java/com/richtext/renderer/NodeRenderer.kt | 18 +++++------------- .../java/com/richtext/renderer/Renderer.kt | 8 +++----- .../com/richtext/spans/RichTextHeadingSpan.kt | 10 ++-------- .../java/com/richtext/theme/HeaderConfig.kt | 10 ---------- .../java/com/richtext/theme/RichTextTheme.kt | 14 -------------- 6 files changed, 11 insertions(+), 60 deletions(-) delete mode 100644 android/src/main/java/com/richtext/theme/HeaderConfig.kt delete mode 100644 android/src/main/java/com/richtext/theme/RichTextTheme.kt diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 0f80c4c0..7c8b05a1 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -12,13 +12,11 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.richtext.parser.Parser import com.richtext.renderer.Renderer -import com.richtext.theme.RichTextTheme class RichTextView : AppCompatTextView { private val parser = Parser() private val renderer = Renderer() - private var theme = RichTextTheme.defaultTheme() private var onLinkPressCallback: ((String) -> Unit)? = null private var typefaceDirty = false @@ -55,7 +53,7 @@ class RichTextView : AppCompatTextView { try { val document = parser.parseMarkdown(markdown) if (document != null) { - val styledText = renderer.renderDocument(document, theme, onLinkPressCallback) + val styledText = renderer.renderDocument(document, onLinkPressCallback) setText(styledText) movementMethod = LinkMovementMethod.getInstance() } else { @@ -66,13 +64,6 @@ class RichTextView : AppCompatTextView { } } - fun updateTheme(newTheme: RichTextTheme) { - theme = newTheme - if (text.isNotEmpty()) { - val markdown = text.toString() - setMarkdownContent(markdown) - } - } fun setOnLinkPressCallback(callback: (String) -> Unit) { onLinkPressCallback = callback diff --git a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt index 2c2da050..e23b4377 100644 --- a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt +++ b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt @@ -2,7 +2,6 @@ package com.richtext.renderer import android.text.SpannableStringBuilder import android.text.style.UnderlineSpan -import com.richtext.theme.RichTextTheme import com.richtext.spans.RichTextLinkSpan import com.richtext.spans.RichTextHeadingSpan import com.richtext.spans.RichTextParagraphSpan @@ -13,7 +12,6 @@ interface NodeRenderer { fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) } @@ -22,13 +20,12 @@ class DocumentRenderer : NodeRenderer { override fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) { val document = node as Document var child = document.firstChild while (child != null) { - NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) child = child.next } } @@ -38,7 +35,6 @@ class ParagraphRenderer : NodeRenderer { override fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) { val paragraph = node as Paragraph @@ -46,7 +42,7 @@ class ParagraphRenderer : NodeRenderer { var child = paragraph.firstChild while (child != null) { - NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) child = child.next } @@ -69,7 +65,6 @@ class HeadingRenderer : NodeRenderer { override fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) { val heading = node as Heading @@ -77,7 +72,7 @@ class HeadingRenderer : NodeRenderer { var child = heading.firstChild while (child != null) { - NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) child = child.next } @@ -85,7 +80,7 @@ class HeadingRenderer : NodeRenderer { if (contentLength > 0) { // Use dedicated RichTextHeadingSpan instead of generic StyleSpan builder.setSpan( - RichTextHeadingSpan(theme, heading.level), + RichTextHeadingSpan(heading.level), start, start + contentLength, android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE @@ -99,7 +94,6 @@ class TextRenderer : NodeRenderer { override fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) { val text = node as Text @@ -125,7 +119,6 @@ class LinkRenderer : NodeRenderer { override fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) { val link = node as Link @@ -135,7 +128,7 @@ class LinkRenderer : NodeRenderer { // Render link content var child = link.firstChild while (child != null) { - NodeRendererFactory.getRenderer(child).render(child, builder, theme, onLinkPress) + NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) child = child.next } @@ -164,7 +157,6 @@ class LineBreakRenderer : NodeRenderer { override fun render( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? ) { builder.append("\n") diff --git a/android/src/main/java/com/richtext/renderer/Renderer.kt b/android/src/main/java/com/richtext/renderer/Renderer.kt index 84fc9454..fbde4ea9 100644 --- a/android/src/main/java/com/richtext/renderer/Renderer.kt +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -7,13 +7,12 @@ import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan import org.commonmark.node.* -import com.richtext.theme.RichTextTheme class Renderer { - fun renderDocument(document: Document, theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null): SpannableString { + fun renderDocument(document: Document, onLinkPress: ((String) -> Unit)? = null): SpannableString { val builder = SpannableStringBuilder() - renderNode(document, builder, theme, onLinkPress) + renderNode(document, builder, onLinkPress) return SpannableString(builder) } @@ -21,10 +20,9 @@ class Renderer { private fun renderNode( node: Node, builder: SpannableStringBuilder, - theme: RichTextTheme, onLinkPress: ((String) -> Unit)? = null ) { val renderer = NodeRendererFactory.getRenderer(node) - renderer.render(node, builder, theme, onLinkPress) + renderer.render(node, builder, onLinkPress) } } diff --git a/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt index 03780c2a..03c20047 100644 --- a/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt +++ b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt @@ -3,22 +3,16 @@ package com.richtext.spans import android.graphics.Typeface import android.text.TextPaint import android.text.style.MetricAffectingSpan -import com.richtext.theme.RichTextTheme class RichTextHeadingSpan( - private val theme: RichTextTheme, private val level: Int ) : MetricAffectingSpan() { override fun updateDrawState(tp: TextPaint) { - if (theme.headerConfig.isBold) { - tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) - } + tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) } override fun updateMeasureState(tp: TextPaint) { - if (theme.headerConfig.isBold) { - tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) - } + tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) } } diff --git a/android/src/main/java/com/richtext/theme/HeaderConfig.kt b/android/src/main/java/com/richtext/theme/HeaderConfig.kt deleted file mode 100644 index 2d2c4149..00000000 --- a/android/src/main/java/com/richtext/theme/HeaderConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.richtext.theme - -data class HeaderConfig( - val scale: Float = 2.0f, - val isBold: Boolean = true -) { - companion object { - fun defaultConfig(): HeaderConfig = HeaderConfig() - } -} diff --git a/android/src/main/java/com/richtext/theme/RichTextTheme.kt b/android/src/main/java/com/richtext/theme/RichTextTheme.kt deleted file mode 100644 index 01096867..00000000 --- a/android/src/main/java/com/richtext/theme/RichTextTheme.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.richtext.theme - -import android.graphics.Typeface -import android.graphics.Color - -data class RichTextTheme( - val baseFont: Typeface = Typeface.DEFAULT, - val textColor: Int = Color.BLACK, - val headerConfig: HeaderConfig = HeaderConfig.defaultConfig() -) { - companion object { - fun defaultTheme(): RichTextTheme = RichTextTheme() - } -} From 5b2ce66f4cccf1812a8d71e1a332a7abdc8a8a56 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 14:47:51 +0200 Subject: [PATCH 12/14] feat(android): implement RichTextViewManager interface and enhance event handling for link presses --- .../java/com/richtext/RichTextViewManager.kt | 109 +++++++++++------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextViewManager.kt b/android/src/main/java/com/richtext/RichTextViewManager.kt index 88115857..e23903f0 100644 --- a/android/src/main/java/com/richtext/RichTextViewManager.kt +++ b/android/src/main/java/com/richtext/RichTextViewManager.kt @@ -1,68 +1,91 @@ package com.richtext +import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.ViewProps import com.facebook.react.uimanager.ViewDefaults +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.RichTextViewManagerDelegate +import com.facebook.react.viewmanagers.RichTextViewManagerInterface import com.richtext.events.LinkPressEvent -class RichTextViewManager : SimpleViewManager() { +@ReactModule(name = RichTextViewManager.NAME) +class RichTextViewManager : SimpleViewManager(), + RichTextViewManagerInterface { - private var reactContext: ThemedReactContext? = null + private val mDelegate: ViewManagerDelegate = RichTextViewManagerDelegate(this) - override fun getName(): String = "RichTextView" + override fun getDelegate(): ViewManagerDelegate? { + return mDelegate + } - override fun createViewInstance(reactContext: ThemedReactContext): RichTextView { - this.reactContext = reactContext - return RichTextView(reactContext) - } + override fun getName(): String { + return NAME + } - @ReactProp(name = "markdown") - fun setMarkdown(view: RichTextView?, markdown: String?) { - view?.setOnLinkPressCallback { url -> - emitOnLinkPress(view, url) - } + override fun createViewInstance(reactContext: ThemedReactContext): RichTextView { + return RichTextView(reactContext) + } - view?.setMarkdownContent(markdown ?: "No markdown content") - } + override fun getExportedCustomDirectEventTypeConstants(): MutableMap { + val map = mutableMapOf() + map.put(LinkPressEvent.EVENT_NAME, mapOf("registrationName" to LinkPressEvent.EVENT_NAME)) + return map + } - @ReactProp(name = "fontSize", defaultFloat = ViewDefaults.FONT_SIZE_SP) - fun setFontSize(view: RichTextView?, fontSize: Float) { - view?.setFontSize(fontSize) + @ReactProp(name = "markdown") + override fun setMarkdown(view: RichTextView?, markdown: String?) { + view?.setOnLinkPressCallback { url -> + emitOnLinkPress(view, url) } - @ReactProp(name = "fontFamily") - fun setFontFamily(view: RichTextView?, family: String?) { - view?.setFontFamily(family) - } + view?.setMarkdownContent(markdown ?: "No markdown content") + } - @ReactProp(name = ViewProps.COLOR, customType = "Color") - fun setColor(view: RichTextView?, color: Int?) { - view?.setColor(color) - } - @ReactProp(name = "fontWeight") - fun setFontWeight(view: RichTextView?, weight: String?) { - view?.setFontWeight(weight) - } + @ReactProp(name = "fontSize", defaultInt = ViewDefaults.FONT_SIZE_SP.toInt()) + override fun setFontSize(view: RichTextView?, fontSize: Int) { + view?.setFontSize(fontSize.toFloat()) + } - @ReactProp(name = "fontStyle") - fun setFontStyle(view: RichTextView?, style: String?) { - view?.setFontStyle(style) - } + @ReactProp(name = "fontFamily") + override fun setFontFamily(view: RichTextView?, family: String?) { + view?.setFontFamily(family) + } - override fun onAfterUpdateTransaction(view: RichTextView) { - super.onAfterUpdateTransaction(view) - view.updateTypeface() - } + @ReactProp(name = ViewProps.COLOR, customType = "Color") + override fun setColor(view: RichTextView?, color: Int?) { + view?.setColor(color) + } - private fun emitOnLinkPress(view: RichTextView, url: String) { - val surfaceId = UIManagerHelper.getSurfaceId(reactContext!!) - val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext!!, view.id) - val event = LinkPressEvent(surfaceId, view.id, url) + @ReactProp(name = "fontWeight") + override fun setFontWeight(view: RichTextView?, weight: String?) { + view?.setFontWeight(weight) + } - eventDispatcher?.dispatchEvent(event) - } + @ReactProp(name = "fontStyle") + override fun setFontStyle(view: RichTextView?, style: String?) { + view?.setFontStyle(style) + } + + override fun onAfterUpdateTransaction(view: RichTextView) { + super.onAfterUpdateTransaction(view) + view.updateTypeface() + } + + private fun emitOnLinkPress(view: RichTextView, url: String) { + val context = view.context as com.facebook.react.bridge.ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + val event = LinkPressEvent(surfaceId, view.id, url) + + eventDispatcher?.dispatchEvent(event) + } + + companion object { + const val NAME = "RichTextView" + } } From fd5b78c0f52cda4fd1a5e34f51694a3c263a4fda Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 14:52:50 +0200 Subject: [PATCH 13/14] refactor(android): clean up and standardize formatting in RichTextView and related span classes --- .../main/java/com/richtext/RichTextView.kt | 219 +++++++++--------- .../com/richtext/renderer/NodeRenderer.kt | 23 +- .../java/com/richtext/renderer/Renderer.kt | 4 - .../com/richtext/spans/RichTextHeadingSpan.kt | 16 +- .../com/richtext/spans/RichTextLinkSpan.kt | 30 +-- .../richtext/spans/RichTextParagraphSpan.kt | 18 +- .../java/com/richtext/spans/RichTextSpans.kt | 22 +- .../com/richtext/spans/RichTextTextSpan.kt | 18 +- 8 files changed, 172 insertions(+), 178 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 7c8b05a1..d3623d4f 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -15,122 +15,123 @@ import com.richtext.renderer.Renderer class RichTextView : AppCompatTextView { - private val parser = Parser() - private val renderer = Renderer() - private var onLinkPressCallback: ((String) -> Unit)? = null - - private var typefaceDirty = false - private var didAttachToWindow = false - - var fontSize: Float? = null - private var fontFamily: String? = null - private var fontStyle: Int = ReactConstants.UNSET - private var fontWeight: Int = ReactConstants.UNSET - - constructor(context: Context) : super(context) { - prepareComponent() - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - prepareComponent() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) { - prepareComponent() - } - - private fun prepareComponent() { + private val parser = Parser() + private val renderer = Renderer() + private var onLinkPressCallback: ((String) -> Unit)? = null + + private var typefaceDirty = false + private var didAttachToWindow = false + + var fontSize: Float? = null + private var fontFamily: String? = null + private var fontStyle: Int = ReactConstants.UNSET + private var fontWeight: Int = ReactConstants.UNSET + + constructor(context: Context) : super(context) { + prepareComponent() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + prepareComponent() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + prepareComponent() + } + + private fun prepareComponent() { + movementMethod = LinkMovementMethod.getInstance() + setPadding(0, 0, 0, 0) + setBackgroundColor(Color.TRANSPARENT) + } + + fun setMarkdownContent(markdown: String) { + try { + val document = parser.parseMarkdown(markdown) + if (document != null) { + val styledText = renderer.renderDocument(document, onLinkPressCallback) + setText(styledText) movementMethod = LinkMovementMethod.getInstance() - setPadding(0, 0, 0, 0) - setBackgroundColor(Color.TRANSPARENT) - } - - fun setMarkdownContent(markdown: String) { - try { - val document = parser.parseMarkdown(markdown) - if (document != null) { - val styledText = renderer.renderDocument(document, onLinkPressCallback) - setText(styledText) - movementMethod = LinkMovementMethod.getInstance() - } else { - text = "Error parsing markdown - Document is null" - } - } catch (e: Exception) { - text = "Error: ${e.message}" - } - } - - - fun setOnLinkPressCallback(callback: (String) -> Unit) { - onLinkPressCallback = callback - } - - fun emitOnLinkPress(url: String) { - val context = this.context as? com.facebook.react.bridge.ReactContext ?: return - val surfaceId = com.facebook.react.uimanager.UIManagerHelper.getSurfaceId(context) - val dispatcher = com.facebook.react.uimanager.UIManagerHelper.getEventDispatcherForReactTag(context, id) - - dispatcher?.dispatchEvent( - com.richtext.events.LinkPressEvent( - surfaceId, - id, - url - ) - ) - } - - fun setFontSize(size: Float) { - fontSize = size - textSize = size - typefaceDirty = true - updateTypeface() + } else { + text = "Error parsing markdown - Document is null" + } + } catch (e: Exception) { + text = "Error: ${e.message}" } - - fun setFontFamily(family: String?) { - fontFamily = family - typefaceDirty = true - updateTypeface() + } + + + fun setOnLinkPressCallback(callback: (String) -> Unit) { + onLinkPressCallback = callback + } + + fun emitOnLinkPress(url: String) { + val context = this.context as? com.facebook.react.bridge.ReactContext ?: return + val surfaceId = com.facebook.react.uimanager.UIManagerHelper.getSurfaceId(context) + val dispatcher = + com.facebook.react.uimanager.UIManagerHelper.getEventDispatcherForReactTag(context, id) + + dispatcher?.dispatchEvent( + com.richtext.events.LinkPressEvent( + surfaceId, + id, + url + ) + ) + } + + fun setFontSize(size: Float) { + fontSize = size + textSize = size + typefaceDirty = true + updateTypeface() + } + + fun setFontFamily(family: String?) { + fontFamily = family + typefaceDirty = true + updateTypeface() + } + + fun setFontWeight(weight: String?) { + val parsedWeight = parseFontWeight(weight) + if (parsedWeight != fontWeight) { + fontWeight = parsedWeight + typefaceDirty = true + updateTypeface() } - - fun setFontWeight(weight: String?) { - val parsedWeight = parseFontWeight(weight) - if (parsedWeight != fontWeight) { - fontWeight = parsedWeight - typefaceDirty = true - updateTypeface() - } + } + + fun setFontStyle(style: String?) { + val parsedStyle = parseFontStyle(style) + if (parsedStyle != fontStyle) { + fontStyle = parsedStyle + typefaceDirty = true + updateTypeface() } + } - fun setFontStyle(style: String?) { - val parsedStyle = parseFontStyle(style) - if (parsedStyle != fontStyle) { - fontStyle = parsedStyle - typefaceDirty = true - updateTypeface() - } + fun setColor(color: Int?) { + if (color != null) { + setTextColor(color) } + } - fun setColor(color: Int?) { - if (color != null) { - setTextColor(color) - } - } + fun updateTypeface() { + if (!typefaceDirty) return + typefaceDirty = false - fun updateTypeface() { - if (!typefaceDirty) return - typefaceDirty = false - - val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets) - setTypeface(newTypeface) - } + val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets) + setTypeface(newTypeface) + } - override fun onAttachedToWindow() { - super.onAttachedToWindow() - didAttachToWindow = true - updateTypeface() - } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + didAttachToWindow = true + updateTypeface() + } } diff --git a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt index e23b4377..22c5bbce 100644 --- a/android/src/main/java/com/richtext/renderer/NodeRenderer.kt +++ b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt @@ -39,14 +39,13 @@ class ParagraphRenderer : NodeRenderer { ) { val paragraph = node as Paragraph val start = builder.length - + var child = paragraph.firstChild while (child != null) { NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) child = child.next } - - // Apply paragraph span to the entire paragraph content + val contentLength = builder.length - start if (contentLength > 0) { builder.setSpan( @@ -56,7 +55,7 @@ class ParagraphRenderer : NodeRenderer { android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE ) } - + builder.append("\n") } } @@ -69,7 +68,7 @@ class HeadingRenderer : NodeRenderer { ) { val heading = node as Heading val start = builder.length - + var child = heading.firstChild while (child != null) { NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) @@ -78,7 +77,6 @@ class HeadingRenderer : NodeRenderer { val contentLength = builder.length - start if (contentLength > 0) { - // Use dedicated RichTextHeadingSpan instead of generic StyleSpan builder.setSpan( RichTextHeadingSpan(heading.level), start, @@ -99,10 +97,9 @@ class TextRenderer : NodeRenderer { val text = node as Text val content = text.literal ?: "" val start = builder.length - + builder.append(content) - - // Apply text span to the text content + val contentLength = builder.length - start if (contentLength > 0) { builder.setSpan( @@ -125,14 +122,12 @@ class LinkRenderer : NodeRenderer { val start = builder.length val url = link.destination ?: "" - // Render link content var child = link.firstChild while (child != null) { NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) child = child.next } - // Apply link styling if content was added val contentLength = builder.length - start if (contentLength > 0) { builder.setSpan( @@ -142,7 +137,6 @@ class LinkRenderer : NodeRenderer { android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE ) - // Add underline builder.setSpan( UnderlineSpan(), start, @@ -173,7 +167,10 @@ object NodeRendererFactory { is Link -> LinkRenderer() is HardLineBreak, is SoftLineBreak -> LineBreakRenderer() else -> { - android.util.Log.w("NodeRendererFactory", "No renderer found for node type: ${node.javaClass.simpleName}") + android.util.Log.w( + "NodeRendererFactory", + "No renderer found for node type: ${node.javaClass.simpleName}" + ) TextRenderer() // Fallback to text renderer } } diff --git a/android/src/main/java/com/richtext/renderer/Renderer.kt b/android/src/main/java/com/richtext/renderer/Renderer.kt index fbde4ea9..1828f0ac 100644 --- a/android/src/main/java/com/richtext/renderer/Renderer.kt +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -1,11 +1,7 @@ package com.richtext.renderer -import android.graphics.Typeface import android.text.SpannableString import android.text.SpannableStringBuilder -import android.text.style.StyleSpan -import android.text.style.URLSpan -import android.text.style.UnderlineSpan import org.commonmark.node.* class Renderer { diff --git a/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt index 03c20047..1dd32bbf 100644 --- a/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt +++ b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt @@ -5,14 +5,14 @@ import android.text.TextPaint import android.text.style.MetricAffectingSpan class RichTextHeadingSpan( - private val level: Int + private val level: Int ) : MetricAffectingSpan() { - - override fun updateDrawState(tp: TextPaint) { - tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) - } - override fun updateMeasureState(tp: TextPaint) { - tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) - } + override fun updateDrawState(tp: TextPaint) { + tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) + } + + override fun updateMeasureState(tp: TextPaint) { + tp.typeface = Typeface.create(tp.typeface, Typeface.BOLD) + } } diff --git a/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt b/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt index 22f101af..c03bbb54 100644 --- a/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt +++ b/android/src/main/java/com/richtext/spans/RichTextLinkSpan.kt @@ -6,22 +6,22 @@ import android.view.View import com.richtext.RichTextView class RichTextLinkSpan( - private val url: String, - private val onLinkPress: ((String) -> Unit)? + private val url: String, + private val onLinkPress: ((String) -> Unit)? ) : ClickableSpan() { - - override fun onClick(widget: View) { - if (onLinkPress != null) { - onLinkPress(url) - } else if (widget is RichTextView) { - // Emit event directly from view (enriched pattern) - widget.emitOnLinkPress(url) - } - } - override fun updateDrawState(textPaint: TextPaint) { - super.updateDrawState(textPaint) - textPaint.isUnderlineText = true - textPaint.color = textPaint.linkColor + override fun onClick(widget: View) { + if (onLinkPress != null) { + onLinkPress(url) + } else if (widget is RichTextView) { + // Emit event directly from view (enriched pattern) + widget.emitOnLinkPress(url) } + } + + override fun updateDrawState(textPaint: TextPaint) { + super.updateDrawState(textPaint) + textPaint.isUnderlineText = true + textPaint.color = textPaint.linkColor + } } diff --git a/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt b/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt index 8bbc9e24..d21c4772 100644 --- a/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt +++ b/android/src/main/java/com/richtext/spans/RichTextParagraphSpan.kt @@ -4,14 +4,14 @@ import android.text.TextPaint import android.text.style.MetricAffectingSpan class RichTextParagraphSpan : MetricAffectingSpan() { - - override fun updateDrawState(tp: TextPaint) { - // Paragraph-specific styling can be added here - // For now, we just use default paragraph styling - } - override fun updateMeasureState(tp: TextPaint) { - // Paragraph-specific measurement can be added here - // For now, we just use default paragraph measurement - } + override fun updateDrawState(tp: TextPaint) { + // Paragraph-specific styling can be added here + // For now, we just use default paragraph styling + } + + override fun updateMeasureState(tp: TextPaint) { + // Paragraph-specific measurement can be added here + // For now, we just use default paragraph measurement + } } diff --git a/android/src/main/java/com/richtext/spans/RichTextSpans.kt b/android/src/main/java/com/richtext/spans/RichTextSpans.kt index 84a3bb31..77d100ad 100644 --- a/android/src/main/java/com/richtext/spans/RichTextSpans.kt +++ b/android/src/main/java/com/richtext/spans/RichTextSpans.kt @@ -3,16 +3,16 @@ package com.richtext.spans data class BaseSpanConfig(val clazz: Class<*>) object RichTextSpans { - // Currently supported styles - const val LINK = "link" - const val HEADING = "heading" - const val PARAGRAPH = "paragraph" - const val TEXT = "text" + // Currently supported styles + const val LINK = "link" + const val HEADING = "heading" + const val PARAGRAPH = "paragraph" + const val TEXT = "text" - val supportedSpans: Map = mapOf( - LINK to BaseSpanConfig(RichTextLinkSpan::class.java), - HEADING to BaseSpanConfig(RichTextHeadingSpan::class.java), - PARAGRAPH to BaseSpanConfig(RichTextParagraphSpan::class.java), - TEXT to BaseSpanConfig(RichTextTextSpan::class.java), - ) + val supportedSpans: Map = mapOf( + LINK to BaseSpanConfig(RichTextLinkSpan::class.java), + HEADING to BaseSpanConfig(RichTextHeadingSpan::class.java), + PARAGRAPH to BaseSpanConfig(RichTextParagraphSpan::class.java), + TEXT to BaseSpanConfig(RichTextTextSpan::class.java), + ) } diff --git a/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt b/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt index 5e3e643d..ec506eee 100644 --- a/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt +++ b/android/src/main/java/com/richtext/spans/RichTextTextSpan.kt @@ -4,14 +4,14 @@ import android.text.TextPaint import android.text.style.MetricAffectingSpan class RichTextTextSpan : MetricAffectingSpan() { - - override fun updateDrawState(tp: TextPaint) { - // Text-specific styling can be added here - // For now, we just use default text styling - } - override fun updateMeasureState(tp: TextPaint) { - // Text-specific measurement can be added here - // For now, we just use default text measurement - } + override fun updateDrawState(tp: TextPaint) { + // Text-specific styling can be added here + // For now, we just use default text styling + } + + override fun updateMeasureState(tp: TextPaint) { + // Text-specific measurement can be added here + // For now, we just use default text measurement + } } From f1184f3c32a7b4e211e1e155a57012ac8a5d9ae4 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 24 Oct 2025 14:59:51 +0200 Subject: [PATCH 14/14] fix(android): improve error handling in RichTextView by logging parsing errors --- android/src/main/java/com/richtext/RichTextView.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index d3623d4f..2790c33a 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -2,7 +2,6 @@ package com.richtext import android.content.Context import android.graphics.Color -import android.graphics.Typeface import android.text.method.LinkMovementMethod import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView @@ -54,13 +53,15 @@ class RichTextView : AppCompatTextView { val document = parser.parseMarkdown(markdown) if (document != null) { val styledText = renderer.renderDocument(document, onLinkPressCallback) - setText(styledText) + text = styledText movementMethod = LinkMovementMethod.getInstance() } else { - text = "Error parsing markdown - Document is null" + android.util.Log.e("RichTextView", "Failed to parse markdown - Document is null") + text = "" } } catch (e: Exception) { - text = "Error: ${e.message}" + android.util.Log.e("RichTextView", "Error parsing markdown: ${e.message}") + text = "" } }