diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..a1954016 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.0.2 diff --git a/ReactNativeEnrichedMarkdown.podspec b/ReactNativeEnrichedMarkdown.podspec index e191da8a..86bcc2af 100644 --- a/ReactNativeEnrichedMarkdown.podspec +++ b/ReactNativeEnrichedMarkdown.podspec @@ -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' } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index dceb56a7..0fece653 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -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 = emptyList() var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null @@ -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 } @@ -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) } @@ -244,6 +256,8 @@ class EnrichedMarkdown maxFontSizeMultiplier = this@EnrichedMarkdown.maxFontSizeMultiplier onLinkPress = onLinkPressCallback onLinkLongPress = onLinkLongPressCallback + onMentionPress = onMentionPressCallback + onCitationPress = onCitationPressCallback applyTableNode(segment.node) } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt index 75516569..8952110b 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt @@ -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 @@ -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() diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index 89a31534..f391ae25 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -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 @@ -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) diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt index aa9614e4..8bc7931e 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt @@ -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 @@ -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) @@ -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 } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/events/CitationPressEvent.kt b/android/src/main/java/com/swmansion/enriched/markdown/events/CitationPressEvent.kt new file mode 100644 index 00000000..b41d4fb8 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/events/CitationPressEvent.kt @@ -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(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" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/events/MentionPressEvent.kt b/android/src/main/java/com/swmansion/enriched/markdown/events/MentionPressEvent.kt new file mode 100644 index 00000000..211d608e --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/events/MentionPressEvent.kt @@ -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(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" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt b/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt index 5389284f..a2429769 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt @@ -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 @@ -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 diff --git a/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt index 7beb976e..81c2179e 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt @@ -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, @@ -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), @@ -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) + } } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt b/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt index 8379c63a..7cc6e2aa 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt @@ -1,6 +1,8 @@ package com.swmansion.enriched.markdown.renderer import android.graphics.Typeface +import com.swmansion.enriched.markdown.styles.CitationStyle +import com.swmansion.enriched.markdown.styles.MentionStyle import com.swmansion.enriched.markdown.styles.StyleConfig /** Shared style cache for spans to avoid redundant calculations. */ @@ -27,6 +29,9 @@ class SpanStyleCache( val spoilerParticleDensity: Float = style.spoilerStyle.particleDensity val spoilerParticleSpeed: Float = style.spoilerStyle.particleSpeed val spoilerSolidBorderRadius: Float = style.spoilerStyle.solidBorderRadius + val mentionStyle: MentionStyle = style.mentionStyle + val mentionTypeface: Typeface? = style.mentionTypeface + val citationStyle: CitationStyle = style.citationStyle private fun buildColorsToPreserve(style: StyleConfig): IntArray { val paragraphColor = style.paragraphStyle.color @@ -48,6 +53,19 @@ class SpanStyleCache( style.taskListStyle.checkedTextColor .takeIf { it != 0 } ?.let { add(it) } + // Inline chip colors (mention / citation). Container spans like + // BaseListSpan and BlockquoteSpan overwrite text color via + // `applyColorPreserving(blockColor, colorsToPreserve)`. Including the + // chip colors here ensures the mention/citation foreground set by + // MentionSpan / CitationSpan survives that overwrite — otherwise the + // chip text falls back to the surrounding block color inside lists or + // blockquotes. + style.mentionStyle.color + .takeIf { it != 0 && it != paragraphColor } + ?.let { add(it) } + style.citationStyle.color + .takeIf { it != 0 && it != paragraphColor } + ?.let { add(it) } }.toIntArray() } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt b/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt index f8230f6e..a0889524 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt @@ -10,6 +10,7 @@ import android.text.Layout import android.text.Spanned import android.text.TextPaint import android.text.style.LeadingMarginSpan +import android.text.style.LineBackgroundSpan import android.text.style.MetricAffectingSpan import com.swmansion.enriched.markdown.renderer.BlockStyle import com.swmansion.enriched.markdown.renderer.SpanStyleCache @@ -23,7 +24,8 @@ class BlockquoteSpan( private val context: Context, private val styleCache: SpanStyleCache, ) : MetricAffectingSpan(), - LeadingMarginSpan { + LeadingMarginSpan, + LineBackgroundSpan { private val levelSpacing: Float = blockquoteStyle.borderWidth + blockquoteStyle.gapWidth private val blockStyle = BlockStyle( @@ -60,8 +62,6 @@ class BlockquoteSpan( // Essential check from original: only the deepest span draws to prevent over-rendering background if (shouldSkipDrawing(text, start)) return - drawBackground(c, top, bottom, layout) - val borderPaint = configureBorderPaint() val borderTop = top.toFloat() val borderBottom = bottom.toFloat() @@ -73,6 +73,31 @@ class BlockquoteSpan( } } + /** + * Drawn BEFORE glyphs and before other [LineBackgroundSpan]s attached to the + * same line, so inline backgrounds painted by mention / code spans render on + * top of the blockquote fill instead of being covered by it (the previous + * implementation painted the blockquote fill from [drawLeadingMargin], which + * runs AFTER [LineBackgroundSpan.drawBackground] and erased the mention + * pill). + */ + override fun drawBackground( + canvas: Canvas, + paint: Paint, + left: Int, + right: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + lineNum: Int, + ) { + if (shouldSkipDrawing(text, start)) return + drawBackground(canvas, top, bottom, right) + } + @SuppressLint("WrongConstant") // Result of mask is always valid: 0, 1, 2, or 3 private fun applyTextStyle(tp: TextPaint) { tp.textSize = blockStyle.fontSize @@ -125,10 +150,10 @@ class BlockquoteSpan( c: Canvas, top: Int, bottom: Int, - layout: Layout?, + right: Int, ) { val bgColor = blockquoteStyle.backgroundColor?.takeIf { it != Color.TRANSPARENT } ?: return val backgroundPaint = configureBackgroundPaint(bgColor) - c.drawRect(0f, top.toFloat(), layout?.width?.toFloat() ?: 0f, bottom.toFloat(), backgroundPaint) + c.drawRect(0f, top.toFloat(), right.toFloat(), bottom.toFloat(), backgroundPaint) } } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt b/android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt new file mode 100644 index 00000000..01e0b3d4 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt @@ -0,0 +1,145 @@ +package com.swmansion.enriched.markdown.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.style.ReplacementSpan +import com.swmansion.enriched.markdown.styles.CitationStyle + +/** + * Inline citation marker. Renders atomically (via [ReplacementSpan]) so the + * renderer can apply: + * - font-size multiplier (smaller than surrounding text) + * - explicit baselineOffsetPx (parity with iOS `NSBaselineOffsetAttributeName`) + * - optional padded background (chip look when `backgroundColor` is set) + * + * Padding always contributes to the advance width / line height so adjacent + * text and wrapping behave correctly even when no background is drawn. + */ +class CitationSpan( + val url: String, + val displayText: String, + private val citationStyle: CitationStyle, +) : ReplacementSpan() { + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + + private fun configureTextPaint(basePaint: Paint) { + textPaint.set(basePaint) + val multiplier = citationStyle.fontSizeMultiplier + if (multiplier > 0f) { + textPaint.textSize = basePaint.textSize * multiplier + } + textPaint.color = citationStyle.color + textPaint.isUnderlineText = citationStyle.underline + if (citationStyle.fontWeight.isNotEmpty()) { + val base = textPaint.typeface ?: Typeface.DEFAULT + val weightStyle = + when (citationStyle.fontWeight.lowercase()) { + "bold", "700", "800", "900" -> Typeface.BOLD + else -> Typeface.NORMAL + } + textPaint.typeface = Typeface.create(base, weightStyle) + } + } + + private fun textWidth(): Float = textPaint.measureText(displayText) + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt?, + ): Int { + configureTextPaint(paint) + + val totalWidth = textWidth() + citationStyle.paddingHorizontal * 2f + + if (fm != null) { + // Base metrics come from the surrounding paragraph so the citation + // sits on the same line as the host text. Padding is added to top/bottom + // so a visible background extends past the glyph bounds. + val base = paint.fontMetricsInt + val offset = resolveBaselineOffset() + val verticalInset = citationStyle.paddingVertical.toInt() + fm.ascent = base.ascent - verticalInset - offset.toInt() + fm.top = base.top - verticalInset - offset.toInt() + fm.descent = base.descent + verticalInset + fm.bottom = base.bottom + verticalInset + fm.leading = base.leading + } + + return totalWidth.toInt() + 1 + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint, + ) { + configureTextPaint(paint) + + val offset = resolveBaselineOffset() + val paddingH = citationStyle.paddingHorizontal + val paddingV = citationStyle.paddingVertical + + val textW = textWidth() + val chipWidth = textW + paddingH * 2f + val metrics = textPaint.fontMetricsInt + val textAscent = metrics.ascent.toFloat() + val textDescent = metrics.descent.toFloat() + + // Baseline for the citation glyph, raised above the host baseline. + val glyphBaseline = y - offset + + // Background rectangle bounds the shifted glyph plus vertical padding. + val bgTop = glyphBaseline + textAscent - paddingV + val bgBottom = glyphBaseline + textDescent + paddingV + + val maxRadius = minOf((bgBottom - bgTop) / 2f, chipWidth / 2f) + val radius = minOf(citationStyle.borderRadius, maxRadius) + val chipRect = RectF(x, bgTop, x + chipWidth, bgBottom) + + if (citationStyle.backgroundColor != null && citationStyle.backgroundColor != 0) { + fillPaint.color = citationStyle.backgroundColor + canvas.drawRoundRect(chipRect, radius, radius, fillPaint) + } + + if (citationStyle.borderColor != null && citationStyle.borderColor != 0 && citationStyle.borderWidth > 0f) { + strokePaint.color = citationStyle.borderColor + strokePaint.strokeWidth = citationStyle.borderWidth + // Inset the stroke by half its width so the border stays inside the chip + // rect (matches the iOS UIBezierPath stroke). + val halfStroke = citationStyle.borderWidth / 2f + val borderRect = + RectF( + chipRect.left + halfStroke, + chipRect.top + halfStroke, + chipRect.right - halfStroke, + chipRect.bottom - halfStroke, + ) + val borderRadius = minOf(radius, minOf(borderRect.width(), borderRect.height()) / 2f) + canvas.drawRoundRect(borderRect, borderRadius, borderRadius, strokePaint) + } + + canvas.drawText(displayText, x + paddingH, glyphBaseline, textPaint) + } + + private fun resolveBaselineOffset(): Float = + if (citationStyle.baselineOffsetPx != 0f) { + citationStyle.baselineOffsetPx + } else { + // Fallback: raise the smaller glyph so its mid-line sits near the + // cap-height of the surrounding text. + -textPaint.ascent() * 0.5f + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpacerSpan.kt b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpacerSpan.kt new file mode 100644 index 00000000..83b6c1b3 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpacerSpan.kt @@ -0,0 +1,55 @@ +package com.swmansion.enriched.markdown.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan + +/** + * Reserves a fixed horizontal advance on a single sentinel character + * (typically ZWSP) appended after an inline mention. This mirrors the trailing + * NSKern iOS uses to space consecutive mention pills apart — the mention's + * own `LineBackgroundSpan` draws its pill extending `paddingHorizontal` past + * the glyph run on both sides, and without reserved advance here the pills of + * two adjacent mentions would visually overlap. + * + * The span draws nothing, so the sentinel character is invisible; it only + * affects layout. + */ +class MentionSpacerSpan( + private val widthPx: Float, +) : ReplacementSpan() { + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt?, + ): Int { + if (fm != null) { + // Match the surrounding line metrics so the sentinel doesn't affect + // line height. + val metrics = paint.fontMetricsInt + fm.ascent = metrics.ascent + fm.top = metrics.top + fm.descent = metrics.descent + fm.bottom = metrics.bottom + fm.leading = metrics.leading + } + return widthPx.toInt().coerceAtLeast(0) + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint, + ) { + // Intentionally no-op — the sentinel is invisible and only reserves + // advance width so adjacent mention pills don't visually overlap. + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt new file mode 100644 index 00000000..a0efb4fb --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt @@ -0,0 +1,157 @@ +package com.swmansion.enriched.markdown.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.LineBackgroundSpan +import android.text.style.MetricAffectingSpan +import com.swmansion.enriched.markdown.styles.MentionStyle +import kotlin.math.max +import kotlin.math.min + +/** + * Styles and paints the pill "chip" behind an inline mention. The mention + * text itself lives in the underlying Spannable as real characters, so copy, + * paste, selection, and accessibility all behave like ordinary text — the + * pill appearance is produced by this span's [LineBackgroundSpan.drawBackground] + * pass. + * + * The span exposes [url] for tap dispatching and an [isPressed] flag the tap + * handler can toggle to drive the pressedOpacity feedback. + */ +class MentionSpan( + val url: String, + val displayText: String, + private val mentionStyle: MentionStyle, + private val mentionTypeface: Typeface?, +) : MetricAffectingSpan(), + LineBackgroundSpan { + @Volatile + var isPressed: Boolean = false + + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val strokePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + } + private val rect = RectF() + + override fun updateMeasureState(textPaint: TextPaint) { + applyTextStyling(textPaint) + } + + override fun updateDrawState(tp: TextPaint) { + applyTextStyling(tp) + tp.color = mentionStyle.color + } + + private fun applyTextStyling(paint: TextPaint) { + if (mentionStyle.fontSize > 0f) { + paint.textSize = mentionStyle.fontSize + } + if (mentionTypeface != null) { + paint.typeface = mentionTypeface + } else if (mentionStyle.fontWeight.isNotEmpty()) { + val base = paint.typeface ?: Typeface.DEFAULT + val weightStyle = + when (mentionStyle.fontWeight.lowercase()) { + "bold", "700", "800", "900" -> Typeface.BOLD + else -> Typeface.NORMAL + } + paint.typeface = Typeface.create(base, weightStyle) + } + } + + override fun drawBackground( + canvas: Canvas, + paint: Paint, + left: Int, + right: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + lineNum: Int, + ) { + if (text !is Spanned) return + val spanStart = text.getSpanStart(this) + val spanEnd = text.getSpanEnd(this) + if (spanStart < 0 || spanEnd <= spanStart) return + + // Only paint on the line segment(s) the span intersects with. + val drawStart = max(spanStart, start) + val drawEnd = min(spanEnd, end) + if (drawStart >= drawEnd) return + + val opacity = if (isPressed) mentionStyle.pressedOpacity.coerceIn(0f, 1f) else 1f + + val textPaint = (paint as? TextPaint) ?: TextPaint(paint).apply { set(paint) } + // LineBackgroundSpan is invoked before the glyphs are drawn, so the paint + // hasn't been run through updateDrawState yet; apply mention-specific + // styling locally so measurements here match the rendered text exactly. + val localPaint = TextPaint(textPaint) + applyTextStyling(localPaint) + + val startOffset = horizontalOffset(text, start, end, drawStart, localPaint) + val endOffset = horizontalOffset(text, start, end, drawEnd, localPaint) + val paddingH = mentionStyle.paddingHorizontal + val paddingV = mentionStyle.paddingVertical + + val pillLeft = left + min(startOffset, endOffset) - paddingH + val pillRight = left + max(startOffset, endOffset) + paddingH + // Derive vertical extent from the mention's own font metrics (not the + // line's `top`/`bottom`) so the pill hugs the mention text. Using the + // line bounds would stretch the pill to the paragraph's lineHeight, + // which is visibly taller than the glyph when lineHeight > natural + // font height (or when anything else on the line has bigger metrics). + val fm = localPaint.fontMetrics + // ascent is negative (above baseline), descent is positive (below). + val pillTop = baseline + fm.ascent - paddingV + val pillBottom = baseline + fm.descent + paddingV + if (pillRight <= pillLeft || pillBottom <= pillTop) return + + rect.set(pillLeft, pillTop, pillRight, pillBottom) + + val radius = + min( + mentionStyle.borderRadius, + min(rect.width(), rect.height()) / 2f, + ) + + fillPaint.color = mentionStyle.backgroundColor + fillPaint.alpha = + ((fillPaint.color ushr 24) and 0xFF).let { baseAlpha -> + (baseAlpha * opacity).toInt().coerceIn(0, 255) + } + canvas.drawRoundRect(rect, radius, radius, fillPaint) + + if (mentionStyle.borderWidth > 0f) { + strokePaint.strokeWidth = mentionStyle.borderWidth + strokePaint.color = mentionStyle.borderColor + strokePaint.alpha = + ((strokePaint.color ushr 24) and 0xFF).let { baseAlpha -> + (baseAlpha * opacity).toInt().coerceIn(0, 255) + } + canvas.drawRoundRect(rect, radius, radius, strokePaint) + } + } + + private fun horizontalOffset( + text: CharSequence, + lineStart: Int, + lineEnd: Int, + index: Int, + paint: TextPaint, + ): Float { + if (index <= lineStart) return 0f + val lineText = text.subSequence(lineStart, lineEnd) + val layout = StaticLayout.Builder.obtain(lineText, 0, lineText.length, paint, Int.MAX_VALUE / 2).build() + return layout.getPrimaryHorizontal(index - lineStart) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt b/android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt new file mode 100644 index 00000000..8d8f3325 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt @@ -0,0 +1,50 @@ +package com.swmansion.enriched.markdown.styles + +import com.facebook.react.bridge.ReadableMap + +data class CitationStyle( + val color: Int, + val fontSizeMultiplier: Float, + val baselineOffsetPx: Float, + val fontWeight: String, + val underline: Boolean, + val backgroundColor: Int?, + val paddingHorizontal: Float, + val paddingVertical: Float, + val borderColor: Int?, + val borderWidth: Float, + val borderRadius: Float, +) { + companion object { + fun fromReadableMap( + map: ReadableMap, + parser: StyleParser, + ): CitationStyle { + val color = parser.parseColor(map, "color") + val fontSizeMultiplier = parser.parseOptionalDouble(map, "fontSizeMultiplier", 0.7).toFloat() + val baselineOffsetPx = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "baselineOffsetPx").toFloat()) + val fontWeight = parser.parseString(map, "fontWeight") + val underline = parser.parseBoolean(map, "underline", false) + val backgroundColor = parser.parseOptionalColor(map, "backgroundColor") + val paddingHorizontal = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingHorizontal").toFloat()) + val paddingVertical = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingVertical").toFloat()) + val borderColor = parser.parseOptionalColor(map, "borderColor") + val borderWidth = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderWidth").toFloat()) + val borderRadius = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderRadius", 999.0).toFloat()) + + return CitationStyle( + color = color, + fontSizeMultiplier = if (fontSizeMultiplier > 0) fontSizeMultiplier else 0.7f, + baselineOffsetPx = baselineOffsetPx, + fontWeight = fontWeight, + underline = underline, + backgroundColor = backgroundColor, + paddingHorizontal = paddingHorizontal, + paddingVertical = paddingVertical, + borderColor = borderColor, + borderWidth = borderWidth, + borderRadius = borderRadius, + ) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/styles/MentionStyle.kt b/android/src/main/java/com/swmansion/enriched/markdown/styles/MentionStyle.kt new file mode 100644 index 00000000..81dbc6a1 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/styles/MentionStyle.kt @@ -0,0 +1,50 @@ +package com.swmansion.enriched.markdown.styles + +import com.facebook.react.bridge.ReadableMap + +data class MentionStyle( + val color: Int, + val backgroundColor: Int, + val borderColor: Int, + val borderWidth: Float, + val borderRadius: Float, + val paddingHorizontal: Float, + val paddingVertical: Float, + val fontFamily: String, + val fontWeight: String, + val fontSize: Float, + val pressedOpacity: Float, +) { + companion object { + fun fromReadableMap( + map: ReadableMap, + parser: StyleParser, + ): MentionStyle { + val color = parser.parseColor(map, "color") + val backgroundColor = parser.parseColor(map, "backgroundColor") + val borderColor = parser.parseColor(map, "borderColor") + val borderWidth = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderWidth").toFloat()) + val borderRadius = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderRadius").toFloat()) + val paddingHorizontal = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingHorizontal").toFloat()) + val paddingVertical = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingVertical").toFloat()) + val fontFamily = parser.parseString(map, "fontFamily") + val fontWeight = parser.parseString(map, "fontWeight") + val fontSize = parser.toPixelFromSP(parser.parseOptionalDouble(map, "fontSize").toFloat()) + val pressedOpacity = parser.parseOptionalDouble(map, "pressedOpacity", 0.6).toFloat() + + return MentionStyle( + color = color, + backgroundColor = backgroundColor, + borderColor = borderColor, + borderWidth = borderWidth, + borderRadius = borderRadius, + paddingHorizontal = paddingHorizontal, + paddingVertical = paddingVertical, + fontFamily = fontFamily, + fontWeight = fontWeight, + fontSize = fontSize, + pressedOpacity = pressedOpacity.coerceIn(0f, 1f), + ) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt b/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt index a851ac9a..27705be6 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt @@ -234,6 +234,32 @@ class StyleConfig( SpoilerStyle.fromReadableMap(map, styleParser) } + val mentionStyle: MentionStyle by lazy { + val map = + requireNotNull(style.getMap("mention")) { + "Mention style not found. JS should always provide defaults." + } + MentionStyle.fromReadableMap(map, styleParser) + } + + val citationStyle: CitationStyle by lazy { + val map = + requireNotNull(style.getMap("citation")) { + "Citation style not found. JS should always provide defaults." + } + CitationStyle.fromReadableMap(map, styleParser) + } + + val mentionTypeface: Typeface? by lazy { + val family = mentionStyle.fontFamily.takeIf { it.isNotEmpty() } + val weight = parseFontWeight(mentionStyle.fontWeight) + if (family != null) { + applyStyles(null, ReactConstants.UNSET, weight, family, assets) + } else { + null + } + } + val tableTypeface: Typeface? by lazy { val fontFamily = tableStyle.fontFamily.takeIf { it.isNotEmpty() } val fontWeight = parseFontWeight(tableStyle.fontWeight) @@ -297,7 +323,9 @@ class StyleConfig( taskListStyle == other.taskListStyle && mathStyle == other.mathStyle && inlineMathStyle == other.inlineMathStyle && - spoilerStyle == other.spoilerStyle + spoilerStyle == other.spoilerStyle && + mentionStyle == other.mentionStyle && + citationStyle == other.citationStyle } override fun hashCode(): Int { @@ -320,6 +348,8 @@ class StyleConfig( result = 31 * result + mathStyle.hashCode() result = 31 * result + inlineMathStyle.hashCode() result = 31 * result + spoilerStyle.hashCode() + result = 31 * result + mentionStyle.hashCode() + result = 31 * result + citationStyle.hashCode() return result } } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index 9f3152da..6fb88b7e 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -4,9 +4,11 @@ import android.view.View import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.markdown.events.CitationPressEvent import com.swmansion.enriched.markdown.events.ContextMenuItemPressEvent import com.swmansion.enriched.markdown.events.LinkLongPressEvent import com.swmansion.enriched.markdown.events.LinkPressEvent +import com.swmansion.enriched.markdown.events.MentionPressEvent import com.swmansion.enriched.markdown.events.TaskListItemPressEvent import com.swmansion.enriched.markdown.parser.Md4cFlags @@ -19,6 +21,10 @@ fun markdownEventTypeConstants(): MutableMap { mapOf("registrationName" to TaskListItemPressEvent.EVENT_NAME) map[ContextMenuItemPressEvent.EVENT_NAME] = mapOf("registrationName" to ContextMenuItemPressEvent.EVENT_NAME) + map[MentionPressEvent.EVENT_NAME] = + mapOf("registrationName" to MentionPressEvent.EVENT_NAME) + map[CitationPressEvent.EVENT_NAME] = + mapOf("registrationName" to CitationPressEvent.EVENT_NAME) return map } @@ -42,6 +48,28 @@ fun emitLinkLongPress( eventDispatcher?.dispatchEvent(LinkLongPressEvent(surfaceId, view.id, url)) } +fun emitMentionPress( + view: View, + url: String, + text: String, +) { + val context = view.context as com.facebook.react.bridge.ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + eventDispatcher?.dispatchEvent(MentionPressEvent(surfaceId, view.id, url, text)) +} + +fun emitCitationPress( + view: View, + url: String, + text: String, +) { + val context = view.context as com.facebook.react.bridge.ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + eventDispatcher?.dispatchEvent(CitationPressEvent(surfaceId, view.id, url, text)) +} + fun emitTaskListItemPress( view: View, taskIndex: Int, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt index 8a79d7f6..9ae5d368 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt @@ -5,12 +5,14 @@ import android.text.style.UnderlineSpan import android.widget.TextView import com.swmansion.enriched.markdown.EnrichedMarkdownText import com.swmansion.enriched.markdown.spans.BlockquoteSpan +import com.swmansion.enriched.markdown.spans.CitationSpan import com.swmansion.enriched.markdown.spans.CodeBlockSpan import com.swmansion.enriched.markdown.spans.CodeSpan import com.swmansion.enriched.markdown.spans.EmphasisSpan import com.swmansion.enriched.markdown.spans.HeadingSpan import com.swmansion.enriched.markdown.spans.ImageSpan import com.swmansion.enriched.markdown.spans.LinkSpan +import com.swmansion.enriched.markdown.spans.MentionSpan import com.swmansion.enriched.markdown.spans.OrderedListSpan import com.swmansion.enriched.markdown.spans.StrikethroughSpan import com.swmansion.enriched.markdown.spans.StrongSpan @@ -301,17 +303,21 @@ object MarkdownExtractor { val hasStrikethrough = spannable.getSpans(start, end, StrikethroughSpan::class.java).isNotEmpty() val hasUnderline = spannable.getSpans(start, end, UnderlineSpan::class.java).isNotEmpty() val linkSpans = spannable.getSpans(start, end, LinkSpan::class.java) + val mentionSpans = spannable.getSpans(start, end, MentionSpan::class.java) + val citationSpans = spannable.getSpans(start, end, CitationSpan::class.java) + + val hasAnyLinkShape = linkSpans.isNotEmpty() || mentionSpans.isNotEmpty() || citationSpans.isNotEmpty() var result = text // Innermost first - if (hasCode && linkSpans.isEmpty()) { + if (hasCode && !hasAnyLinkShape) { result = "`$result`" } if (hasStrikethrough) { result = "~~$result~~" } - if (hasUnderline && linkSpans.isEmpty()) { + if (hasUnderline && !hasAnyLinkShape) { result = "$result" } if (hasEmphasis) { @@ -320,9 +326,16 @@ object MarkdownExtractor { if (hasStrong) { result = "**$result**" } - if (linkSpans.isNotEmpty()) { - result = "[$text](${linkSpans[0].url})" - } + // Mentions and citations share the `[text](scheme://url)` shape as regular + // links — just with distinct URL schemes — so a selected range that + // covers one emits the full markdown link, preserving the target URL. + result = + when { + mentionSpans.isNotEmpty() -> "[$text](mention://${mentionSpans[0].url})" + citationSpans.isNotEmpty() -> "[$text](citation://${citationSpans[0].url})" + linkSpans.isNotEmpty() -> "[$text](${linkSpans[0].url})" + else -> result + } return result } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt index b8e9a348..1bd80786 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt @@ -1,5 +1,17 @@ package com.swmansion.enriched.markdown.utils.text.span import android.text.SpannableString +import android.text.Spanned const val SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + +/** + * EXCLUSIVE_EXCLUSIVE flags with the maximum span priority bit set. Higher + * priority means the span is iterated FIRST during the text view's draw + * passes (e.g. `Layout.drawBackground`), which means it's painted FIRST — so + * lower-priority spans drawn afterwards end up on top visually. Use this for + * full-width container backgrounds (like blockquote) that must sit UNDER any + * inline chip / pill backgrounds on the same line. + */ +const val SPAN_FLAGS_CONTAINER_BACKGROUND = + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE or ((0xFF) shl Spanned.SPAN_PRIORITY_SHIFT) diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt index 1f3e6936..40fcf428 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt @@ -6,8 +6,10 @@ import android.widget.TextView import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.NativeGestureUtil +import com.swmansion.enriched.markdown.events.CitationPressEvent import com.swmansion.enriched.markdown.events.LinkLongPressEvent import com.swmansion.enriched.markdown.events.LinkPressEvent +import com.swmansion.enriched.markdown.events.MentionPressEvent fun View.emitLinkPressEvent(url: String) { val reactContext = context as? ReactContext ?: return @@ -23,6 +25,26 @@ fun View.emitLinkLongPressEvent(url: String) { dispatcher?.dispatchEvent(LinkLongPressEvent(surfaceId, id, url)) } +fun View.emitMentionPressEvent( + url: String, + text: String, +) { + val reactContext = context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + dispatcher?.dispatchEvent(MentionPressEvent(surfaceId, id, url, text)) +} + +fun View.emitCitationPressEvent( + url: String, + text: String, +) { + val reactContext = context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + dispatcher?.dispatchEvent(CitationPressEvent(surfaceId, id, url, text)) +} + /** * Cancels the JS touch for an active link tap, preventing parent * Pressable/TouchableOpacity from firing onPress for the same tap. diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt index 401e11e5..7d692ad4 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt @@ -8,12 +8,24 @@ import android.text.method.LinkMovementMethod import android.view.MotionEvent import android.view.ViewConfiguration import android.widget.TextView +import com.swmansion.enriched.markdown.spans.CitationSpan import com.swmansion.enriched.markdown.spans.LinkSpan +import com.swmansion.enriched.markdown.spans.MentionSpan import com.swmansion.enriched.markdown.spans.SpoilerSpan import com.swmansion.enriched.markdown.spoiler.SpoilerCapable import kotlin.math.abs class LinkLongPressMovementMethod : LinkMovementMethod() { + /** + * Optional callback invoked when a [MentionSpan] is tapped. The mention pill + * is a [android.text.style.ReplacementSpan], not a [android.text.style.ClickableSpan], + * so the standard LinkMovementMethod dispatch doesn't reach it. + */ + var onMentionTap: ((url: String, text: String) -> Unit)? = null + + /** Optional callback invoked when a [CitationSpan] is tapped. */ + var onCitationTap: ((url: String, text: String) -> Unit)? = null + private val handler = Handler(Looper.getMainLooper()) private var longPressRunnable: Runnable? = null @@ -23,6 +35,10 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { var isLinkTouchActive: Boolean = false private set + private var activeMentionSpan: MentionSpan? = null + private var pendingMentionTapOffset: Int = -1 + private var pendingCitationTapOffset: Int = -1 + override fun onTouchEvent( widget: TextView, buffer: Spannable, @@ -36,6 +52,19 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { val span = findLinkSpan(widget, buffer, event) isLinkTouchActive = span != null span?.let { scheduleLongPress(widget, it) } + + findMentionSpan(widget, buffer, event)?.let { mention -> + activeMentionSpan = mention + mention.isPressed = true + widget.invalidate() + pendingMentionTapOffset = charOffsetAt(widget, event) ?: -1 + } + + if (activeMentionSpan == null) { + findCitationSpan(widget, buffer, event)?.let { _ -> + pendingCitationTapOffset = charOffsetAt(widget, event) ?: -1 + } + } } MotionEvent.ACTION_MOVE -> { @@ -45,6 +74,8 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { ) { cancelLongPress() isLinkTouchActive = false + clearMentionPressedState(widget) + pendingCitationTapOffset = -1 } } @@ -56,13 +87,44 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { } if (handleSpoilerTap(widget, buffer, event)) { Selection.removeSelection(buffer) + clearMentionPressedState(widget) + pendingCitationTapOffset = -1 return true } + + val mention = activeMentionSpan + if (mention != null) { + // Only emit if finger is still over the same mention span. + val stillOverMention = findMentionSpan(widget, buffer, event) === mention + clearMentionPressedState(widget) + pendingMentionTapOffset = -1 + if (stillOverMention) { + onMentionTap?.invoke(mention.url, mention.displayText) + return true + } + } + + if (pendingCitationTapOffset >= 0) { + val currentOffset = charOffsetAt(widget, event) ?: -1 + val citation = + if (currentOffset >= 0) { + buffer.getSpans(currentOffset, currentOffset, CitationSpan::class.java).firstOrNull() + } else { + null + } + pendingCitationTapOffset = -1 + if (citation != null) { + onCitationTap?.invoke(citation.url, citation.displayText) + return true + } + } } MotionEvent.ACTION_CANCEL -> { cancelLongPress() isLinkTouchActive = false + clearMentionPressedState(widget) + pendingCitationTapOffset = -1 if (widget.hasSelection()) { Selection.removeSelection(buffer) } @@ -127,6 +189,32 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { return buffer.getSpans(offset, offset, LinkSpan::class.java).firstOrNull() } + private fun findMentionSpan( + widget: TextView, + buffer: Spannable, + event: MotionEvent, + ): MentionSpan? { + val offset = charOffsetAt(widget, event) ?: return null + return buffer.getSpans(offset, offset, MentionSpan::class.java).firstOrNull() + } + + private fun findCitationSpan( + widget: TextView, + buffer: Spannable, + event: MotionEvent, + ): CitationSpan? { + val offset = charOffsetAt(widget, event) ?: return null + return buffer.getSpans(offset, offset, CitationSpan::class.java).firstOrNull() + } + + private fun clearMentionPressedState(widget: TextView) { + activeMentionSpan?.let { + it.isPressed = false + widget.invalidate() + } + activeMentionSpan = null + } + private fun handleSpoilerTap( widget: TextView, buffer: Spannable, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt index e358b1e2..e5e30a66 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt @@ -11,6 +11,7 @@ import android.view.ViewParent import android.widget.TextView import com.swmansion.enriched.markdown.EnrichedMarkdown import com.swmansion.enriched.markdown.EnrichedMarkdownText +import com.swmansion.enriched.markdown.spans.CitationSpan import com.swmansion.enriched.markdown.spans.ImageSpan import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.common.layout.isLayoutRTL @@ -123,7 +124,10 @@ private fun TextView.copyWithHTML() { val spannable = text as? Spannable ?: return val selectedText = spannable.subSequence(start, end) - val plainText = selectedText.toString() + // Citation text (e.g. superscript markers) is reference metadata, not prose; + // strip it from the plain-text flavor so pasting into a plain text target + // yields clean copy. Rich flavors (HTML below) still keep the marker. + val plainText = buildPlainTextWithoutCitations(selectedText) val styleConfig = (this as? EnrichedMarkdownText)?.markdownStyle @@ -148,6 +152,38 @@ private fun TextView.copyWithHTML() { } } +/** + * Rebuilds a plain-text string from a selected CharSequence with any ranges + * tagged by a [CitationSpan] elided. The selection is indexed from 0, so span + * positions need to be translated against the passed-in CharSequence. + */ +private fun buildPlainTextWithoutCitations(selection: CharSequence): String { + if (selection !is Spannable) return selection.toString() + + val spans = selection.getSpans(0, selection.length, CitationSpan::class.java) + if (spans.isEmpty()) return selection.toString() + + // Collect non-overlapping, non-zero-length ranges sorted by start index. + val ranges = + spans + .map { selection.getSpanStart(it) to selection.getSpanEnd(it) } + .filter { it.second > it.first } + .sortedBy { it.first } + + val result = StringBuilder(selection.length) + var cursor = 0 + for ((spanStart, spanEnd) in ranges) { + if (spanStart > cursor) { + result.append(selection.subSequence(cursor, spanStart)) + } + cursor = maxOf(cursor, spanEnd) + } + if (cursor < selection.length) { + result.append(selection.subSequence(cursor, selection.length)) + } + return result.toString() +} + private fun TextView.copyMarkdownToClipboard() { val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: return val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager diff --git a/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt b/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt index a9c6f6d2..6606f5fe 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt @@ -49,6 +49,8 @@ class TableContainerView( var maxFontSizeMultiplier = 0f var onLinkPress: ((String) -> Unit)? = null var onLinkLongPress: ((String) -> Unit)? = null + var onMentionPress: ((url: String, text: String) -> Unit)? = null + var onCitationPress: ((url: String, text: String) -> Unit)? = null private val scrollView = HorizontalScrollView(context).apply { @@ -206,6 +208,10 @@ class TableContainerView( showContextMenu(view) true } + (movementMethod as? LinkLongPressMovementMethod)?.apply { + onMentionTap = { url, mentionText -> this@TableContainerView.onMentionPress?.invoke(url, mentionText) } + onCitationTap = { url, citationText -> this@TableContainerView.onCitationPress?.invoke(url, citationText) } + } } val horizontalPadding = tableStyle.cellPaddingHorizontal val verticalPadding = tableStyle.cellPaddingVertical diff --git a/apps/example/Gemfile b/apps/example/Gemfile index 6a4c5f17..2163ea08 100644 --- a/apps/example/Gemfile +++ b/apps/example/Gemfile @@ -14,3 +14,5 @@ gem 'bigdecimal' gem 'logger' gem 'benchmark' gem 'mutex_m' +# kconv was removed from stdlib; CFPropertyList (CocoaPods) still expects it via nkf. +gem 'nkf' diff --git a/apps/example/Gemfile.lock b/apps/example/Gemfile.lock index 4c317a6d..075ae447 100644 --- a/apps/example/Gemfile.lock +++ b/apps/example/Gemfile.lock @@ -2,7 +2,7 @@ GEM remote: https://rubygems.org/ specs: CFPropertyList (3.0.8) - activesupport (7.2.3) + activesupport (7.2.3.1) base64 benchmark (>= 0.3) bigdecimal @@ -11,10 +11,10 @@ GEM drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) + minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.9) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) @@ -22,7 +22,7 @@ GEM atomos (0.1.3) base64 (0.3.0) benchmark (0.5.0) - bigdecimal (4.0.1) + bigdecimal (4.1.1) claide (1.1.0) cocoapods (1.15.2) addressable (~> 2.8) @@ -66,9 +66,10 @@ GEM connection_pool (3.0.2) drb (2.2.3) escape (0.0.4) - ethon (0.15.0) + ethon (0.18.0) ffi (>= 1.15.0) - ffi (1.17.3) + logger + ffi (1.17.4) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -76,23 +77,21 @@ GEM mutex_m i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.18.1) + json (2.19.3) logger (1.7.0) - minitest (6.0.2) - drb (~> 2.0) - prism (~> 1.5) + minitest (5.27.0) molinillo (0.8.0) mutex_m (0.3.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) - prism (1.9.0) + nkf (0.2.0) public_suffix (4.0.7) rexml (3.4.4) ruby-macho (2.5.1) securerandom (0.4.1) - typhoeus (1.5.0) - ethon (>= 0.9.0, < 0.16.0) + typhoeus (1.6.0) + ethon (>= 0.18.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) xcodeproj (1.25.1) @@ -114,6 +113,7 @@ DEPENDENCIES concurrent-ruby (< 1.3.4) logger mutex_m + nkf xcodeproj (< 1.26.0) RUBY VERSION diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 6b240433..3128429a 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2117,7 +2117,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da - hermes-engine: 40811a005e96e04818cff405ec04a5b4c4411c1c + hermes-engine: f6578d972f9b73b756304d62c2537e7f69329eca iosMath: f7a6cbadf9d836d2149c2a84c435b1effc244cba RCTDeprecation: af44b104091a34482596cd9bd7e8d90c4e9b4bd7 RCTRequired: bb77b070f75f53398ce43c0aaaa58337cebe2bf6 @@ -2127,7 +2127,7 @@ SPEC CHECKSUMS: React: 1ba7d364ade7d883a1ec055bfc3606f35fdee17b React-callinvoker: bc2a26f8d84fb01f003fc6de6c9337b64715f95b React-Core: 7840d3a80b43a95c5e80ef75146bd70925ebab0f - React-Core-prebuilt: 7965d06a81dcc544164f8e98b26d35ae2a4eb36e + React-Core-prebuilt: e5482a51694507e64658e1c0be8753a92fc4e849 React-CoreModules: 2eb010400b63b89e53a324ffb3c112e4c7c3ce42 React-cxxreact: a558e92199d26f145afa9e62c4233cf8e7950efe React-debug: 755200a6e7f5e6e0a40ff8d215493d43cce285fc @@ -2189,8 +2189,8 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: e96e93b493d8d86eeaee3e590ba0be53f6abe46f ReactCodegen: 797de5178718324c6eba3327b07f9a423fbd5787 ReactCommon: 07572bf9e687c8a52fbe4a3641e9e3a1a477c78e - ReactNativeDependencies: 0811b43c669e637a9f3c485fdb106f187fa88398 - ReactNativeEnrichedMarkdown: 1daba1851810704ba2f5c6e5fff638a94661e317 + ReactNativeDependencies: f2497ee045a976e64dec20d371611288e835df1f + ReactNativeEnrichedMarkdown: 63665119b6d5c634c76f5f4caf46dc4ebd23863e Yoga: c0b3f2c7e8d3e327e450223a2414ca3fa296b9a2 PODFILE CHECKSUM: 9c5417fc84515945aa2357a49779fde55434ae62 diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index e4c44a7a..77ef0eb6 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -11,6 +11,8 @@ import { import { EnrichedMarkdownText, type LinkPressEvent, + type CitationPressEvent, + type MentionPressEvent, } from 'react-native-enriched-markdown'; import { SafeAreaView } from 'react-native-safe-area-context'; import { sampleMarkdown } from './sampleMarkdown'; @@ -63,6 +65,38 @@ export default function App() { ]); }; + const handleCitationPress = (event: CitationPressEvent) => { + const { url } = event; + Alert.alert('Citation Pressed!', `You tapped on: ${url}`, [ + { + text: 'Open in Browser', + onPress: () => { + Linking.openURL(url); + }, + }, + { + text: 'Cancel', + style: 'cancel', + }, + ]); + }; + + const handleMentionPress = (event: MentionPressEvent) => { + const { url } = event; + Alert.alert('Mention Pressed!', `You tapped on: ${url}`, [ + { + text: 'Open in Browser', + onPress: () => { + Linking.openURL(url); + }, + }, + { + text: 'Cancel', + style: 'cancel', + }, + ]); + }; + return ( @@ -91,6 +125,8 @@ export default function App() { flavor="github" markdown={sampleMarkdown} onLinkPress={handleLinkPress} + onCitationPress={handleCitationPress} + onMentionPress={handleMentionPress} markdownStyle={markdownStyle} contextMenuItems={contextMenuItems} /> diff --git a/apps/example/src/markdownStyles.ts b/apps/example/src/markdownStyles.ts index f741cadd..057f6d29 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -142,4 +142,28 @@ export const customMarkdownStyle: MarkdownStyle = { checkedTextColor: '#9ca3af', checkedStrikethrough: true, }, + mention: { + backgroundColor: '#EBEBFF', + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 99, + paddingHorizontal: 4, + paddingVertical: 0, + fontFamily: 'Montserrat-Regular', + fontSize: 14, + color: '#2561fB', + }, + citation: { + backgroundColor: '#EBEBFF', + color: '#9B9BFD', + fontSizeMultiplier: 0.6, + baselineOffsetPx: 8, + fontWeight: '', + underline: false, + paddingHorizontal: 4, + paddingVertical: 4, + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 99, + }, }; diff --git a/apps/example/src/sampleMarkdown.ts b/apps/example/src/sampleMarkdown.ts index bb7263dc..b2916445 100644 --- a/apps/example/src/sampleMarkdown.ts +++ b/apps/example/src/sampleMarkdown.ts @@ -1,5 +1,5 @@ export const sampleMarkdown = ` -# The Hidden World of Forest Ecosystems +# The Hidden World of Forest Ecosystems!! Forests cover approximately **31% of the Earth's land surface**, providing habitat for countless species and playing a vital role in our planet's health. These magnificent ecosystems have existed for over *300 million years*, evolving alongside the creatures that call them home. @@ -11,15 +11,15 @@ Forests cover approximately **31% of the Earth's land surface**, providing habit Forests are often called the *lungs of the Earth*. They absorb **carbon dioxide** and release oxygen through photosynthesis — a process essential for all life on our planet. A single mature tree can absorb up to \`48 pounds\` of CO₂ per year. -> In every walk with nature, one receives far more than he seeks. +> In every walk with nature, [@John Muir](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) one receives far more than he seeks. [4](citation://https://www.google.com) > > — John Muir ### Key Benefits -- **Climate regulation** through carbon sequestration -- *Biodiversity* hotspots supporting millions of species -- Natural water filtration and ***flood prevention*** +- **Climate regulation** through carbon sequestration [@Casper](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) [@Arby](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) +- *Biodiversity* hotspots supporting millions of species [+resume software engineer](mention://Uploads/twilio-script.py?type=file) +- Natural water filtration and ***flood prevention*** [1](citation://https://www.google.com) [2](citation://https://www.google.com?q=123) [3](citation://https://www.google.com?q=123&abc=123) [4](citation://https://www.google.com?q=123) [5](citation://https://www.google.com?q=123) [6](citation://https://www.google.com?q=123) [7](citation://https://www.google.com?q=123) [8](citation://https://www.google.com?q=123) [9](citation://https://www.google.com?q=123) [10](citation://https://www.google.com?q=123) - Source of medicine, food, and raw materials - Soil erosion prevention and **nutrient cycling** - Recreation and *mental health* benefits @@ -28,7 +28,7 @@ Forests are often called the *lungs of the Earth*. They absorb **carbon dioxide* Forests contribute over **$1.3 trillion** to the global economy annually. They provide: -- Timber and *wood products* +- Timber and *wood products* [4](citation://https://www.google.com) [5](citation://https://www.google.com) [6](citation://https://www.google.com) [7](citation://https://www.google.com) [8](citation://https://www.google.com) [9](citation://https://www.google.com) [7](citation://https://www.google.com) - Non-timber forest products like **nuts and berries** - Ecotourism opportunities - ***Carbon credits*** for climate mitigation @@ -104,10 +104,10 @@ The largest terrestrial biome, spanning across **Northern Russia, Canada, and Sc | Forest Type | Coverage | Annual Rainfall | Biodiversity | Carbon Storage | |------------|----------|-----------------|--------------|----------------| -| Tropical Rainforest | ~7% of land | 80-400 inches | Highest (50%+ species) | High | -| Temperate Forest | ~16% of land | 30-60 inches | Moderate | Moderate | +| Tropical Rainforest [4](citation://https://www.google.com) [5](citation://https://www.google.com) | ~7% of land | 80-400 inches | Highest (50%+ species) | High | +| Temperate Forest [@Casper](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) | ~16% of land | 30-60 inches | Moderate | Moderate | | Boreal Forest (Taiga) | ~11% of land | 15-40 inches | Lower | Highest | -| Mediterranean Forest | ~2% of land | 20-40 inches | Moderate | Moderate | +| Mediterranean Forest | ~2% of land | 20-40 inches | Moderate | Moderate [@John Muir](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user)| --- @@ -136,7 +136,7 @@ class TreeNetwork { this.trees = []; this.fungalConnections = new Map(); } - + connectTrees(tree1, tree2) { // Trees share nutrients through mycorrhizal networks this.fungalConnections.set(\`\${tree1.id}-\${tree2.id}\`, { @@ -309,6 +309,14 @@ Conservation efforts are making a difference: | ***The Nature Conservancy*** | Land protection | Protected 125M+ acres | | One Tree Planted | Reforestation | Planted 100M+ trees | + +| Organization | Focus Area | +|--------------|------------| +| **WFF** | Global conservation | +| *RA* | Sustainable agriculture | +| ***TNC*** | Land protection | +| ***OTP*** | Reforestation | + --- ## The Future of Forests @@ -330,11 +338,11 @@ def detect_deforestation(region): """Monitor forest cover changes using satellite imagery""" current_cover = satellite_imagery.get_forest_cover(region) previous_cover = satellite_imagery.get_historical_cover(region, years_ago=1) - + deforestation_rate = (previous_cover - current_cover) / previous_cover if deforestation_rate > 0.05: # 5% threshold alert_conservation_team(region, deforestation_rate) - + return deforestation_rate \`\`\` diff --git a/apps/macos-example/src/sampleMarkdown.ts b/apps/macos-example/src/sampleMarkdown.ts index 1fd851b9..f4deab3d 100644 --- a/apps/macos-example/src/sampleMarkdown.ts +++ b/apps/macos-example/src/sampleMarkdown.ts @@ -122,7 +122,7 @@ class TreeNetwork { this.trees = []; this.fungalConnections = new Map(); } - + connectTrees(tree1, tree2) { // Trees share nutrients through mycorrhizal networks this.fungalConnections.set(\`\${tree1.id}-\${tree2.id}\`, { @@ -316,11 +316,11 @@ def detect_deforestation(region): """Monitor forest cover changes using satellite imagery""" current_cover = satellite_imagery.get_forest_cover(region) previous_cover = satellite_imagery.get_historical_cover(region, years_ago=1) - + deforestation_rate = (previous_cover - current_cover) / previous_cover if deforestation_rate > 0.05: # 5% threshold alert_conservation_team(region, deforestation_rate) - + return deforestation_rate \`\`\` diff --git a/apps/web-example/src/App.tsx b/apps/web-example/src/App.tsx index 7547f280..418fd0fd 100644 --- a/apps/web-example/src/App.tsx +++ b/apps/web-example/src/App.tsx @@ -5,6 +5,8 @@ import type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + CitationPressEvent, + MentionPressEvent, } from 'react-native-enriched-markdown'; import { sampleMarkdown } from './sampleMarkdown'; @@ -96,7 +98,7 @@ console.log(greet("العالم")); `.trim(); interface EventLog { - kind: 'link' | 'linkLong' | 'task'; + kind: 'link' | 'linkLong' | 'task' | 'citation' | 'mention'; label: string; detail: string; } @@ -105,6 +107,8 @@ const KIND_COLOR: Record = { link: '#2563EB', linkLong: '#7C3AED', task: '#059669', + citation: '#9B9BFD', + mention: '#2563EB', }; export default function App() { @@ -130,6 +134,18 @@ export default function App() { [] ); + const handleCitationPress = (event: CitationPressEvent) => { + const { url } = event; + setLastEvent({ kind: 'citation', label: 'onCitationPress', detail: url }); + Linking.openURL(url); + }; + + const handleMentionPress = (event: MentionPressEvent) => { + const { url } = event; + setLastEvent({ kind: 'mention', label: 'onMentionPress', detail: url }); + Linking.openURL(url); + }; + return ( @@ -153,6 +169,33 @@ export default function App() { onLinkPress={onLinkPress} onLinkLongPress={onLinkLongPress} onTaskListItemPress={onTaskListItemPress} + onCitationPress={handleCitationPress} + onMentionPress={handleMentionPress} + markdownStyle={{ + mention: { + backgroundColor: '#EBEBFF', + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 99, + paddingHorizontal: 4, + paddingVertical: 0, + fontSize: 14, + color: '#2563fb', + }, + citation: { + backgroundColor: '#EBEBFF', + color: '#9B9BFD', + fontSizeMultiplier: 0.5, + baselineOffsetPx: 7, + fontWeight: '', + underline: false, + paddingHorizontal: 2, + paddingVertical: 2, + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 99, + }, + }} /> diff --git a/ios/EnrichedMarkdown.mm b/ios/EnrichedMarkdown.mm index 9227b789..1580c6d2 100644 --- a/ios/EnrichedMarkdown.mm +++ b/ios/EnrichedMarkdown.mm @@ -559,6 +559,34 @@ - (TableContainerView *)createTableViewForSegment:(EMTableSegment *)tableSegment } }; + tableView.onMentionPress = ^(NSString *url, NSString *text) { + EnrichedMarkdown *strongSelf = weakSelf; + if (!strongSelf) + return; + + auto eventEmitter = std::static_pointer_cast(strongSelf->_eventEmitter); + if (eventEmitter) { + eventEmitter->onMentionPress({ + .url = std::string([(url ?: @"") UTF8String] ?: ""), + .text = std::string([(text ?: @"") UTF8String] ?: ""), + }); + } + }; + + tableView.onCitationPress = ^(NSString *url, NSString *text) { + EnrichedMarkdown *strongSelf = weakSelf; + if (!strongSelf) + return; + + auto eventEmitter = std::static_pointer_cast(strongSelf->_eventEmitter); + if (eventEmitter) { + eventEmitter->onCitationPress({ + .url = std::string([(url ?: @"") UTF8String] ?: ""), + .text = std::string([(text ?: @"") UTF8String] ?: ""), + }); + } + }; + [tableView applyTableNode:tableSegment.tableNode]; return tableView; @@ -760,11 +788,28 @@ - (void)textTapped:(ENRMTapRecognizer *)recognizer } } - NSString *url = linkURLAtTapLocation(textView, recognizer); - if (url) { + NSString *linkURL = nil; + NSString *mentionURL = nil; + NSString *mentionText = nil; + NSString *citationURL = nil; + NSString *citationText = nil; + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionURL, &mentionText, &citationURL, + &citationText)) { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { - eventEmitter->onLinkPress({.url = std::string([url UTF8String])}); + if (mentionURL) { + eventEmitter->onMentionPress({ + .url = std::string([mentionURL UTF8String] ?: ""), + .text = std::string([(mentionText ?: @"") UTF8String] ?: ""), + }); + } else if (citationURL) { + eventEmitter->onCitationPress({ + .url = std::string([citationURL UTF8String] ?: ""), + .text = std::string([(citationText ?: @"") UTF8String] ?: ""), + }); + } else if (linkURL) { + eventEmitter->onLinkPress({.url = std::string([linkURL UTF8String])}); + } } return; } diff --git a/ios/EnrichedMarkdownText.mm b/ios/EnrichedMarkdownText.mm index 239e435a..1356619a 100644 --- a/ios/EnrichedMarkdownText.mm +++ b/ios/EnrichedMarkdownText.mm @@ -537,11 +537,28 @@ - (void)textTapped:(ENRMTapRecognizer *)recognizer return; } - NSString *url = linkURLAtTapLocation(textView, recognizer); - if (url) { + NSString *linkURL = nil; + NSString *mentionURL = nil; + NSString *mentionText = nil; + NSString *citationURL = nil; + NSString *citationText = nil; + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionURL, &mentionText, &citationURL, + &citationText)) { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { - eventEmitter->onLinkPress({.url = std::string([url UTF8String])}); + if (mentionURL) { + eventEmitter->onMentionPress({ + .url = std::string([mentionURL UTF8String] ?: ""), + .text = std::string([(mentionText ?: @"") UTF8String] ?: ""), + }); + } else if (citationURL) { + eventEmitter->onCitationPress({ + .url = std::string([citationURL UTF8String] ?: ""), + .text = std::string([(citationText ?: @"") UTF8String] ?: ""), + }); + } else if (linkURL) { + eventEmitter->onLinkPress({.url = std::string([linkURL UTF8String])}); + } } return; } diff --git a/ios/renderer/LinkRenderer.h b/ios/renderer/LinkRenderer.h index ff5b8d20..b784b0af 100644 --- a/ios/renderer/LinkRenderer.h +++ b/ios/renderer/LinkRenderer.h @@ -2,6 +2,16 @@ #import "NodeRenderer.h" #import "RenderContext.h" +/** + * Attribute names used by the link/mention/citation renderer to tag ranges of + * the rendered NSAttributedString. Tap dispatching reads these to decide which + * JS event to fire for a given character. + */ +FOUNDATION_EXPORT NSString *const ENRMMentionURLAttributeName; +FOUNDATION_EXPORT NSString *const ENRMMentionTextAttributeName; +FOUNDATION_EXPORT NSString *const ENRMCitationURLAttributeName; +FOUNDATION_EXPORT NSString *const ENRMCitationTextAttributeName; + @interface LinkRenderer : NSObject - (instancetype)initWithRendererFactory:(id)rendererFactory config:(id)config; @end diff --git a/ios/renderer/LinkRenderer.m b/ios/renderer/LinkRenderer.m index 8c37b26d..f7353a21 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -5,6 +5,14 @@ #import "StyleConfig.h" #import +NSString *const ENRMMentionURLAttributeName = @"ENRMMentionURL"; +NSString *const ENRMMentionTextAttributeName = @"ENRMMentionText"; +NSString *const ENRMCitationURLAttributeName = @"ENRMCitationURL"; +NSString *const ENRMCitationTextAttributeName = @"ENRMCitationText"; + +static NSString *const kMentionScheme = @"mention://"; +static NSString *const kCitationScheme = @"citation://"; + @implementation LinkRenderer { RendererFactory *_rendererFactory; StyleConfig *_config; @@ -20,41 +28,109 @@ - (instancetype)initWithRendererFactory:(id)rendererFactory config:(id)config return self; } +#pragma mark - Scheme helpers + +static BOOL isMentionURL(NSString *url) +{ + return [url hasPrefix:kMentionScheme]; +} + +static BOOL isCitationURL(NSString *url) +{ + return [url hasPrefix:kCitationScheme]; +} + +static NSString *stripScheme(NSString *url, NSString *scheme) +{ + if ([url hasPrefix:scheme]) { + return [url substringFromIndex:scheme.length]; + } + return url; +} + +// Stamps NSKern on the character before and the last character of an inline +// chip range so its drawn background never overlaps surrounding text. +// +// Kern value is `paddingHorizontal`: exactly enough to shift the chip's left +// overhang off the previous glyph (leading kern) and the following glyph off +// the chip's right overhang (trailing kern). With this amount, a chip just +// touches its neighbors rather than gapping — adjacent chips separated by a +// source-markdown space show the natural `space_width` between them, which +// matches the gap CSS `paddingInline` produces on web. Using `paddingHorizontal +// * 2` would double up across a shared boundary character and push consecutive +// chips visibly far apart. +// +// Adjacent chips writing the same value on the shared boundary character is +// idempotent. +static void applyChipKern(NSMutableAttributedString *output, NSRange chipRange, CGFloat paddingHorizontal) +{ + if (chipRange.length == 0 || paddingHorizontal <= 0) + return; + + NSNumber *kernValue = @(paddingHorizontal); + + // Trailing kern on the last glyph of the chip. + NSRange trailing = NSMakeRange(NSMaxRange(chipRange) - 1, 1); + [output addAttribute:NSKernAttributeName value:kernValue range:trailing]; + + // Leading kern on the character immediately before the chip, if any. This + // pushes the chip right so its left overhang doesn't cover preceding text. + if (chipRange.location > 0) { + NSRange leading = NSMakeRange(chipRange.location - 1, 1); + [output addAttribute:NSKernAttributeName value:kernValue range:leading]; + } +} + #pragma mark - Rendering - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +{ + NSString *url = node.attributes[@"url"] ?: @""; + + if (isMentionURL(url)) { + [self renderMentionNode:node url:url into:output context:context]; + return; + } + + if (isCitationURL(url)) { + [self renderCitationNode:node url:url into:output context:context]; + return; + } + + [self renderLinkNode:node url:url into:output context:context]; +} + +#pragma mark - Link (default / existing behavior) + +- (void)renderLinkNode:(MarkdownASTNode *)node + url:(NSString *)url + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; - // 1. Render children first to establish base attributes [_rendererFactory renderChildrenOfNode:node into:output context:context]; NSRange range = NSMakeRange(start, output.length - start); if (range.length == 0) return; - // 2. Extract configuration - NSString *url = node.attributes[@"url"] ?: @""; RCTUIColor *linkColor = [_config linkColor]; NSNumber *underlineStyle = @([_config linkUnderline] ? NSUnderlineStyleSingle : NSUnderlineStyleNone); NSString *linkFontFamily = [_config linkFontFamily]; - // 3. Apply core link functionality (non-destructive) [output addAttribute:NSLinkAttributeName value:url range:range]; - // 4. Optimize visual attributes via enumeration to avoid redundant updates [output enumerateAttributesInRange:range options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(NSDictionary *attrs, NSRange subrange, BOOL *stop) { NSMutableDictionary *newAttributes = [NSMutableDictionary dictionary]; - // Only apply link color if the subrange isn't already colored by the link style if (linkColor && ![attrs[NSForegroundColorAttributeName] isEqual:linkColor]) { newAttributes[NSForegroundColorAttributeName] = linkColor; newAttributes[NSUnderlineColorAttributeName] = linkColor; } - // Only update underline style if it differs from the config if (![attrs[NSUnderlineStyleAttributeName] isEqual:underlineStyle]) { newAttributes[NSUnderlineStyleAttributeName] = underlineStyle; } @@ -80,8 +156,142 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out } }]; - // 5. Register for touch handling [context registerLinkRange:range url:url]; } -@end \ No newline at end of file +#pragma mark - Mention + +- (void)renderMentionNode:(MarkdownASTNode *)node + url:(NSString *)url + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + // Collapse children into a plain display string. The pill itself is rendered + // as inline text (not an NSTextAttachment), so native copy/paste/selection + // behave exactly like normal text — the "pill" look is painted by + // `MentionBackground` during the layout manager's draw cycle. + NSMutableAttributedString *childBuffer = [[NSMutableAttributedString alloc] init]; + [_rendererFactory renderChildrenOfNode:node into:childBuffer context:context]; + NSString *displayText = childBuffer.string ?: @""; + if (displayText.length == 0) + return; + + NSString *mentionURL = stripScheme(url, kMentionScheme); + + // Inherit surrounding paragraph attributes so the pill text participates in + // the current line's metrics. + NSDictionary *baseAttrs = output.length > 0 ? [output attributesAtIndex:output.length - 1 effectiveRange:NULL] : @{}; + NSMutableDictionary *attrs = [NSMutableDictionary dictionaryWithDictionary:baseAttrs]; + + UIFont *mentionFont = [_config mentionFont]; + if (mentionFont) { + attrs[NSFontAttributeName] = mentionFont; + } + RCTUIColor *mentionColor = [_config mentionColor]; + if (mentionColor) { + attrs[NSForegroundColorAttributeName] = mentionColor; + } + attrs[ENRMMentionURLAttributeName] = mentionURL; + attrs[ENRMMentionTextAttributeName] = displayText; + + NSUInteger start = output.length; + [output appendAttributedString:[[NSAttributedString alloc] initWithString:displayText attributes:attrs]]; + NSRange outputRange = NSMakeRange(start, output.length - start); + + // The drawn pill extends `paddingHorizontal` beyond the glyph run on both + // sides. Inline text doesn't reserve any advance for that visual padding, so + // without extra spacing the pill would cover the character immediately + // before the mention (and likewise any adjacent mention/citation following + // it). Stamping NSKern on: + // - the character BEFORE the mention (adds leading advance), and + // - the LAST character of the mention (adds trailing advance) + // pushes the surrounding text outside the pill edges, matching what CSS + // `paddingInline` produces on web. Adjacent chips' kerns land on the same + // boundary character and are idempotent. + CGFloat mentionPaddingH = [_config mentionPaddingHorizontal]; + if (mentionPaddingH > 0 && outputRange.length > 0) { + applyChipKern(output, outputRange, mentionPaddingH); + } + + [context registerMentionRange:outputRange url:mentionURL text:displayText]; +} + +#pragma mark - Citation + +- (void)renderCitationNode:(MarkdownASTNode *)node + url:(NSString *)url + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + NSUInteger start = output.length; + [_rendererFactory renderChildrenOfNode:node into:output context:context]; + NSRange range = NSMakeRange(start, output.length - start); + if (range.length == 0) + return; + + NSString *targetURL = stripScheme(url, kCitationScheme); + NSString *labelText = [[output attributedSubstringFromRange:range] string] ?: @""; + + CGFloat multiplier = [_config citationFontSizeMultiplier]; + CGFloat baselineOffsetPx = [_config citationBaselineOffsetPx]; + RCTUIColor *citationColor = [_config citationColor]; + NSString *fontWeight = [_config citationFontWeight]; + BOOL underline = [_config citationUnderline]; + CGFloat paddingHorizontal = [_config citationPaddingHorizontal]; + + [output enumerateAttributesInRange:range + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(NSDictionary *attrs, NSRange subrange, BOOL *stop) { + NSMutableDictionary *newAttributes = [NSMutableDictionary dictionary]; + + UIFont *currentFont = attrs[NSFontAttributeName]; + if (currentFont && multiplier > 0) { + CGFloat newSize = currentFont.pointSize * multiplier; + UIFont *scaled = [RCTFont updateFont:currentFont + withFamily:nil + size:@(newSize) + weight:fontWeight.length > 0 ? fontWeight : nil + style:nil + variant:nil + scaleMultiplier:1.0]; + if (scaled) { + newAttributes[NSFontAttributeName] = scaled; + + CGFloat offset = baselineOffsetPx; + if (offset == 0) { + offset = (currentFont.capHeight - scaled.capHeight) * 0.5; + } + newAttributes[NSBaselineOffsetAttributeName] = @(offset); + } + } else if (baselineOffsetPx != 0) { + newAttributes[NSBaselineOffsetAttributeName] = @(baselineOffsetPx); + } + + if (citationColor) { + newAttributes[NSForegroundColorAttributeName] = citationColor; + } + + if (underline) { + newAttributes[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); + if (citationColor) { + newAttributes[NSUnderlineColorAttributeName] = citationColor; + } + } + + if (newAttributes.count > 0) { + [output addAttributes:newAttributes range:subrange]; + } + }]; + + [output addAttribute:ENRMCitationURLAttributeName value:targetURL range:range]; + [output addAttribute:ENRMCitationTextAttributeName value:labelText range:range]; + + // Stamp NSKern on the characters flanking the chip so the drawn background + // has symmetric clearance from surrounding text (previous glyph on the + // left, following glyph on the right). + applyChipKern(output, range, paddingHorizontal); + + [context registerCitationRange:range url:targetURL text:labelText]; +} + +@end diff --git a/ios/renderer/RenderContext.h b/ios/renderer/RenderContext.h index c7aa2b01..ef3e7202 100644 --- a/ios/renderer/RenderContext.h +++ b/ios/renderer/RenderContext.h @@ -25,6 +25,12 @@ typedef NS_ENUM(NSInteger, ListType) { ListTypeUnordered, ListTypeOrdered }; @interface RenderContext : NSObject @property (nonatomic, strong) NSMutableArray *linkRanges; @property (nonatomic, strong) NSMutableArray *linkURLs; +@property (nonatomic, strong) NSMutableArray *mentionRanges; +@property (nonatomic, strong) NSMutableArray *mentionURLs; +@property (nonatomic, strong) NSMutableArray *mentionTexts; +@property (nonatomic, strong) NSMutableArray *citationRanges; +@property (nonatomic, strong) NSMutableArray *citationURLs; +@property (nonatomic, strong) NSMutableArray *citationTexts; @property (nonatomic, strong) NSMutableArray *headingRanges; @property (nonatomic, strong) NSMutableArray *headingLevels; @property (nonatomic, strong) NSMutableArray *imageRanges; @@ -53,6 +59,8 @@ typedef NS_ENUM(NSInteger, ListType) { ListTypeUnordered, ListTypeOrdered }; - (NSMutableParagraphStyle *)spacerStyleWithHeight:(CGFloat)height spacing:(CGFloat)spacing; - (NSMutableParagraphStyle *)blockSpacerStyleWithMargin:(CGFloat)margin; - (void)registerLinkRange:(NSRange)range url:(NSString *)url; +- (void)registerMentionRange:(NSRange)range url:(NSString *)url text:(NSString *)text; +- (void)registerCitationRange:(NSRange)range url:(NSString *)url text:(NSString *)text; - (void)applyLinkAttributesToString:(NSMutableAttributedString *)attributedString; - (void)registerHeadingRange:(NSRange)range level:(NSInteger)level text:(NSString *)text; diff --git a/ios/renderer/RenderContext.m b/ios/renderer/RenderContext.m index 68a6ff48..6e0336c3 100644 --- a/ios/renderer/RenderContext.m +++ b/ios/renderer/RenderContext.m @@ -17,6 +17,12 @@ - (instancetype)init if (self = [super init]) { _linkRanges = [NSMutableArray array]; _linkURLs = [NSMutableArray array]; + _mentionRanges = [NSMutableArray array]; + _mentionURLs = [NSMutableArray array]; + _mentionTexts = [NSMutableArray array]; + _citationRanges = [NSMutableArray array]; + _citationURLs = [NSMutableArray array]; + _citationTexts = [NSMutableArray array]; _headingRanges = [NSMutableArray array]; _headingLevels = [NSMutableArray array]; _imageRanges = [NSMutableArray array]; @@ -105,6 +111,24 @@ - (void)registerLinkRange:(NSRange)range url:(NSString *)url [self.linkURLs addObject:url ?: @""]; } +- (void)registerMentionRange:(NSRange)range url:(NSString *)url text:(NSString *)text +{ + if (range.length == 0) + return; + [self.mentionRanges addObject:[NSValue valueWithRange:range]]; + [self.mentionURLs addObject:url ?: @""]; + [self.mentionTexts addObject:text ?: @""]; +} + +- (void)registerCitationRange:(NSRange)range url:(NSString *)url text:(NSString *)text +{ + if (range.length == 0) + return; + [self.citationRanges addObject:[NSValue valueWithRange:range]]; + [self.citationURLs addObject:url ?: @""]; + [self.citationTexts addObject:text ?: @""]; +} + - (void)applyLinkAttributesToString:(NSMutableAttributedString *)attributedString { NSUInteger length = attributedString.length; @@ -243,6 +267,12 @@ - (void)reset { [_linkRanges removeAllObjects]; [_linkURLs removeAllObjects]; + [_mentionRanges removeAllObjects]; + [_mentionURLs removeAllObjects]; + [_mentionTexts removeAllObjects]; + [_citationRanges removeAllObjects]; + [_citationURLs removeAllObjects]; + [_citationTexts removeAllObjects]; [_headingRanges removeAllObjects]; [_headingLevels removeAllObjects]; [_imageRanges removeAllObjects]; diff --git a/ios/styles/StyleConfig.h b/ios/styles/StyleConfig.h index 7ad552d3..ecdedff1 100644 --- a/ios/styles/StyleConfig.h +++ b/ios/styles/StyleConfig.h @@ -364,5 +364,52 @@ - (void)setSpoilerParticleSpeed:(CGFloat)newValue; - (CGFloat)spoilerSolidBorderRadius; - (void)setSpoilerSolidBorderRadius:(CGFloat)newValue; +// Mention properties +- (RCTUIColor *)mentionColor; +- (void)setMentionColor:(RCTUIColor *)newValue; +- (RCTUIColor *)mentionBackgroundColor; +- (void)setMentionBackgroundColor:(RCTUIColor *)newValue; +- (RCTUIColor *)mentionBorderColor; +- (void)setMentionBorderColor:(RCTUIColor *)newValue; +- (CGFloat)mentionBorderWidth; +- (void)setMentionBorderWidth:(CGFloat)newValue; +- (CGFloat)mentionBorderRadius; +- (void)setMentionBorderRadius:(CGFloat)newValue; +- (CGFloat)mentionPaddingHorizontal; +- (void)setMentionPaddingHorizontal:(CGFloat)newValue; +- (CGFloat)mentionPaddingVertical; +- (void)setMentionPaddingVertical:(CGFloat)newValue; +- (NSString *)mentionFontFamily; +- (void)setMentionFontFamily:(NSString *)newValue; +- (NSString *)mentionFontWeight; +- (void)setMentionFontWeight:(NSString *)newValue; +- (CGFloat)mentionFontSize; +- (void)setMentionFontSize:(CGFloat)newValue; +- (CGFloat)mentionPressedOpacity; +- (void)setMentionPressedOpacity:(CGFloat)newValue; +- (UIFont *)mentionFont; +// Citation properties +- (RCTUIColor *)citationColor; +- (void)setCitationColor:(RCTUIColor *)newValue; +- (CGFloat)citationFontSizeMultiplier; +- (void)setCitationFontSizeMultiplier:(CGFloat)newValue; +- (CGFloat)citationBaselineOffsetPx; +- (void)setCitationBaselineOffsetPx:(CGFloat)newValue; +- (NSString *)citationFontWeight; +- (void)setCitationFontWeight:(NSString *)newValue; +- (BOOL)citationUnderline; +- (void)setCitationUnderline:(BOOL)newValue; +- (RCTUIColor *)citationBackgroundColor; +- (void)setCitationBackgroundColor:(RCTUIColor *)newValue; +- (CGFloat)citationPaddingHorizontal; +- (void)setCitationPaddingHorizontal:(CGFloat)newValue; +- (CGFloat)citationPaddingVertical; +- (void)setCitationPaddingVertical:(CGFloat)newValue; +- (RCTUIColor *)citationBorderColor; +- (void)setCitationBorderColor:(RCTUIColor *)newValue; +- (CGFloat)citationBorderWidth; +- (void)setCitationBorderWidth:(CGFloat)newValue; +- (CGFloat)citationBorderRadius; +- (void)setCitationBorderRadius:(CGFloat)newValue; @end diff --git a/ios/styles/StyleConfig.mm b/ios/styles/StyleConfig.mm index 3b4e668e..a8422df7 100644 --- a/ios/styles/StyleConfig.mm +++ b/ios/styles/StyleConfig.mm @@ -226,6 +226,32 @@ @implementation StyleConfig { CGFloat _spoilerParticleDensity; CGFloat _spoilerParticleSpeed; CGFloat _spoilerSolidBorderRadius; + // Mention properties + RCTUIColor *_mentionColor; + RCTUIColor *_mentionBackgroundColor; + RCTUIColor *_mentionBorderColor; + CGFloat _mentionBorderWidth; + CGFloat _mentionBorderRadius; + CGFloat _mentionPaddingHorizontal; + CGFloat _mentionPaddingVertical; + NSString *_mentionFontFamily; + NSString *_mentionFontWeight; + CGFloat _mentionFontSize; + CGFloat _mentionPressedOpacity; + UIFont *_mentionFont; + BOOL _mentionFontNeedsRecreation; + // Citation properties + RCTUIColor *_citationColor; + CGFloat _citationFontSizeMultiplier; + CGFloat _citationBaselineOffsetPx; + NSString *_citationFontWeight; + BOOL _citationUnderline; + RCTUIColor *_citationBackgroundColor; + CGFloat _citationPaddingHorizontal; + CGFloat _citationPaddingVertical; + RCTUIColor *_citationBorderColor; + CGFloat _citationBorderWidth; + CGFloat _citationBorderRadius; } - (instancetype)init @@ -255,6 +281,9 @@ - (instancetype)init _tableFontNeedsRecreation = YES; _tableHeaderFontNeedsRecreation = YES; _linkUnderline = YES; + _mentionFontNeedsRecreation = YES; + _citationFontSizeMultiplier = 0.7; + _mentionPressedOpacity = 0.6; return self; } @@ -282,6 +311,7 @@ - (void)setFontScaleMultiplier:(CGFloat)newValue _codeBlockFontNeedsRecreation = YES; _blockquoteFontNeedsRecreation = YES; _tableFontNeedsRecreation = YES; + _mentionFontNeedsRecreation = YES; } } @@ -316,6 +346,7 @@ - (void)setMaxFontSizeMultiplier:(CGFloat)newValue _codeBlockFontNeedsRecreation = YES; _blockquoteFontNeedsRecreation = YES; _tableFontNeedsRecreation = YES; + _mentionFontNeedsRecreation = YES; } } @@ -496,6 +527,30 @@ - (id)copyWithZone:(NSZone *)zone copy->_spoilerParticleSpeed = _spoilerParticleSpeed; copy->_spoilerSolidBorderRadius = _spoilerSolidBorderRadius; + copy->_mentionColor = [_mentionColor copy]; + copy->_mentionBackgroundColor = [_mentionBackgroundColor copy]; + copy->_mentionBorderColor = [_mentionBorderColor copy]; + copy->_mentionBorderWidth = _mentionBorderWidth; + copy->_mentionBorderRadius = _mentionBorderRadius; + copy->_mentionPaddingHorizontal = _mentionPaddingHorizontal; + copy->_mentionPaddingVertical = _mentionPaddingVertical; + copy->_mentionFontFamily = [_mentionFontFamily copy]; + copy->_mentionFontWeight = [_mentionFontWeight copy]; + copy->_mentionFontSize = _mentionFontSize; + copy->_mentionPressedOpacity = _mentionPressedOpacity; + copy->_mentionFontNeedsRecreation = YES; + copy->_citationColor = [_citationColor copy]; + copy->_citationFontSizeMultiplier = _citationFontSizeMultiplier; + copy->_citationBaselineOffsetPx = _citationBaselineOffsetPx; + copy->_citationFontWeight = [_citationFontWeight copy]; + copy->_citationUnderline = _citationUnderline; + copy->_citationBackgroundColor = [_citationBackgroundColor copy]; + copy->_citationPaddingHorizontal = _citationPaddingHorizontal; + copy->_citationPaddingVertical = _citationPaddingVertical; + copy->_citationBorderColor = [_citationBorderColor copy]; + copy->_citationBorderWidth = _citationBorderWidth; + copy->_citationBorderRadius = _citationBorderRadius; + return copy; } @@ -2390,4 +2445,251 @@ - (void)setSpoilerSolidBorderRadius:(CGFloat)newValue _spoilerSolidBorderRadius = newValue; } +// ── Mention ───────────────────────────────────────────────────────────── + +- (RCTUIColor *)mentionColor +{ + return _mentionColor; +} + +- (void)setMentionColor:(RCTUIColor *)newValue +{ + _mentionColor = newValue; +} + +- (RCTUIColor *)mentionBackgroundColor +{ + return _mentionBackgroundColor; +} + +- (void)setMentionBackgroundColor:(RCTUIColor *)newValue +{ + _mentionBackgroundColor = newValue; +} + +- (RCTUIColor *)mentionBorderColor +{ + return _mentionBorderColor; +} + +- (void)setMentionBorderColor:(RCTUIColor *)newValue +{ + _mentionBorderColor = newValue; +} + +- (CGFloat)mentionBorderWidth +{ + return _mentionBorderWidth; +} + +- (void)setMentionBorderWidth:(CGFloat)newValue +{ + _mentionBorderWidth = newValue; +} + +- (CGFloat)mentionBorderRadius +{ + return _mentionBorderRadius; +} + +- (void)setMentionBorderRadius:(CGFloat)newValue +{ + _mentionBorderRadius = newValue; +} + +- (CGFloat)mentionPaddingHorizontal +{ + return _mentionPaddingHorizontal; +} + +- (void)setMentionPaddingHorizontal:(CGFloat)newValue +{ + _mentionPaddingHorizontal = newValue; +} + +- (CGFloat)mentionPaddingVertical +{ + return _mentionPaddingVertical; +} + +- (void)setMentionPaddingVertical:(CGFloat)newValue +{ + _mentionPaddingVertical = newValue; +} + +- (NSString *)mentionFontFamily +{ + return _mentionFontFamily; +} + +- (void)setMentionFontFamily:(NSString *)newValue +{ + _mentionFontFamily = newValue; + _mentionFontNeedsRecreation = YES; +} + +- (NSString *)mentionFontWeight +{ + return _mentionFontWeight; +} + +- (void)setMentionFontWeight:(NSString *)newValue +{ + _mentionFontWeight = newValue; + _mentionFontNeedsRecreation = YES; +} + +- (CGFloat)mentionFontSize +{ + return _mentionFontSize; +} + +- (void)setMentionFontSize:(CGFloat)newValue +{ + _mentionFontSize = newValue; + _mentionFontNeedsRecreation = YES; +} + +- (CGFloat)mentionPressedOpacity +{ + return _mentionPressedOpacity; +} + +- (void)setMentionPressedOpacity:(CGFloat)newValue +{ + _mentionPressedOpacity = newValue; +} + +- (UIFont *)mentionFont +{ + if (_mentionFontNeedsRecreation || !_mentionFont) { + // Fall back to paragraph font size when mention.fontSize is 0 (inherit). + CGFloat size = _mentionFontSize > 0 ? _mentionFontSize : _paragraphFontSize; + if (size <= 0) { + size = 16; + } + _mentionFont = [RCTFont updateFont:nil + withFamily:_mentionFontFamily + size:@(size) + weight:normalizedFontWeight(_mentionFontWeight) + style:nil + variant:nil + scaleMultiplier:[self effectiveScaleMultiplierForFontSize:size]]; + _mentionFontNeedsRecreation = NO; + } + return _mentionFont; +} + +// ── Citation ──────────────────────────────────────────────────────────── + +- (RCTUIColor *)citationColor +{ + return _citationColor; +} + +- (void)setCitationColor:(RCTUIColor *)newValue +{ + _citationColor = newValue; +} + +- (CGFloat)citationFontSizeMultiplier +{ + return _citationFontSizeMultiplier > 0 ? _citationFontSizeMultiplier : 0.7; +} + +- (void)setCitationFontSizeMultiplier:(CGFloat)newValue +{ + _citationFontSizeMultiplier = newValue; +} + +- (CGFloat)citationBaselineOffsetPx +{ + return _citationBaselineOffsetPx; +} + +- (void)setCitationBaselineOffsetPx:(CGFloat)newValue +{ + _citationBaselineOffsetPx = newValue; +} + +- (NSString *)citationFontWeight +{ + return _citationFontWeight; +} + +- (void)setCitationFontWeight:(NSString *)newValue +{ + _citationFontWeight = newValue; +} + +- (BOOL)citationUnderline +{ + return _citationUnderline; +} + +- (void)setCitationUnderline:(BOOL)newValue +{ + _citationUnderline = newValue; +} + +- (RCTUIColor *)citationBackgroundColor +{ + return _citationBackgroundColor; +} + +- (void)setCitationBackgroundColor:(RCTUIColor *)newValue +{ + _citationBackgroundColor = newValue; +} + +- (CGFloat)citationPaddingHorizontal +{ + return _citationPaddingHorizontal; +} + +- (void)setCitationPaddingHorizontal:(CGFloat)newValue +{ + _citationPaddingHorizontal = newValue; +} + +- (CGFloat)citationPaddingVertical +{ + return _citationPaddingVertical; +} + +- (void)setCitationPaddingVertical:(CGFloat)newValue +{ + _citationPaddingVertical = newValue; +} + +- (RCTUIColor *)citationBorderColor +{ + return _citationBorderColor; +} + +- (void)setCitationBorderColor:(RCTUIColor *)newValue +{ + _citationBorderColor = newValue; +} + +- (CGFloat)citationBorderWidth +{ + return _citationBorderWidth; +} + +- (void)setCitationBorderWidth:(CGFloat)newValue +{ + _citationBorderWidth = newValue; +} + +- (CGFloat)citationBorderRadius +{ + return _citationBorderRadius; +} + +- (void)setCitationBorderRadius:(CGFloat)newValue +{ + _citationBorderRadius = newValue; +} + @end diff --git a/ios/utils/CitationBackground.h b/ios/utils/CitationBackground.h new file mode 100644 index 00000000..9aaff123 --- /dev/null +++ b/ios/utils/CitationBackground.h @@ -0,0 +1,24 @@ +#pragma once +#import "ENRMUIKit.h" +#import "StyleConfig.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Draws the padded rounded background behind any glyph range tagged with + * `ENRMCitationURLAttributeName`. Inline-text rendering of citations means + * copy/paste work naturally; the pill appearance is achieved purely by this + * background pass inside the NSLayoutManager draw cycle. + */ +@interface CitationBackground : NSObject + +- (instancetype)initWithConfig:(StyleConfig *)config; + +- (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow + layoutManager:(NSLayoutManager *)layoutManager + textContainer:(NSTextContainer *)textContainer + atPoint:(CGPoint)origin; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/utils/CitationBackground.m b/ios/utils/CitationBackground.m new file mode 100644 index 00000000..c04c1574 --- /dev/null +++ b/ios/utils/CitationBackground.m @@ -0,0 +1,158 @@ +#import "CitationBackground.h" +#import "ENRMUIKit.h" +#import "LinkRenderer.h" + +@implementation CitationBackground { + StyleConfig *_config; +} + +- (instancetype)initWithConfig:(StyleConfig *)config +{ + self = [super init]; + if (self) { + _config = config; + } + return self; +} + +- (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow + layoutManager:(NSLayoutManager *)layoutManager + textContainer:(NSTextContainer *)textContainer + atPoint:(CGPoint)origin +{ + NSTextStorage *textStorage = layoutManager.textStorage; + if (!textStorage || textStorage.length == 0) + return; + + NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphsToShow actualGlyphRange:NULL]; + if (charRange.location == NSNotFound || charRange.length == 0) + return; + + RCTUIColor *bgColor = [_config citationBackgroundColor]; + CGFloat paddingH = [_config citationPaddingHorizontal]; + CGFloat paddingV = [_config citationPaddingVertical]; + RCTUIColor *borderColor = [_config citationBorderColor]; + CGFloat borderWidth = [_config citationBorderWidth]; + CGFloat borderRadius = [_config citationBorderRadius]; + + // Nothing to paint when neither a fill nor a stroke would be visible. + if (!bgColor && (!borderColor || borderWidth <= 0)) + return; + + NSUInteger totalGlyphs = [layoutManager numberOfGlyphs]; + + [textStorage + enumerateAttribute:ENRMCitationURLAttributeName + inRange:NSMakeRange(0, textStorage.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (!value || range.length == 0) + return; + if (NSIntersectionRange(range, charRange).length == 0) + return; + + NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:range actualCharacterRange:NULL]; + if (glyphRange.location == NSNotFound || glyphRange.length == 0) + return; + + // Pick up the font actually applied to the citation glyphs so the + // chip can be sized to the (smaller) citation font, not the full + // line height. + UIFont *citationFont = [textStorage attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:NULL]; + + [layoutManager + enumerateLineFragmentsForGlyphRange:glyphRange + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *tc, + NSRange lineRange, BOOL *lineStop) { + NSRange intersect = NSIntersectionRange(lineRange, glyphRange); + if (intersect.length == 0) + return; + + // Horizontal extent: compute from glyph ADVANCE positions + // (not ink bounds) so the chip hugs each digit the same + // way proportional text naturally lays out. Using + // boundingRectForGlyphRange: here would include any + // trailing kerning we added to space consecutive chips + // apart, causing them to visually overlap. + NSUInteger firstGlyph = intersect.location; + NSUInteger lastGlyph = NSMaxRange(intersect) - 1; + + CGPoint firstLoc = [layoutManager locationForGlyphAtIndex:firstGlyph]; + CGFloat chipLeftX = lineRect.origin.x + firstLoc.x; + + CGFloat chipRightX; + NSUInteger afterLastGlyph = NSMaxRange(intersect); + BOOL canQueryNext = (afterLastGlyph < totalGlyphs) && + (afterLastGlyph < NSMaxRange(lineRange)); + if (canQueryNext) { + CGPoint nextLoc = + [layoutManager locationForGlyphAtIndex:afterLastGlyph]; + chipRightX = lineRect.origin.x + nextLoc.x; + + // Subtract any trailing kern we stamped on the last + // character of the citation so the chip doesn't include + // that spacing gap. + NSUInteger lastCharIndex = + [layoutManager characterIndexForGlyphAtIndex:lastGlyph]; + if (lastCharIndex < textStorage.length) { + NSNumber *kern = [textStorage attribute:NSKernAttributeName + atIndex:lastCharIndex + effectiveRange:NULL]; + if (kern) { + chipRightX -= [kern doubleValue]; + } + } + } else { + // Last glyph on the line or last in the buffer — fall + // back to the last glyph's ink bounding rect (trailing + // kerning is irrelevant here since there's nothing + // after it). + CGRect lastRect = + [layoutManager boundingRectForGlyphRange:NSMakeRange(lastGlyph, 1) + inTextContainer:textContainer]; + chipRightX = lastRect.origin.x + lastRect.size.width; + } + + // Vertical extent: derive from the citation glyphs' own + // baseline + font metrics so the chip hugs the smaller + // superscript text rather than stretching to the line + // height. `locationForGlyphAtIndex:` returns the baseline + // in the line fragment's coordinate system and already + // accounts for NSBaselineOffsetAttributeName. + CGFloat baselineY = lineRect.origin.y + firstLoc.y; + + CGFloat ascent = citationFont ? citationFont.ascender : 0; + // UIFont.descender is negative (points below baseline); + // subtract it to move downward from the baseline. + CGFloat descent = citationFont ? citationFont.descender : 0; + + CGFloat chipTop = baselineY - ascent - paddingV; + CGFloat chipBottom = baselineY - descent + paddingV; + + CGRect chipRect = + CGRectMake(chipLeftX + origin.x - paddingH, chipTop + origin.y, + MAX(0, (chipRightX - chipLeftX)) + paddingH * 2, + MAX(0, chipBottom - chipTop)); + + CGFloat maxRadius = MIN(chipRect.size.width, chipRect.size.height) / 2.0; + CGFloat radius = MIN(borderRadius, maxRadius); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect + cornerRadius:radius]; + + if (bgColor) { + [bgColor setFill]; + [path fill]; + } + + if (borderColor && borderWidth > 0) { + path.lineWidth = borderWidth; + [borderColor setStroke]; + [path stroke]; + } + }]; + }]; +} + +@end diff --git a/ios/utils/LinkTapUtils.h b/ios/utils/LinkTapUtils.h index f7b6c188..a0eced0a 100644 --- a/ios/utils/LinkTapUtils.h +++ b/ios/utils/LinkTapUtils.h @@ -14,7 +14,17 @@ NSString *_Nullable linkURLAtTapLocation(ENRMPlatformTextView *textView, ENRMTap /// Returns the link URL at the given character range, or nil if none found. NSString *_Nullable linkURLAtRange(ENRMPlatformTextView *textView, NSRange characterRange); -/// Returns YES if the point (in textView coordinates) is on a link or task list checkbox. +/// Returns the inline element (link, mention, or citation) at the tap location. +/// The out parameters are populated only when a matching element is present. +/// Returns YES when any element was matched, NO otherwise. +BOOL inlineElementAtTapLocation(ENRMPlatformTextView *textView, ENRMTapRecognizer *recognizer, + NSString *_Nullable *_Nullable outLinkURL, NSString *_Nullable *_Nullable outMentionURL, + NSString *_Nullable *_Nullable outMentionText, + NSString *_Nullable *_Nullable outCitationURL, + NSString *_Nullable *_Nullable outCitationText); + +/// Returns YES if the point (in textView coordinates) is on a link, mention, +/// citation, spoiler, or task list checkbox. BOOL isPointOnInteractiveElement(ENRMPlatformTextView *textView, CGPoint point); #ifdef __cplusplus diff --git a/ios/utils/LinkTapUtils.m b/ios/utils/LinkTapUtils.m index 26ea2d4d..5d0ebd88 100644 --- a/ios/utils/LinkTapUtils.m +++ b/ios/utils/LinkTapUtils.m @@ -1,6 +1,7 @@ #import "LinkTapUtils.h" #import "ENRMSpoilerTapUtils.h" #import "ENRMTextHitTest.h" +#import "LinkRenderer.h" NSString *_Nullable linkURLAtTapLocation(ENRMPlatformTextView *textView, ENRMTapRecognizer *recognizer) { @@ -21,6 +22,47 @@ return [attrText attribute:@"linkURL" atIndex:characterRange.location effectiveRange:NULL]; } +BOOL inlineElementAtTapLocation(ENRMPlatformTextView *textView, ENRMTapRecognizer *recognizer, + NSString *_Nullable *_Nullable outLinkURL, NSString *_Nullable *_Nullable outMentionURL, + NSString *_Nullable *_Nullable outMentionText, + NSString *_Nullable *_Nullable outCitationURL, + NSString *_Nullable *_Nullable outCitationText) +{ + NSUInteger characterIndex = ENRMCharacterIndexForTap(textView, recognizer); + if (characterIndex == NSNotFound) + return NO; + + NSAttributedString *attrText = ENRMGetAttributedText(textView); + NSDictionary *attrs = [attrText attributesAtIndex:characterIndex effectiveRange:NULL]; + + NSString *mentionURL = attrs[ENRMMentionURLAttributeName]; + if (mentionURL) { + if (outMentionURL) + *outMentionURL = mentionURL; + if (outMentionText) + *outMentionText = attrs[ENRMMentionTextAttributeName] ?: @""; + return YES; + } + + NSString *citationURL = attrs[ENRMCitationURLAttributeName]; + if (citationURL) { + if (outCitationURL) + *outCitationURL = citationURL; + if (outCitationText) + *outCitationText = attrs[ENRMCitationTextAttributeName] ?: @""; + return YES; + } + + NSString *linkURL = attrs[@"linkURL"]; + if (linkURL) { + if (outLinkURL) + *outLinkURL = linkURL; + return YES; + } + + return NO; +} + BOOL isPointOnInteractiveElement(ENRMPlatformTextView *textView, CGPoint point) { NSUInteger charIndex = ENRMCharacterIndexAtPoint(textView, point); @@ -28,5 +70,7 @@ BOOL isPointOnInteractiveElement(ENRMPlatformTextView *textView, CGPoint point) return NO; NSDictionary *attrs = [ENRMGetAttributedText(textView) attributesAtIndex:charIndex effectiveRange:NULL]; - return attrs[@"linkURL"] != nil || [attrs[@"TaskItem"] boolValue] || attrs[SpoilerAttributeName] != nil; + return attrs[@"linkURL"] != nil || attrs[ENRMMentionURLAttributeName] != nil || + attrs[ENRMCitationURLAttributeName] != nil || [attrs[@"TaskItem"] boolValue] || + attrs[SpoilerAttributeName] != nil; } diff --git a/ios/utils/MarkdownExtractor.m b/ios/utils/MarkdownExtractor.m index fc1dd0df..e29c4616 100644 --- a/ios/utils/MarkdownExtractor.m +++ b/ios/utils/MarkdownExtractor.m @@ -8,6 +8,7 @@ #import "ENRMMathInlineAttachment.h" #endif #import "LastElementUtils.h" +#import "LinkRenderer.h" #import "ListItemRenderer.h" #import "RuntimeKeys.h" #import "ThematicBreakAttachment.h" @@ -290,7 +291,18 @@ static void extractFontTraits(NSDictionary *attrs, BOOL *isBold, BOOL *isItalic, NSNumber *underlineStyle = attrs[NSUnderlineStyleAttributeName]; BOOL isUnderline = (underlineStyle != nil && [underlineStyle integerValue] != 0); + // Mentions / citations are stored as inline text tagged with custom + // attributes. When the range covers a mention we emit + // `[text](mention://)`; citations likewise become + // `[text](citation://)` so copy/paste roundtrips cleanly. NSString *linkURL = attrs[NSLinkAttributeName]; + NSString *mentionURL = attrs[ENRMMentionURLAttributeName]; + NSString *citationURL = attrs[ENRMCitationURLAttributeName]; + if (mentionURL) { + linkURL = [@"mention://" stringByAppendingString:mentionURL]; + } else if (citationURL) { + linkURL = [@"citation://" stringByAppendingString:citationURL]; + } NSString *segment = applyInlineFormatting(text, isBold, isItalic, isMonospace, isStrikethrough, isUnderline, linkURL); diff --git a/ios/utils/MentionBackground.h b/ios/utils/MentionBackground.h new file mode 100644 index 00000000..8ea931e6 --- /dev/null +++ b/ios/utils/MentionBackground.h @@ -0,0 +1,25 @@ +#pragma once +#import "ENRMUIKit.h" +#import "StyleConfig.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Draws the rounded-pill background + optional border behind any glyph range + * tagged with the `ENRMMentionURLAttributeName` attribute. Runs from inside + * `NSLayoutManager.drawBackgroundForGlyphRange:` so mention pills don't + * require an NSTextAttachment — selection, copy/paste, and long-press all + * behave like normal inline text. + */ +@interface MentionBackground : NSObject + +- (instancetype)initWithConfig:(StyleConfig *)config; + +- (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow + layoutManager:(NSLayoutManager *)layoutManager + textContainer:(NSTextContainer *)textContainer + atPoint:(CGPoint)origin; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/utils/MentionBackground.m b/ios/utils/MentionBackground.m new file mode 100644 index 00000000..a4bb7074 --- /dev/null +++ b/ios/utils/MentionBackground.m @@ -0,0 +1,153 @@ +#import "MentionBackground.h" +#import "ENRMUIKit.h" +#import "LinkRenderer.h" + +@implementation MentionBackground { + StyleConfig *_config; +} + +- (instancetype)initWithConfig:(StyleConfig *)config +{ + self = [super init]; + if (self) { + _config = config; + } + return self; +} + +- (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow + layoutManager:(NSLayoutManager *)layoutManager + textContainer:(NSTextContainer *)textContainer + atPoint:(CGPoint)origin +{ + NSTextStorage *textStorage = layoutManager.textStorage; + if (!textStorage || textStorage.length == 0) + return; + + NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphsToShow actualGlyphRange:NULL]; + if (charRange.location == NSNotFound || charRange.length == 0) + return; + + RCTUIColor *bgColor = [_config mentionBackgroundColor]; + RCTUIColor *borderColor = [_config mentionBorderColor]; + CGFloat borderWidth = [_config mentionBorderWidth]; + CGFloat borderRadius = [_config mentionBorderRadius]; + CGFloat paddingH = [_config mentionPaddingHorizontal]; + CGFloat paddingV = [_config mentionPaddingVertical]; + + // Bail early when there is nothing visible to draw. + if (!bgColor && (!borderColor || borderWidth <= 0)) + return; + + NSUInteger totalGlyphs = [layoutManager numberOfGlyphs]; + + [textStorage + enumerateAttribute:ENRMMentionURLAttributeName + inRange:NSMakeRange(0, textStorage.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (!value || range.length == 0) + return; + if (NSIntersectionRange(range, charRange).length == 0) + return; + + NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:range actualCharacterRange:NULL]; + if (glyphRange.location == NSNotFound || glyphRange.length == 0) + return; + + // Pick up the font actually applied to the mention glyphs so the + // pill can be sized to the mention font, not to the (possibly + // taller) line height. + UIFont *mentionFont = [textStorage attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:NULL]; + + [layoutManager + enumerateLineFragmentsForGlyphRange:glyphRange + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *tc, + NSRange lineRange, BOOL *lineStop) { + NSRange intersect = NSIntersectionRange(lineRange, glyphRange); + if (intersect.length == 0) + return; + + // Horizontal extent: compute from glyph ADVANCE positions + // (not ink bounds) so the pill hugs the glyph run exactly, + // and subtract any trailing kerning we stamped on the + // last character. Using boundingRectForGlyphRange: here + // would include that kerning and let adjacent mention + // pills visually overlap. + NSUInteger firstGlyph = intersect.location; + NSUInteger lastGlyph = NSMaxRange(intersect) - 1; + + CGPoint firstLoc = [layoutManager locationForGlyphAtIndex:firstGlyph]; + CGFloat pillLeftX = lineRect.origin.x + firstLoc.x; + + CGFloat pillRightX; + NSUInteger afterLastGlyph = NSMaxRange(intersect); + BOOL canQueryNext = (afterLastGlyph < totalGlyphs) && + (afterLastGlyph < NSMaxRange(lineRange)); + if (canQueryNext) { + CGPoint nextLoc = + [layoutManager locationForGlyphAtIndex:afterLastGlyph]; + pillRightX = lineRect.origin.x + nextLoc.x; + + // Subtract the trailing NSKern (if any) so the pill + // ends exactly at the last glyph's natural advance, + // not inside the kerning gap used to space chips. + NSUInteger lastCharIndex = + [layoutManager characterIndexForGlyphAtIndex:lastGlyph]; + if (lastCharIndex < textStorage.length) { + NSNumber *kern = [textStorage attribute:NSKernAttributeName + atIndex:lastCharIndex + effectiveRange:NULL]; + if (kern) { + pillRightX -= [kern doubleValue]; + } + } + } else { + // End of line or end of buffer: fall back to the last + // glyph's ink bounding rect (trailing kerning is + // irrelevant when nothing follows it on the line). + CGRect lastRect = + [layoutManager boundingRectForGlyphRange:NSMakeRange(lastGlyph, 1) + inTextContainer:textContainer]; + pillRightX = lastRect.origin.x + lastRect.size.width; + } + + // Vertical extent: derive from the mention glyphs' own + // baseline + font metrics so the pill hugs the mention + // text rather than stretching to the full line height. + CGFloat baselineY = lineRect.origin.y + firstLoc.y; + + CGFloat ascent = mentionFont ? mentionFont.ascender : 0; + // UIFont.descender is negative; subtract to move down. + CGFloat descent = mentionFont ? mentionFont.descender : 0; + + CGFloat pillTop = baselineY - ascent - paddingV; + CGFloat pillBottom = baselineY - descent + paddingV; + + CGRect pillRect = + CGRectMake(pillLeftX + origin.x - paddingH, pillTop + origin.y, + MAX(0, (pillRightX - pillLeftX)) + paddingH * 2, + MAX(0, pillBottom - pillTop)); + + CGFloat radius = MIN( + borderRadius, MIN(pillRect.size.width, pillRect.size.height) / 2.0); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:pillRect + cornerRadius:radius]; + + if (bgColor) { + [bgColor setFill]; + [path fill]; + } + + if (borderColor && borderWidth > 0) { + path.lineWidth = borderWidth; + [borderColor setStroke]; + [path stroke]; + } + }]; + }]; +} + +@end diff --git a/ios/utils/PasteboardUtils.m b/ios/utils/PasteboardUtils.m index c89ce549..7a674c51 100644 --- a/ios/utils/PasteboardUtils.m +++ b/ios/utils/PasteboardUtils.m @@ -1,6 +1,7 @@ #import "PasteboardUtils.h" #import "ENRMImageAttachment.h" #import "HTMLGenerator.h" +#import "LinkRenderer.h" #import "MarkdownExtractor.h" #import "RTFExportUtils.h" #import "StyleConfig.h" @@ -53,6 +54,71 @@ static void addHTMLData(NSMutableDictionary *items, NSAttributedString *attribut } } +/** + * Returns a copy of the attributed string with any character ranges tagged by + * `ENRMCitationURLAttributeName` removed entirely. Citations are reference + * metadata, not prose — stripping them here keeps every export flavor + * (plain, HTML, RTF, RTFD, markdown) consistently citation-free, so pasting + * into a rich-text destination like Notes or Mail no longer surfaces the + * citation marker. + */ +static NSAttributedString *attributedStringWithoutCitations(NSAttributedString *attributedString) +{ + NSRange fullRange = NSMakeRange(0, attributedString.length); + NSMutableArray *rangesToRemove = [NSMutableArray array]; + + [attributedString enumerateAttribute:ENRMCitationURLAttributeName + inRange:fullRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (!value || range.length == 0) + return; + [rangesToRemove addObject:[NSValue valueWithRange:range]]; + }]; + + if (rangesToRemove.count == 0) + return attributedString; + + NSMutableAttributedString *mutable = [attributedString mutableCopy]; + // Delete in reverse so earlier ranges remain valid after the edits. + for (NSInteger i = (NSInteger)rangesToRemove.count - 1; i >= 0; i--) { + NSRange range = [rangesToRemove[i] rangeValue]; + if (NSMaxRange(range) <= mutable.length) { + [mutable deleteCharactersInRange:range]; + } + } + return mutable; +} + +/** + * Strips `[text](citation://...)` occurrences from a pre-extracted markdown + * string so the markdown pasteboard flavor stays consistent with the other + * flavors in the default Copy action. + */ +static NSString *markdownWithoutCitations(NSString *markdown) +{ + if (markdown.length == 0) + return markdown; + + static NSRegularExpression *regex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + // Matches a standard CommonMark link where the URL begins with `citation://`. + // The label body uses `[^\]]*` so it stops at the first `]` — nested + // brackets in citation labels aren't a supported case in our emitter. + regex = [NSRegularExpression regularExpressionWithPattern:@"\\[[^\\]]*\\]\\(citation://[^\\)]*\\)" + options:0 + error:NULL]; + }); + + if (!regex) + return markdown; + return [regex stringByReplacingMatchesInString:markdown + options:0 + range:NSMakeRange(0, markdown.length) + withTemplate:@""]; +} + #pragma mark - Public API void copyStringToPasteboard(NSString *string) @@ -90,20 +156,29 @@ void copyAttributedStringToPasteboard(NSAttributedString *attributedString, NSSt if (!attributedString || attributedString.length == 0) return; + // Elide citations before deriving any export flavor so rich-text + // destinations (Notes, Mail, Pages, contenteditable, etc.) don't end up + // with the reference marker when the user pastes. The dedicated + // "Copy as Markdown" action still preserves citations for round-tripping. + NSAttributedString *cleaned = attributedStringWithoutCitations(attributedString); + if (cleaned.length == 0) + return; + NSMutableDictionary *items = [NSMutableDictionary dictionary]; - items[kUTIPlainText] = attributedString.string; + items[kUTIPlainText] = cleaned.string; - if (markdown.length > 0) { - items[kUTIMarkdown] = markdown; + NSString *cleanedMarkdown = markdownWithoutCitations(markdown); + if (cleanedMarkdown.length > 0) { + items[kUTIMarkdown] = cleanedMarkdown; } if (styleConfig) { - addHTMLData(items, attributedString, styleConfig); + addHTMLData(items, cleaned, styleConfig); } // RTF export requires preprocessing (backgrounds, markers, normalized spacing) - NSAttributedString *rtfPrepared = prepareAttributedStringForRTFExport(attributedString, styleConfig); + NSAttributedString *rtfPrepared = prepareAttributedStringForRTFExport(cleaned, styleConfig); NSRange rtfRange = NSMakeRange(0, rtfPrepared.length); addRTFDData(items, rtfPrepared, rtfRange); diff --git a/ios/utils/RuntimeKeys.h b/ios/utils/RuntimeKeys.h index 5e1bef5e..182b8d46 100644 --- a/ios/utils/RuntimeKeys.h +++ b/ios/utils/RuntimeKeys.h @@ -32,6 +32,14 @@ extern void *kListMarkerDrawerKey; // Used by TextViewLayoutManager for code block background drawing extern void *kCodeBlockBackgroundKey; +// Key for storing MentionBackground instance on NSLayoutManager +// Used by TextViewLayoutManager for inline mention pill drawing +extern void *kMentionBackgroundKey; + +// Key for storing CitationBackground instance on NSLayoutManager +// Used by TextViewLayoutManager for inline citation chip drawing +extern void *kCitationBackgroundKey; + // Custom attribute keys for markdown type tracking (used for Copy Markdown) extern NSString *const MarkdownTypeAttributeName; diff --git a/ios/utils/RuntimeKeys.m b/ios/utils/RuntimeKeys.m index 02ae3e90..e2030f73 100644 --- a/ios/utils/RuntimeKeys.m +++ b/ios/utils/RuntimeKeys.m @@ -6,6 +6,8 @@ void *kBlockquoteBorderKey = &kBlockquoteBorderKey; void *kListMarkerDrawerKey = &kListMarkerDrawerKey; void *kCodeBlockBackgroundKey = &kCodeBlockBackgroundKey; +void *kMentionBackgroundKey = &kMentionBackgroundKey; +void *kCitationBackgroundKey = &kCitationBackgroundKey; // Custom attribute for markdown type tracking NSString *const MarkdownTypeAttributeName = @"MarkdownType"; diff --git a/ios/utils/StylePropsUtils.h b/ios/utils/StylePropsUtils.h index f74311aa..99ca21f5 100644 --- a/ios/utils/StylePropsUtils.h +++ b/ios/utils/StylePropsUtils.h @@ -1064,5 +1064,161 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty changed = YES; } + // ── Mention ───────────────────────────────────────────────────────────── + + if (newStyle.mention.color != oldStyle.mention.color) { + if (newStyle.mention.color) { + [config setMentionColor:RCTUIColorFromSharedColor(newStyle.mention.color)]; + } else { + [config setMentionColor:nullptr]; + } + changed = YES; + } + + if (newStyle.mention.backgroundColor != oldStyle.mention.backgroundColor) { + if (newStyle.mention.backgroundColor) { + [config setMentionBackgroundColor:RCTUIColorFromSharedColor(newStyle.mention.backgroundColor)]; + } else { + [config setMentionBackgroundColor:nullptr]; + } + changed = YES; + } + + if (newStyle.mention.borderColor != oldStyle.mention.borderColor) { + if (newStyle.mention.borderColor) { + [config setMentionBorderColor:RCTUIColorFromSharedColor(newStyle.mention.borderColor)]; + } else { + [config setMentionBorderColor:nullptr]; + } + changed = YES; + } + + if (newStyle.mention.borderWidth != oldStyle.mention.borderWidth) { + [config setMentionBorderWidth:newStyle.mention.borderWidth]; + changed = YES; + } + + if (newStyle.mention.borderRadius != oldStyle.mention.borderRadius) { + [config setMentionBorderRadius:newStyle.mention.borderRadius]; + changed = YES; + } + + if (newStyle.mention.paddingHorizontal != oldStyle.mention.paddingHorizontal) { + [config setMentionPaddingHorizontal:newStyle.mention.paddingHorizontal]; + changed = YES; + } + + if (newStyle.mention.paddingVertical != oldStyle.mention.paddingVertical) { + [config setMentionPaddingVertical:newStyle.mention.paddingVertical]; + changed = YES; + } + + if (newStyle.mention.fontFamily != oldStyle.mention.fontFamily) { + if (!newStyle.mention.fontFamily.empty()) { + NSString *fontFamily = [[NSString alloc] initWithUTF8String:newStyle.mention.fontFamily.c_str()]; + [config setMentionFontFamily:fontFamily]; + } else { + [config setMentionFontFamily:nullptr]; + } + changed = YES; + } + + if (newStyle.mention.fontWeight != oldStyle.mention.fontWeight) { + if (!newStyle.mention.fontWeight.empty()) { + NSString *fontWeight = [[NSString alloc] initWithUTF8String:newStyle.mention.fontWeight.c_str()]; + [config setMentionFontWeight:fontWeight]; + } else { + [config setMentionFontWeight:nullptr]; + } + changed = YES; + } + + if (newStyle.mention.fontSize != oldStyle.mention.fontSize) { + [config setMentionFontSize:newStyle.mention.fontSize]; + changed = YES; + } + + if (newStyle.mention.pressedOpacity != oldStyle.mention.pressedOpacity) { + [config setMentionPressedOpacity:newStyle.mention.pressedOpacity]; + changed = YES; + } + + // ── Citation ──────────────────────────────────────────────────────────── + + if (newStyle.citation.color != oldStyle.citation.color) { + if (newStyle.citation.color) { + [config setCitationColor:RCTUIColorFromSharedColor(newStyle.citation.color)]; + } else { + [config setCitationColor:nullptr]; + } + changed = YES; + } + + if (newStyle.citation.fontSizeMultiplier != oldStyle.citation.fontSizeMultiplier) { + [config setCitationFontSizeMultiplier:newStyle.citation.fontSizeMultiplier]; + changed = YES; + } + + if (newStyle.citation.baselineOffsetPx != oldStyle.citation.baselineOffsetPx) { + [config setCitationBaselineOffsetPx:newStyle.citation.baselineOffsetPx]; + changed = YES; + } + + if (newStyle.citation.fontWeight != oldStyle.citation.fontWeight) { + if (!newStyle.citation.fontWeight.empty()) { + NSString *fontWeight = [[NSString alloc] initWithUTF8String:newStyle.citation.fontWeight.c_str()]; + [config setCitationFontWeight:fontWeight]; + } else { + [config setCitationFontWeight:nullptr]; + } + changed = YES; + } + + { + BOOL newUnderline = newStyle.citation.underline ? YES : NO; + if (newStyle.citation.underline != oldStyle.citation.underline || [config citationUnderline] != newUnderline) { + [config setCitationUnderline:newUnderline]; + changed = YES; + } + } + + if (newStyle.citation.backgroundColor != oldStyle.citation.backgroundColor) { + if (newStyle.citation.backgroundColor) { + [config setCitationBackgroundColor:RCTUIColorFromSharedColor(newStyle.citation.backgroundColor)]; + } else { + [config setCitationBackgroundColor:nullptr]; + } + changed = YES; + } + + if (newStyle.citation.paddingHorizontal != oldStyle.citation.paddingHorizontal) { + [config setCitationPaddingHorizontal:newStyle.citation.paddingHorizontal]; + changed = YES; + } + + if (newStyle.citation.paddingVertical != oldStyle.citation.paddingVertical) { + [config setCitationPaddingVertical:newStyle.citation.paddingVertical]; + changed = YES; + } + + if (newStyle.citation.borderColor != oldStyle.citation.borderColor) { + if (newStyle.citation.borderColor) { + [config setCitationBorderColor:RCTUIColorFromSharedColor(newStyle.citation.borderColor)]; + } else { + [config setCitationBorderColor:nullptr]; + } + changed = YES; + } + + if (newStyle.citation.borderWidth != oldStyle.citation.borderWidth) { + [config setCitationBorderWidth:newStyle.citation.borderWidth]; + changed = YES; + } + + if (newStyle.citation.borderRadius != oldStyle.citation.borderRadius) { + [config setCitationBorderRadius:newStyle.citation.borderRadius]; + changed = YES; + } + return changed; } diff --git a/ios/utils/TextViewLayoutManager.mm b/ios/utils/TextViewLayoutManager.mm index dca3717b..4bf11b4a 100644 --- a/ios/utils/TextViewLayoutManager.mm +++ b/ios/utils/TextViewLayoutManager.mm @@ -1,8 +1,10 @@ #import "TextViewLayoutManager.h" #import "BlockquoteBorder.h" +#import "CitationBackground.h" #import "CodeBackground.h" #import "CodeBlockBackground.h" #import "ListMarkerDrawer.h" +#import "MentionBackground.h" #import "RuntimeKeys.h" #import "StyleConfig.h" #import @@ -42,6 +44,12 @@ - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origi ListMarkerDrawer *markerDrawer = [self getListMarkerDrawerWithConfig:config]; [markerDrawer drawMarkersForGlyphRange:glyphsToShow layoutManager:self textContainer:textContainer atPoint:origin]; + + MentionBackground *mentionBg = [self getMentionBackgroundWithConfig:config]; + [mentionBg drawBackgroundsForGlyphRange:glyphsToShow layoutManager:self textContainer:textContainer atPoint:origin]; + + CitationBackground *citationBg = [self getCitationBackgroundWithConfig:config]; + [citationBg drawBackgroundsForGlyphRange:glyphsToShow layoutManager:self textContainer:textContainer atPoint:origin]; } #pragma mark - Safe Property Accessors @@ -89,6 +97,26 @@ - (CodeBlockBackground *)getCodeBlockBackgroundWithConfig:(StyleConfig *)config return obj; } +- (MentionBackground *)getMentionBackgroundWithConfig:(StyleConfig *)config +{ + MentionBackground *obj = objc_getAssociatedObject(self, kMentionBackgroundKey); + if (!obj) { + obj = [[MentionBackground alloc] initWithConfig:config]; + objc_setAssociatedObject(self, kMentionBackgroundKey, obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return obj; +} + +- (CitationBackground *)getCitationBackgroundWithConfig:(StyleConfig *)config +{ + CitationBackground *obj = objc_getAssociatedObject(self, kCitationBackgroundKey); + if (!obj) { + obj = [[CitationBackground alloc] initWithConfig:config]; + objc_setAssociatedObject(self, kCitationBackgroundKey, obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return obj; +} + #pragma mark - Configuration - (StyleConfig *)config @@ -103,6 +131,8 @@ - (void)setConfig:(StyleConfig *)config objc_setAssociatedObject(self, kCodeBlockBackgroundKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(self, kBlockquoteBorderKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(self, kListMarkerDrawerKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(self, kMentionBackgroundKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(self, kCitationBackgroundKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(self, kStyleConfigKey, config, OBJC_ASSOCIATION_RETAIN_NONATOMIC); diff --git a/ios/views/TableContainerView.h b/ios/views/TableContainerView.h index b444236b..00d1d35f 100644 --- a/ios/views/TableContainerView.h +++ b/ios/views/TableContainerView.h @@ -7,6 +7,8 @@ NS_ASSUME_NONNULL_BEGIN typedef void (^TableLinkPressBlock)(NSString *url); +typedef void (^TableMentionPressBlock)(NSString *url, NSString *text); +typedef void (^TableCitationPressBlock)(NSString *url, NSString *text); @interface TableContainerView : RCTUIView @@ -23,6 +25,8 @@ typedef void (^TableLinkPressBlock)(NSString *url); @property (nonatomic, copy, nullable) TableLinkPressBlock onLinkPress; @property (nonatomic, copy, nullable) TableLinkPressBlock onLinkLongPress; +@property (nonatomic, copy, nullable) TableMentionPressBlock onMentionPress; +@property (nonatomic, copy, nullable) TableCitationPressBlock onCitationPress; @property (nonatomic, assign) BOOL enableLinkPreview; diff --git a/ios/views/TableContainerView.m b/ios/views/TableContainerView.m index d538e5e0..47091ced 100644 --- a/ios/views/TableContainerView.m +++ b/ios/views/TableContainerView.m @@ -409,9 +409,21 @@ - (UITextView *)createCellTextView - (void)cellTextTapped:(UITapGestureRecognizer *)recognizer { UITextView *textView = (UITextView *)recognizer.view; - NSString *url = linkURLAtTapLocation(textView, recognizer); - if (url && self.onLinkPress) - self.onLinkPress(url); + NSString *linkURL = nil; + NSString *mentionURL = nil; + NSString *mentionText = nil; + NSString *citationURL = nil; + NSString *citationText = nil; + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionURL, &mentionText, &citationURL, + &citationText)) { + if (mentionURL && self.onMentionPress) { + self.onMentionPress(mentionURL, mentionText ?: @""); + } else if (citationURL && self.onCitationPress) { + self.onCitationPress(citationURL, citationText ?: @""); + } else if (linkURL && self.onLinkPress) { + self.onLinkPress(linkURL); + } + } } - (BOOL)textView:(UITextView *)textView diff --git a/src/EnrichedMarkdownNativeComponent.ts b/src/EnrichedMarkdownNativeComponent.ts index e81e2d96..915bd6fc 100644 --- a/src/EnrichedMarkdownNativeComponent.ts +++ b/src/EnrichedMarkdownNativeComponent.ts @@ -152,6 +152,34 @@ interface SpoilerStyleInternal { solid: SpoilerSolidStyleInternal; } +interface MentionStyleInternal { + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; + borderWidth: CodegenTypes.Float; + borderRadius: CodegenTypes.Float; + paddingHorizontal: CodegenTypes.Float; + paddingVertical: CodegenTypes.Float; + fontFamily: string; + fontWeight: string; + fontSize: CodegenTypes.Float; + pressedOpacity: CodegenTypes.Float; +} + +interface CitationStyleInternal { + color: ColorValue; + fontSizeMultiplier: CodegenTypes.Float; + baselineOffsetPx: CodegenTypes.Float; + fontWeight: string; + underline: boolean; + backgroundColor: ColorValue; + paddingHorizontal: CodegenTypes.Float; + paddingVertical: CodegenTypes.Float; + borderColor: ColorValue; + borderWidth: CodegenTypes.Float; + borderRadius: CodegenTypes.Float; +} + export interface MarkdownStyleInternal { paragraph: ParagraphStyleInternal; h1: HeadingStyleInternal; @@ -177,6 +205,8 @@ export interface MarkdownStyleInternal { math: MathStyleInternal; inlineMath: InlineMathStyleInternal; spoiler: SpoilerStyleInternal; + mention: MentionStyleInternal; + citation: CitationStyleInternal; } export interface LinkPressEvent { @@ -187,6 +217,16 @@ export interface LinkLongPressEvent { url: string; } +export interface MentionPressEvent { + url: string; + text: string; +} + +export interface CitationPressEvent { + url: string; + text: string; +} + export interface TaskListItemPressEvent { index: CodegenTypes.Int32; checked: boolean; @@ -253,6 +293,14 @@ export interface NativeProps extends ViewProps { * Receives the 0-based task index, current checked state, and the item's plain text. */ onTaskListItemPress?: CodegenTypes.BubblingEventHandler; + /** + * Callback fired when an inline mention pill (`mention://`) is pressed. + */ + onMentionPress?: CodegenTypes.BubblingEventHandler; + /** + * Callback fired when an inline citation (`citation://`) is pressed. + */ + onCitationPress?: CodegenTypes.BubblingEventHandler; /** * Controls whether the system link preview is shown on long press (iOS only). * diff --git a/src/EnrichedMarkdownTextNativeComponent.ts b/src/EnrichedMarkdownTextNativeComponent.ts index a76a48d6..9ec7abef 100644 --- a/src/EnrichedMarkdownTextNativeComponent.ts +++ b/src/EnrichedMarkdownTextNativeComponent.ts @@ -152,6 +152,34 @@ interface SpoilerStyleInternal { solid: SpoilerSolidStyleInternal; } +interface MentionStyleInternal { + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; + borderWidth: CodegenTypes.Float; + borderRadius: CodegenTypes.Float; + paddingHorizontal: CodegenTypes.Float; + paddingVertical: CodegenTypes.Float; + fontFamily: string; + fontWeight: string; + fontSize: CodegenTypes.Float; + pressedOpacity: CodegenTypes.Float; +} + +interface CitationStyleInternal { + color: ColorValue; + fontSizeMultiplier: CodegenTypes.Float; + baselineOffsetPx: CodegenTypes.Float; + fontWeight: string; + underline: boolean; + backgroundColor: ColorValue; + paddingHorizontal: CodegenTypes.Float; + paddingVertical: CodegenTypes.Float; + borderColor: ColorValue; + borderWidth: CodegenTypes.Float; + borderRadius: CodegenTypes.Float; +} + export interface MarkdownStyleInternal { paragraph: ParagraphStyleInternal; h1: HeadingStyleInternal; @@ -177,6 +205,8 @@ export interface MarkdownStyleInternal { math: MathStyleInternal; inlineMath: InlineMathStyleInternal; spoiler: SpoilerStyleInternal; + mention: MentionStyleInternal; + citation: CitationStyleInternal; } export interface LinkPressEvent { @@ -187,6 +217,16 @@ export interface LinkLongPressEvent { url: string; } +export interface MentionPressEvent { + url: string; + text: string; +} + +export interface CitationPressEvent { + url: string; + text: string; +} + export interface TaskListItemPressEvent { index: CodegenTypes.Int32; checked: boolean; @@ -253,6 +293,14 @@ export interface NativeProps extends ViewProps { * Receives the 0-based task index, current checked state, and the item's plain text. */ onTaskListItemPress?: CodegenTypes.BubblingEventHandler; + /** + * Callback fired when an inline mention pill (`mention://`) is pressed. + */ + onMentionPress?: CodegenTypes.BubblingEventHandler; + /** + * Callback fired when an inline citation (`citation://`) is pressed. + */ + onCitationPress?: CodegenTypes.BubblingEventHandler; /** * Controls whether the system link preview is shown on long press (iOS only). * diff --git a/src/index.tsx b/src/index.tsx index 4ff26c42..284ce789 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,8 @@ export type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, } from './types/events'; export { EnrichedMarkdownInput } from './EnrichedMarkdownInput'; diff --git a/src/index.web.tsx b/src/index.web.tsx index 4b4305cf..23e14a79 100644 --- a/src/index.web.tsx +++ b/src/index.web.tsx @@ -5,4 +5,6 @@ export type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, } from './types/events'; diff --git a/src/native/EnrichedMarkdownText.tsx b/src/native/EnrichedMarkdownText.tsx index 236c2b7c..efa1bb57 100644 --- a/src/native/EnrichedMarkdownText.tsx +++ b/src/native/EnrichedMarkdownText.tsx @@ -13,12 +13,20 @@ import type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, OnContextMenuItemPressEvent, } from '../types/events'; export type { MarkdownStyle, Md4cFlags }; export type { EnrichedMarkdownTextProps, ContextMenuItem }; -export type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent }; +export type { + LinkPressEvent, + LinkLongPressEvent, + TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, +}; const defaultMd4cFlags: Md4cFlags = { underline: false, @@ -32,6 +40,8 @@ export const EnrichedMarkdownText = ({ onLinkPress, onLinkLongPress, onTaskListItemPress, + onMentionPress, + onCitationPress, enableLinkPreview, selectable = true, md4cFlags = defaultMd4cFlags, @@ -120,12 +130,30 @@ export const EnrichedMarkdownText = ({ [onTaskListItemPress] ); + const handleMentionPress = useCallback( + (e: NativeSyntheticEvent) => { + const { url, text } = e.nativeEvent; + onMentionPress?.({ url, text }); + }, + [onMentionPress] + ); + + const handleCitationPress = useCallback( + (e: NativeSyntheticEvent) => { + const { url, text } = e.nativeEvent; + onCitationPress?.({ url, text }); + }, + [onCitationPress] + ); + const sharedProps = { markdown, markdownStyle: normalizedStyle, onLinkPress: handleLinkPress, onLinkLongPress: handleLinkLongPress, onTaskListItemPress: handleTaskListItemPress, + onMentionPress: onMentionPress ? handleMentionPress : undefined, + onCitationPress: onCitationPress ? handleCitationPress : undefined, enableLinkPreview: onLinkLongPress == null && (enableLinkPreview ?? true), selectable, md4cFlags: normalizedMd4cFlags, diff --git a/src/normalizeMarkdownStyle.ts b/src/normalizeMarkdownStyle.ts index b9b1c2fa..2946a70a 100644 --- a/src/normalizeMarkdownStyle.ts +++ b/src/normalizeMarkdownStyle.ts @@ -204,6 +204,32 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({ particles: { density: 8, speed: 20 }, solid: { borderRadius: 4 }, }, + mention: { + color: normalizeColor('#1D4ED8')!, + backgroundColor: normalizeColor('#DBEAFE')!, + borderColor: normalizeColor('#BFDBFE')!, + borderWidth: 0, + borderRadius: 999, + paddingHorizontal: 6, + paddingVertical: 1, + fontFamily: '', + fontWeight: '500', + fontSize: 0, + pressedOpacity: 0.6, + }, + citation: { + color: normalizeColor('#2563EB')!, + fontSizeMultiplier: 0.7, + baselineOffsetPx: 0, + fontWeight: '', + underline: false, + backgroundColor: 'transparent', + paddingHorizontal: 0, + paddingVertical: 0, + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 999, + }, }) as MarkdownStyleInternal; const refCache = new WeakMap(); diff --git a/src/normalizeMarkdownStyle.web.ts b/src/normalizeMarkdownStyle.web.ts index 5167d3f4..34c7bcdd 100644 --- a/src/normalizeMarkdownStyle.web.ts +++ b/src/normalizeMarkdownStyle.web.ts @@ -185,6 +185,32 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({ particles: { density: 8, speed: 20 }, solid: { borderRadius: 4 }, }, + mention: { + color: '#1D4ED8', + backgroundColor: '#DBEAFE', + borderColor: '#BFDBFE', + borderWidth: 0, + borderRadius: 999, + paddingHorizontal: 6, + paddingVertical: 1, + fontFamily: '', + fontWeight: '500', + fontSize: 0, + pressedOpacity: 0.6, + }, + citation: { + color: '#2563EB', + fontSizeMultiplier: 0.7, + baselineOffsetPx: 0, + fontWeight: '', + underline: false, + backgroundColor: 'transparent', + paddingHorizontal: 0, + paddingVertical: 0, + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 999, + }, }); const refCache = new WeakMap(); diff --git a/src/types/MarkdownStyle.ts b/src/types/MarkdownStyle.ts index 75fb5185..9c282ae9 100644 --- a/src/types/MarkdownStyle.ts +++ b/src/types/MarkdownStyle.ts @@ -180,6 +180,69 @@ interface SpoilerStyle { solid?: SpoilerSolidStyle; } +interface MentionStyle { + color?: string; + backgroundColor?: string; + borderColor?: string; + borderWidth?: number; + borderRadius?: number; + paddingHorizontal?: number; + paddingVertical?: number; + fontFamily?: string; + fontWeight?: string; + fontSize?: number; + /** + * Alpha multiplier applied while the pill is pressed (0-1). + * Provides built-in press feedback matching native expectations. + * @default 0.6 + */ + pressedOpacity?: number; +} + +interface CitationStyle { + color?: string; + /** + * Multiplier applied to the surrounding font size. + * @default 0.7 + */ + fontSizeMultiplier?: number; + /** + * Explicit baseline offset in px. When undefined, derived from font metrics + * for iOS/Android parity. + */ + baselineOffsetPx?: number; + fontWeight?: string; + underline?: boolean; + backgroundColor?: string; + /** + * Horizontal padding (in px) applied to the citation marker. Increases both + * the tap target and the width of the background when one is set. + * @default 0 + */ + paddingHorizontal?: number; + /** + * Vertical padding (in px) applied to the citation marker. Increases both + * the tap target and the height of the background when one is set. + * @default 0 + */ + paddingVertical?: number; + /** + * Border color rendered around the citation marker. Only visible when + * `borderWidth` is > 0. + */ + borderColor?: string; + /** + * Border width (in px) rendered around the citation marker. + * @default 0 + */ + borderWidth?: number; + /** + * Corner radius (in px) of the citation marker's background/border. + * Defaults to a fully-rounded pill. + */ + borderRadius?: number; +} + export interface MarkdownStyle { paragraph?: ParagraphStyle; h1?: HeadingStyle; @@ -205,6 +268,8 @@ export interface MarkdownStyle { math?: MathStyle; inlineMath?: InlineMathStyle; spoiler?: SpoilerStyle; + mention?: MentionStyle; + citation?: CitationStyle; } /** diff --git a/src/types/MarkdownStyleInternal.ts b/src/types/MarkdownStyleInternal.ts index 8cb52ae0..101e53c3 100644 --- a/src/types/MarkdownStyleInternal.ts +++ b/src/types/MarkdownStyleInternal.ts @@ -152,6 +152,34 @@ interface SpoilerStyleInternal { solid: SpoilerSolidStyleInternal; } +interface MentionStyleInternal { + color: string; + backgroundColor: string; + borderColor: string; + borderWidth: number; + borderRadius: number; + paddingHorizontal: number; + paddingVertical: number; + fontFamily: string; + fontWeight: string; + fontSize: number; + pressedOpacity: number; +} + +interface CitationStyleInternal { + color: string; + fontSizeMultiplier: number; + baselineOffsetPx: number; + fontWeight: string; + underline: boolean; + backgroundColor: string; + paddingHorizontal: number; + paddingVertical: number; + borderColor: string; + borderWidth: number; + borderRadius: number; +} + export interface MarkdownStyleInternal { paragraph: ParagraphStyleInternal; h1: HeadingStyleInternal; @@ -177,4 +205,6 @@ export interface MarkdownStyleInternal { math: MathStyleInternal; inlineMath: InlineMathStyleInternal; spoiler: SpoilerStyleInternal; + mention: MentionStyleInternal; + citation: CitationStyleInternal; } diff --git a/src/types/MarkdownTextProps.ts b/src/types/MarkdownTextProps.ts index d450699e..75a36993 100644 --- a/src/types/MarkdownTextProps.ts +++ b/src/types/MarkdownTextProps.ts @@ -4,6 +4,8 @@ import type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, } from './events'; /** @@ -69,6 +71,20 @@ export interface EnrichedMarkdownTextProps extends Omit { * @platform ios, android, web */ onTaskListItemPress?: (event: TaskListItemPressEvent) => void; + /** + * Callback fired when an inline mention pill is pressed. + * Mentions are authored as `[label](mention://)` in markdown; the + * renderer draws them as a pill and surfaces the post-scheme URL separately. + * @platform ios, android, web + */ + onMentionPress?: (event: MentionPressEvent) => void; + /** + * Callback fired when an inline citation is pressed. + * Citations are authored as `[label](citation://)` in markdown; the + * renderer draws them as a superscript marker and surfaces the target url. + * @platform ios, android, web + */ + onCitationPress?: (event: CitationPressEvent) => void; /** * Controls whether the system link preview is shown on long press (iOS only). * diff --git a/src/types/MarkdownTextProps.web.ts b/src/types/MarkdownTextProps.web.ts index fa9d4014..bf117492 100644 --- a/src/types/MarkdownTextProps.web.ts +++ b/src/types/MarkdownTextProps.web.ts @@ -4,6 +4,8 @@ import type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, } from './events'; export interface EnrichedMarkdownTextProps @@ -56,6 +58,18 @@ export interface EnrichedMarkdownTextProps * @platform ios, android, web */ onTaskListItemPress?: (event: TaskListItemPressEvent) => void; + /** + * Callback fired when an inline mention pill is pressed. + * Mentions are authored as `[label](mention://)` in markdown. + * @platform ios, android, web + */ + onMentionPress?: (event: MentionPressEvent) => void; + /** + * Callback fired when an inline citation is pressed. + * Citations are authored as `[label](citation://)` in markdown. + * @platform ios, android, web + */ + onCitationPress?: (event: CitationPressEvent) => void; /** * Controls text selection. * - iOS: Controls text selection and link previews on long press. diff --git a/src/types/events.ts b/src/types/events.ts index 11f876c7..6f7491ea 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -12,6 +12,16 @@ export interface TaskListItemPressEvent { text: string; } +export interface MentionPressEvent { + url: string; + text: string; +} + +export interface CitationPressEvent { + url: string; + text: string; +} + /** * Native-level context menu item config sent to the native component. * Does not include the `onPress` callback — callbacks are managed on the JS side. diff --git a/src/web/EnrichedMarkdownText.tsx b/src/web/EnrichedMarkdownText.tsx index 70eb3a4c..082db819 100644 --- a/src/web/EnrichedMarkdownText.tsx +++ b/src/web/EnrichedMarkdownText.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useMemo, type CSSProperties } from 'react'; +import { + useState, + useEffect, + useMemo, + useCallback, + type CSSProperties, + type ClipboardEvent, +} from 'react'; import type { EnrichedMarkdownTextProps } from '../types/MarkdownTextProps.web'; import { normalizeMarkdownStyle } from '../normalizeMarkdownStyle.web'; import { @@ -8,6 +15,7 @@ import { } from './styles'; import { parseMarkdown } from './parseMarkdown'; import { RenderNode } from './renderers'; +import { CITATION_CLASS } from './renderers/InlineRenderers'; import type { ASTNode, RendererCallbacks, RenderCapabilities } from './types'; import { indexTaskItems, markInlineImages } from './utils'; import { loadKaTeX } from './katex'; @@ -20,6 +28,8 @@ export const EnrichedMarkdownText = ({ onLinkPress, onLinkLongPress, onTaskListItemPress, + onMentionPress, + onCitationPress, allowTrailingMargin = false, containerStyle, selectable = true, @@ -74,8 +84,20 @@ export const EnrichedMarkdownText = ({ }, [markdown, underline, latexMath]); const callbacks = useMemo( - () => ({ onLinkPress, onLinkLongPress, onTaskListItemPress }), - [onLinkPress, onLinkLongPress, onTaskListItemPress] + () => ({ + onLinkPress, + onLinkLongPress, + onTaskListItemPress, + onMentionPress, + onCitationPress, + }), + [ + onLinkPress, + onLinkLongPress, + onTaskListItemPress, + onMentionPress, + onCitationPress, + ] ); const capabilities = useMemo(() => ({ katex }), [katex]); @@ -105,6 +127,120 @@ export const EnrichedMarkdownText = ({ [containerStyle, selectable] ); + // The browser's default copy picks up the text content of the selected + // DOM, which would include citation markers. Citations are reference + // metadata, not prose, so we rewrite the plain-text flavor to elide them + // while keeping the HTML flavor intact for rich-text destinations. + // + // Mentions render a tiny sibling + + {displayText} + + + ); +} + +function CitationRenderer({ + url, + styles, + callbacks, + node, + renderChildren, +}: SchemeRendererProps) { + const targetUrl = url.slice(CITATION_SCHEME.length); + const displayText = extractNodeText(node); + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + callbacks.onCitationPress?.({ url: targetUrl, text: displayText }); + }; + + return ( + + {renderChildren(node)} + + ); +} + function LatexMathInlineRenderer({ node, styles, diff --git a/src/web/styles.ts b/src/web/styles.ts index 4d0ff8e0..dd333e2b 100644 --- a/src/web/styles.ts +++ b/src/web/styles.ts @@ -244,6 +244,57 @@ function linkStyle(style: MarkdownStyleInternal): CSSProperties { }; } +function mentionStyle(style: MarkdownStyleInternal): CSSProperties { + const mention = style.mention; + return { + display: 'inline-flex', + alignItems: 'center', + boxSizing: 'border-box', + color: mention.color, + backgroundColor: mention.backgroundColor, + borderColor: mention.borderColor, + borderStyle: mention.borderWidth > 0 ? 'solid' : undefined, + borderWidth: mention.borderWidth, + borderRadius: mention.borderRadius, + paddingInline: mention.paddingHorizontal, + paddingBlock: mention.paddingVertical, + fontFamily: normalizeFontFamily(mention.fontFamily), + fontWeight: normalizeFontWeight(mention.fontWeight), + fontSize: mention.fontSize || undefined, + cursor: 'pointer', + transition: 'opacity 0.12s ease-in-out', + lineHeight: 1, + }; +} + +function citationStyle(style: MarkdownStyleInternal): CSSProperties { + const citation = style.citation; + const hasBackground = + !!citation.backgroundColor && citation.backgroundColor !== 'transparent'; + const hasBorder = + !!citation.borderColor && + citation.borderColor !== 'transparent' && + citation.borderWidth > 0; + return { + color: citation.color, + fontSize: `calc(1em * ${citation.fontSizeMultiplier})`, + verticalAlign: 'baseline', + position: 'relative', + top: citation.baselineOffsetPx ? -citation.baselineOffsetPx : undefined, + fontWeight: normalizeFontWeight(citation.fontWeight), + backgroundColor: hasBackground ? citation.backgroundColor : undefined, + textDecoration: citation.underline ? 'underline' : undefined, + paddingInline: citation.paddingHorizontal || undefined, + paddingBlock: citation.paddingVertical || undefined, + borderStyle: hasBorder ? 'solid' : undefined, + borderColor: hasBorder ? citation.borderColor : undefined, + borderWidth: hasBorder ? citation.borderWidth : undefined, + borderRadius: + hasBackground || hasBorder ? citation.borderRadius : undefined, + cursor: 'pointer', + }; +} + function strikethroughStyle(style: MarkdownStyleInternal): CSSProperties { return { textDecorationLine: 'line-through', @@ -410,6 +461,9 @@ export interface Styles { tableHeaderCell: Record; tableCell: Record; taskCheckbox: CSSProperties; + mention: CSSProperties; + citation: CSSProperties; + mentionPressedOpacity: number; } type ColumnAlign = 'left' | 'center' | 'right' | 'default'; @@ -462,6 +516,9 @@ export function buildStyles(style: MarkdownStyleInternal): Styles { default: tableCellStyle(style, 'default'), }, taskCheckbox: taskCheckboxStyle(style), + mention: mentionStyle(style), + citation: citationStyle(style), + mentionPressedOpacity: style.mention.pressedOpacity, }; stylesStore.set(style, result); diff --git a/src/web/types.ts b/src/web/types.ts index 3a297c54..50d7469e 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -5,6 +5,8 @@ import type { LinkPressEvent, LinkLongPressEvent, TaskListItemPressEvent, + MentionPressEvent, + CitationPressEvent, } from '../types/events'; import type { KaTeXInstance } from './katex'; @@ -68,6 +70,8 @@ export interface RendererCallbacks { onLinkPress?: (event: LinkPressEvent) => void; onLinkLongPress?: (event: LinkLongPressEvent) => void; onTaskListItemPress?: (event: TaskListItemPressEvent) => void; + onMentionPress?: (event: MentionPressEvent) => void; + onCitationPress?: (event: CitationPressEvent) => void; } export interface RenderCapabilities {