diff --git a/android/build.gradle b/android/build.gradle index 662514f3..b6046f84 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.27.0" } diff --git a/android/src/main/java/com/richtext/RichTextView.kt b/android/src/main/java/com/richtext/RichTextView.kt index 1937a68f..2790c33a 100644 --- a/android/src/main/java/com/richtext/RichTextView.kt +++ b/android/src/main/java/com/richtext/RichTextView.kt @@ -1,15 +1,138 @@ package com.richtext import android.content.Context +import android.graphics.Color +import android.text.method.LinkMovementMethod import android.util.AttributeSet -import android.view.View +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 -class RichTextView : View { - constructor(context: Context?) : super(context) - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( +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() { + 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) + text = styledText + movementMethod = LinkMovementMethod.getInstance() + } else { + android.util.Log.e("RichTextView", "Failed to parse markdown - Document is null") + text = "" + } + } catch (e: Exception) { + android.util.Log.e("RichTextView", "Error parsing markdown: ${e.message}") + text = "" + } + } + + + 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 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 4293955e..e23903f0 100644 --- a/android/src/main/java/com/richtext/RichTextViewManager.kt +++ b/android/src/main/java/com/richtext/RichTextViewManager.kt @@ -1,22 +1,22 @@ 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.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 @ReactModule(name = RichTextViewManager.NAME) class RichTextViewManager : SimpleViewManager(), RichTextViewManagerInterface { - private val mDelegate: ViewManagerDelegate - init { - mDelegate = RichTextViewManagerDelegate(this) - } + private val mDelegate: ViewManagerDelegate = RichTextViewManagerDelegate(this) override fun getDelegate(): ViewManagerDelegate? { return mDelegate @@ -26,13 +26,63 @@ class RichTextViewManager : SimpleViewManager(), return NAME } - public override fun createViewInstance(context: ThemedReactContext): RichTextView { - return RichTextView(context) + override fun createViewInstance(reactContext: ThemedReactContext): RichTextView { + return RichTextView(reactContext) + } + + override fun getExportedCustomDirectEventTypeConstants(): MutableMap { + val map = mutableMapOf() + map.put(LinkPressEvent.EVENT_NAME, mapOf("registrationName" to LinkPressEvent.EVENT_NAME)) + return map + } + + @ReactProp(name = "markdown") + override fun setMarkdown(view: RichTextView?, markdown: String?) { + view?.setOnLinkPressCallback { url -> + emitOnLinkPress(view, url) + } + + view?.setMarkdownContent(markdown ?: "No markdown content") + } + + + @ReactProp(name = "fontSize", defaultInt = ViewDefaults.FONT_SIZE_SP.toInt()) + override fun setFontSize(view: RichTextView?, fontSize: Int) { + view?.setFontSize(fontSize.toFloat()) + } + + @ReactProp(name = "fontFamily") + override fun setFontFamily(view: RichTextView?, family: String?) { + view?.setFontFamily(family) + } + + @ReactProp(name = ViewProps.COLOR, customType = "Color") + override fun setColor(view: RichTextView?, color: Int?) { + view?.setColor(color) + } + + @ReactProp(name = "fontWeight") + override fun setFontWeight(view: RichTextView?, weight: String?) { + view?.setFontWeight(weight) + } + + @ReactProp(name = "fontStyle") + override fun setFontStyle(view: RichTextView?, style: String?) { + view?.setFontStyle(style) } - @ReactProp(name = "color") - override fun setColor(view: RichTextView?, color: String?) { - view?.setBackgroundColor(Color.parseColor(color)) + 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 { 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..e6505a09 --- /dev/null +++ b/android/src/main/java/com/richtext/parser/Parser.kt @@ -0,0 +1,30 @@ +package com.richtext.parser + +import android.util.Log +import org.commonmark.node.Document +import org.commonmark.parser.Parser + +class Parser { + + private val parser = Parser.builder().build() + + 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/NodeRenderer.kt b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt new file mode 100644 index 00000000..22c5bbce --- /dev/null +++ b/android/src/main/java/com/richtext/renderer/NodeRenderer.kt @@ -0,0 +1,178 @@ +package com.richtext.renderer + +import android.text.SpannableStringBuilder +import android.text.style.UnderlineSpan +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 { + fun render( + node: Node, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)? + ) +} + +class DocumentRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)? + ) { + val document = node as Document + var child = document.firstChild + while (child != null) { + NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) + child = child.next + } + } +} + +class ParagraphRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + 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, onLinkPress) + child = child.next + } + + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + RichTextParagraphSpan(), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + builder.append("\n") + } +} + +class HeadingRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + 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, onLinkPress) + child = child.next + } + + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + RichTextHeadingSpan(heading.level), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + builder.append("\n") + } +} + +class TextRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)? + ) { + val text = node as Text + val content = text.literal ?: "" + val start = builder.length + + builder.append(content) + + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + RichTextTextSpan(), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } +} + +class LinkRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)? + ) { + val link = node as Link + val start = builder.length + val url = link.destination ?: "" + + var child = link.firstChild + while (child != null) { + NodeRendererFactory.getRenderer(child).render(child, builder, onLinkPress) + child = child.next + } + + val contentLength = builder.length - start + if (contentLength > 0) { + builder.setSpan( + RichTextLinkSpan(url, onLinkPress), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + builder.setSpan( + UnderlineSpan(), + start, + start + contentLength, + android.text.SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } +} + +class LineBreakRenderer : NodeRenderer { + override fun render( + node: Node, + builder: SpannableStringBuilder, + 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 new file mode 100644 index 00000000..1828f0ac --- /dev/null +++ b/android/src/main/java/com/richtext/renderer/Renderer.kt @@ -0,0 +1,24 @@ +package com.richtext.renderer + +import android.text.SpannableString +import android.text.SpannableStringBuilder +import org.commonmark.node.* + +class Renderer { + fun renderDocument(document: Document, onLinkPress: ((String) -> Unit)? = null): SpannableString { + val builder = SpannableStringBuilder() + + renderNode(document, builder, onLinkPress) + + return SpannableString(builder) + } + + private fun renderNode( + node: Node, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)? = null + ) { + val renderer = NodeRendererFactory.getRenderer(node) + 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 new file mode 100644 index 00000000..1dd32bbf --- /dev/null +++ b/android/src/main/java/com/richtext/spans/RichTextHeadingSpan.kt @@ -0,0 +1,18 @@ +package com.richtext.spans + +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +class RichTextHeadingSpan( + 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) + } +} 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..c03bbb54 --- /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..d21c4772 --- /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..77d100ad --- /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..ec506eee --- /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 + } +} 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. */