Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.0.2
11 changes: 10 additions & 1 deletion ReactNativeEnrichedMarkdown.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,17 @@ Pod::Spec.new do |s|
s.dependency 'iosMath', '~> 0.9'
end

# Quoted imports like #import "Foo.h" do not search subdirs recursively; list every
# ios folder that contains headers so renderer/ utils/ attachments/ etc. cross-imports resolve.
ios_header_paths = %w[
ios ios/attachments ios/input ios/input/internals ios/input/styles ios/internals ios/parser
ios/renderer ios/styles ios/utils ios/views
].map { |p| "\"$(PODS_TARGET_SRCROOT)/#{p}\"" }.join(' ')

s.pod_target_xcconfig = {
'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/cpp/md4c" "$(PODS_TARGET_SRCROOT)/cpp/parser" "$(PODS_TARGET_SRCROOT)/ios/internals" "$(PODS_TARGET_SRCROOT)/ios/input/internals"',
'HEADER_SEARCH_PATHS' => "\"$(PODS_TARGET_SRCROOT)/cpp/md4c\" \"$(PODS_TARGET_SRCROOT)/cpp/parser\" #{ios_header_paths}",
# React / SwiftUI modules use framework-style modules; our ObjC uses plain quoted includes.
'CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES' => 'YES',
'GCC_PREPROCESSOR_DEFINITIONS' => preprocessor_defs,
'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class EnrichedMarkdown

private var onLinkPressCallback: ((String) -> Unit)? = null
private var onLinkLongPressCallback: ((String) -> Unit)? = null
private var onMentionPressCallback: ((String, String) -> Unit)? = null
private var onCitationPressCallback: ((String, String) -> Unit)? = null
private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null
private var contextMenuItemTexts: List<String> = emptyList()
var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
Expand Down Expand Up @@ -136,6 +138,14 @@ class EnrichedMarkdown
onLinkLongPressCallback = callback
}

fun setOnMentionPressCallback(callback: ((url: String, text: String) -> Unit)?) {
onMentionPressCallback = callback
}

fun setOnCitationPressCallback(callback: ((url: String, text: String) -> Unit)?) {
onCitationPressCallback = callback
}

fun setOnTaskListItemPressCallback(callback: ((taskIndex: Int, checked: Boolean, itemText: String) -> Unit)?) {
onTaskListItemPressCallback = callback
}
Expand Down Expand Up @@ -224,6 +234,8 @@ class EnrichedMarkdown
justificationMode = android.text.Layout.JUSTIFICATION_MODE_INTER_WORD
}
lastElementMarginBottom = segment.lastElementMarginBottom
onMentionPressCallback = this@EnrichedMarkdown.onMentionPressCallback
onCitationPressCallback = this@EnrichedMarkdown.onCitationPressCallback
applyStyledText(segment.styledText)
segment.imageSpans.forEach { it.registerTextView(this) }

Expand All @@ -244,6 +256,8 @@ class EnrichedMarkdown
maxFontSizeMultiplier = this@EnrichedMarkdown.maxFontSizeMultiplier
onLinkPress = onLinkPressCallback
onLinkLongPress = onLinkLongPressCallback
onMentionPress = onMentionPressCallback
onCitationPress = onCitationPressCallback
applyTableNode(segment.node)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class EnrichedMarkdownInternalText
checkboxTouchHelper.onCheckboxTap = value
}

var onMentionPressCallback: ((url: String, text: String) -> Unit)? = null
var onCitationPressCallback: ((url: String, text: String) -> Unit)? = null

override val segmentMarginBottom: Int get() = lastElementMarginBottom.toInt()

override var spoilerOverlayDrawer: SpoilerOverlayDrawer? = null
Expand All @@ -61,9 +64,11 @@ class EnrichedMarkdownInternalText
fun applyStyledText(styledText: CharSequence) {
text = styledText

if (movementMethod !is LinkLongPressMovementMethod) {
movementMethod = LinkLongPressMovementMethod.createInstance()
}
val method =
(movementMethod as? LinkLongPressMovementMethod)
?: LinkLongPressMovementMethod.createInstance().also { movementMethod = it }
method.onMentionTap = { url, mentionText -> onMentionPressCallback?.invoke(url, mentionText) }
method.onCitationTap = { url, citationText -> onCitationPressCallback?.invoke(url, citationText) }

spoilerOverlayDrawer = SpoilerOverlayDrawer.setupIfNeeded(this, styledText, spoilerOverlayDrawer, spoilerOverlay)
accessibilityHelper.invalidateAccessibilityItems()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import com.facebook.react.viewmanagers.EnrichedMarkdownManagerDelegate
import com.facebook.react.viewmanagers.EnrichedMarkdownManagerInterface
import com.facebook.yoga.YogaMeasureMode
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
import com.swmansion.enriched.markdown.utils.common.emitCitationPress
import com.swmansion.enriched.markdown.utils.common.emitContextMenuItemPress
import com.swmansion.enriched.markdown.utils.common.emitLinkLongPress
import com.swmansion.enriched.markdown.utils.common.emitLinkPress
import com.swmansion.enriched.markdown.utils.common.emitMentionPress
import com.swmansion.enriched.markdown.utils.common.emitTaskListItemPress
import com.swmansion.enriched.markdown.utils.common.markdownEventTypeConstants
import com.swmansion.enriched.markdown.utils.common.parseContextMenuItems
Expand Down Expand Up @@ -54,6 +56,14 @@ class EnrichedMarkdownManager :
emitLinkLongPress(view, url)
}

view?.setOnMentionPressCallback { url, text ->
emitMentionPress(view, url, text)
}

view?.setOnCitationPressCallback { url, text ->
emitCitationPress(view, url, text)
}

view?.setOnTaskListItemPressCallback { taskIndex, checked, itemText ->
val newChecked = !checked
val updatedMarkdown = TaskListToggleUtils.toggleAtIndex(view.currentMarkdown, taskIndex, newChecked)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
import com.swmansion.enriched.markdown.utils.text.view.emitCitationPressEvent
import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
import com.swmansion.enriched.markdown.utils.text.view.emitMentionPressEvent
import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView
import java.util.concurrent.Executors

Expand Down Expand Up @@ -228,9 +230,11 @@ class EnrichedMarkdownText

text = styledText

if (movementMethod !is LinkLongPressMovementMethod) {
movementMethod = LinkLongPressMovementMethod.createInstance()
}
val method =
(movementMethod as? LinkLongPressMovementMethod)
?: LinkLongPressMovementMethod.createInstance().also { movementMethod = it }
method.onMentionTap = { url, mentionText -> emitOnMentionPress(url, mentionText) }
method.onCitationTap = { url, citationText -> emitOnCitationPress(url, citationText) }

renderer.getCollectedImageSpans().forEach { span ->
span.registerTextView(this)
Expand Down Expand Up @@ -266,6 +270,20 @@ class EnrichedMarkdownText
emitLinkLongPressEvent(url)
}

fun emitOnMentionPress(
url: String,
text: String,
) {
emitMentionPressEvent(url, text)
}

fun emitOnCitationPress(
url: String,
text: String,
) {
emitCitationPressEvent(url, text)
}

fun setOnLinkPressCallback(callback: (String) -> Unit) {
onLinkPressCallback = callback
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.swmansion.enriched.markdown.events

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event

class CitationPressEvent(
surfaceId: Int,
viewId: Int,
private val url: String,
private val text: String,
) : Event<CitationPressEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME

override fun getEventData(): WritableMap {
val eventData: WritableMap = Arguments.createMap()
eventData.putString("url", url)
eventData.putString("text", text)
return eventData
}

companion object {
const val EVENT_NAME: String = "onCitationPress"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.swmansion.enriched.markdown.events

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event

class MentionPressEvent(
surfaceId: Int,
viewId: Int,
private val url: String,
private val text: String,
) : Event<MentionPressEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME

override fun getEventData(): WritableMap {
val eventData: WritableMap = Arguments.createMap()
eventData.putString("url", url)
eventData.putString("text", text)
return eventData
}

companion object {
const val EVENT_NAME: String = "onMentionPress"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.swmansion.enriched.markdown.renderer
import android.text.SpannableStringBuilder
import com.swmansion.enriched.markdown.parser.MarkdownASTNode
import com.swmansion.enriched.markdown.spans.BlockquoteSpan
import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_CONTAINER_BACKGROUND
import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE
import com.swmansion.enriched.markdown.utils.text.span.applyMarginBottom
import com.swmansion.enriched.markdown.utils.text.span.applyMarginTop
Expand Down Expand Up @@ -45,12 +46,16 @@ class BlockquoteRenderer(
.map { builder.getSpanStart(it) to builder.getSpanEnd(it) }
.sortedBy { it.first }

// The Accent Bar Span covers the full range for visual continuity
// The Accent Bar Span covers the full range for visual continuity.
// Use high-priority flags so BlockquoteSpan's LineBackgroundSpan pass
// runs FIRST on each line — the blockquote fill is painted first, then
// inline chip/pill backgrounds (mention pills etc.) draw on top of it
// instead of being covered by it.
builder.setSpan(
BlockquoteSpan(style, depth, factory.context, factory.styleCache),
start,
end,
SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE,
SPAN_FLAGS_CONTAINER_BACKGROUND,
)

// Apply styling only to segments that are NOT nested quotes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ package com.swmansion.enriched.markdown.renderer

import android.text.SpannableStringBuilder
import com.swmansion.enriched.markdown.parser.MarkdownASTNode
import com.swmansion.enriched.markdown.spans.CitationSpan
import com.swmansion.enriched.markdown.spans.LinkSpan
import com.swmansion.enriched.markdown.spans.MentionSpacerSpan
import com.swmansion.enriched.markdown.spans.MentionSpan
import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE

class LinkRenderer(
private val config: RendererConfig,
) : NodeRenderer {
companion object {
private const val MENTION_SCHEME = "mention://"
private const val CITATION_SCHEME = "citation://"
}

override fun render(
node: MarkdownASTNode,
builder: SpannableStringBuilder,
Expand All @@ -17,6 +25,43 @@ class LinkRenderer(
) {
val url = node.getAttribute("url") ?: return

when {
url.startsWith(MENTION_SCHEME) -> {
renderMention(
url.removePrefix(MENTION_SCHEME),
node,
builder,
onLinkPress,
onLinkLongPress,
factory,
)
}

url.startsWith(CITATION_SCHEME) -> {
renderCitation(
url.removePrefix(CITATION_SCHEME),
node,
builder,
onLinkPress,
onLinkLongPress,
factory,
)
}

else -> {
renderLink(url, node, builder, onLinkPress, onLinkLongPress, factory)
}
}
}

private fun renderLink(
url: String,
node: MarkdownASTNode,
builder: SpannableStringBuilder,
onLinkPress: ((String) -> Unit)?,
onLinkLongPress: ((String) -> Unit)?,
factory: RendererFactory,
) {
factory.renderWithSpan(builder, { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) }) { start, end, blockStyle ->
builder.setSpan(
LinkSpan(url, onLinkPress, onLinkLongPress, factory.styleCache, blockStyle, factory.context),
Expand All @@ -26,4 +71,73 @@ class LinkRenderer(
)
}
}

private fun renderMention(
url: String,
node: MarkdownASTNode,
builder: SpannableStringBuilder,
onLinkPress: ((String) -> Unit)?,
onLinkLongPress: ((String) -> Unit)?,
factory: RendererFactory,
) {
// Render children into a throwaway buffer to derive the plain display
// label (any inline formatting inside the mention collapses to text).
val labelBuffer = SpannableStringBuilder()
factory.renderChildren(node, labelBuffer, onLinkPress, onLinkLongPress)
val displayText = labelBuffer.toString()
if (displayText.isEmpty()) return

// Append the displayText as real characters so copy/paste, selection, and
// accessibility traversal all see the mention as normal text. The pill
// background is painted by the MentionSpan's LineBackgroundSpan pass.
val start = builder.length
builder.append(displayText)
val end = builder.length

val span =
MentionSpan(
url = url,
displayText = displayText,
mentionStyle = factory.styleCache.mentionStyle,
mentionTypeface = factory.styleCache.mentionTypeface,
)
builder.setSpan(span, start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)

// The pill background extends `paddingHorizontal` past the glyph run on
// each side, but the underlying inline text doesn't reserve any advance
// for that visual overhang. Without extra spacing, two adjacent mention
// pills (separated only by a space in the source markdown) visually
// overlap. Appending a zero-width sentinel char with a MentionSpacerSpan
// reserves `paddingHorizontal * 2` of advance after each mention — the
// Android-side equivalent of the NSKern we apply on iOS.
val mentionStyle = factory.styleCache.mentionStyle
if (mentionStyle.paddingHorizontal > 0f) {
val spacerStart = builder.length
builder.append("\u200B") // zero-width space
builder.setSpan(
MentionSpacerSpan(mentionStyle.paddingHorizontal * 2f),
spacerStart,
builder.length,
SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE,
)
}
}

private fun renderCitation(
url: String,
node: MarkdownASTNode,
builder: SpannableStringBuilder,
onLinkPress: ((String) -> Unit)?,
onLinkLongPress: ((String) -> Unit)?,
factory: RendererFactory,
) {
val start = builder.length
factory.renderChildren(node, builder, onLinkPress, onLinkLongPress)
val end = builder.length
if (end <= start) return

val displayText = builder.subSequence(start, end).toString()
val span = CitationSpan(url = url, displayText = displayText, citationStyle = factory.styleCache.citationStyle)
builder.setSpan(span, start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)
}
}
Loading