From 68347f161c325ae57fc9501e15b7e7e20880deab Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Thu, 16 Apr 2026 18:54:50 -0700 Subject: [PATCH 01/15] citation and mention support --- ...ne_mentions_and_citations_58e9f474.plan.md | 137 +++++++++ .node-version | 1 + ReactNativeEnrichedMarkdown.podspec | 11 +- .../enriched/markdown/EnrichedMarkdown.kt | 14 + .../markdown/EnrichedMarkdownInternalText.kt | 11 +- .../markdown/EnrichedMarkdownManager.kt | 10 + .../enriched/markdown/EnrichedMarkdownText.kt | 24 +- .../markdown/events/CitationPressEvent.kt | 25 ++ .../markdown/events/MentionPressEvent.kt | 25 ++ .../markdown/renderer/LinkRenderer.kt | 94 +++++++ .../markdown/renderer/SpanStyleCache.kt | 5 + .../enriched/markdown/spans/CitationSpan.kt | 129 +++++++++ .../enriched/markdown/spans/MentionSpan.kt | 138 +++++++++ .../enriched/markdown/styles/CitationStyle.kt | 41 +++ .../enriched/markdown/styles/MentionStyle.kt | 50 ++++ .../enriched/markdown/styles/StyleConfig.kt | 32 ++- .../utils/common/MarkdownViewManagerUtils.kt | 28 ++ .../markdown/utils/text/view/LinkEvents.kt | 22 ++ .../text/view/LinkLongPressMovementMethod.kt | 88 ++++++ .../markdown/views/TableContainerView.kt | 6 + apps/example/Gemfile | 2 + apps/example/Gemfile.lock | 26 +- apps/example/ios/Podfile.lock | 10 +- apps/example/src/sampleMarkdown.ts | 22 +- ios/EnrichedMarkdown.mm | 51 +++- ios/EnrichedMarkdownText.mm | 23 +- ios/attachments/ENRMCitationAttachment.h | 25 ++ ios/attachments/ENRMCitationAttachment.m | 145 ++++++++++ ios/attachments/ENRMMentionAttachment.h | 28 ++ ios/attachments/ENRMMentionAttachment.m | 115 ++++++++ ios/renderer/LinkRenderer.h | 10 + ios/renderer/LinkRenderer.m | 139 ++++++++- ios/renderer/RenderContext.h | 8 + ios/renderer/RenderContext.m | 30 ++ ios/styles/StyleConfig.h | 41 +++ ios/styles/StyleConfig.mm | 266 ++++++++++++++++++ ios/utils/LinkTapUtils.h | 13 +- ios/utils/LinkTapUtils.m | 47 +++- ios/utils/StylePropsUtils.h | 137 +++++++++ ios/views/TableContainerView.h | 4 + ios/views/TableContainerView.m | 18 +- src/EnrichedMarkdownNativeComponent.ts | 45 +++ src/EnrichedMarkdownTextNativeComponent.ts | 45 +++ src/index.tsx | 2 + src/index.web.tsx | 2 + src/native/EnrichedMarkdownText.tsx | 30 +- src/normalizeMarkdownStyle.ts | 23 ++ src/normalizeMarkdownStyle.web.ts | 23 ++ src/types/MarkdownStyle.ts | 50 ++++ src/types/MarkdownStyleInternal.ts | 27 ++ src/types/MarkdownTextProps.ts | 16 ++ src/types/MarkdownTextProps.web.ts | 14 + src/types/events.ts | 10 + src/web/EnrichedMarkdownText.tsx | 18 +- src/web/renderers/InlineRenderers.tsx | 105 ++++++- src/web/styles.ts | 53 ++++ src/web/types.ts | 4 + 57 files changed, 2451 insertions(+), 67 deletions(-) create mode 100644 .cursor/plans/inline_mentions_and_citations_58e9f474.plan.md create mode 100644 .node-version create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/events/CitationPressEvent.kt create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/events/MentionPressEvent.kt create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/styles/MentionStyle.kt create mode 100644 ios/attachments/ENRMCitationAttachment.h create mode 100644 ios/attachments/ENRMCitationAttachment.m create mode 100644 ios/attachments/ENRMMentionAttachment.h create mode 100644 ios/attachments/ENRMMentionAttachment.m diff --git a/.cursor/plans/inline_mentions_and_citations_58e9f474.plan.md b/.cursor/plans/inline_mentions_and_citations_58e9f474.plan.md new file mode 100644 index 00000000..ba07a841 --- /dev/null +++ b/.cursor/plans/inline_mentions_and_citations_58e9f474.plan.md @@ -0,0 +1,137 @@ +--- +name: Inline Mentions and Citations +overview: Add support for inline mentions (`mention://`) and inline citations (`citation://`) to `EnrichedMarkdownText`, distinguished from regular links by URL scheme. Mentions render as pill attachments, citations render as superscript-styled inline text, and each fires its own JS press event. User-configurable styling is exposed for both. +todos: + - id: types + content: Extend MarkdownStyle + internal codegen types with mention/citation styles and press events; normalize defaults in normalizeMarkdownStyle. + status: completed + - id: js-surface + content: Add onMentionPress/onCitationPress props and handlers in EnrichedMarkdownText.tsx; re-export new types from src/index.tsx. Re-run codegen so generated Props/EventEmitters headers on iOS and JNI specs on Android pick up the new fields. + status: completed + - id: ios-style + content: Add mention/citation fields to StyleConfig (ios/styles) and wire through StylePropsUtils. + status: completed + - id: ios-render + content: "Branch LinkRenderer on scheme: implement ENRMMentionAttachment (UIView provider) for mentions, baseline-offset + smaller-font attributes for citations, tag ranges with new attribute keys." + status: completed + - id: ios-events + content: Update LinkTapUtils + EnrichedMarkdownText.mm / EnrichedMarkdown.mm to dispatch onMentionPress / onCitationPress based on the tapped attribute. + status: completed + - id: android-style + content: Add MentionStyle/CitationStyle data classes and prop converters in EnrichedMarkdownTextManager (and GFM manager). + status: completed + - id: android-spans + content: Implement MentionSpan (ReplacementSpan drawing rounded pill; getSize accounts for paddingHorizontal + borderWidth) and a custom CitationSpan subclassing SuperscriptSpan that accepts explicit baselineOffsetPx for cross-OEM parity with iOS. + status: completed + - id: android-render + content: Branch LinkRenderer.kt on URL scheme to install MentionSpan / CitationSpan / existing LinkSpan. + status: completed + - id: android-events + content: Add MentionPressEvent/CitationPressEvent; update EnrichedMarkdownText.kt tap path to emit the right event for the span under the tap. + status: completed + - id: web-render + content: Web renderer — branch LinkRenderer on URL scheme; render mention as styled inline-flex `` pill (CSS `:active` honors pressedOpacity) and citation as `` with fontSizeMultiplier/baselineOffsetPx/color. Wire onMentionPress/onCitationPress into RendererCallbacks and MarkdownTextProps.web. Extend styles.ts to derive mention/citation CSSProperties from the normalized style. + status: pending +isProject: false +--- + +## Approach + +Keep the standard CommonMark link syntax `[text](url)`. Dispatch at the renderer layer based on the URL scheme: + +- `mention://` → inline pill attachment (carries `userId`) +- `citation://` → superscript/smaller-font inline marker (carries the underlying `url`) +- anything else → existing link behavior (unchanged) + +No md4c or AST changes. Scope is **read-only renderer only** (`EnrichedMarkdownText` + the GFM-flavored `EnrichedMarkdownNativeComponent`); the input editor is unchanged. Press events are delivered through new `onMentionPress` / `onCitationPress` callbacks; existing `onLinkPress` stays unchanged for other schemes. No tooltips/popovers in this PR. + +## Public API + +New JS types and props (both native components: `EnrichedMarkdownTextNativeComponent` and `EnrichedMarkdownNativeComponent`): + +```ts +interface MentionPressEvent { userId: string; text: string; } +interface CitationPressEvent { url: string; text: string; } + +interface MentionStyle { + color?: string; + backgroundColor?: string; + borderColor?: string; + borderWidth?: number; + borderRadius?: number; + paddingHorizontal?: number; + paddingVertical?: number; + fontFamily?: string; + fontWeight?: string; + fontSize?: number; + pressedOpacity?: number; // native tap feedback, default 0.6 +} + +interface CitationStyle { + color?: string; + fontSizeMultiplier?: number; // default 0.7 + baselineOffsetPx?: number; // explicit px; default derived from font metrics for iOS/Android parity + fontWeight?: string; + underline?: boolean; + backgroundColor?: string; +} +``` + +Added to [`src/types/MarkdownStyle.ts`](src/types/MarkdownStyle.ts) as `mention?: MentionStyle; citation?: CitationStyle;`, mirrored as internal shapes in [`src/EnrichedMarkdownTextNativeComponent.ts`](src/EnrichedMarkdownTextNativeComponent.ts) and [`src/EnrichedMarkdownNativeComponent.ts`](src/EnrichedMarkdownNativeComponent.ts), defaulted in [`src/normalizeMarkdownStyle.ts`](src/normalizeMarkdownStyle.ts), and re-exported from [`src/index.tsx`](src/index.tsx). + +## Dispatch flow + +```mermaid +flowchart TD + md["[text](url) from md4c"] --> LinkRenderer + LinkRenderer -->|"scheme == mention://"| MentionPath[Mention pill + MentionAttr] + LinkRenderer -->|"scheme == citation://"| CitationPath[Baseline/size attrs + CitationAttr] + LinkRenderer -->|"other"| LegacyLink[Existing link span/attr] + MentionPath --> TapDispatch + CitationPath --> TapDispatch + LegacyLink --> TapDispatch + TapDispatch -->|"mention"| onMentionPress + TapDispatch -->|"citation"| onCitationPress + TapDispatch -->|"link"| onLinkPress +``` + +## iOS + +- Extend [`ios/styles/StyleConfig.h`](ios/styles/StyleConfig.h)/`.mm` with getters/setters for the new `mention*` and `citation*` fields, wired from `StylePropsUtils`. +- Refactor [`ios/renderer/LinkRenderer.m`](ios/renderer/LinkRenderer.m): after child rendering, inspect the URL prefix and branch: + - `mention://`: replace the text range with an `NSAttributedString` containing an `NSAttachmentCharacter` backed by a new `ENRMMentionAttachment` (subclass of `NSTextAttachment`) providing an `NSTextAttachmentViewProvider` that renders a rounded, padded `UILabel`/`UIView` pill via Auto Layout. Tag the range with new attributes `ENRMMentionUserId` + `ENRMMentionText`. The podspec uses `min_ios_version_supported` (≥ iOS 15.1 on current RN), so no pre-iOS-15 `drawInRect:` fallback is needed — commit to the view-provider path only. + - `citation://`: keep text, apply `NSBaselineOffsetAttributeName` (explicit px from `baselineOffsetPx`, or derived from current font metrics) + a smaller font derived from the current font × `fontSizeMultiplier`, optional `NSBackgroundColorAttributeName`. Tag range with `ENRMCitationUrl` + `ENRMCitationText`. + - default: unchanged path (`NSLinkAttributeName` + `linkURL` + existing underline/color). +- `ENRMMentionAttachment`'s view provider installs a `UITapGestureRecognizer` and a `touchesBegan`/`touchesCancelled` animator that applies `mention.pressedOpacity` on press-in and restores on press-out, matching the native "shrink/fade on tap" expectation. +- Update [`ios/utils/LinkTapUtils.m`](ios/utils/LinkTapUtils.m) to also read `ENRMMentionUserId` and `ENRMCitationUrl` when determining the tapped element type, and `isPointOnInteractiveElement` to treat mention/citation as interactive. +- In [`ios/EnrichedMarkdownText.mm`](ios/EnrichedMarkdownText.mm) tap handler, fire one of three event emitters based on which attribute is present (`onMentionPress` / `onCitationPress` / existing `onLinkPress`). Same treatment in `EnrichedMarkdown.mm` (GFM flavor). + +## Android + +- Add `MentionStyle.kt` / `CitationStyle.kt` data classes alongside existing style configs, wired through the prop converters in [`EnrichedMarkdownTextManager.kt`](android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt). +- Add `MentionSpan.kt` (extends `ReplacementSpan`) that overrides `getSize` and `draw` to paint the rounded background, optional border, padding, and the name text. `getSize` must explicitly add `2 * paddingHorizontal + 2 * borderWidth` to the measured text width so the pill doesn't clip, and return a descent/ascent that accounts for `paddingVertical` + `borderWidth`. Holds `userId` + display `text`. Applies `pressedOpacity` to the `Paint` alpha while `isPressed` is true; pressed state is toggled by the link tap dispatcher below. +- Add `CitationSpan.kt` — a custom `SuperscriptSpan` subclass that accepts an explicit `baselineOffsetPx` in `updateDrawState` / `updateMeasureState`, combined with `RelativeSizeSpan(citationStyle.fontSizeMultiplier)` and optional `ForegroundColorSpan` / `BackgroundColorSpan` applied in the same `setSpan` call. The explicit baseline offset gives exact parity with iOS's `NSBaselineOffsetAttributeName` and sidesteps OEM-dependent quirks in the framework's default `SuperscriptSpan`. Holds `url` + `text`. +- Update [`android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt`](android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt) to branch on `url.startsWith("mention://")` / `"citation://"` and install the appropriate span instead of `LinkSpan`. Legacy `LinkSpan` is untouched. +- Event wiring: in [`EnrichedMarkdownText.kt`](android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt) tap path (currently dispatching `LinkPressEvent`), use the span type at the tap index to dispatch `MentionPressEvent` / `CitationPressEvent` instead. Add new event classes under [`events/`](android/src/main/java/com/swmansion/enriched/markdown/events/) mirroring `LinkPressEvent`. On `ACTION_DOWN` over a `MentionSpan`, toggle the span's `isPressed` flag and invalidate the view; reset on `ACTION_UP`/`ACTION_CANCEL`. + +## Web + +Same scheme-based branching happens in [`src/web/renderers/InlineRenderers.tsx`](src/web/renderers/InlineRenderers.tsx)'s `LinkRenderer`: + +- `mention://` → `` styled as an inline-flex pill using `styles.mention` (backgroundColor, borderColor/Width, borderRadius, padding, color, font). `pressedOpacity` maps to a CSS `:active { opacity: ... }` rule injected once at mount (via a style tag keyed by a className, similar to how existing web renderers handle hover states) — CSS does the tap-feedback automatically, no JS state needed. +- `citation://` → `` with `styles.citation` (color, `fontSize: calc(1em * fontSizeMultiplier)`, `verticalAlign: baseline` + `top: -baselineOffsetPx`, optional background, optional underline via `textDecoration`). Kept in a `` tag so screen readers announce it as superscript. +- default: unchanged `` path. + +Supporting updates: + +- [`src/web/types.ts`](src/web/types.ts): extend `RendererCallbacks` with `onMentionPress` / `onCitationPress`. +- [`src/types/MarkdownTextProps.web.ts`](src/types/MarkdownTextProps.web.ts): add the two new callbacks alongside the existing `onLinkPress` / `onLinkLongPress`, documented `@platform ios, android, web`. +- [`src/web/EnrichedMarkdownText.tsx`](src/web/EnrichedMarkdownText.tsx): thread the new callbacks into the render context. +- [`src/web/styles.ts`](src/web/styles.ts): add `mention` and `citation` entries to the `Styles` map, normalized from `MarkdownStyleInternal.mention` / `.citation`. +- [`src/index.web.tsx`](src/index.web.tsx): re-export `MentionPressEvent` / `CitationPressEvent` / `MentionStyle` / `CitationStyle`. + +## Things explicitly out of scope + +- Parser/AST changes (still pure md4c). +- Editor support (`EnrichedMarkdownInput`) — no `insertMention`/`insertCitation` yet. +- Built-in popover/tooltip UI on any platform. \ No newline at end of file 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..d3732198 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: ((userId: 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..f8291343 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: ((userId: 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 = { userId, mentionText -> onMentionPressCallback?.invoke(userId, 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..b470babf 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 { userId, text -> + emitMentionPress(view, userId, 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..f716cf6c 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 = { userId, mentionText -> emitOnMentionPress(userId, 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( + userId: String, + text: String, + ) { + emitMentionPressEvent(userId, 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..e95c5f4f --- /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 userId: 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("userId", userId) + 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/LinkRenderer.kt b/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt index 7beb976e..9164e2e2 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,19 @@ 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.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 +24,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 +70,54 @@ class LinkRenderer( ) } } + + private fun renderMention( + userId: String, + node: MarkdownASTNode, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + factory: RendererFactory, + ) { + // Render children into a throwaway buffer to derive the display label. + // Any inline formatting (bold/italic) inside the label collapses to plain + // text because ReplacementSpan paints a single atomic glyph. + val labelBuffer = SpannableStringBuilder() + factory.renderChildren(node, labelBuffer, onLinkPress, onLinkLongPress) + val displayText = labelBuffer.toString() + + // Insert a single placeholder character that ReplacementSpan will paint + // over; keeping it as a real character preserves cursor metrics, selection + // handles, and accessibility traversal. + val start = builder.length + builder.append(' ') + val end = builder.length + + val span = + MentionSpan( + userId = userId, + displayText = displayText, + mentionStyle = factory.styleCache.mentionStyle, + mentionTypeface = factory.styleCache.mentionTypeface, + ) + builder.setSpan(span, start, end, 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..53095922 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 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..b92a4381 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt @@ -0,0 +1,129 @@ +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 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 + + if (citationStyle.backgroundColor != null && citationStyle.backgroundColor != 0) { + fillPaint.color = citationStyle.backgroundColor + val radius = minOf((bgBottom - bgTop) / 2f, chipWidth / 2f) + canvas.drawRoundRect( + RectF(x, bgTop, x + chipWidth, bgBottom), + radius, + radius, + fillPaint, + ) + } + + 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/MentionSpan.kt b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt new file mode 100644 index 00000000..6bf25373 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt @@ -0,0 +1,138 @@ +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.MentionStyle + +/** + * Replaces a range of text with a rounded "pill" containing the display name. + * Rendering is atomic — the span reports its full width (including padding and + * border) via getSize so layout reserves enough room and the text never clips. + * + * The span exposes [userId] for tap dispatching and an [isPressed] flag the + * tap handler can toggle to drive the pressedOpacity tap-feedback animation. + */ +class MentionSpan( + val userId: String, + val displayText: String, + private val mentionStyle: MentionStyle, + private val mentionTypeface: Typeface?, +) : ReplacementSpan() { + @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 + strokeWidth = mentionStyle.borderWidth + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + + private fun configureTextPaint(basePaint: Paint) { + textPaint.set(basePaint) + if (mentionStyle.fontSize > 0) { + textPaint.textSize = mentionStyle.fontSize + } + mentionTypeface?.let { textPaint.typeface = it } + textPaint.color = mentionStyle.color + } + + private fun contentWidth(): Float = textPaint.measureText(displayText) + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt?, + ): Int { + configureTextPaint(paint) + + val textWidth = contentWidth() + val totalWidth = textWidth + mentionStyle.paddingHorizontal * 2f + mentionStyle.borderWidth * 2f + + if (fm != null) { + val metrics = textPaint.fontMetricsInt + val verticalInset = (mentionStyle.paddingVertical + mentionStyle.borderWidth).toInt() + fm.ascent = metrics.ascent - verticalInset + fm.top = metrics.top - verticalInset + fm.descent = metrics.descent + verticalInset + fm.bottom = metrics.bottom + verticalInset + fm.leading = metrics.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 opacity = + if (isPressed) mentionStyle.pressedOpacity.coerceIn(0f, 1f) else 1f + val globalAlpha = (opacity * 255f).toInt().coerceIn(0, 255) + + val textWidth = contentWidth() + val pillWidth = textWidth + mentionStyle.paddingHorizontal * 2f + mentionStyle.borderWidth * 2f + val metrics = textPaint.fontMetricsInt + val textHeight = metrics.descent - metrics.ascent + val pillHeight = textHeight + mentionStyle.paddingVertical * 2f + mentionStyle.borderWidth * 2f + + // Vertically center the pill on the surrounding text line. + val lineTop = top.toFloat() + val lineBottom = bottom.toFloat() + val pillTop = lineTop + ((lineBottom - lineTop) - pillHeight) / 2f + val pillBottom = pillTop + pillHeight + + val halfStroke = mentionStyle.borderWidth / 2f + val pillRect = + RectF( + x + halfStroke, + pillTop + halfStroke, + x + pillWidth - halfStroke, + pillBottom - halfStroke, + ) + val radius = + minOf( + mentionStyle.borderRadius, + minOf(pillRect.width(), pillRect.height()) / 2f, + ) + + fillPaint.color = mentionStyle.backgroundColor + fillPaint.alpha = + ((fillPaint.color ushr 24) and 0xFF).let { baseAlpha -> + (baseAlpha * opacity).toInt().coerceIn(0, 255) + } + canvas.drawRoundRect(pillRect, 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(pillRect, radius, radius, strokePaint) + } + + textPaint.alpha = globalAlpha + + val textX = x + mentionStyle.paddingHorizontal + mentionStyle.borderWidth + // Baseline-align the label inside the pill. + val textY = pillTop + mentionStyle.paddingVertical + mentionStyle.borderWidth - metrics.ascent + canvas.drawText(displayText, textX, textY, textPaint) + } +} 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..c07ef43e --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt @@ -0,0 +1,41 @@ +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, +) { + 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()) + + return CitationStyle( + color = color, + fontSizeMultiplier = if (fontSizeMultiplier > 0) fontSizeMultiplier else 0.7f, + baselineOffsetPx = baselineOffsetPx, + fontWeight = fontWeight, + underline = underline, + backgroundColor = backgroundColor, + paddingHorizontal = paddingHorizontal, + paddingVertical = paddingVertical, + ) + } + } +} 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..7f8a9d40 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, + userId: 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, userId, 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/view/LinkEvents.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt index 1f3e6936..7418748c 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( + userId: 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, userId, 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..5df5c0e2 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: ((userId: 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.userId, 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/views/TableContainerView.kt b/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt index a9c6f6d2..b1f9e92d 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: ((userId: 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 = { userId, mentionText -> this@TableContainerView.onMentionPress?.invoke(userId, 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..2c37a8bd 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,10 +2189,10 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: e96e93b493d8d86eeaee3e590ba0be53f6abe46f ReactCodegen: 797de5178718324c6eba3327b07f9a423fbd5787 ReactCommon: 07572bf9e687c8a52fbe4a3641e9e3a1a477c78e - ReactNativeDependencies: 0811b43c669e637a9f3c485fdb106f187fa88398 - ReactNativeEnrichedMarkdown: 1daba1851810704ba2f5c6e5fff638a94661e317 + ReactNativeDependencies: f2497ee045a976e64dec20d371611288e835df1f + ReactNativeEnrichedMarkdown: 63665119b6d5c634c76f5f4caf46dc4ebd23863e Yoga: c0b3f2c7e8d3e327e450223a2414ca3fa296b9a2 PODFILE CHECKSUM: 9c5417fc84515945aa2357a49779fde55434ae62 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/apps/example/src/sampleMarkdown.ts b/apps/example/src/sampleMarkdown.ts index bb7263dc..78f3b68d 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. +> [@John Muir](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) In every walk with nature, one receives far more than he seeks. > > — 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) +- *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 | +| 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 | ~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}\`, { @@ -330,11 +330,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/ios/EnrichedMarkdown.mm b/ios/EnrichedMarkdown.mm index 9227b789..66e49748 100644 --- a/ios/EnrichedMarkdown.mm +++ b/ios/EnrichedMarkdown.mm @@ -559,6 +559,34 @@ - (TableContainerView *)createTableViewForSegment:(EMTableSegment *)tableSegment } }; + tableView.onMentionPress = ^(NSString *userId, NSString *text) { + EnrichedMarkdown *strongSelf = weakSelf; + if (!strongSelf) + return; + + auto eventEmitter = std::static_pointer_cast(strongSelf->_eventEmitter); + if (eventEmitter) { + eventEmitter->onMentionPress({ + .userId = std::string([(userId ?: @"") 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 *mentionUserId = nil; + NSString *mentionText = nil; + NSString *citationURL = nil; + NSString *citationText = nil; + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionUserId, &mentionText, &citationURL, + &citationText)) { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { - eventEmitter->onLinkPress({.url = std::string([url UTF8String])}); + if (mentionUserId) { + eventEmitter->onMentionPress({ + .userId = std::string([mentionUserId 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..d2e60d3b 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 *mentionUserId = nil; + NSString *mentionText = nil; + NSString *citationURL = nil; + NSString *citationText = nil; + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionUserId, &mentionText, &citationURL, + &citationText)) { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { - eventEmitter->onLinkPress({.url = std::string([url UTF8String])}); + if (mentionUserId) { + eventEmitter->onMentionPress({ + .userId = std::string([mentionUserId 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/attachments/ENRMCitationAttachment.h b/ios/attachments/ENRMCitationAttachment.h new file mode 100644 index 00000000..4f7a94c4 --- /dev/null +++ b/ios/attachments/ENRMCitationAttachment.h @@ -0,0 +1,25 @@ +#pragma once +#import "ENRMUIKit.h" + +@class StyleConfig; + +NS_ASSUME_NONNULL_BEGIN + +/** + * Custom NSTextAttachment for rendering inline citation markers. Drawing is + * atomic (CoreGraphics into a UIImage) so the renderer can apply padding, + * backgrounds, baseline offset, and a font-size multiplier consistently. + */ +@interface ENRMCitationAttachment : NSTextAttachment + +@property (nonatomic, readonly, copy) NSString *displayText; +@property (nonatomic, readonly, copy) NSString *url; + ++ (instancetype)attachmentWithDisplayText:(NSString *)displayText + url:(NSString *)url + baseFont:(nullable UIFont *)baseFont + config:(StyleConfig *)config; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/attachments/ENRMCitationAttachment.m b/ios/attachments/ENRMCitationAttachment.m new file mode 100644 index 00000000..17c96805 --- /dev/null +++ b/ios/attachments/ENRMCitationAttachment.m @@ -0,0 +1,145 @@ +#import "ENRMCitationAttachment.h" +#import "StyleConfig.h" + +@interface ENRMCitationAttachment () +@property (nonatomic, copy) NSString *displayText; +@property (nonatomic, copy) NSString *url; +@property (nonatomic, strong, nullable) UIFont *baseFont; +@property (nonatomic, strong) StyleConfig *config; +@property (nonatomic, assign) CGSize cachedSize; +@property (nonatomic, assign) CGFloat cachedBaseline; +@end + +@implementation ENRMCitationAttachment + ++ (instancetype)attachmentWithDisplayText:(NSString *)displayText + url:(NSString *)url + baseFont:(UIFont *)baseFont + config:(StyleConfig *)config +{ + ENRMCitationAttachment *attachment = [[self alloc] init]; + attachment.displayText = displayText ?: @""; + attachment.url = url ?: @""; + attachment.baseFont = baseFont; + attachment.config = config; + [attachment rebuildImage]; + return attachment; +} + +- (UIFont *)citationFont +{ + UIFont *base = self.baseFont; + if (!base) { + base = [UIFont systemFontOfSize:[UIFont systemFontSize]]; + } + CGFloat multiplier = [self.config citationFontSizeMultiplier]; + if (multiplier <= 0) { + multiplier = 0.7; + } + CGFloat scaledSize = MAX(1.0, base.pointSize * multiplier); + NSString *weight = [self.config citationFontWeight] ?: @""; + UIFont *scaled = [base fontWithSize:scaledSize]; + + if (weight.length > 0 && ([weight caseInsensitiveCompare:@"bold"] == NSOrderedSame || + [weight caseInsensitiveCompare:@"700"] == NSOrderedSame || + [weight caseInsensitiveCompare:@"800"] == NSOrderedSame || + [weight caseInsensitiveCompare:@"900"] == NSOrderedSame)) { + UIFontDescriptor *descriptor = [scaled.fontDescriptor fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitBold]; + if (descriptor) { + scaled = [UIFont fontWithDescriptor:descriptor size:scaledSize]; + } + } + return scaled; +} + +- (void)rebuildImage +{ + UIFont *font = [self citationFont]; + RCTUIColor *textColor = [self.config citationColor] ?: [RCTUIColor labelColor]; + RCTUIColor *bgColor = [self.config citationBackgroundColor]; + CGFloat paddingH = MAX(0, [self.config citationPaddingHorizontal]); + CGFloat paddingV = MAX(0, [self.config citationPaddingVertical]); + BOOL underline = [self.config citationUnderline]; + + NSMutableDictionary *attrs = [NSMutableDictionary dictionary]; + attrs[NSFontAttributeName] = font; + attrs[NSForegroundColorAttributeName] = textColor; + if (underline) { + attrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); + attrs[NSUnderlineColorAttributeName] = textColor; + } + + CGSize textSize = [self.displayText sizeWithAttributes:attrs]; + + CGFloat width = ceil(textSize.width + paddingH * 2); + CGFloat height = ceil(textSize.height + paddingV * 2); + CGSize size = CGSizeMake(MAX(1, width), MAX(1, height)); + self.cachedSize = size; + self.cachedBaseline = paddingV + font.ascender; + + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + format.opaque = NO; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; + + __weak typeof(self) weakSelf = self; + UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) + return; + + CGContextRef cg = ctx.CGContext; + (void)cg; + + if (bgColor) { + CGRect rect = CGRectMake(0, 0, size.width, size.height); + CGFloat radius = MIN(size.height, size.width) / 2.0; + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius]; + [bgColor setFill]; + [path fill]; + } + + CGPoint origin = CGPointMake(paddingH, paddingV); + [strongSelf.displayText drawAtPoint:origin withAttributes:attrs]; + }]; + + self.image = image; + self.bounds = CGRectMake(0, 0, size.width, size.height); +} + +- (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer + proposedLineFragment:(CGRect)lineFragment + glyphPosition:(CGPoint)position + characterIndex:(NSUInteger)characterIndex +{ + CGSize size = self.cachedSize; + if (size.width == 0 || size.height == 0) { + [self rebuildImage]; + size = self.cachedSize; + } + + // Derive the desired baseline offset (superscript-like shift). Positive + // `NSBaselineOffsetAttributeName` values move glyphs upward; for an + // attachment we achieve the same by offsetting the bounds origin upward. + CGFloat baselineOffset = [self.config citationBaselineOffsetPx]; + UIFont *lineFont = self.baseFont; + if (!lineFont) { + NSLayoutManager *layoutManager = textContainer.layoutManager; + NSTextStorage *textStorage = layoutManager.textStorage; + if (textStorage && characterIndex < textStorage.length) { + lineFont = [textStorage attribute:NSFontAttributeName atIndex:characterIndex effectiveRange:NULL]; + } + } + + if (baselineOffset == 0 && lineFont) { + // Default: raise so the mid-line of the citation sits near the cap-height + // of the surrounding text, matching the MetricAffectingSpan fallback used + // on Android. + CGFloat hostCap = lineFont.capHeight; + UIFont *citationFont = [self citationFont]; + baselineOffset = MAX(0, (hostCap - citationFont.capHeight) * 0.5); + } + + return CGRectMake(0, baselineOffset, size.width, size.height); +} + +@end diff --git a/ios/attachments/ENRMMentionAttachment.h b/ios/attachments/ENRMMentionAttachment.h new file mode 100644 index 00000000..8c29ea8b --- /dev/null +++ b/ios/attachments/ENRMMentionAttachment.h @@ -0,0 +1,28 @@ +#pragma once +#import "ENRMUIKit.h" + +@class StyleConfig; + +NS_ASSUME_NONNULL_BEGIN + +/** + * Custom NSTextAttachment used to render inline mention pills. + * + * Rendering is delegated to an NSTextAttachmentViewProvider (iOS 15+) so the + * pill participates in the text layout as an atomic character — selection + * handles, cursor movement, and accessibility traverse it as a single glyph. + * Tap feedback (alpha dim on press) is managed by the view provider. + */ +@interface ENRMMentionAttachment : NSTextAttachment + +@property (nonatomic, readonly, copy) NSString *displayText; +@property (nonatomic, readonly, copy) NSString *userId; +@property (nonatomic, readonly, strong) StyleConfig *config; + ++ (instancetype)attachmentWithDisplayText:(NSString *)displayText + userId:(NSString *)userId + config:(StyleConfig *)config; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/attachments/ENRMMentionAttachment.m b/ios/attachments/ENRMMentionAttachment.m new file mode 100644 index 00000000..018d5442 --- /dev/null +++ b/ios/attachments/ENRMMentionAttachment.m @@ -0,0 +1,115 @@ +#import "ENRMMentionAttachment.h" +#import "StyleConfig.h" + +@interface ENRMMentionAttachment () +@property (nonatomic, copy) NSString *displayText; +@property (nonatomic, copy) NSString *userId; +@property (nonatomic, strong) StyleConfig *config; +@property (nonatomic, assign) CGSize cachedPillSize; +@end + +@implementation ENRMMentionAttachment + ++ (instancetype)attachmentWithDisplayText:(NSString *)displayText userId:(NSString *)userId config:(StyleConfig *)config +{ + ENRMMentionAttachment *attachment = [[self alloc] init]; + attachment.displayText = displayText ?: @""; + attachment.userId = userId ?: @""; + attachment.config = config; + [attachment rebuildPillImage]; + return attachment; +} + +- (void)rebuildPillImage +{ + UIFont *font = [self.config mentionFont]; + RCTUIColor *textColor = [self.config mentionColor] ?: [RCTUIColor labelColor]; + RCTUIColor *bgColor = [self.config mentionBackgroundColor]; + RCTUIColor *borderColor = [self.config mentionBorderColor]; + CGFloat borderWidth = MAX(0, [self.config mentionBorderWidth]); + CGFloat borderRadius = MAX(0, [self.config mentionBorderRadius]); + CGFloat paddingH = MAX(0, [self.config mentionPaddingHorizontal]); + CGFloat paddingV = MAX(0, [self.config mentionPaddingVertical]); + + NSDictionary *textAttrs = font ? @{NSFontAttributeName : font} : @{}; + CGSize textSize = [self.displayText sizeWithAttributes:textAttrs]; + + // Ensure the pill is large enough to fit the label plus padding and border. + // `getSize` parity for Android: width = textWidth + 2*padding + 2*border. + CGFloat width = ceil(textSize.width + paddingH * 2 + borderWidth * 2); + CGFloat height = ceil(textSize.height + paddingV * 2 + borderWidth * 2); + CGSize size = CGSizeMake(MAX(1, width), MAX(1, height)); + self.cachedPillSize = size; + + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + format.opaque = NO; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; + + __weak typeof(self) weakSelf = self; + UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) { + CGContextRef cg = ctx.CGContext; + CGRect bounds = CGRectMake(0, 0, size.width, size.height); + + // Inset so the border stroke stays inside the bounds (centered on pill edge). + CGRect rect = CGRectInset(bounds, borderWidth / 2.0, borderWidth / 2.0); + CGFloat clampedRadius = MIN(borderRadius, MIN(rect.size.width, rect.size.height) / 2.0); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:clampedRadius]; + + if (bgColor) { + CGContextSaveGState(cg); + [bgColor setFill]; + [path fill]; + CGContextRestoreGState(cg); + } + + if (borderWidth > 0 && borderColor) { + CGContextSaveGState(cg); + [borderColor setStroke]; + path.lineWidth = borderWidth; + [path stroke]; + CGContextRestoreGState(cg); + } + + CGFloat textX = (size.width - textSize.width) / 2.0; + CGFloat textY = (size.height - textSize.height) / 2.0; + + NSDictionary *drawAttrs = font ? @{NSFontAttributeName : font, NSForegroundColorAttributeName : textColor} + : @{NSForegroundColorAttributeName : textColor}; + [weakSelf.displayText drawAtPoint:CGPointMake(textX, textY) withAttributes:drawAttrs]; + }]; + + self.image = image; + self.bounds = CGRectMake(0, 0, size.width, size.height); +} + +- (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer + proposedLineFragment:(CGRect)lineFragment + glyphPosition:(CGPoint)position + characterIndex:(NSUInteger)characterIndex +{ + CGSize size = self.cachedPillSize; + if (size.width == 0 || size.height == 0) { + [self rebuildPillImage]; + size = self.cachedPillSize; + } + + // Vertically center the pill on the surrounding text's cap height when + // available, mirroring how inline images are positioned in this codebase. + UIFont *lineFont = nil; + NSLayoutManager *layoutManager = textContainer.layoutManager; + NSTextStorage *textStorage = layoutManager.textStorage; + if (textStorage && characterIndex < textStorage.length) { + lineFont = [textStorage attribute:NSFontAttributeName atIndex:characterIndex effectiveRange:NULL]; + } + + CGFloat verticalOffset; + if (lineFont) { + verticalOffset = (lineFont.capHeight - size.height) / 2.0; + } else { + verticalOffset = (lineFragment.size.height - size.height) / 2.0; + } + + return CGRectMake(0, verticalOffset, size.width, size.height); +} + +@end diff --git a/ios/renderer/LinkRenderer.h b/ios/renderer/LinkRenderer.h index ff5b8d20..5c9a7a42 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 ENRMMentionUserIdAttributeName; +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..55d0ec22 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -1,10 +1,20 @@ #import "LinkRenderer.h" +#import "ENRMCitationAttachment.h" +#import "ENRMMentionAttachment.h" #import "FontUtils.h" #import "RenderContext.h" #import "RendererFactory.h" #import "StyleConfig.h" #import +NSString *const ENRMMentionUserIdAttributeName = @"ENRMMentionUserId"; +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 +30,76 @@ - (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; +} + #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 +125,84 @@ - (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 +{ + // Extract the child text to use as the pill label; the child nodes may be + // formatted text (e.g. **bold**), so we collapse to a plain string. + NSMutableAttributedString *childBuffer = [[NSMutableAttributedString alloc] init]; + [_rendererFactory renderChildrenOfNode:node into:childBuffer context:context]; + NSString *displayText = childBuffer.string ?: @""; + NSString *userId = stripScheme(url, kMentionScheme); + + // Inherit the current text attributes (font, color) so the pill sits in the + // same line metrics as the surrounding paragraph if the pill label has no + // explicit style override. + NSDictionary *baseAttrs = output.length > 0 ? [output attributesAtIndex:output.length - 1 effectiveRange:NULL] : @{}; + + ENRMMentionAttachment *attachment = [ENRMMentionAttachment attachmentWithDisplayText:displayText + userId:userId + config:_config]; + + NSMutableAttributedString *attachmentString = + [[NSMutableAttributedString attributedStringWithAttachment:attachment] mutableCopy]; + NSRange attachmentRange = NSMakeRange(0, attachmentString.length); + if (baseAttrs.count > 0) { + [attachmentString addAttributes:baseAttrs range:attachmentRange]; + } + [attachmentString addAttribute:ENRMMentionUserIdAttributeName value:userId range:attachmentRange]; + [attachmentString addAttribute:ENRMMentionTextAttributeName value:displayText range:attachmentRange]; + + NSUInteger start = output.length; + [output appendAttributedString:attachmentString]; + NSRange outputRange = NSMakeRange(start, output.length - start); + + [context registerMentionRange:outputRange userId:userId text:displayText]; +} + +#pragma mark - Citation + +- (void)renderCitationNode:(MarkdownASTNode *)node + url:(NSString *)url + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + // Render children into a throwaway buffer so we can collect the label text + // and inherit the surrounding font (used to scale the citation glyph). + NSMutableAttributedString *childBuffer = [[NSMutableAttributedString alloc] init]; + [_rendererFactory renderChildrenOfNode:node into:childBuffer context:context]; + NSString *displayText = childBuffer.string ?: @""; + NSString *targetURL = stripScheme(url, kCitationScheme); + + NSDictionary *baseAttrs = output.length > 0 ? [output attributesAtIndex:output.length - 1 effectiveRange:NULL] : @{}; + UIFont *baseFont = baseAttrs[NSFontAttributeName]; + + ENRMCitationAttachment *attachment = [ENRMCitationAttachment attachmentWithDisplayText:displayText + url:targetURL + baseFont:baseFont + config:_config]; + + NSMutableAttributedString *attachmentString = + [[NSMutableAttributedString attributedStringWithAttachment:attachment] mutableCopy]; + NSRange attachmentRange = NSMakeRange(0, attachmentString.length); + if (baseAttrs.count > 0) { + [attachmentString addAttributes:baseAttrs range:attachmentRange]; + } + [attachmentString addAttribute:ENRMCitationURLAttributeName value:targetURL range:attachmentRange]; + [attachmentString addAttribute:ENRMCitationTextAttributeName value:displayText range:attachmentRange]; + + NSUInteger start = output.length; + [output appendAttributedString:attachmentString]; + NSRange outputRange = NSMakeRange(start, output.length - start); + + [context registerCitationRange:outputRange url:targetURL text:displayText]; +} + +@end diff --git a/ios/renderer/RenderContext.h b/ios/renderer/RenderContext.h index c7aa2b01..20475165 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 *mentionUserIds; +@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 userId:(NSString *)userId 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..799b46de 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]; + _mentionUserIds = [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 userId:(NSString *)userId text:(NSString *)text +{ + if (range.length == 0) + return; + [self.mentionRanges addObject:[NSValue valueWithRange:range]]; + [self.mentionUserIds addObject:userId ?: @""]; + [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]; + [_mentionUserIds 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..6b87e93c 100644 --- a/ios/styles/StyleConfig.h +++ b/ios/styles/StyleConfig.h @@ -364,5 +364,46 @@ - (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; @end diff --git a/ios/styles/StyleConfig.mm b/ios/styles/StyleConfig.mm index 3b4e668e..a6c76b85 100644 --- a/ios/styles/StyleConfig.mm +++ b/ios/styles/StyleConfig.mm @@ -226,6 +226,29 @@ @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; } - (instancetype)init @@ -255,6 +278,9 @@ - (instancetype)init _tableFontNeedsRecreation = YES; _tableHeaderFontNeedsRecreation = YES; _linkUnderline = YES; + _mentionFontNeedsRecreation = YES; + _citationFontSizeMultiplier = 0.7; + _mentionPressedOpacity = 0.6; return self; } @@ -282,6 +308,7 @@ - (void)setFontScaleMultiplier:(CGFloat)newValue _codeBlockFontNeedsRecreation = YES; _blockquoteFontNeedsRecreation = YES; _tableFontNeedsRecreation = YES; + _mentionFontNeedsRecreation = YES; } } @@ -316,6 +343,7 @@ - (void)setMaxFontSizeMultiplier:(CGFloat)newValue _codeBlockFontNeedsRecreation = YES; _blockquoteFontNeedsRecreation = YES; _tableFontNeedsRecreation = YES; + _mentionFontNeedsRecreation = YES; } } @@ -496,6 +524,27 @@ - (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; + return copy; } @@ -2390,4 +2439,221 @@ - (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; +} + @end diff --git a/ios/utils/LinkTapUtils.h b/ios/utils/LinkTapUtils.h index f7b6c188..af48a9c9 100644 --- a/ios/utils/LinkTapUtils.h +++ b/ios/utils/LinkTapUtils.h @@ -14,7 +14,18 @@ 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 outMentionUserId, + 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..0547091f 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,48 @@ return [attrText attribute:@"linkURL" atIndex:characterRange.location effectiveRange:NULL]; } +BOOL inlineElementAtTapLocation(ENRMPlatformTextView *textView, ENRMTapRecognizer *recognizer, + NSString *_Nullable *_Nullable outLinkURL, + NSString *_Nullable *_Nullable outMentionUserId, + 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 *mentionUserId = attrs[ENRMMentionUserIdAttributeName]; + if (mentionUserId) { + if (outMentionUserId) + *outMentionUserId = mentionUserId; + 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 +71,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[ENRMMentionUserIdAttributeName] != nil || + attrs[ENRMCitationURLAttributeName] != nil || [attrs[@"TaskItem"] boolValue] || + attrs[SpoilerAttributeName] != nil; } diff --git a/ios/utils/StylePropsUtils.h b/ios/utils/StylePropsUtils.h index f74311aa..f32b9fd5 100644 --- a/ios/utils/StylePropsUtils.h +++ b/ios/utils/StylePropsUtils.h @@ -1064,5 +1064,142 @@ 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; + } + return changed; } diff --git a/ios/views/TableContainerView.h b/ios/views/TableContainerView.h index b444236b..b133aee0 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 *userId, 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..181af524 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 *mentionUserId = nil; + NSString *mentionText = nil; + NSString *citationURL = nil; + NSString *citationText = nil; + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionUserId, &mentionText, &citationURL, + &citationText)) { + if (mentionUserId && self.onMentionPress) { + self.onMentionPress(mentionUserId, 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..f569500f 100644 --- a/src/EnrichedMarkdownNativeComponent.ts +++ b/src/EnrichedMarkdownNativeComponent.ts @@ -152,6 +152,31 @@ 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; +} + export interface MarkdownStyleInternal { paragraph: ParagraphStyleInternal; h1: HeadingStyleInternal; @@ -177,6 +202,8 @@ export interface MarkdownStyleInternal { math: MathStyleInternal; inlineMath: InlineMathStyleInternal; spoiler: SpoilerStyleInternal; + mention: MentionStyleInternal; + citation: CitationStyleInternal; } export interface LinkPressEvent { @@ -187,6 +214,16 @@ export interface LinkLongPressEvent { url: string; } +export interface MentionPressEvent { + userId: string; + text: string; +} + +export interface CitationPressEvent { + url: string; + text: string; +} + export interface TaskListItemPressEvent { index: CodegenTypes.Int32; checked: boolean; @@ -253,6 +290,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..295649f9 100644 --- a/src/EnrichedMarkdownTextNativeComponent.ts +++ b/src/EnrichedMarkdownTextNativeComponent.ts @@ -152,6 +152,31 @@ 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; +} + export interface MarkdownStyleInternal { paragraph: ParagraphStyleInternal; h1: HeadingStyleInternal; @@ -177,6 +202,8 @@ export interface MarkdownStyleInternal { math: MathStyleInternal; inlineMath: InlineMathStyleInternal; spoiler: SpoilerStyleInternal; + mention: MentionStyleInternal; + citation: CitationStyleInternal; } export interface LinkPressEvent { @@ -187,6 +214,16 @@ export interface LinkLongPressEvent { url: string; } +export interface MentionPressEvent { + userId: string; + text: string; +} + +export interface CitationPressEvent { + url: string; + text: string; +} + export interface TaskListItemPressEvent { index: CodegenTypes.Int32; checked: boolean; @@ -253,6 +290,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..dde302cc 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 { userId, text } = e.nativeEvent; + onMentionPress?.({ userId, 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..c904cb67 100644 --- a/src/normalizeMarkdownStyle.ts +++ b/src/normalizeMarkdownStyle.ts @@ -204,6 +204,29 @@ 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, + }, }) as MarkdownStyleInternal; const refCache = new WeakMap(); diff --git a/src/normalizeMarkdownStyle.web.ts b/src/normalizeMarkdownStyle.web.ts index 5167d3f4..bf0d5801 100644 --- a/src/normalizeMarkdownStyle.web.ts +++ b/src/normalizeMarkdownStyle.web.ts @@ -185,6 +185,29 @@ 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, + }, }); const refCache = new WeakMap(); diff --git a/src/types/MarkdownStyle.ts b/src/types/MarkdownStyle.ts index 75fb5185..e2b8a194 100644 --- a/src/types/MarkdownStyle.ts +++ b/src/types/MarkdownStyle.ts @@ -180,6 +180,54 @@ 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; +} + export interface MarkdownStyle { paragraph?: ParagraphStyle; h1?: HeadingStyle; @@ -205,6 +253,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..7befe862 100644 --- a/src/types/MarkdownStyleInternal.ts +++ b/src/types/MarkdownStyleInternal.ts @@ -152,6 +152,31 @@ 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; +} + export interface MarkdownStyleInternal { paragraph: ParagraphStyleInternal; h1: HeadingStyleInternal; @@ -177,4 +202,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..a2480ef5 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 user id 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..d82dfd4b 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..d2b22d0f 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -12,6 +12,16 @@ export interface TaskListItemPressEvent { text: string; } +export interface MentionPressEvent { + userId: 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..80fc2e14 100644 --- a/src/web/EnrichedMarkdownText.tsx +++ b/src/web/EnrichedMarkdownText.tsx @@ -20,6 +20,8 @@ export const EnrichedMarkdownText = ({ onLinkPress, onLinkLongPress, onTaskListItemPress, + onMentionPress, + onCitationPress, allowTrailingMargin = false, containerStyle, selectable = true, @@ -74,8 +76,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]); diff --git a/src/web/renderers/InlineRenderers.tsx b/src/web/renderers/InlineRenderers.tsx index f25cb2a6..ecf7d764 100644 --- a/src/web/renderers/InlineRenderers.tsx +++ b/src/web/renderers/InlineRenderers.tsx @@ -3,6 +3,15 @@ import type { RendererProps, RendererMap } from '../types'; import { extractNodeText } from '../utils'; import { KaTeXRenderer } from './KaTeXRenderer'; +const MENTION_SCHEME = 'mention://'; +const CITATION_SCHEME = 'citation://'; +const MENTION_CLASS = 'enriched-mention'; + +// The `:active` rule honors the consumer-configured `pressedOpacity` via a CSS +// variable the mention span sets inline. Rendered as a ` + + {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..2ad3113f 100644 --- a/src/web/styles.ts +++ b/src/web/styles.ts @@ -244,6 +244,53 @@ 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', + userSelect: 'none', + 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'; + 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, + // Round the chip when a background is set; a small radius keeps inline + // citation markers looking like pills rather than square boxes. + borderRadius: + hasBackground && citation.paddingHorizontal > 0 ? 999 : undefined, + cursor: 'pointer', + }; +} + function strikethroughStyle(style: MarkdownStyleInternal): CSSProperties { return { textDecorationLine: 'line-through', @@ -410,6 +457,9 @@ export interface Styles { tableHeaderCell: Record; tableCell: Record; taskCheckbox: CSSProperties; + mention: CSSProperties; + citation: CSSProperties; + mentionPressedOpacity: number; } type ColumnAlign = 'left' | 'center' | 'right' | 'default'; @@ -462,6 +512,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 { From 45cb7fd07af5773a63efb10bb3342c39b54e71d5 Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Thu, 16 Apr 2026 19:22:12 -0700 Subject: [PATCH 02/15] fix mention press signature --- .../swmansion/enriched/markdown/EnrichedMarkdown.kt | 2 +- .../markdown/EnrichedMarkdownInternalText.kt | 4 ++-- .../enriched/markdown/EnrichedMarkdownManager.kt | 4 ++-- .../enriched/markdown/EnrichedMarkdownText.kt | 6 +++--- .../enriched/markdown/events/MentionPressEvent.kt | 4 ++-- .../enriched/markdown/renderer/LinkRenderer.kt | 4 ++-- .../enriched/markdown/spans/MentionSpan.kt | 4 ++-- .../utils/common/MarkdownViewManagerUtils.kt | 4 ++-- .../enriched/markdown/utils/text/view/LinkEvents.kt | 4 ++-- .../utils/text/view/LinkLongPressMovementMethod.kt | 4 ++-- .../enriched/markdown/views/TableContainerView.kt | 4 ++-- apps/example/src/sampleMarkdown.ts | 2 +- ios/EnrichedMarkdown.mm | 12 ++++++------ ios/EnrichedMarkdownText.mm | 8 ++++---- ios/attachments/ENRMMentionAttachment.h | 6 ++---- ios/attachments/ENRMMentionAttachment.m | 6 +++--- ios/renderer/LinkRenderer.h | 2 +- ios/renderer/LinkRenderer.m | 10 +++++----- ios/renderer/RenderContext.h | 4 ++-- ios/renderer/RenderContext.m | 8 ++++---- ios/utils/LinkTapUtils.h | 3 +-- ios/utils/LinkTapUtils.m | 13 ++++++------- ios/views/TableContainerView.h | 2 +- ios/views/TableContainerView.m | 8 ++++---- src/EnrichedMarkdownNativeComponent.ts | 4 ++-- src/EnrichedMarkdownTextNativeComponent.ts | 4 ++-- src/native/EnrichedMarkdownText.tsx | 4 ++-- src/types/MarkdownTextProps.ts | 4 ++-- src/types/MarkdownTextProps.web.ts | 2 +- src/types/events.ts | 2 +- src/web/renderers/InlineRenderers.tsx | 6 +++--- 31 files changed, 75 insertions(+), 79 deletions(-) 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 d3732198..0fece653 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -138,7 +138,7 @@ class EnrichedMarkdown onLinkLongPressCallback = callback } - fun setOnMentionPressCallback(callback: ((userId: String, text: String) -> Unit)?) { + fun setOnMentionPressCallback(callback: ((url: String, text: String) -> Unit)?) { onMentionPressCallback = callback } 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 f8291343..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,7 +38,7 @@ class EnrichedMarkdownInternalText checkboxTouchHelper.onCheckboxTap = value } - var onMentionPressCallback: ((userId: String, text: String) -> Unit)? = null + var onMentionPressCallback: ((url: String, text: String) -> Unit)? = null var onCitationPressCallback: ((url: String, text: String) -> Unit)? = null override val segmentMarginBottom: Int get() = lastElementMarginBottom.toInt() @@ -67,7 +67,7 @@ class EnrichedMarkdownInternalText val method = (movementMethod as? LinkLongPressMovementMethod) ?: LinkLongPressMovementMethod.createInstance().also { movementMethod = it } - method.onMentionTap = { userId, mentionText -> onMentionPressCallback?.invoke(userId, mentionText) } + method.onMentionTap = { url, mentionText -> onMentionPressCallback?.invoke(url, mentionText) } method.onCitationTap = { url, citationText -> onCitationPressCallback?.invoke(url, citationText) } spoilerOverlayDrawer = SpoilerOverlayDrawer.setupIfNeeded(this, styledText, spoilerOverlayDrawer, spoilerOverlay) 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 b470babf..f391ae25 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -56,8 +56,8 @@ class EnrichedMarkdownManager : emitLinkLongPress(view, url) } - view?.setOnMentionPressCallback { userId, text -> - emitMentionPress(view, userId, text) + view?.setOnMentionPressCallback { url, text -> + emitMentionPress(view, url, text) } view?.setOnCitationPressCallback { url, text -> 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 f716cf6c..8bc7931e 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt @@ -233,7 +233,7 @@ class EnrichedMarkdownText val method = (movementMethod as? LinkLongPressMovementMethod) ?: LinkLongPressMovementMethod.createInstance().also { movementMethod = it } - method.onMentionTap = { userId, mentionText -> emitOnMentionPress(userId, mentionText) } + method.onMentionTap = { url, mentionText -> emitOnMentionPress(url, mentionText) } method.onCitationTap = { url, citationText -> emitOnCitationPress(url, citationText) } renderer.getCollectedImageSpans().forEach { span -> @@ -271,10 +271,10 @@ class EnrichedMarkdownText } fun emitOnMentionPress( - userId: String, + url: String, text: String, ) { - emitMentionPressEvent(userId, text) + emitMentionPressEvent(url, text) } fun emitOnCitationPress( 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 index e95c5f4f..211d608e 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/events/MentionPressEvent.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/events/MentionPressEvent.kt @@ -7,14 +7,14 @@ import com.facebook.react.uimanager.events.Event class MentionPressEvent( surfaceId: Int, viewId: Int, - private val userId: String, + 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("userId", userId) + eventData.putString("url", url) eventData.putString("text", text) return eventData } 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 9164e2e2..d44eba0f 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 @@ -72,7 +72,7 @@ class LinkRenderer( } private fun renderMention( - userId: String, + url: String, node: MarkdownASTNode, builder: SpannableStringBuilder, onLinkPress: ((String) -> Unit)?, @@ -95,7 +95,7 @@ class LinkRenderer( val span = MentionSpan( - userId = userId, + url = url, displayText = displayText, mentionStyle = factory.styleCache.mentionStyle, mentionTypeface = factory.styleCache.mentionTypeface, 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 index 6bf25373..f69aa015 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt @@ -12,11 +12,11 @@ import com.swmansion.enriched.markdown.styles.MentionStyle * Rendering is atomic — the span reports its full width (including padding and * border) via getSize so layout reserves enough room and the text never clips. * - * The span exposes [userId] for tap dispatching and an [isPressed] flag the + * The span exposes [url] for tap dispatching and an [isPressed] flag the * tap handler can toggle to drive the pressedOpacity tap-feedback animation. */ class MentionSpan( - val userId: String, + val url: String, val displayText: String, private val mentionStyle: MentionStyle, private val mentionTypeface: Typeface?, 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 7f8a9d40..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 @@ -50,13 +50,13 @@ fun emitLinkLongPress( fun emitMentionPress( view: View, - userId: String, + 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, userId, text)) + eventDispatcher?.dispatchEvent(MentionPressEvent(surfaceId, view.id, url, text)) } fun emitCitationPress( 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 7418748c..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 @@ -26,13 +26,13 @@ fun View.emitLinkLongPressEvent(url: String) { } fun View.emitMentionPressEvent( - userId: String, + 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, userId, text)) + dispatcher?.dispatchEvent(MentionPressEvent(surfaceId, id, url, text)) } fun View.emitCitationPressEvent( 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 5df5c0e2..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 @@ -21,7 +21,7 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { * is a [android.text.style.ReplacementSpan], not a [android.text.style.ClickableSpan], * so the standard LinkMovementMethod dispatch doesn't reach it. */ - var onMentionTap: ((userId: String, text: String) -> Unit)? = null + var onMentionTap: ((url: String, text: String) -> Unit)? = null /** Optional callback invoked when a [CitationSpan] is tapped. */ var onCitationTap: ((url: String, text: String) -> Unit)? = null @@ -99,7 +99,7 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { clearMentionPressedState(widget) pendingMentionTapOffset = -1 if (stillOverMention) { - onMentionTap?.invoke(mention.userId, mention.displayText) + onMentionTap?.invoke(mention.url, mention.displayText) return true } } 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 b1f9e92d..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,7 +49,7 @@ class TableContainerView( var maxFontSizeMultiplier = 0f var onLinkPress: ((String) -> Unit)? = null var onLinkLongPress: ((String) -> Unit)? = null - var onMentionPress: ((userId: String, text: String) -> Unit)? = null + var onMentionPress: ((url: String, text: String) -> Unit)? = null var onCitationPress: ((url: String, text: String) -> Unit)? = null private val scrollView = @@ -209,7 +209,7 @@ class TableContainerView( true } (movementMethod as? LinkLongPressMovementMethod)?.apply { - onMentionTap = { userId, mentionText -> this@TableContainerView.onMentionPress?.invoke(userId, mentionText) } + onMentionTap = { url, mentionText -> this@TableContainerView.onMentionPress?.invoke(url, mentionText) } onCitationTap = { url, citationText -> this@TableContainerView.onCitationPress?.invoke(url, citationText) } } } diff --git a/apps/example/src/sampleMarkdown.ts b/apps/example/src/sampleMarkdown.ts index 78f3b68d..1c1919dc 100644 --- a/apps/example/src/sampleMarkdown.ts +++ b/apps/example/src/sampleMarkdown.ts @@ -105,7 +105,7 @@ The largest terrestrial biome, spanning across **Northern Russia, Canada, and Sc | Forest Type | Coverage | Annual Rainfall | Biodiversity | Carbon Storage | |------------|----------|-----------------|--------------|----------------| | 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 | ~16% of land | 30-60 inches | Moderate | Moderate | +| 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 [@John Muir](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user)| diff --git a/ios/EnrichedMarkdown.mm b/ios/EnrichedMarkdown.mm index 66e49748..1580c6d2 100644 --- a/ios/EnrichedMarkdown.mm +++ b/ios/EnrichedMarkdown.mm @@ -559,7 +559,7 @@ - (TableContainerView *)createTableViewForSegment:(EMTableSegment *)tableSegment } }; - tableView.onMentionPress = ^(NSString *userId, NSString *text) { + tableView.onMentionPress = ^(NSString *url, NSString *text) { EnrichedMarkdown *strongSelf = weakSelf; if (!strongSelf) return; @@ -567,7 +567,7 @@ - (TableContainerView *)createTableViewForSegment:(EMTableSegment *)tableSegment auto eventEmitter = std::static_pointer_cast(strongSelf->_eventEmitter); if (eventEmitter) { eventEmitter->onMentionPress({ - .userId = std::string([(userId ?: @"") UTF8String] ?: ""), + .url = std::string([(url ?: @"") UTF8String] ?: ""), .text = std::string([(text ?: @"") UTF8String] ?: ""), }); } @@ -789,17 +789,17 @@ - (void)textTapped:(ENRMTapRecognizer *)recognizer } NSString *linkURL = nil; - NSString *mentionUserId = nil; + NSString *mentionURL = nil; NSString *mentionText = nil; NSString *citationURL = nil; NSString *citationText = nil; - if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionUserId, &mentionText, &citationURL, + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionURL, &mentionText, &citationURL, &citationText)) { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { - if (mentionUserId) { + if (mentionURL) { eventEmitter->onMentionPress({ - .userId = std::string([mentionUserId UTF8String] ?: ""), + .url = std::string([mentionURL UTF8String] ?: ""), .text = std::string([(mentionText ?: @"") UTF8String] ?: ""), }); } else if (citationURL) { diff --git a/ios/EnrichedMarkdownText.mm b/ios/EnrichedMarkdownText.mm index d2e60d3b..1356619a 100644 --- a/ios/EnrichedMarkdownText.mm +++ b/ios/EnrichedMarkdownText.mm @@ -538,17 +538,17 @@ - (void)textTapped:(ENRMTapRecognizer *)recognizer } NSString *linkURL = nil; - NSString *mentionUserId = nil; + NSString *mentionURL = nil; NSString *mentionText = nil; NSString *citationURL = nil; NSString *citationText = nil; - if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionUserId, &mentionText, &citationURL, + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionURL, &mentionText, &citationURL, &citationText)) { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { - if (mentionUserId) { + if (mentionURL) { eventEmitter->onMentionPress({ - .userId = std::string([mentionUserId UTF8String] ?: ""), + .url = std::string([mentionURL UTF8String] ?: ""), .text = std::string([(mentionText ?: @"") UTF8String] ?: ""), }); } else if (citationURL) { diff --git a/ios/attachments/ENRMMentionAttachment.h b/ios/attachments/ENRMMentionAttachment.h index 8c29ea8b..e26742a4 100644 --- a/ios/attachments/ENRMMentionAttachment.h +++ b/ios/attachments/ENRMMentionAttachment.h @@ -16,12 +16,10 @@ NS_ASSUME_NONNULL_BEGIN @interface ENRMMentionAttachment : NSTextAttachment @property (nonatomic, readonly, copy) NSString *displayText; -@property (nonatomic, readonly, copy) NSString *userId; +@property (nonatomic, readonly, copy) NSString *url; @property (nonatomic, readonly, strong) StyleConfig *config; -+ (instancetype)attachmentWithDisplayText:(NSString *)displayText - userId:(NSString *)userId - config:(StyleConfig *)config; ++ (instancetype)attachmentWithDisplayText:(NSString *)displayText url:(NSString *)url config:(StyleConfig *)config; @end diff --git a/ios/attachments/ENRMMentionAttachment.m b/ios/attachments/ENRMMentionAttachment.m index 018d5442..1163bc65 100644 --- a/ios/attachments/ENRMMentionAttachment.m +++ b/ios/attachments/ENRMMentionAttachment.m @@ -3,18 +3,18 @@ @interface ENRMMentionAttachment () @property (nonatomic, copy) NSString *displayText; -@property (nonatomic, copy) NSString *userId; +@property (nonatomic, copy) NSString *url; @property (nonatomic, strong) StyleConfig *config; @property (nonatomic, assign) CGSize cachedPillSize; @end @implementation ENRMMentionAttachment -+ (instancetype)attachmentWithDisplayText:(NSString *)displayText userId:(NSString *)userId config:(StyleConfig *)config ++ (instancetype)attachmentWithDisplayText:(NSString *)displayText url:(NSString *)url config:(StyleConfig *)config { ENRMMentionAttachment *attachment = [[self alloc] init]; attachment.displayText = displayText ?: @""; - attachment.userId = userId ?: @""; + attachment.url = url ?: @""; attachment.config = config; [attachment rebuildPillImage]; return attachment; diff --git a/ios/renderer/LinkRenderer.h b/ios/renderer/LinkRenderer.h index 5c9a7a42..b784b0af 100644 --- a/ios/renderer/LinkRenderer.h +++ b/ios/renderer/LinkRenderer.h @@ -7,7 +7,7 @@ * the rendered NSAttributedString. Tap dispatching reads these to decide which * JS event to fire for a given character. */ -FOUNDATION_EXPORT NSString *const ENRMMentionUserIdAttributeName; +FOUNDATION_EXPORT NSString *const ENRMMentionURLAttributeName; FOUNDATION_EXPORT NSString *const ENRMMentionTextAttributeName; FOUNDATION_EXPORT NSString *const ENRMCitationURLAttributeName; FOUNDATION_EXPORT NSString *const ENRMCitationTextAttributeName; diff --git a/ios/renderer/LinkRenderer.m b/ios/renderer/LinkRenderer.m index 55d0ec22..143bbb1d 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -7,7 +7,7 @@ #import "StyleConfig.h" #import -NSString *const ENRMMentionUserIdAttributeName = @"ENRMMentionUserId"; +NSString *const ENRMMentionURLAttributeName = @"ENRMMentionURL"; NSString *const ENRMMentionTextAttributeName = @"ENRMMentionText"; NSString *const ENRMCitationURLAttributeName = @"ENRMCitationURL"; NSString *const ENRMCitationTextAttributeName = @"ENRMCitationText"; @@ -140,7 +140,7 @@ - (void)renderMentionNode:(MarkdownASTNode *)node NSMutableAttributedString *childBuffer = [[NSMutableAttributedString alloc] init]; [_rendererFactory renderChildrenOfNode:node into:childBuffer context:context]; NSString *displayText = childBuffer.string ?: @""; - NSString *userId = stripScheme(url, kMentionScheme); + NSString *mentionURL = stripScheme(url, kMentionScheme); // Inherit the current text attributes (font, color) so the pill sits in the // same line metrics as the surrounding paragraph if the pill label has no @@ -148,7 +148,7 @@ - (void)renderMentionNode:(MarkdownASTNode *)node NSDictionary *baseAttrs = output.length > 0 ? [output attributesAtIndex:output.length - 1 effectiveRange:NULL] : @{}; ENRMMentionAttachment *attachment = [ENRMMentionAttachment attachmentWithDisplayText:displayText - userId:userId + url:mentionURL config:_config]; NSMutableAttributedString *attachmentString = @@ -157,14 +157,14 @@ - (void)renderMentionNode:(MarkdownASTNode *)node if (baseAttrs.count > 0) { [attachmentString addAttributes:baseAttrs range:attachmentRange]; } - [attachmentString addAttribute:ENRMMentionUserIdAttributeName value:userId range:attachmentRange]; + [attachmentString addAttribute:ENRMMentionURLAttributeName value:mentionURL range:attachmentRange]; [attachmentString addAttribute:ENRMMentionTextAttributeName value:displayText range:attachmentRange]; NSUInteger start = output.length; [output appendAttributedString:attachmentString]; NSRange outputRange = NSMakeRange(start, output.length - start); - [context registerMentionRange:outputRange userId:userId text:displayText]; + [context registerMentionRange:outputRange url:mentionURL text:displayText]; } #pragma mark - Citation diff --git a/ios/renderer/RenderContext.h b/ios/renderer/RenderContext.h index 20475165..ef3e7202 100644 --- a/ios/renderer/RenderContext.h +++ b/ios/renderer/RenderContext.h @@ -26,7 +26,7 @@ typedef NS_ENUM(NSInteger, ListType) { ListTypeUnordered, ListTypeOrdered }; @property (nonatomic, strong) NSMutableArray *linkRanges; @property (nonatomic, strong) NSMutableArray *linkURLs; @property (nonatomic, strong) NSMutableArray *mentionRanges; -@property (nonatomic, strong) NSMutableArray *mentionUserIds; +@property (nonatomic, strong) NSMutableArray *mentionURLs; @property (nonatomic, strong) NSMutableArray *mentionTexts; @property (nonatomic, strong) NSMutableArray *citationRanges; @property (nonatomic, strong) NSMutableArray *citationURLs; @@ -59,7 +59,7 @@ 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 userId:(NSString *)userId text:(NSString *)text; +- (void)registerMentionRange:(NSRange)range url:(NSString *)url text:(NSString *)text; - (void)registerCitationRange:(NSRange)range url:(NSString *)url text:(NSString *)text; - (void)applyLinkAttributesToString:(NSMutableAttributedString *)attributedString; diff --git a/ios/renderer/RenderContext.m b/ios/renderer/RenderContext.m index 799b46de..6e0336c3 100644 --- a/ios/renderer/RenderContext.m +++ b/ios/renderer/RenderContext.m @@ -18,7 +18,7 @@ - (instancetype)init _linkRanges = [NSMutableArray array]; _linkURLs = [NSMutableArray array]; _mentionRanges = [NSMutableArray array]; - _mentionUserIds = [NSMutableArray array]; + _mentionURLs = [NSMutableArray array]; _mentionTexts = [NSMutableArray array]; _citationRanges = [NSMutableArray array]; _citationURLs = [NSMutableArray array]; @@ -111,12 +111,12 @@ - (void)registerLinkRange:(NSRange)range url:(NSString *)url [self.linkURLs addObject:url ?: @""]; } -- (void)registerMentionRange:(NSRange)range userId:(NSString *)userId text:(NSString *)text +- (void)registerMentionRange:(NSRange)range url:(NSString *)url text:(NSString *)text { if (range.length == 0) return; [self.mentionRanges addObject:[NSValue valueWithRange:range]]; - [self.mentionUserIds addObject:userId ?: @""]; + [self.mentionURLs addObject:url ?: @""]; [self.mentionTexts addObject:text ?: @""]; } @@ -268,7 +268,7 @@ - (void)reset [_linkRanges removeAllObjects]; [_linkURLs removeAllObjects]; [_mentionRanges removeAllObjects]; - [_mentionUserIds removeAllObjects]; + [_mentionURLs removeAllObjects]; [_mentionTexts removeAllObjects]; [_citationRanges removeAllObjects]; [_citationURLs removeAllObjects]; diff --git a/ios/utils/LinkTapUtils.h b/ios/utils/LinkTapUtils.h index af48a9c9..a0eced0a 100644 --- a/ios/utils/LinkTapUtils.h +++ b/ios/utils/LinkTapUtils.h @@ -18,8 +18,7 @@ NSString *_Nullable linkURLAtRange(ENRMPlatformTextView *textView, NSRange chara /// 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 outMentionUserId, + NSString *_Nullable *_Nullable outLinkURL, NSString *_Nullable *_Nullable outMentionURL, NSString *_Nullable *_Nullable outMentionText, NSString *_Nullable *_Nullable outCitationURL, NSString *_Nullable *_Nullable outCitationText); diff --git a/ios/utils/LinkTapUtils.m b/ios/utils/LinkTapUtils.m index 0547091f..5d0ebd88 100644 --- a/ios/utils/LinkTapUtils.m +++ b/ios/utils/LinkTapUtils.m @@ -23,8 +23,7 @@ } BOOL inlineElementAtTapLocation(ENRMPlatformTextView *textView, ENRMTapRecognizer *recognizer, - NSString *_Nullable *_Nullable outLinkURL, - NSString *_Nullable *_Nullable outMentionUserId, + NSString *_Nullable *_Nullable outLinkURL, NSString *_Nullable *_Nullable outMentionURL, NSString *_Nullable *_Nullable outMentionText, NSString *_Nullable *_Nullable outCitationURL, NSString *_Nullable *_Nullable outCitationText) @@ -36,10 +35,10 @@ BOOL inlineElementAtTapLocation(ENRMPlatformTextView *textView, ENRMTapRecognize NSAttributedString *attrText = ENRMGetAttributedText(textView); NSDictionary *attrs = [attrText attributesAtIndex:characterIndex effectiveRange:NULL]; - NSString *mentionUserId = attrs[ENRMMentionUserIdAttributeName]; - if (mentionUserId) { - if (outMentionUserId) - *outMentionUserId = mentionUserId; + NSString *mentionURL = attrs[ENRMMentionURLAttributeName]; + if (mentionURL) { + if (outMentionURL) + *outMentionURL = mentionURL; if (outMentionText) *outMentionText = attrs[ENRMMentionTextAttributeName] ?: @""; return YES; @@ -71,7 +70,7 @@ BOOL isPointOnInteractiveElement(ENRMPlatformTextView *textView, CGPoint point) return NO; NSDictionary *attrs = [ENRMGetAttributedText(textView) attributesAtIndex:charIndex effectiveRange:NULL]; - return attrs[@"linkURL"] != nil || attrs[ENRMMentionUserIdAttributeName] != nil || + return attrs[@"linkURL"] != nil || attrs[ENRMMentionURLAttributeName] != nil || attrs[ENRMCitationURLAttributeName] != nil || [attrs[@"TaskItem"] boolValue] || attrs[SpoilerAttributeName] != nil; } diff --git a/ios/views/TableContainerView.h b/ios/views/TableContainerView.h index b133aee0..00d1d35f 100644 --- a/ios/views/TableContainerView.h +++ b/ios/views/TableContainerView.h @@ -7,7 +7,7 @@ NS_ASSUME_NONNULL_BEGIN typedef void (^TableLinkPressBlock)(NSString *url); -typedef void (^TableMentionPressBlock)(NSString *userId, NSString *text); +typedef void (^TableMentionPressBlock)(NSString *url, NSString *text); typedef void (^TableCitationPressBlock)(NSString *url, NSString *text); @interface TableContainerView : RCTUIView diff --git a/ios/views/TableContainerView.m b/ios/views/TableContainerView.m index 181af524..47091ced 100644 --- a/ios/views/TableContainerView.m +++ b/ios/views/TableContainerView.m @@ -410,14 +410,14 @@ - (void)cellTextTapped:(UITapGestureRecognizer *)recognizer { UITextView *textView = (UITextView *)recognizer.view; NSString *linkURL = nil; - NSString *mentionUserId = nil; + NSString *mentionURL = nil; NSString *mentionText = nil; NSString *citationURL = nil; NSString *citationText = nil; - if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionUserId, &mentionText, &citationURL, + if (inlineElementAtTapLocation(textView, recognizer, &linkURL, &mentionURL, &mentionText, &citationURL, &citationText)) { - if (mentionUserId && self.onMentionPress) { - self.onMentionPress(mentionUserId, mentionText ?: @""); + if (mentionURL && self.onMentionPress) { + self.onMentionPress(mentionURL, mentionText ?: @""); } else if (citationURL && self.onCitationPress) { self.onCitationPress(citationURL, citationText ?: @""); } else if (linkURL && self.onLinkPress) { diff --git a/src/EnrichedMarkdownNativeComponent.ts b/src/EnrichedMarkdownNativeComponent.ts index f569500f..b53e1357 100644 --- a/src/EnrichedMarkdownNativeComponent.ts +++ b/src/EnrichedMarkdownNativeComponent.ts @@ -215,7 +215,7 @@ export interface LinkLongPressEvent { } export interface MentionPressEvent { - userId: string; + url: string; text: string; } @@ -291,7 +291,7 @@ export interface NativeProps extends ViewProps { */ onTaskListItemPress?: CodegenTypes.BubblingEventHandler; /** - * Callback fired when an inline mention pill (`mention://`) is pressed. + * Callback fired when an inline mention pill (`mention://`) is pressed. */ onMentionPress?: CodegenTypes.BubblingEventHandler; /** diff --git a/src/EnrichedMarkdownTextNativeComponent.ts b/src/EnrichedMarkdownTextNativeComponent.ts index 295649f9..28821d21 100644 --- a/src/EnrichedMarkdownTextNativeComponent.ts +++ b/src/EnrichedMarkdownTextNativeComponent.ts @@ -215,7 +215,7 @@ export interface LinkLongPressEvent { } export interface MentionPressEvent { - userId: string; + url: string; text: string; } @@ -291,7 +291,7 @@ export interface NativeProps extends ViewProps { */ onTaskListItemPress?: CodegenTypes.BubblingEventHandler; /** - * Callback fired when an inline mention pill (`mention://`) is pressed. + * Callback fired when an inline mention pill (`mention://`) is pressed. */ onMentionPress?: CodegenTypes.BubblingEventHandler; /** diff --git a/src/native/EnrichedMarkdownText.tsx b/src/native/EnrichedMarkdownText.tsx index dde302cc..efa1bb57 100644 --- a/src/native/EnrichedMarkdownText.tsx +++ b/src/native/EnrichedMarkdownText.tsx @@ -132,8 +132,8 @@ export const EnrichedMarkdownText = ({ const handleMentionPress = useCallback( (e: NativeSyntheticEvent) => { - const { userId, text } = e.nativeEvent; - onMentionPress?.({ userId, text }); + const { url, text } = e.nativeEvent; + onMentionPress?.({ url, text }); }, [onMentionPress] ); diff --git a/src/types/MarkdownTextProps.ts b/src/types/MarkdownTextProps.ts index a2480ef5..75a36993 100644 --- a/src/types/MarkdownTextProps.ts +++ b/src/types/MarkdownTextProps.ts @@ -73,8 +73,8 @@ export interface EnrichedMarkdownTextProps extends Omit { 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 user id separately. + * 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; diff --git a/src/types/MarkdownTextProps.web.ts b/src/types/MarkdownTextProps.web.ts index d82dfd4b..bf117492 100644 --- a/src/types/MarkdownTextProps.web.ts +++ b/src/types/MarkdownTextProps.web.ts @@ -60,7 +60,7 @@ export interface EnrichedMarkdownTextProps onTaskListItemPress?: (event: TaskListItemPressEvent) => void; /** * Callback fired when an inline mention pill is pressed. - * Mentions are authored as `[label](mention://)` in markdown. + * Mentions are authored as `[label](mention://)` in markdown. * @platform ios, android, web */ onMentionPress?: (event: MentionPressEvent) => void; diff --git a/src/types/events.ts b/src/types/events.ts index d2b22d0f..6f7491ea 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -13,7 +13,7 @@ export interface TaskListItemPressEvent { } export interface MentionPressEvent { - userId: string; + url: string; text: string; } diff --git a/src/web/renderers/InlineRenderers.tsx b/src/web/renderers/InlineRenderers.tsx index ecf7d764..0eef342d 100644 --- a/src/web/renderers/InlineRenderers.tsx +++ b/src/web/renderers/InlineRenderers.tsx @@ -107,12 +107,12 @@ function MentionRenderer({ callbacks, node, }: SchemeRendererProps) { - const userId = url.slice(MENTION_SCHEME.length); + const mentionUrl = url.slice(MENTION_SCHEME.length); const displayText = extractNodeText(node); const handleClick = (event: MouseEvent) => { event.preventDefault(); - callbacks.onMentionPress?.({ userId, text: displayText }); + callbacks.onMentionPress?.({ url: mentionUrl, text: displayText }); }; const style = { @@ -129,7 +129,7 @@ function MentionRenderer({ role="button" tabIndex={0} aria-label={`Mention: ${displayText}`} - data-user-id={userId} + data-mention-url={mentionUrl} onClick={handleClick} style={style} > From a5e4e9a7c5bf055e3e37ab839806b12791f10c5a Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 12:04:53 -0700 Subject: [PATCH 03/15] ios: allow copy/paste mention and citations as normal text --- apps/example/ios/Podfile.lock | 2 +- apps/example/src/App.tsx | 35 ++++++ apps/example/src/markdownStyles.ts | 21 ++++ ios/attachments/ENRMCitationAttachment.h | 25 ---- ios/attachments/ENRMCitationAttachment.m | 145 ----------------------- ios/attachments/ENRMMentionAttachment.h | 26 ---- ios/attachments/ENRMMentionAttachment.m | 115 ------------------ ios/renderer/LinkRenderer.m | 123 ++++++++++++------- ios/utils/CitationBackground.h | 24 ++++ ios/utils/CitationBackground.m | 79 ++++++++++++ ios/utils/MarkdownExtractor.m | 12 ++ ios/utils/MentionBackground.h | 25 ++++ ios/utils/MentionBackground.m | 91 ++++++++++++++ ios/utils/RuntimeKeys.h | 8 ++ ios/utils/RuntimeKeys.m | 2 + ios/utils/TextViewLayoutManager.mm | 30 +++++ 16 files changed, 407 insertions(+), 356 deletions(-) delete mode 100644 ios/attachments/ENRMCitationAttachment.h delete mode 100644 ios/attachments/ENRMCitationAttachment.m delete mode 100644 ios/attachments/ENRMMentionAttachment.h delete mode 100644 ios/attachments/ENRMMentionAttachment.m create mode 100644 ios/utils/CitationBackground.h create mode 100644 ios/utils/CitationBackground.m create mode 100644 ios/utils/MentionBackground.h create mode 100644 ios/utils/MentionBackground.m diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 2c37a8bd..3128429a 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2195,4 +2195,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 9c5417fc84515945aa2357a49779fde55434ae62 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index e4c44a7a..459ab89c 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,37 @@ 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 +124,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..b8fb363a 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -142,4 +142,25 @@ export const customMarkdownStyle: MarkdownStyle = { checkedTextColor: '#9ca3af', checkedStrikethrough: true, }, + mention: { + backgroundColor: '#EBEBFF', + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 4, + paddingHorizontal: 4, + paddingVertical: 2, + fontFamily: 'Montserrat-Regular', + fontSize: 14, + color: '#2563fb', + }, + citation: { + backgroundColor: '#EBEBFF', + color: '#9B9BFD', + fontSizeMultiplier: 0.7, + baselineOffsetPx: 1.5, + fontWeight: '', + underline: false, + paddingHorizontal: 0, + paddingVertical: 0, + }, }; diff --git a/ios/attachments/ENRMCitationAttachment.h b/ios/attachments/ENRMCitationAttachment.h deleted file mode 100644 index 4f7a94c4..00000000 --- a/ios/attachments/ENRMCitationAttachment.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once -#import "ENRMUIKit.h" - -@class StyleConfig; - -NS_ASSUME_NONNULL_BEGIN - -/** - * Custom NSTextAttachment for rendering inline citation markers. Drawing is - * atomic (CoreGraphics into a UIImage) so the renderer can apply padding, - * backgrounds, baseline offset, and a font-size multiplier consistently. - */ -@interface ENRMCitationAttachment : NSTextAttachment - -@property (nonatomic, readonly, copy) NSString *displayText; -@property (nonatomic, readonly, copy) NSString *url; - -+ (instancetype)attachmentWithDisplayText:(NSString *)displayText - url:(NSString *)url - baseFont:(nullable UIFont *)baseFont - config:(StyleConfig *)config; - -@end - -NS_ASSUME_NONNULL_END diff --git a/ios/attachments/ENRMCitationAttachment.m b/ios/attachments/ENRMCitationAttachment.m deleted file mode 100644 index 17c96805..00000000 --- a/ios/attachments/ENRMCitationAttachment.m +++ /dev/null @@ -1,145 +0,0 @@ -#import "ENRMCitationAttachment.h" -#import "StyleConfig.h" - -@interface ENRMCitationAttachment () -@property (nonatomic, copy) NSString *displayText; -@property (nonatomic, copy) NSString *url; -@property (nonatomic, strong, nullable) UIFont *baseFont; -@property (nonatomic, strong) StyleConfig *config; -@property (nonatomic, assign) CGSize cachedSize; -@property (nonatomic, assign) CGFloat cachedBaseline; -@end - -@implementation ENRMCitationAttachment - -+ (instancetype)attachmentWithDisplayText:(NSString *)displayText - url:(NSString *)url - baseFont:(UIFont *)baseFont - config:(StyleConfig *)config -{ - ENRMCitationAttachment *attachment = [[self alloc] init]; - attachment.displayText = displayText ?: @""; - attachment.url = url ?: @""; - attachment.baseFont = baseFont; - attachment.config = config; - [attachment rebuildImage]; - return attachment; -} - -- (UIFont *)citationFont -{ - UIFont *base = self.baseFont; - if (!base) { - base = [UIFont systemFontOfSize:[UIFont systemFontSize]]; - } - CGFloat multiplier = [self.config citationFontSizeMultiplier]; - if (multiplier <= 0) { - multiplier = 0.7; - } - CGFloat scaledSize = MAX(1.0, base.pointSize * multiplier); - NSString *weight = [self.config citationFontWeight] ?: @""; - UIFont *scaled = [base fontWithSize:scaledSize]; - - if (weight.length > 0 && ([weight caseInsensitiveCompare:@"bold"] == NSOrderedSame || - [weight caseInsensitiveCompare:@"700"] == NSOrderedSame || - [weight caseInsensitiveCompare:@"800"] == NSOrderedSame || - [weight caseInsensitiveCompare:@"900"] == NSOrderedSame)) { - UIFontDescriptor *descriptor = [scaled.fontDescriptor fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitBold]; - if (descriptor) { - scaled = [UIFont fontWithDescriptor:descriptor size:scaledSize]; - } - } - return scaled; -} - -- (void)rebuildImage -{ - UIFont *font = [self citationFont]; - RCTUIColor *textColor = [self.config citationColor] ?: [RCTUIColor labelColor]; - RCTUIColor *bgColor = [self.config citationBackgroundColor]; - CGFloat paddingH = MAX(0, [self.config citationPaddingHorizontal]); - CGFloat paddingV = MAX(0, [self.config citationPaddingVertical]); - BOOL underline = [self.config citationUnderline]; - - NSMutableDictionary *attrs = [NSMutableDictionary dictionary]; - attrs[NSFontAttributeName] = font; - attrs[NSForegroundColorAttributeName] = textColor; - if (underline) { - attrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); - attrs[NSUnderlineColorAttributeName] = textColor; - } - - CGSize textSize = [self.displayText sizeWithAttributes:attrs]; - - CGFloat width = ceil(textSize.width + paddingH * 2); - CGFloat height = ceil(textSize.height + paddingV * 2); - CGSize size = CGSizeMake(MAX(1, width), MAX(1, height)); - self.cachedSize = size; - self.cachedBaseline = paddingV + font.ascender; - - UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; - format.opaque = NO; - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; - - __weak typeof(self) weakSelf = self; - UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) { - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) - return; - - CGContextRef cg = ctx.CGContext; - (void)cg; - - if (bgColor) { - CGRect rect = CGRectMake(0, 0, size.width, size.height); - CGFloat radius = MIN(size.height, size.width) / 2.0; - UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius]; - [bgColor setFill]; - [path fill]; - } - - CGPoint origin = CGPointMake(paddingH, paddingV); - [strongSelf.displayText drawAtPoint:origin withAttributes:attrs]; - }]; - - self.image = image; - self.bounds = CGRectMake(0, 0, size.width, size.height); -} - -- (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer - proposedLineFragment:(CGRect)lineFragment - glyphPosition:(CGPoint)position - characterIndex:(NSUInteger)characterIndex -{ - CGSize size = self.cachedSize; - if (size.width == 0 || size.height == 0) { - [self rebuildImage]; - size = self.cachedSize; - } - - // Derive the desired baseline offset (superscript-like shift). Positive - // `NSBaselineOffsetAttributeName` values move glyphs upward; for an - // attachment we achieve the same by offsetting the bounds origin upward. - CGFloat baselineOffset = [self.config citationBaselineOffsetPx]; - UIFont *lineFont = self.baseFont; - if (!lineFont) { - NSLayoutManager *layoutManager = textContainer.layoutManager; - NSTextStorage *textStorage = layoutManager.textStorage; - if (textStorage && characterIndex < textStorage.length) { - lineFont = [textStorage attribute:NSFontAttributeName atIndex:characterIndex effectiveRange:NULL]; - } - } - - if (baselineOffset == 0 && lineFont) { - // Default: raise so the mid-line of the citation sits near the cap-height - // of the surrounding text, matching the MetricAffectingSpan fallback used - // on Android. - CGFloat hostCap = lineFont.capHeight; - UIFont *citationFont = [self citationFont]; - baselineOffset = MAX(0, (hostCap - citationFont.capHeight) * 0.5); - } - - return CGRectMake(0, baselineOffset, size.width, size.height); -} - -@end diff --git a/ios/attachments/ENRMMentionAttachment.h b/ios/attachments/ENRMMentionAttachment.h deleted file mode 100644 index e26742a4..00000000 --- a/ios/attachments/ENRMMentionAttachment.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once -#import "ENRMUIKit.h" - -@class StyleConfig; - -NS_ASSUME_NONNULL_BEGIN - -/** - * Custom NSTextAttachment used to render inline mention pills. - * - * Rendering is delegated to an NSTextAttachmentViewProvider (iOS 15+) so the - * pill participates in the text layout as an atomic character — selection - * handles, cursor movement, and accessibility traverse it as a single glyph. - * Tap feedback (alpha dim on press) is managed by the view provider. - */ -@interface ENRMMentionAttachment : NSTextAttachment - -@property (nonatomic, readonly, copy) NSString *displayText; -@property (nonatomic, readonly, copy) NSString *url; -@property (nonatomic, readonly, strong) StyleConfig *config; - -+ (instancetype)attachmentWithDisplayText:(NSString *)displayText url:(NSString *)url config:(StyleConfig *)config; - -@end - -NS_ASSUME_NONNULL_END diff --git a/ios/attachments/ENRMMentionAttachment.m b/ios/attachments/ENRMMentionAttachment.m deleted file mode 100644 index 1163bc65..00000000 --- a/ios/attachments/ENRMMentionAttachment.m +++ /dev/null @@ -1,115 +0,0 @@ -#import "ENRMMentionAttachment.h" -#import "StyleConfig.h" - -@interface ENRMMentionAttachment () -@property (nonatomic, copy) NSString *displayText; -@property (nonatomic, copy) NSString *url; -@property (nonatomic, strong) StyleConfig *config; -@property (nonatomic, assign) CGSize cachedPillSize; -@end - -@implementation ENRMMentionAttachment - -+ (instancetype)attachmentWithDisplayText:(NSString *)displayText url:(NSString *)url config:(StyleConfig *)config -{ - ENRMMentionAttachment *attachment = [[self alloc] init]; - attachment.displayText = displayText ?: @""; - attachment.url = url ?: @""; - attachment.config = config; - [attachment rebuildPillImage]; - return attachment; -} - -- (void)rebuildPillImage -{ - UIFont *font = [self.config mentionFont]; - RCTUIColor *textColor = [self.config mentionColor] ?: [RCTUIColor labelColor]; - RCTUIColor *bgColor = [self.config mentionBackgroundColor]; - RCTUIColor *borderColor = [self.config mentionBorderColor]; - CGFloat borderWidth = MAX(0, [self.config mentionBorderWidth]); - CGFloat borderRadius = MAX(0, [self.config mentionBorderRadius]); - CGFloat paddingH = MAX(0, [self.config mentionPaddingHorizontal]); - CGFloat paddingV = MAX(0, [self.config mentionPaddingVertical]); - - NSDictionary *textAttrs = font ? @{NSFontAttributeName : font} : @{}; - CGSize textSize = [self.displayText sizeWithAttributes:textAttrs]; - - // Ensure the pill is large enough to fit the label plus padding and border. - // `getSize` parity for Android: width = textWidth + 2*padding + 2*border. - CGFloat width = ceil(textSize.width + paddingH * 2 + borderWidth * 2); - CGFloat height = ceil(textSize.height + paddingV * 2 + borderWidth * 2); - CGSize size = CGSizeMake(MAX(1, width), MAX(1, height)); - self.cachedPillSize = size; - - UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; - format.opaque = NO; - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; - - __weak typeof(self) weakSelf = self; - UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) { - CGContextRef cg = ctx.CGContext; - CGRect bounds = CGRectMake(0, 0, size.width, size.height); - - // Inset so the border stroke stays inside the bounds (centered on pill edge). - CGRect rect = CGRectInset(bounds, borderWidth / 2.0, borderWidth / 2.0); - CGFloat clampedRadius = MIN(borderRadius, MIN(rect.size.width, rect.size.height) / 2.0); - UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:clampedRadius]; - - if (bgColor) { - CGContextSaveGState(cg); - [bgColor setFill]; - [path fill]; - CGContextRestoreGState(cg); - } - - if (borderWidth > 0 && borderColor) { - CGContextSaveGState(cg); - [borderColor setStroke]; - path.lineWidth = borderWidth; - [path stroke]; - CGContextRestoreGState(cg); - } - - CGFloat textX = (size.width - textSize.width) / 2.0; - CGFloat textY = (size.height - textSize.height) / 2.0; - - NSDictionary *drawAttrs = font ? @{NSFontAttributeName : font, NSForegroundColorAttributeName : textColor} - : @{NSForegroundColorAttributeName : textColor}; - [weakSelf.displayText drawAtPoint:CGPointMake(textX, textY) withAttributes:drawAttrs]; - }]; - - self.image = image; - self.bounds = CGRectMake(0, 0, size.width, size.height); -} - -- (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer - proposedLineFragment:(CGRect)lineFragment - glyphPosition:(CGPoint)position - characterIndex:(NSUInteger)characterIndex -{ - CGSize size = self.cachedPillSize; - if (size.width == 0 || size.height == 0) { - [self rebuildPillImage]; - size = self.cachedPillSize; - } - - // Vertically center the pill on the surrounding text's cap height when - // available, mirroring how inline images are positioned in this codebase. - UIFont *lineFont = nil; - NSLayoutManager *layoutManager = textContainer.layoutManager; - NSTextStorage *textStorage = layoutManager.textStorage; - if (textStorage && characterIndex < textStorage.length) { - lineFont = [textStorage attribute:NSFontAttributeName atIndex:characterIndex effectiveRange:NULL]; - } - - CGFloat verticalOffset; - if (lineFont) { - verticalOffset = (lineFont.capHeight - size.height) / 2.0; - } else { - verticalOffset = (lineFragment.size.height - size.height) / 2.0; - } - - return CGRectMake(0, verticalOffset, size.width, size.height); -} - -@end diff --git a/ios/renderer/LinkRenderer.m b/ios/renderer/LinkRenderer.m index 143bbb1d..c2f4efb3 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -1,6 +1,4 @@ #import "LinkRenderer.h" -#import "ENRMCitationAttachment.h" -#import "ENRMMentionAttachment.h" #import "FontUtils.h" #import "RenderContext.h" #import "RendererFactory.h" @@ -135,33 +133,36 @@ - (void)renderMentionNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context { - // Extract the child text to use as the pill label; the child nodes may be - // formatted text (e.g. **bold**), so we collapse to a plain string. + // 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 the current text attributes (font, color) so the pill sits in the - // same line metrics as the surrounding paragraph if the pill label has no - // explicit style override. + // 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]; - ENRMMentionAttachment *attachment = [ENRMMentionAttachment attachmentWithDisplayText:displayText - url:mentionURL - config:_config]; - - NSMutableAttributedString *attachmentString = - [[NSMutableAttributedString attributedStringWithAttachment:attachment] mutableCopy]; - NSRange attachmentRange = NSMakeRange(0, attachmentString.length); - if (baseAttrs.count > 0) { - [attachmentString addAttributes:baseAttrs range:attachmentRange]; + UIFont *mentionFont = [_config mentionFont]; + if (mentionFont) { + attrs[NSFontAttributeName] = mentionFont; } - [attachmentString addAttribute:ENRMMentionURLAttributeName value:mentionURL range:attachmentRange]; - [attachmentString addAttribute:ENRMMentionTextAttributeName value:displayText range:attachmentRange]; + RCTUIColor *mentionColor = [_config mentionColor]; + if (mentionColor) { + attrs[NSForegroundColorAttributeName] = mentionColor; + } + attrs[ENRMMentionURLAttributeName] = mentionURL; + attrs[ENRMMentionTextAttributeName] = displayText; NSUInteger start = output.length; - [output appendAttributedString:attachmentString]; + [output appendAttributedString:[[NSAttributedString alloc] initWithString:displayText attributes:attrs]]; NSRange outputRange = NSMakeRange(start, output.length - start); [context registerMentionRange:outputRange url:mentionURL text:displayText]; @@ -174,35 +175,69 @@ - (void)renderCitationNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context { - // Render children into a throwaway buffer so we can collect the label text - // and inherit the surrounding font (used to scale the citation glyph). - NSMutableAttributedString *childBuffer = [[NSMutableAttributedString alloc] init]; - [_rendererFactory renderChildrenOfNode:node into:childBuffer context:context]; - NSString *displayText = childBuffer.string ?: @""; + 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] ?: @""; - NSDictionary *baseAttrs = output.length > 0 ? [output attributesAtIndex:output.length - 1 effectiveRange:NULL] : @{}; - UIFont *baseFont = baseAttrs[NSFontAttributeName]; - - ENRMCitationAttachment *attachment = [ENRMCitationAttachment attachmentWithDisplayText:displayText - url:targetURL - baseFont:baseFont - config:_config]; - - NSMutableAttributedString *attachmentString = - [[NSMutableAttributedString attributedStringWithAttachment:attachment] mutableCopy]; - NSRange attachmentRange = NSMakeRange(0, attachmentString.length); - if (baseAttrs.count > 0) { - [attachmentString addAttributes:baseAttrs range:attachmentRange]; - } - [attachmentString addAttribute:ENRMCitationURLAttributeName value:targetURL range:attachmentRange]; - [attachmentString addAttribute:ENRMCitationTextAttributeName value:displayText range:attachmentRange]; + CGFloat multiplier = [_config citationFontSizeMultiplier]; + CGFloat baselineOffsetPx = [_config citationBaselineOffsetPx]; + RCTUIColor *citationColor = [_config citationColor]; + NSString *fontWeight = [_config citationFontWeight]; + BOOL underline = [_config citationUnderline]; - NSUInteger start = output.length; - [output appendAttributedString:attachmentString]; - NSRange outputRange = NSMakeRange(start, output.length - start); + [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]; - [context registerCitationRange:outputRange url:targetURL text:displayText]; + [context registerCitationRange:range url:targetURL text:labelText]; } @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..ce0c623e --- /dev/null +++ b/ios/utils/CitationBackground.m @@ -0,0 +1,79 @@ +#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]; + + if (!bgColor) + return; + + [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; + + [layoutManager + enumerateLineFragmentsForGlyphRange:glyphRange + usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *tc, + NSRange lineRange, BOOL *lineStop) { + NSRange intersect = NSIntersectionRange(lineRange, glyphRange); + if (intersect.length == 0) + return; + + CGRect glyphRect = + [layoutManager boundingRectForGlyphRange:intersect + inTextContainer:textContainer]; + + CGRect chipRect = CGRectMake(glyphRect.origin.x + origin.x - paddingH, + glyphRect.origin.y + origin.y - paddingV, + glyphRect.size.width + paddingH * 2, + glyphRect.size.height + paddingV * 2); + + CGFloat radius = MIN(chipRect.size.width, chipRect.size.height) / 2.0; + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect + cornerRadius:radius]; + + [bgColor setFill]; + [path fill]; + }]; + }]; +} + +@end 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..572bb0c5 --- /dev/null +++ b/ios/utils/MentionBackground.m @@ -0,0 +1,91 @@ +#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; + + [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; + + [layoutManager + enumerateLineFragmentsForGlyphRange:glyphRange + usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *tc, + NSRange lineRange, BOOL *lineStop) { + NSRange intersect = NSIntersectionRange(lineRange, glyphRange); + if (intersect.length == 0) + return; + + CGRect glyphRect = + [layoutManager boundingRectForGlyphRange:intersect + inTextContainer:textContainer]; + CGRect pillRect = CGRectMake(glyphRect.origin.x + origin.x - paddingH, + glyphRect.origin.y + origin.y - paddingV, + glyphRect.size.width + paddingH * 2, + glyphRect.size.height + paddingV * 2); + + 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/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/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); From 5ca23e358eda7fd783461930ac3cd5c945a4f3fe Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 12:18:50 -0700 Subject: [PATCH 04/15] android: allow copy/paste mention and citations as normal text --- .../markdown/renderer/LinkRenderer.kt | 14 +- .../enriched/markdown/spans/MentionSpan.kt | 174 ++++++++++-------- apps/example/src/App.tsx | 1 + apps/example/src/markdownStyles.ts | 2 +- apps/web-example/src/App.tsx | 20 +- 5 files changed, 121 insertions(+), 90 deletions(-) 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 d44eba0f..b7163ab9 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 @@ -79,18 +79,18 @@ class LinkRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { - // Render children into a throwaway buffer to derive the display label. - // Any inline formatting (bold/italic) inside the label collapses to plain - // text because ReplacementSpan paints a single atomic glyph. + // 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 - // Insert a single placeholder character that ReplacementSpan will paint - // over; keeping it as a real character preserves cursor metrics, selection - // handles, and accessibility traversal. + // 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(' ') + builder.append(displayText) val end = builder.length val span = 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 index f69aa015..c841bcb2 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt @@ -4,23 +4,32 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface -import android.text.style.ReplacementSpan +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 /** - * Replaces a range of text with a rounded "pill" containing the display name. - * Rendering is atomic — the span reports its full width (including padding and - * border) via getSize so layout reserves enough room and the text never clips. + * 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 tap-feedback animation. + * 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?, -) : ReplacementSpan() { +) : MetricAffectingSpan(), + LineBackgroundSpan { @Volatile var isPressed: Boolean = false @@ -28,87 +37,84 @@ class MentionSpan( private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = mentionStyle.borderWidth } - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + private val rect = RectF() - private fun configureTextPaint(basePaint: Paint) { - textPaint.set(basePaint) - if (mentionStyle.fontSize > 0) { - textPaint.textSize = mentionStyle.fontSize - } - mentionTypeface?.let { textPaint.typeface = it } - textPaint.color = mentionStyle.color + override fun updateMeasureState(textPaint: TextPaint) { + applyTextStyling(textPaint) } - private fun contentWidth(): Float = textPaint.measureText(displayText) + override fun updateDrawState(tp: TextPaint) { + applyTextStyling(tp) + tp.color = mentionStyle.color + } - override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt?, - ): Int { - configureTextPaint(paint) - - val textWidth = contentWidth() - val totalWidth = textWidth + mentionStyle.paddingHorizontal * 2f + mentionStyle.borderWidth * 2f - - if (fm != null) { - val metrics = textPaint.fontMetricsInt - val verticalInset = (mentionStyle.paddingVertical + mentionStyle.borderWidth).toInt() - fm.ascent = metrics.ascent - verticalInset - fm.top = metrics.top - verticalInset - fm.descent = metrics.descent + verticalInset - fm.bottom = metrics.bottom + verticalInset - fm.leading = metrics.leading + 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) } - - return totalWidth.toInt() + 1 } - override fun draw( + override fun drawBackground( canvas: Canvas, + paint: Paint, + left: Int, + right: Int, + top: Int, + baseline: Int, + bottom: Int, text: CharSequence, start: Int, end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint, + lineNum: Int, ) { - configureTextPaint(paint) - - val opacity = - if (isPressed) mentionStyle.pressedOpacity.coerceIn(0f, 1f) else 1f - val globalAlpha = (opacity * 255f).toInt().coerceIn(0, 255) - - val textWidth = contentWidth() - val pillWidth = textWidth + mentionStyle.paddingHorizontal * 2f + mentionStyle.borderWidth * 2f - val metrics = textPaint.fontMetricsInt - val textHeight = metrics.descent - metrics.ascent - val pillHeight = textHeight + mentionStyle.paddingVertical * 2f + mentionStyle.borderWidth * 2f - - // Vertically center the pill on the surrounding text line. - val lineTop = top.toFloat() - val lineBottom = bottom.toFloat() - val pillTop = lineTop + ((lineBottom - lineTop) - pillHeight) / 2f - val pillBottom = pillTop + pillHeight - - val halfStroke = mentionStyle.borderWidth / 2f - val pillRect = - RectF( - x + halfStroke, - pillTop + halfStroke, - x + pillWidth - halfStroke, - pillBottom - halfStroke, - ) + 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 + val pillTop = top - paddingV + val pillBottom = bottom + paddingV + if (pillRight <= pillLeft || pillBottom <= pillTop) return + + rect.set(pillLeft, pillTop, pillRight, pillBottom) + val radius = - minOf( + min( mentionStyle.borderRadius, - minOf(pillRect.width(), pillRect.height()) / 2f, + min(rect.width(), rect.height()) / 2f, ) fillPaint.color = mentionStyle.backgroundColor @@ -116,7 +122,7 @@ class MentionSpan( ((fillPaint.color ushr 24) and 0xFF).let { baseAlpha -> (baseAlpha * opacity).toInt().coerceIn(0, 255) } - canvas.drawRoundRect(pillRect, radius, radius, fillPaint) + canvas.drawRoundRect(rect, radius, radius, fillPaint) if (mentionStyle.borderWidth > 0f) { strokePaint.strokeWidth = mentionStyle.borderWidth @@ -125,14 +131,20 @@ class MentionSpan( ((strokePaint.color ushr 24) and 0xFF).let { baseAlpha -> (baseAlpha * opacity).toInt().coerceIn(0, 255) } - canvas.drawRoundRect(pillRect, radius, radius, strokePaint) + canvas.drawRoundRect(rect, radius, radius, strokePaint) } + } - textPaint.alpha = globalAlpha - - val textX = x + mentionStyle.paddingHorizontal + mentionStyle.borderWidth - // Baseline-align the label inside the pill. - val textY = pillTop + mentionStyle.paddingVertical + mentionStyle.borderWidth - metrics.ascent - canvas.drawText(displayText, textX, textY, textPaint) + 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/apps/example/src/App.tsx b/apps/example/src/App.tsx index 459ab89c..77ef0eb6 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -96,6 +96,7 @@ export default function App() { }, ]); }; + return ( diff --git a/apps/example/src/markdownStyles.ts b/apps/example/src/markdownStyles.ts index b8fb363a..7100744c 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -156,7 +156,7 @@ export const customMarkdownStyle: MarkdownStyle = { citation: { backgroundColor: '#EBEBFF', color: '#9B9BFD', - fontSizeMultiplier: 0.7, + fontSizeMultiplier: 0.5, baselineOffsetPx: 1.5, fontWeight: '', underline: false, diff --git a/apps/web-example/src/App.tsx b/apps/web-example/src/App.tsx index 7547f280..ee242748 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,8 @@ export default function App() { onLinkPress={onLinkPress} onLinkLongPress={onLinkLongPress} onTaskListItemPress={onTaskListItemPress} + onCitationPress={handleCitationPress} + onMentionPress={handleMentionPress} /> From dbf4d5054ceeafdb0b7a2198946e5a07252f9fad Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 12:57:34 -0700 Subject: [PATCH 05/15] exclude citation from pasted content --- .../utils/text/view/SelectionActionMode.kt | 38 ++++++++- ios/utils/PasteboardUtils.m | 85 +++++++++++++++++-- src/web/EnrichedMarkdownText.tsx | 68 ++++++++++++++- src/web/renderers/InlineRenderers.tsx | 2 + 4 files changed, 185 insertions(+), 8 deletions(-) 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/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/src/web/EnrichedMarkdownText.tsx b/src/web/EnrichedMarkdownText.tsx index 80fc2e14..f7d60e8e 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'; @@ -119,6 +127,62 @@ 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. + // + // DOM types aren't in the tsconfig lib list, so we narrow through + // locally-scoped interfaces to access only the few APIs we need. + const handleCopy = useCallback((event: ClipboardEvent) => { + const globals = globalThis as unknown as { + window?: { + getSelection?: () => { + rangeCount: number; + getRangeAt: (i: number) => { + collapsed: boolean; + cloneContents: () => unknown; + }; + } | null; + }; + document?: { + createElement: (tag: string) => { + appendChild: (node: unknown) => void; + querySelectorAll: ( + selector: string + ) => Iterable<{ remove: () => void }>; + textContent: string | null; + innerHTML: string; + }; + }; + }; + + const win = globals.window; + const doc = globals.document; + if (!win || !doc) return; + + const selection = win.getSelection?.(); + if (!selection || selection.rangeCount === 0) return; + const range = selection.getRangeAt(0); + if (range.collapsed) return; + + const container = doc.createElement('div'); + container.appendChild(range.cloneContents()); + + for (const node of container.querySelectorAll(`.${CITATION_CLASS}`)) { + node.remove(); + } + + const clipboardData = ( + event as unknown as { + clipboardData: { setData: (type: string, data: string) => void }; + } + ).clipboardData; + clipboardData.setData('text/plain', container.textContent ?? ''); + clipboardData.setData('text/html', container.innerHTML); + event.preventDefault(); + }, []); + if (parseError) { return (
@@ -133,7 +197,7 @@ export const EnrichedMarkdownText = ({ const lastIdx = children.length - 1; return ( -
+
{children.map((child, index) => ( ` element so we @@ -156,6 +157,7 @@ function CitationRenderer({ return ( Date: Fri, 17 Apr 2026 12:59:17 -0700 Subject: [PATCH 06/15] web: allow select and highlight mentions --- src/web/styles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/web/styles.ts b/src/web/styles.ts index 2ad3113f..b430c7af 100644 --- a/src/web/styles.ts +++ b/src/web/styles.ts @@ -262,7 +262,6 @@ function mentionStyle(style: MarkdownStyleInternal): CSSProperties { fontWeight: normalizeFontWeight(mention.fontWeight), fontSize: mention.fontSize || undefined, cursor: 'pointer', - userSelect: 'none', transition: 'opacity 0.12s ease-in-out', lineHeight: 1, }; From 30a50467b8b40569803c8485b17bb5013280ecb7 Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 14:23:10 -0700 Subject: [PATCH 07/15] add citation border props --- ...mention_citation_tooltips_18cf1b1d.plan.md | 268 ++++++++++++++++++ .../enriched/markdown/spans/CitationSpan.kt | 30 +- .../enriched/markdown/styles/CitationStyle.kt | 9 + apps/example/src/markdownStyles.ts | 13 +- apps/web-example/src/App.tsx | 25 ++ ios/styles/StyleConfig.h | 6 + ios/styles/StyleConfig.mm | 36 +++ ios/utils/CitationBackground.m | 56 +++- ios/utils/MentionBackground.m | 35 ++- ios/utils/StylePropsUtils.h | 19 ++ src/EnrichedMarkdownNativeComponent.ts | 3 + src/EnrichedMarkdownTextNativeComponent.ts | 3 + src/normalizeMarkdownStyle.ts | 3 + src/normalizeMarkdownStyle.web.ts | 3 + src/types/MarkdownStyle.ts | 15 + src/types/MarkdownStyleInternal.ts | 3 + src/web/styles.ts | 11 +- 17 files changed, 509 insertions(+), 29 deletions(-) create mode 100644 .cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md diff --git a/.cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md b/.cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md new file mode 100644 index 00000000..9349a20b --- /dev/null +++ b/.cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md @@ -0,0 +1,268 @@ +--- +name: mention citation tooltips +overview: Emit hover events with pill coordinates on web so consumers can render their own mention/citation tooltips, and add optional rect data to the existing onMentionPress / onCitationPress events on all platforms. +todos: + - id: event_types + content: Add InlineElementRect and extend MentionPressEvent / CitationPressEvent with optional rect in src/types/events.ts; add new MentionHoverEvent / CitationHoverEvent + status: pending + - id: types_web + content: Extend src/types/MarkdownTextProps.web.ts with onMentionHoverChange and onCitationHoverChange + status: pending + - id: renderer_types + content: Extend RendererCallbacks in src/web/types.ts with the two new hover callbacks + status: pending + - id: wire_inline_renderers + content: In src/web/renderers/InlineRenderers.tsx, fire onMentionHoverChange / onCitationHoverChange on pointerenter / pointerleave / focus / blur, and include rect in onMentionPress / onCitationPress on click + status: pending + - id: wire_web_root + content: Thread the two new hover props through src/web/EnrichedMarkdownText.tsx with stable memoization + status: pending + - id: types_shared + content: Mirror the event-type additions in src/types/MarkdownTextProps.ts so rect shows up on the native-facing press events too (documented per-platform as container-relative) + status: pending +isProject: false +--- + +## Goal + +- Web: emit hover-in / hover-out events with the hovered pill's viewport coordinates so consumers can render their own tooltip. +- All platforms: add an optional `rect` field to the existing `onMentionPress` / `onCitationPress` events. No new long-press events, no gesture changes on mobile. + +## Public API + +### Event types — [src/types/events.ts](src/types/events.ts) + +```ts +export interface InlineElementRect { + /** + * Web: viewport-relative (from getBoundingClientRect()). + * iOS / Android: container-relative (origin is the EnrichedMarkdownText view). + */ + x: number; + y: number; + width: number; + height: number; +} + +export interface MentionPressEvent { + url: string; + text: string; + /** Pill bounding rect at press time. Present on all platforms. */ + rect?: InlineElementRect; +} + +export interface CitationPressEvent { + url: string; + text: string; + rect?: InlineElementRect; +} + +export interface MentionHoverEvent { + url: string; + text: string; + hovered: boolean; + /** Present when hovered === true; omitted on hover-out. */ + rect?: InlineElementRect; +} + +export interface CitationHoverEvent { + url: string; + text: string; + hovered: boolean; + rect?: InlineElementRect; +} +``` + +`rect` is optional to preserve back-compat (future platforms / edge cases where we can't compute it can omit it). + +### New props — [src/types/MarkdownTextProps.web.ts](src/types/MarkdownTextProps.web.ts) + +```ts +onMentionHoverChange?: (event: MentionHoverEvent) => void; +onCitationHoverChange?: (event: CitationHoverEvent) => void; +``` + +Single event with `hovered: boolean` rather than two separate in/out events so consumers can drive a `useState` with one handler and avoid stale-state races. + +## Web implementation (this pass) + +Files touched: + +- [src/types/events.ts](src/types/events.ts): add `InlineElementRect`, extend `MentionPressEvent` / `CitationPressEvent` with `rect`, add `MentionHoverEvent` / `CitationHoverEvent`. +- [src/types/MarkdownTextProps.web.ts](src/types/MarkdownTextProps.web.ts): add `onMentionHoverChange`, `onCitationHoverChange`. +- [src/types/MarkdownTextProps.ts](src/types/MarkdownTextProps.ts): no new props (native keeps same two press callbacks), but imports updated event types automatically. +- [src/web/types.ts](src/web/types.ts): add `onMentionHoverChange` / `onCitationHoverChange` to `RendererCallbacks`. +- [src/web/EnrichedMarkdownText.tsx](src/web/EnrichedMarkdownText.tsx): accept + memoize the two new props into `callbacks`. +- [src/web/renderers/InlineRenderers.tsx](src/web/renderers/InlineRenderers.tsx): wire pointer / focus handlers on mention `` and citation ``; include `rect` on click handlers. + +### Renderer change (sketch) + +```tsx +function MentionRenderer({ url, callbacks, node, styles }: SchemeRendererProps) { + const mentionUrl = url.slice(MENTION_SCHEME.length); + const displayText = extractNodeText(node); + + const rectFromEvent = ( + event: { currentTarget: { getBoundingClientRect: () => InlineElementRect } } + ): InlineElementRect => { + const r = event.currentTarget.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }; + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + callbacks.onMentionPress?.({ + url: mentionUrl, + text: displayText, + rect: rectFromEvent(event), + }); + }; + + const handleHoverIn = (event: PointerEvent | FocusEvent) => { + callbacks.onMentionHoverChange?.({ + url: mentionUrl, + text: displayText, + hovered: true, + rect: rectFromEvent(event), + }); + }; + + const handleHoverOut = () => { + callbacks.onMentionHoverChange?.({ + url: mentionUrl, + text: displayText, + hovered: false, + }); + }; + + // ... existing pressed-opacity style unchanged ... + + return ( + <> + + + {displayText} + + + ); +} +``` + +Citations get the same rect-on-click + hover handlers on ``. + +### Semantics + +- `rect` on web is viewport-coordinates (`getBoundingClientRect()`), captured once at event emission time. +- Hover-in fires on `pointerenter` and `focus` (keyboard a11y). Hover-out fires on `pointerleave` and `blur`. +- No debounce / delay in the library — consumers add `setTimeout` if they want a hover delay. +- Existing click → `onMentionPress` / `onCitationPress` behavior is unchanged except the payload now carries `rect`. + +### Scroll behavior (documented, not handled) + +The library emits `rect` once per event — it does not re-emit on scroll or resize. If the page scrolls (or an ancestor scroll container scrolls) while a tooltip is open, the stored viewport rect goes stale and the tooltip will visually detach from the pill. + +Recommended consumer patterns, listed from simplest to most robust, go into the JSDoc for `onMentionHoverChange` / `onCitationHoverChange`: + +1. **Dismiss on scroll.** Listen to `scroll` on `window` (capture) while hovered and clear tooltip state. Simplest and most common tooltip UX. +2. **Re-measure on scroll.** Keep the mention id in state on hover-in, and re-query the DOM (`document.querySelector(` `` `[data-mention-url="${url}"]` `` `)`) on scroll to refresh position. Works because `InlineRenderers` already stamps `data-mention-url` / `data-citation-url` on the element. +3. **Upgrade to a ref-based lib.** If live repositioning matters, a future iteration of this plan can add an optional `target: HTMLElement` field to the web hover event without breaking back-compat. Out of scope here. + +Same story on native: `rect` is emitted once, and if the user scrolls the surrounding RN `ScrollView` the consumer is responsible for dismissing or re-emitting (typically wired to the `ScrollView`'s `onScroll`). This library does not own the scroll container and has no API for it. + +## Native plan (documented, not implemented) + +Only one change needed on each platform: include `rect` in the existing press event. No new gestures, no new callbacks. + +iOS ([ios/utils/LinkTapUtils.m](ios/utils/LinkTapUtils.m), [ios/EnrichedMarkdownText.mm](ios/EnrichedMarkdownText.mm)): + +- At the point `inlineElementAtTapLocation` resolves a mention or citation, compute the full attribute run's character range (`effectiveRange:` out-param from `attributesAtIndex:`), then call `NSLayoutManager boundingRectForGlyphRange:inTextContainer:` for that glyph range. +- Combine with `lineFragmentRectForGlyphAtIndex:` for the hit glyph when the run is multi-line, so line-height and padding are accounted for correctly (not just the glyph ink extent). +- Convert to the `EnrichedMarkdownText` view's coordinate space by adding the text container inset / the host view's `bounds.origin` offset as used elsewhere in this file. +- Include the rect on the `MentionPressEvent` / `CitationPressEvent` payload emitted from `EnrichedMarkdownText.mm`. + +Android ([android/.../spans/MentionSpan.kt](android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt), [android/.../spans/CitationSpan.kt](android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt), [android/.../utils/text/view/LinkLongPressMovementMethod.kt](android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt)): + +- In `LinkLongPressMovementMethod.onTouchEvent` where `onMentionTap` / `onCitationTap` currently fire, compute the span's rect from `Layout.getPrimaryHorizontal(spanStart..spanEnd)` + `Layout.getLineTop` / `getLineBottom` for the hit line. +- `Layout` coordinates are text-relative, not view-relative. Before emitting, add `textView.totalPaddingLeft` to x and `textView.totalPaddingTop` to y so the rect is aligned with the `EnrichedMarkdownText` View's origin. +- Thread the rect through `MentionPressEvent.kt` / `CitationPressEvent.kt` to the JS event. + +Both platforms report **container-relative** rects (relative to the `EnrichedMarkdownText` view), documented on `InlineElementRect`. + +Since this only extends an existing optional field, it is fully backward-compatible: consumers already reading `url` / `text` see no changes. + +Hover events on native are explicitly out of scope — mobile has no hover. + +### Consumer guidance on native (to include in JSDoc for `rect`) + +Most RN positioning libraries (e.g. `@floating-ui/react-native`) expect window / global coordinates, not container-relative ones. To convert: + +```tsx +const ref = useRef(null); +const [containerOrigin, setContainerOrigin] = useState({ x: 0, y: 0 }); + + { + ref.current?.measureInWindow((x, y) => setContainerOrigin({ x, y })); + }} + onMentionPress={({ url, text, rect }) => { + const windowRect = rect && { + x: rect.x + containerOrigin.x, + y: rect.y + containerOrigin.y, + width: rect.width, + height: rect.height, + }; + // feed windowRect to Floating UI / custom overlay + }} +/> +``` + +Rationale for keeping the emitted rect container-relative rather than global: the component itself may move (animated transitions, keyboard avoidance, parent re-layout), and a container-relative rect remains valid as long as the consumer re-measures the container on layout changes. + +## Flow diagram + +```mermaid +sequenceDiagram + participant User + participant Renderer as InlineRenderers + participant Consumer as App + + Note over User,Consumer: Hover (web only) + User->>Renderer: pointerenter on mention + Renderer->>Consumer: onMentionHoverChange({hovered:true, url, text, rect}) + Consumer->>Consumer: setState -> render custom tooltip overlay + User->>Renderer: pointerleave + Renderer->>Consumer: onMentionHoverChange({hovered:false, url, text}) + + Note over User,Consumer: Press (all platforms) + User->>Renderer: click / tap on mention + Renderer->>Consumer: onMentionPress({url, text, rect}) +``` + +## Out of scope + +- No long-press events for mention / citation on mobile. +- No tooltip UI shipped with the library — consumers own all rendering / positioning / a11y. +- No markdown syntax changes; no new `MarkdownStyle` slots. + +## Risks / notes + +- Multi-line mention wraps: `getBoundingClientRect()` returns the bounding box across all line fragments on web (one tall rect spanning both lines); native equivalents collapse to the hit line. Documented platform asymmetry. If this becomes visually jarring, a future iteration can switch the web rect to `event.currentTarget.getClientRects()[hitLineIndex]` to anchor to the specific hovered line — non-breaking since `InlineElementRect` shape stays the same. +- Rect is optional on both press and hover event types. Consumers must null-check before using it. +- Rect is a point-in-time snapshot. The library does not track the pill through scrolls / resizes; handling that is the consumer's responsibility (see "Scroll behavior" above). Deliberate choice to keep the API thin and uniform across platforms. + +## Future considerations (not in this pass) + +- **`target: HTMLElement` field on web hover event.** Enables live re-positioning with Floating UI / Popper on scroll/resize without consumer re-querying DOM. Additive, non-breaking. +- **`padding` or `inset` in `InlineElementRect`.** If consumers want the rect slightly expanded (e.g. to allow a fade/slide tooltip entry animation without visual jitter at the edges), a numeric padding can be added to the event payload later. Out of scope for v1 — consumers can expand the rect themselves. +- **Per-line-fragment rect on web** for long multi-line mention wraps (see Risks above). 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 index b92a4381..01e0b3d4 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt @@ -23,6 +23,7 @@ class CitationSpan( 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) { @@ -104,15 +105,30 @@ class CitationSpan( 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 - val radius = minOf((bgBottom - bgTop) / 2f, chipWidth / 2f) - canvas.drawRoundRect( - RectF(x, bgTop, x + chipWidth, bgBottom), - radius, - radius, - fillPaint, - ) + 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) 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 index c07ef43e..8d8f3325 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/styles/CitationStyle.kt @@ -11,6 +11,9 @@ data class CitationStyle( val backgroundColor: Int?, val paddingHorizontal: Float, val paddingVertical: Float, + val borderColor: Int?, + val borderWidth: Float, + val borderRadius: Float, ) { companion object { fun fromReadableMap( @@ -25,6 +28,9 @@ data class CitationStyle( 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, @@ -35,6 +41,9 @@ data class CitationStyle( backgroundColor = backgroundColor, paddingHorizontal = paddingHorizontal, paddingVertical = paddingVertical, + borderColor = borderColor, + borderWidth = borderWidth, + borderRadius = borderRadius, ) } } diff --git a/apps/example/src/markdownStyles.ts b/apps/example/src/markdownStyles.ts index 7100744c..7ba72e34 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -146,9 +146,9 @@ export const customMarkdownStyle: MarkdownStyle = { backgroundColor: '#EBEBFF', borderColor: '#ddd6fe', borderWidth: 1, - borderRadius: 4, + borderRadius: 99, paddingHorizontal: 4, - paddingVertical: 2, + paddingVertical: 0, fontFamily: 'Montserrat-Regular', fontSize: 14, color: '#2563fb', @@ -157,10 +157,13 @@ export const customMarkdownStyle: MarkdownStyle = { backgroundColor: '#EBEBFF', color: '#9B9BFD', fontSizeMultiplier: 0.5, - baselineOffsetPx: 1.5, + baselineOffsetPx: 7, fontWeight: '', underline: false, - paddingHorizontal: 0, - paddingVertical: 0, + paddingHorizontal: 4, + paddingVertical: 2, + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 99, }, }; diff --git a/apps/web-example/src/App.tsx b/apps/web-example/src/App.tsx index ee242748..493615a0 100644 --- a/apps/web-example/src/App.tsx +++ b/apps/web-example/src/App.tsx @@ -171,6 +171,31 @@ export default function App() { 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: 4, + paddingVertical: 2, + borderColor: '#ddd6fe', + borderWidth: 1, + borderRadius: 99, + }, + }} /> diff --git a/ios/styles/StyleConfig.h b/ios/styles/StyleConfig.h index 6b87e93c..ecdedff1 100644 --- a/ios/styles/StyleConfig.h +++ b/ios/styles/StyleConfig.h @@ -405,5 +405,11 @@ - (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 a6c76b85..a8422df7 100644 --- a/ios/styles/StyleConfig.mm +++ b/ios/styles/StyleConfig.mm @@ -249,6 +249,9 @@ @implementation StyleConfig { RCTUIColor *_citationBackgroundColor; CGFloat _citationPaddingHorizontal; CGFloat _citationPaddingVertical; + RCTUIColor *_citationBorderColor; + CGFloat _citationBorderWidth; + CGFloat _citationBorderRadius; } - (instancetype)init @@ -544,6 +547,9 @@ - (id)copyWithZone:(NSZone *)zone copy->_citationBackgroundColor = [_citationBackgroundColor copy]; copy->_citationPaddingHorizontal = _citationPaddingHorizontal; copy->_citationPaddingVertical = _citationPaddingVertical; + copy->_citationBorderColor = [_citationBorderColor copy]; + copy->_citationBorderWidth = _citationBorderWidth; + copy->_citationBorderRadius = _citationBorderRadius; return copy; } @@ -2656,4 +2662,34 @@ - (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.m b/ios/utils/CitationBackground.m index ce0c623e..5405b255 100644 --- a/ios/utils/CitationBackground.m +++ b/ios/utils/CitationBackground.m @@ -31,8 +31,12 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow RCTUIColor *bgColor = [_config citationBackgroundColor]; CGFloat paddingH = [_config citationPaddingHorizontal]; CGFloat paddingV = [_config citationPaddingVertical]; + RCTUIColor *borderColor = [_config citationBorderColor]; + CGFloat borderWidth = [_config citationBorderWidth]; + CGFloat borderRadius = [_config citationBorderRadius]; - if (!bgColor) + // Nothing to paint when neither a fill nor a stroke would be visible. + if (!bgColor && (!borderColor || borderWidth <= 0)) return; [textStorage @@ -49,29 +53,63 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow 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 rect, CGRect usedRect, NSTextContainer *tc, + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *tc, NSRange lineRange, BOOL *lineStop) { NSRange intersect = NSIntersectionRange(lineRange, glyphRange); if (intersect.length == 0) return; + // Horizontal extent: tight to the glyph run. CGRect glyphRect = [layoutManager boundingRectForGlyphRange:intersect inTextContainer:textContainer]; - CGRect chipRect = CGRectMake(glyphRect.origin.x + origin.x - paddingH, - glyphRect.origin.y + origin.y - paddingV, - glyphRect.size.width + paddingH * 2, - glyphRect.size.height + paddingV * 2); + // 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. + CGPoint glyphLocation = + [layoutManager locationForGlyphAtIndex:intersect.location]; + CGFloat baselineY = lineRect.origin.y + glyphLocation.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; - CGFloat radius = MIN(chipRect.size.width, chipRect.size.height) / 2.0; + CGRect chipRect = CGRectMake( + glyphRect.origin.x + origin.x - paddingH, chipTop + origin.y, + glyphRect.size.width + 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]; - [bgColor setFill]; - [path fill]; + if (bgColor) { + [bgColor setFill]; + [path fill]; + } + + if (borderColor && borderWidth > 0) { + path.lineWidth = borderWidth; + [borderColor setStroke]; + [path stroke]; + } }]; }]; } diff --git a/ios/utils/MentionBackground.m b/ios/utils/MentionBackground.m index 572bb0c5..8f765575 100644 --- a/ios/utils/MentionBackground.m +++ b/ios/utils/MentionBackground.m @@ -53,21 +53,46 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow 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 rect, CGRect usedRect, NSTextContainer *tc, + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *tc, NSRange lineRange, BOOL *lineStop) { NSRange intersect = NSIntersectionRange(lineRange, glyphRange); if (intersect.length == 0) return; + // Horizontal extent: tight to the mention glyph run. CGRect glyphRect = [layoutManager boundingRectForGlyphRange:intersect inTextContainer:textContainer]; - CGRect pillRect = CGRectMake(glyphRect.origin.x + origin.x - paddingH, - glyphRect.origin.y + origin.y - paddingV, - glyphRect.size.width + paddingH * 2, - glyphRect.size.height + paddingV * 2); + + // 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 + // (which can be taller when other inline elements on the + // line have larger metrics). + CGPoint glyphLocation = + [layoutManager locationForGlyphAtIndex:intersect.location]; + CGFloat baselineY = lineRect.origin.y + glyphLocation.y; + + CGFloat ascent = mentionFont ? mentionFont.ascender : 0; + // UIFont.descender is negative (points below baseline); + // subtract it to move downward from the baseline. + CGFloat descent = mentionFont ? mentionFont.descender : 0; + + CGFloat pillTop = baselineY - ascent - paddingV; + CGFloat pillBottom = baselineY - descent + paddingV; + + CGRect pillRect = CGRectMake( + glyphRect.origin.x + origin.x - paddingH, pillTop + origin.y, + glyphRect.size.width + paddingH * 2, MAX(0, pillBottom - pillTop)); CGFloat radius = MIN( borderRadius, MIN(pillRect.size.width, pillRect.size.height) / 2.0); diff --git a/ios/utils/StylePropsUtils.h b/ios/utils/StylePropsUtils.h index f32b9fd5..99ca21f5 100644 --- a/ios/utils/StylePropsUtils.h +++ b/ios/utils/StylePropsUtils.h @@ -1201,5 +1201,24 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty 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/src/EnrichedMarkdownNativeComponent.ts b/src/EnrichedMarkdownNativeComponent.ts index b53e1357..915bd6fc 100644 --- a/src/EnrichedMarkdownNativeComponent.ts +++ b/src/EnrichedMarkdownNativeComponent.ts @@ -175,6 +175,9 @@ interface CitationStyleInternal { backgroundColor: ColorValue; paddingHorizontal: CodegenTypes.Float; paddingVertical: CodegenTypes.Float; + borderColor: ColorValue; + borderWidth: CodegenTypes.Float; + borderRadius: CodegenTypes.Float; } export interface MarkdownStyleInternal { diff --git a/src/EnrichedMarkdownTextNativeComponent.ts b/src/EnrichedMarkdownTextNativeComponent.ts index 28821d21..9ec7abef 100644 --- a/src/EnrichedMarkdownTextNativeComponent.ts +++ b/src/EnrichedMarkdownTextNativeComponent.ts @@ -175,6 +175,9 @@ interface CitationStyleInternal { backgroundColor: ColorValue; paddingHorizontal: CodegenTypes.Float; paddingVertical: CodegenTypes.Float; + borderColor: ColorValue; + borderWidth: CodegenTypes.Float; + borderRadius: CodegenTypes.Float; } export interface MarkdownStyleInternal { diff --git a/src/normalizeMarkdownStyle.ts b/src/normalizeMarkdownStyle.ts index c904cb67..2946a70a 100644 --- a/src/normalizeMarkdownStyle.ts +++ b/src/normalizeMarkdownStyle.ts @@ -226,6 +226,9 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({ backgroundColor: 'transparent', paddingHorizontal: 0, paddingVertical: 0, + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 999, }, }) as MarkdownStyleInternal; diff --git a/src/normalizeMarkdownStyle.web.ts b/src/normalizeMarkdownStyle.web.ts index bf0d5801..34c7bcdd 100644 --- a/src/normalizeMarkdownStyle.web.ts +++ b/src/normalizeMarkdownStyle.web.ts @@ -207,6 +207,9 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({ backgroundColor: 'transparent', paddingHorizontal: 0, paddingVertical: 0, + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 999, }, }); diff --git a/src/types/MarkdownStyle.ts b/src/types/MarkdownStyle.ts index e2b8a194..9c282ae9 100644 --- a/src/types/MarkdownStyle.ts +++ b/src/types/MarkdownStyle.ts @@ -226,6 +226,21 @@ interface CitationStyle { * @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 { diff --git a/src/types/MarkdownStyleInternal.ts b/src/types/MarkdownStyleInternal.ts index 7befe862..101e53c3 100644 --- a/src/types/MarkdownStyleInternal.ts +++ b/src/types/MarkdownStyleInternal.ts @@ -175,6 +175,9 @@ interface CitationStyleInternal { backgroundColor: string; paddingHorizontal: number; paddingVertical: number; + borderColor: string; + borderWidth: number; + borderRadius: number; } export interface MarkdownStyleInternal { diff --git a/src/web/styles.ts b/src/web/styles.ts index b430c7af..dd333e2b 100644 --- a/src/web/styles.ts +++ b/src/web/styles.ts @@ -271,6 +271,10 @@ 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})`, @@ -282,10 +286,11 @@ function citationStyle(style: MarkdownStyleInternal): CSSProperties { textDecoration: citation.underline ? 'underline' : undefined, paddingInline: citation.paddingHorizontal || undefined, paddingBlock: citation.paddingVertical || undefined, - // Round the chip when a background is set; a small radius keeps inline - // citation markers looking like pills rather than square boxes. + borderStyle: hasBorder ? 'solid' : undefined, + borderColor: hasBorder ? citation.borderColor : undefined, + borderWidth: hasBorder ? citation.borderWidth : undefined, borderRadius: - hasBackground && citation.paddingHorizontal > 0 ? 999 : undefined, + hasBackground || hasBorder ? citation.borderRadius : undefined, cursor: 'pointer', }; } From 93577dad0882d6fb3c561904eee70a6685b7bb7e Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 14:38:07 -0700 Subject: [PATCH 08/15] ios: citation should have margin around it --- apps/example/src/markdownStyles.ts | 2 +- apps/example/src/sampleMarkdown.ts | 2 +- apps/web-example/src/App.tsx | 2 +- ios/renderer/LinkRenderer.m | 12 ++++++ ios/utils/CitationBackground.m | 61 +++++++++++++++++++++++++----- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/apps/example/src/markdownStyles.ts b/apps/example/src/markdownStyles.ts index 7ba72e34..5ba474c8 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -160,7 +160,7 @@ export const customMarkdownStyle: MarkdownStyle = { baselineOffsetPx: 7, fontWeight: '', underline: false, - paddingHorizontal: 4, + paddingHorizontal: 2, paddingVertical: 2, borderColor: '#ddd6fe', borderWidth: 1, diff --git a/apps/example/src/sampleMarkdown.ts b/apps/example/src/sampleMarkdown.ts index 1c1919dc..3b3fdc10 100644 --- a/apps/example/src/sampleMarkdown.ts +++ b/apps/example/src/sampleMarkdown.ts @@ -17,7 +17,7 @@ Forests are often called the *lungs of the Earth*. They absorb **carbon dioxide* ### Key Benefits -- **Climate regulation** through carbon sequestration [@Casper](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) +- **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 diff --git a/apps/web-example/src/App.tsx b/apps/web-example/src/App.tsx index 493615a0..418fd0fd 100644 --- a/apps/web-example/src/App.tsx +++ b/apps/web-example/src/App.tsx @@ -189,7 +189,7 @@ export default function App() { baselineOffsetPx: 7, fontWeight: '', underline: false, - paddingHorizontal: 4, + paddingHorizontal: 2, paddingVertical: 2, borderColor: '#ddd6fe', borderWidth: 1, diff --git a/ios/renderer/LinkRenderer.m b/ios/renderer/LinkRenderer.m index c2f4efb3..a10accea 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -189,6 +189,7 @@ - (void)renderCitationNode:(MarkdownASTNode *)node RCTUIColor *citationColor = [_config citationColor]; NSString *fontWeight = [_config citationFontWeight]; BOOL underline = [_config citationUnderline]; + CGFloat paddingHorizontal = [_config citationPaddingHorizontal]; [output enumerateAttributesInRange:range options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired @@ -237,6 +238,17 @@ - (void)renderCitationNode:(MarkdownASTNode *)node [output addAttribute:ENRMCitationURLAttributeName value:targetURL range:range]; [output addAttribute:ENRMCitationTextAttributeName value:labelText range:range]; + // The drawn chip background extends `paddingHorizontal` beyond the glyph run + // on each side. Inline text doesn't reserve any advance for that visual + // padding, so adjacent citations (and following text) would sit right up + // against our glyphs, causing the drawn chips to overlap. Applying NSKern + // on the last character adds the missing trailing advance so consecutive + // chips have the same natural spacing they'd get on web via CSS padding. + if (paddingHorizontal > 0 && range.length > 0) { + NSRange lastCharRange = NSMakeRange(NSMaxRange(range) - 1, 1); + [output addAttribute:NSKernAttributeName value:@(paddingHorizontal * 2) range:lastCharRange]; + } + [context registerCitationRange:range url:targetURL text:labelText]; } diff --git a/ios/utils/CitationBackground.m b/ios/utils/CitationBackground.m index 5405b255..c04c1574 100644 --- a/ios/utils/CitationBackground.m +++ b/ios/utils/CitationBackground.m @@ -39,6 +39,8 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow if (!bgColor && (!borderColor || borderWidth <= 0)) return; + NSUInteger totalGlyphs = [layoutManager numberOfGlyphs]; + [textStorage enumerateAttribute:ENRMCitationURLAttributeName inRange:NSMakeRange(0, textStorage.length) @@ -68,10 +70,50 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow if (intersect.length == 0) return; - // Horizontal extent: tight to the glyph run. - CGRect glyphRect = - [layoutManager boundingRectForGlyphRange:intersect - inTextContainer:textContainer]; + // 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 @@ -79,9 +121,7 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow // height. `locationForGlyphAtIndex:` returns the baseline // in the line fragment's coordinate system and already // accounts for NSBaselineOffsetAttributeName. - CGPoint glyphLocation = - [layoutManager locationForGlyphAtIndex:intersect.location]; - CGFloat baselineY = lineRect.origin.y + glyphLocation.y; + CGFloat baselineY = lineRect.origin.y + firstLoc.y; CGFloat ascent = citationFont ? citationFont.ascender : 0; // UIFont.descender is negative (points below baseline); @@ -91,9 +131,10 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow CGFloat chipTop = baselineY - ascent - paddingV; CGFloat chipBottom = baselineY - descent + paddingV; - CGRect chipRect = CGRectMake( - glyphRect.origin.x + origin.x - paddingH, chipTop + origin.y, - glyphRect.size.width + paddingH * 2, MAX(0, chipBottom - chipTop)); + 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); From 7bd117ca05aaa08036cd79852d7f9860f881cadc Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 14:51:53 -0700 Subject: [PATCH 09/15] mention should have margin around too --- .../markdown/renderer/LinkRenderer.kt | 20 ++++++ .../markdown/spans/MentionSpacerSpan.kt | 55 +++++++++++++++ ios/renderer/LinkRenderer.m | 12 ++++ ios/utils/MentionBackground.m | 67 ++++++++++++++----- 4 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpacerSpan.kt 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 b7163ab9..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 @@ -4,6 +4,7 @@ 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 @@ -101,6 +102,25 @@ class LinkRenderer( 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( 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/ios/renderer/LinkRenderer.m b/ios/renderer/LinkRenderer.m index a10accea..45a93f42 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -165,6 +165,18 @@ - (void)renderMentionNode:(MarkdownASTNode *)node [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 each + // side. Inline text doesn't reserve any advance for that visual padding, so + // two adjacent mentions (separated only by a space) would have their pills + // visually overlap. Stamping NSKern on the last glyph pushes the following + // character away by the same amount the pill extends, matching what CSS + // `paddingInline` does on web. + CGFloat mentionPaddingH = [_config mentionPaddingHorizontal]; + if (mentionPaddingH > 0 && outputRange.length > 0) { + NSRange lastCharRange = NSMakeRange(NSMaxRange(outputRange) - 1, 1); + [output addAttribute:NSKernAttributeName value:@(mentionPaddingH * 2) range:lastCharRange]; + } + [context registerMentionRange:outputRange url:mentionURL text:displayText]; } diff --git a/ios/utils/MentionBackground.m b/ios/utils/MentionBackground.m index 8f765575..a4bb7074 100644 --- a/ios/utils/MentionBackground.m +++ b/ios/utils/MentionBackground.m @@ -39,6 +39,8 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow if (!bgColor && (!borderColor || borderWidth <= 0)) return; + NSUInteger totalGlyphs = [layoutManager numberOfGlyphs]; + [textStorage enumerateAttribute:ENRMMentionURLAttributeName inRange:NSMakeRange(0, textStorage.length) @@ -68,31 +70,66 @@ - (void)drawBackgroundsForGlyphRange:(NSRange)glyphsToShow if (intersect.length == 0) return; - // Horizontal extent: tight to the mention glyph run. - CGRect glyphRect = - [layoutManager boundingRectForGlyphRange:intersect - inTextContainer:textContainer]; + // 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 - // (which can be taller when other inline elements on the - // line have larger metrics). - CGPoint glyphLocation = - [layoutManager locationForGlyphAtIndex:intersect.location]; - CGFloat baselineY = lineRect.origin.y + glyphLocation.y; + // 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 (points below baseline); - // subtract it to move downward from the baseline. + // 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( - glyphRect.origin.x + origin.x - paddingH, pillTop + origin.y, - glyphRect.size.width + paddingH * 2, MAX(0, pillBottom - pillTop)); + 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); From 65a6494e7ab46a1d5435d60d7de2112677e84d3b Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 15:14:08 -0700 Subject: [PATCH 10/15] android: show mention background color inside blockquote --- .../markdown/renderer/BlockquoteRenderer.kt | 9 +++-- .../markdown/renderer/SpanStyleCache.kt | 13 +++++++ .../enriched/markdown/spans/BlockquoteSpan.kt | 35 ++++++++++++++++--- .../markdown/utils/text/span/SpanFlags.kt | 12 +++++++ apps/example/src/markdownStyles.ts | 2 +- apps/example/src/sampleMarkdown.ts | 2 +- 6 files changed, 64 insertions(+), 9 deletions(-) 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/SpanStyleCache.kt b/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt index 53095922..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 @@ -53,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/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/apps/example/src/markdownStyles.ts b/apps/example/src/markdownStyles.ts index 5ba474c8..16046118 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -151,7 +151,7 @@ export const customMarkdownStyle: MarkdownStyle = { paddingVertical: 0, fontFamily: 'Montserrat-Regular', fontSize: 14, - color: '#2563fb', + color: '#2561fB', }, citation: { backgroundColor: '#EBEBFF', diff --git a/apps/example/src/sampleMarkdown.ts b/apps/example/src/sampleMarkdown.ts index 3b3fdc10..e3dfa2cf 100644 --- a/apps/example/src/sampleMarkdown.ts +++ b/apps/example/src/sampleMarkdown.ts @@ -11,7 +11,7 @@ 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. -> [@John Muir](mention://d81546aa-5f91-408a-b6dd-628e324750bf?type=user) 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 From c258b4d5b03518a661af135c9a25b03c0bff6958 Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 15:24:22 -0700 Subject: [PATCH 11/15] android: mention background size to the mention text itself --- .../swmansion/enriched/markdown/spans/MentionSpan.kt | 11 +++++++++-- apps/example/src/sampleMarkdown.ts | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) 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 index c841bcb2..a0efb4fb 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt @@ -105,8 +105,15 @@ class MentionSpan( val pillLeft = left + min(startOffset, endOffset) - paddingH val pillRight = left + max(startOffset, endOffset) + paddingH - val pillTop = top - paddingV - val pillBottom = bottom + paddingV + // 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) diff --git a/apps/example/src/sampleMarkdown.ts b/apps/example/src/sampleMarkdown.ts index e3dfa2cf..355e48a3 100644 --- a/apps/example/src/sampleMarkdown.ts +++ b/apps/example/src/sampleMarkdown.ts @@ -17,8 +17,8 @@ Forests are often called the *lungs of the Earth*. They absorb **carbon dioxide* ### Key Benefits -- **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) +- **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** From 4c3433d912fdf8af820ce054fed0d7e23c195edf Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 15:31:42 -0700 Subject: [PATCH 12/15] android: include citation links when copy as markdown --- .../text/conversion/MarkdownExtractor.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 } From b233abbbe1478fe2fa843656b610dc7464c58535 Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 20:56:44 -0700 Subject: [PATCH 13/15] ios: citation padding should not cover adjacent content --- apps/example/src/markdownStyles.ts | 8 +-- apps/example/src/sampleMarkdown.ts | 8 +++ apps/macos-example/src/sampleMarkdown.ts | 6 +-- ios/renderer/LinkRenderer.m | 66 +++++++++++++++++------- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/apps/example/src/markdownStyles.ts b/apps/example/src/markdownStyles.ts index 16046118..057f6d29 100644 --- a/apps/example/src/markdownStyles.ts +++ b/apps/example/src/markdownStyles.ts @@ -156,12 +156,12 @@ export const customMarkdownStyle: MarkdownStyle = { citation: { backgroundColor: '#EBEBFF', color: '#9B9BFD', - fontSizeMultiplier: 0.5, - baselineOffsetPx: 7, + fontSizeMultiplier: 0.6, + baselineOffsetPx: 8, fontWeight: '', underline: false, - paddingHorizontal: 2, - paddingVertical: 2, + 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 355e48a3..b2916445 100644 --- a/apps/example/src/sampleMarkdown.ts +++ b/apps/example/src/sampleMarkdown.ts @@ -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 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/ios/renderer/LinkRenderer.m b/ios/renderer/LinkRenderer.m index 45a93f42..f7353a21 100644 --- a/ios/renderer/LinkRenderer.m +++ b/ios/renderer/LinkRenderer.m @@ -48,6 +48,39 @@ static BOOL isCitationURL(NSString *url) 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 @@ -165,16 +198,19 @@ - (void)renderMentionNode:(MarkdownASTNode *)node [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 each - // side. Inline text doesn't reserve any advance for that visual padding, so - // two adjacent mentions (separated only by a space) would have their pills - // visually overlap. Stamping NSKern on the last glyph pushes the following - // character away by the same amount the pill extends, matching what CSS - // `paddingInline` does on web. + // 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) { - NSRange lastCharRange = NSMakeRange(NSMaxRange(outputRange) - 1, 1); - [output addAttribute:NSKernAttributeName value:@(mentionPaddingH * 2) range:lastCharRange]; + applyChipKern(output, outputRange, mentionPaddingH); } [context registerMentionRange:outputRange url:mentionURL text:displayText]; @@ -250,16 +286,10 @@ - (void)renderCitationNode:(MarkdownASTNode *)node [output addAttribute:ENRMCitationURLAttributeName value:targetURL range:range]; [output addAttribute:ENRMCitationTextAttributeName value:labelText range:range]; - // The drawn chip background extends `paddingHorizontal` beyond the glyph run - // on each side. Inline text doesn't reserve any advance for that visual - // padding, so adjacent citations (and following text) would sit right up - // against our glyphs, causing the drawn chips to overlap. Applying NSKern - // on the last character adds the missing trailing advance so consecutive - // chips have the same natural spacing they'd get on web via CSS padding. - if (paddingHorizontal > 0 && range.length > 0) { - NSRange lastCharRange = NSMakeRange(NSMaxRange(range) - 1, 1); - [output addAttribute:NSKernAttributeName value:@(paddingHorizontal * 2) range:lastCharRange]; - } + // 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]; } From 2b2d6b66b6d02f1f01c302cc9373f8b21654e4a1 Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Fri, 17 Apr 2026 21:40:39 -0700 Subject: [PATCH 14/15] remove plan --- ...ne_mentions_and_citations_58e9f474.plan.md | 137 --------- ...mention_citation_tooltips_18cf1b1d.plan.md | 268 ------------------ 2 files changed, 405 deletions(-) delete mode 100644 .cursor/plans/inline_mentions_and_citations_58e9f474.plan.md delete mode 100644 .cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md diff --git a/.cursor/plans/inline_mentions_and_citations_58e9f474.plan.md b/.cursor/plans/inline_mentions_and_citations_58e9f474.plan.md deleted file mode 100644 index ba07a841..00000000 --- a/.cursor/plans/inline_mentions_and_citations_58e9f474.plan.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: Inline Mentions and Citations -overview: Add support for inline mentions (`mention://`) and inline citations (`citation://`) to `EnrichedMarkdownText`, distinguished from regular links by URL scheme. Mentions render as pill attachments, citations render as superscript-styled inline text, and each fires its own JS press event. User-configurable styling is exposed for both. -todos: - - id: types - content: Extend MarkdownStyle + internal codegen types with mention/citation styles and press events; normalize defaults in normalizeMarkdownStyle. - status: completed - - id: js-surface - content: Add onMentionPress/onCitationPress props and handlers in EnrichedMarkdownText.tsx; re-export new types from src/index.tsx. Re-run codegen so generated Props/EventEmitters headers on iOS and JNI specs on Android pick up the new fields. - status: completed - - id: ios-style - content: Add mention/citation fields to StyleConfig (ios/styles) and wire through StylePropsUtils. - status: completed - - id: ios-render - content: "Branch LinkRenderer on scheme: implement ENRMMentionAttachment (UIView provider) for mentions, baseline-offset + smaller-font attributes for citations, tag ranges with new attribute keys." - status: completed - - id: ios-events - content: Update LinkTapUtils + EnrichedMarkdownText.mm / EnrichedMarkdown.mm to dispatch onMentionPress / onCitationPress based on the tapped attribute. - status: completed - - id: android-style - content: Add MentionStyle/CitationStyle data classes and prop converters in EnrichedMarkdownTextManager (and GFM manager). - status: completed - - id: android-spans - content: Implement MentionSpan (ReplacementSpan drawing rounded pill; getSize accounts for paddingHorizontal + borderWidth) and a custom CitationSpan subclassing SuperscriptSpan that accepts explicit baselineOffsetPx for cross-OEM parity with iOS. - status: completed - - id: android-render - content: Branch LinkRenderer.kt on URL scheme to install MentionSpan / CitationSpan / existing LinkSpan. - status: completed - - id: android-events - content: Add MentionPressEvent/CitationPressEvent; update EnrichedMarkdownText.kt tap path to emit the right event for the span under the tap. - status: completed - - id: web-render - content: Web renderer — branch LinkRenderer on URL scheme; render mention as styled inline-flex `` pill (CSS `:active` honors pressedOpacity) and citation as `` with fontSizeMultiplier/baselineOffsetPx/color. Wire onMentionPress/onCitationPress into RendererCallbacks and MarkdownTextProps.web. Extend styles.ts to derive mention/citation CSSProperties from the normalized style. - status: pending -isProject: false ---- - -## Approach - -Keep the standard CommonMark link syntax `[text](url)`. Dispatch at the renderer layer based on the URL scheme: - -- `mention://` → inline pill attachment (carries `userId`) -- `citation://` → superscript/smaller-font inline marker (carries the underlying `url`) -- anything else → existing link behavior (unchanged) - -No md4c or AST changes. Scope is **read-only renderer only** (`EnrichedMarkdownText` + the GFM-flavored `EnrichedMarkdownNativeComponent`); the input editor is unchanged. Press events are delivered through new `onMentionPress` / `onCitationPress` callbacks; existing `onLinkPress` stays unchanged for other schemes. No tooltips/popovers in this PR. - -## Public API - -New JS types and props (both native components: `EnrichedMarkdownTextNativeComponent` and `EnrichedMarkdownNativeComponent`): - -```ts -interface MentionPressEvent { userId: string; text: string; } -interface CitationPressEvent { url: string; text: string; } - -interface MentionStyle { - color?: string; - backgroundColor?: string; - borderColor?: string; - borderWidth?: number; - borderRadius?: number; - paddingHorizontal?: number; - paddingVertical?: number; - fontFamily?: string; - fontWeight?: string; - fontSize?: number; - pressedOpacity?: number; // native tap feedback, default 0.6 -} - -interface CitationStyle { - color?: string; - fontSizeMultiplier?: number; // default 0.7 - baselineOffsetPx?: number; // explicit px; default derived from font metrics for iOS/Android parity - fontWeight?: string; - underline?: boolean; - backgroundColor?: string; -} -``` - -Added to [`src/types/MarkdownStyle.ts`](src/types/MarkdownStyle.ts) as `mention?: MentionStyle; citation?: CitationStyle;`, mirrored as internal shapes in [`src/EnrichedMarkdownTextNativeComponent.ts`](src/EnrichedMarkdownTextNativeComponent.ts) and [`src/EnrichedMarkdownNativeComponent.ts`](src/EnrichedMarkdownNativeComponent.ts), defaulted in [`src/normalizeMarkdownStyle.ts`](src/normalizeMarkdownStyle.ts), and re-exported from [`src/index.tsx`](src/index.tsx). - -## Dispatch flow - -```mermaid -flowchart TD - md["[text](url) from md4c"] --> LinkRenderer - LinkRenderer -->|"scheme == mention://"| MentionPath[Mention pill + MentionAttr] - LinkRenderer -->|"scheme == citation://"| CitationPath[Baseline/size attrs + CitationAttr] - LinkRenderer -->|"other"| LegacyLink[Existing link span/attr] - MentionPath --> TapDispatch - CitationPath --> TapDispatch - LegacyLink --> TapDispatch - TapDispatch -->|"mention"| onMentionPress - TapDispatch -->|"citation"| onCitationPress - TapDispatch -->|"link"| onLinkPress -``` - -## iOS - -- Extend [`ios/styles/StyleConfig.h`](ios/styles/StyleConfig.h)/`.mm` with getters/setters for the new `mention*` and `citation*` fields, wired from `StylePropsUtils`. -- Refactor [`ios/renderer/LinkRenderer.m`](ios/renderer/LinkRenderer.m): after child rendering, inspect the URL prefix and branch: - - `mention://`: replace the text range with an `NSAttributedString` containing an `NSAttachmentCharacter` backed by a new `ENRMMentionAttachment` (subclass of `NSTextAttachment`) providing an `NSTextAttachmentViewProvider` that renders a rounded, padded `UILabel`/`UIView` pill via Auto Layout. Tag the range with new attributes `ENRMMentionUserId` + `ENRMMentionText`. The podspec uses `min_ios_version_supported` (≥ iOS 15.1 on current RN), so no pre-iOS-15 `drawInRect:` fallback is needed — commit to the view-provider path only. - - `citation://`: keep text, apply `NSBaselineOffsetAttributeName` (explicit px from `baselineOffsetPx`, or derived from current font metrics) + a smaller font derived from the current font × `fontSizeMultiplier`, optional `NSBackgroundColorAttributeName`. Tag range with `ENRMCitationUrl` + `ENRMCitationText`. - - default: unchanged path (`NSLinkAttributeName` + `linkURL` + existing underline/color). -- `ENRMMentionAttachment`'s view provider installs a `UITapGestureRecognizer` and a `touchesBegan`/`touchesCancelled` animator that applies `mention.pressedOpacity` on press-in and restores on press-out, matching the native "shrink/fade on tap" expectation. -- Update [`ios/utils/LinkTapUtils.m`](ios/utils/LinkTapUtils.m) to also read `ENRMMentionUserId` and `ENRMCitationUrl` when determining the tapped element type, and `isPointOnInteractiveElement` to treat mention/citation as interactive. -- In [`ios/EnrichedMarkdownText.mm`](ios/EnrichedMarkdownText.mm) tap handler, fire one of three event emitters based on which attribute is present (`onMentionPress` / `onCitationPress` / existing `onLinkPress`). Same treatment in `EnrichedMarkdown.mm` (GFM flavor). - -## Android - -- Add `MentionStyle.kt` / `CitationStyle.kt` data classes alongside existing style configs, wired through the prop converters in [`EnrichedMarkdownTextManager.kt`](android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt). -- Add `MentionSpan.kt` (extends `ReplacementSpan`) that overrides `getSize` and `draw` to paint the rounded background, optional border, padding, and the name text. `getSize` must explicitly add `2 * paddingHorizontal + 2 * borderWidth` to the measured text width so the pill doesn't clip, and return a descent/ascent that accounts for `paddingVertical` + `borderWidth`. Holds `userId` + display `text`. Applies `pressedOpacity` to the `Paint` alpha while `isPressed` is true; pressed state is toggled by the link tap dispatcher below. -- Add `CitationSpan.kt` — a custom `SuperscriptSpan` subclass that accepts an explicit `baselineOffsetPx` in `updateDrawState` / `updateMeasureState`, combined with `RelativeSizeSpan(citationStyle.fontSizeMultiplier)` and optional `ForegroundColorSpan` / `BackgroundColorSpan` applied in the same `setSpan` call. The explicit baseline offset gives exact parity with iOS's `NSBaselineOffsetAttributeName` and sidesteps OEM-dependent quirks in the framework's default `SuperscriptSpan`. Holds `url` + `text`. -- Update [`android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt`](android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt) to branch on `url.startsWith("mention://")` / `"citation://"` and install the appropriate span instead of `LinkSpan`. Legacy `LinkSpan` is untouched. -- Event wiring: in [`EnrichedMarkdownText.kt`](android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt) tap path (currently dispatching `LinkPressEvent`), use the span type at the tap index to dispatch `MentionPressEvent` / `CitationPressEvent` instead. Add new event classes under [`events/`](android/src/main/java/com/swmansion/enriched/markdown/events/) mirroring `LinkPressEvent`. On `ACTION_DOWN` over a `MentionSpan`, toggle the span's `isPressed` flag and invalidate the view; reset on `ACTION_UP`/`ACTION_CANCEL`. - -## Web - -Same scheme-based branching happens in [`src/web/renderers/InlineRenderers.tsx`](src/web/renderers/InlineRenderers.tsx)'s `LinkRenderer`: - -- `mention://` → `` styled as an inline-flex pill using `styles.mention` (backgroundColor, borderColor/Width, borderRadius, padding, color, font). `pressedOpacity` maps to a CSS `:active { opacity: ... }` rule injected once at mount (via a style tag keyed by a className, similar to how existing web renderers handle hover states) — CSS does the tap-feedback automatically, no JS state needed. -- `citation://` → `` with `styles.citation` (color, `fontSize: calc(1em * fontSizeMultiplier)`, `verticalAlign: baseline` + `top: -baselineOffsetPx`, optional background, optional underline via `textDecoration`). Kept in a `` tag so screen readers announce it as superscript. -- default: unchanged `` path. - -Supporting updates: - -- [`src/web/types.ts`](src/web/types.ts): extend `RendererCallbacks` with `onMentionPress` / `onCitationPress`. -- [`src/types/MarkdownTextProps.web.ts`](src/types/MarkdownTextProps.web.ts): add the two new callbacks alongside the existing `onLinkPress` / `onLinkLongPress`, documented `@platform ios, android, web`. -- [`src/web/EnrichedMarkdownText.tsx`](src/web/EnrichedMarkdownText.tsx): thread the new callbacks into the render context. -- [`src/web/styles.ts`](src/web/styles.ts): add `mention` and `citation` entries to the `Styles` map, normalized from `MarkdownStyleInternal.mention` / `.citation`. -- [`src/index.web.tsx`](src/index.web.tsx): re-export `MentionPressEvent` / `CitationPressEvent` / `MentionStyle` / `CitationStyle`. - -## Things explicitly out of scope - -- Parser/AST changes (still pure md4c). -- Editor support (`EnrichedMarkdownInput`) — no `insertMention`/`insertCitation` yet. -- Built-in popover/tooltip UI on any platform. \ No newline at end of file diff --git a/.cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md b/.cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md deleted file mode 100644 index 9349a20b..00000000 --- a/.cursor/plans/mention_citation_tooltips_18cf1b1d.plan.md +++ /dev/null @@ -1,268 +0,0 @@ ---- -name: mention citation tooltips -overview: Emit hover events with pill coordinates on web so consumers can render their own mention/citation tooltips, and add optional rect data to the existing onMentionPress / onCitationPress events on all platforms. -todos: - - id: event_types - content: Add InlineElementRect and extend MentionPressEvent / CitationPressEvent with optional rect in src/types/events.ts; add new MentionHoverEvent / CitationHoverEvent - status: pending - - id: types_web - content: Extend src/types/MarkdownTextProps.web.ts with onMentionHoverChange and onCitationHoverChange - status: pending - - id: renderer_types - content: Extend RendererCallbacks in src/web/types.ts with the two new hover callbacks - status: pending - - id: wire_inline_renderers - content: In src/web/renderers/InlineRenderers.tsx, fire onMentionHoverChange / onCitationHoverChange on pointerenter / pointerleave / focus / blur, and include rect in onMentionPress / onCitationPress on click - status: pending - - id: wire_web_root - content: Thread the two new hover props through src/web/EnrichedMarkdownText.tsx with stable memoization - status: pending - - id: types_shared - content: Mirror the event-type additions in src/types/MarkdownTextProps.ts so rect shows up on the native-facing press events too (documented per-platform as container-relative) - status: pending -isProject: false ---- - -## Goal - -- Web: emit hover-in / hover-out events with the hovered pill's viewport coordinates so consumers can render their own tooltip. -- All platforms: add an optional `rect` field to the existing `onMentionPress` / `onCitationPress` events. No new long-press events, no gesture changes on mobile. - -## Public API - -### Event types — [src/types/events.ts](src/types/events.ts) - -```ts -export interface InlineElementRect { - /** - * Web: viewport-relative (from getBoundingClientRect()). - * iOS / Android: container-relative (origin is the EnrichedMarkdownText view). - */ - x: number; - y: number; - width: number; - height: number; -} - -export interface MentionPressEvent { - url: string; - text: string; - /** Pill bounding rect at press time. Present on all platforms. */ - rect?: InlineElementRect; -} - -export interface CitationPressEvent { - url: string; - text: string; - rect?: InlineElementRect; -} - -export interface MentionHoverEvent { - url: string; - text: string; - hovered: boolean; - /** Present when hovered === true; omitted on hover-out. */ - rect?: InlineElementRect; -} - -export interface CitationHoverEvent { - url: string; - text: string; - hovered: boolean; - rect?: InlineElementRect; -} -``` - -`rect` is optional to preserve back-compat (future platforms / edge cases where we can't compute it can omit it). - -### New props — [src/types/MarkdownTextProps.web.ts](src/types/MarkdownTextProps.web.ts) - -```ts -onMentionHoverChange?: (event: MentionHoverEvent) => void; -onCitationHoverChange?: (event: CitationHoverEvent) => void; -``` - -Single event with `hovered: boolean` rather than two separate in/out events so consumers can drive a `useState` with one handler and avoid stale-state races. - -## Web implementation (this pass) - -Files touched: - -- [src/types/events.ts](src/types/events.ts): add `InlineElementRect`, extend `MentionPressEvent` / `CitationPressEvent` with `rect`, add `MentionHoverEvent` / `CitationHoverEvent`. -- [src/types/MarkdownTextProps.web.ts](src/types/MarkdownTextProps.web.ts): add `onMentionHoverChange`, `onCitationHoverChange`. -- [src/types/MarkdownTextProps.ts](src/types/MarkdownTextProps.ts): no new props (native keeps same two press callbacks), but imports updated event types automatically. -- [src/web/types.ts](src/web/types.ts): add `onMentionHoverChange` / `onCitationHoverChange` to `RendererCallbacks`. -- [src/web/EnrichedMarkdownText.tsx](src/web/EnrichedMarkdownText.tsx): accept + memoize the two new props into `callbacks`. -- [src/web/renderers/InlineRenderers.tsx](src/web/renderers/InlineRenderers.tsx): wire pointer / focus handlers on mention `` and citation ``; include `rect` on click handlers. - -### Renderer change (sketch) - -```tsx -function MentionRenderer({ url, callbacks, node, styles }: SchemeRendererProps) { - const mentionUrl = url.slice(MENTION_SCHEME.length); - const displayText = extractNodeText(node); - - const rectFromEvent = ( - event: { currentTarget: { getBoundingClientRect: () => InlineElementRect } } - ): InlineElementRect => { - const r = event.currentTarget.getBoundingClientRect(); - return { x: r.x, y: r.y, width: r.width, height: r.height }; - }; - - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - callbacks.onMentionPress?.({ - url: mentionUrl, - text: displayText, - rect: rectFromEvent(event), - }); - }; - - const handleHoverIn = (event: PointerEvent | FocusEvent) => { - callbacks.onMentionHoverChange?.({ - url: mentionUrl, - text: displayText, - hovered: true, - rect: rectFromEvent(event), - }); - }; - - const handleHoverOut = () => { - callbacks.onMentionHoverChange?.({ - url: mentionUrl, - text: displayText, - hovered: false, - }); - }; - - // ... existing pressed-opacity style unchanged ... - - return ( - <> - - - {displayText} - - - ); -} -``` - -Citations get the same rect-on-click + hover handlers on ``. - -### Semantics - -- `rect` on web is viewport-coordinates (`getBoundingClientRect()`), captured once at event emission time. -- Hover-in fires on `pointerenter` and `focus` (keyboard a11y). Hover-out fires on `pointerleave` and `blur`. -- No debounce / delay in the library — consumers add `setTimeout` if they want a hover delay. -- Existing click → `onMentionPress` / `onCitationPress` behavior is unchanged except the payload now carries `rect`. - -### Scroll behavior (documented, not handled) - -The library emits `rect` once per event — it does not re-emit on scroll or resize. If the page scrolls (or an ancestor scroll container scrolls) while a tooltip is open, the stored viewport rect goes stale and the tooltip will visually detach from the pill. - -Recommended consumer patterns, listed from simplest to most robust, go into the JSDoc for `onMentionHoverChange` / `onCitationHoverChange`: - -1. **Dismiss on scroll.** Listen to `scroll` on `window` (capture) while hovered and clear tooltip state. Simplest and most common tooltip UX. -2. **Re-measure on scroll.** Keep the mention id in state on hover-in, and re-query the DOM (`document.querySelector(` `` `[data-mention-url="${url}"]` `` `)`) on scroll to refresh position. Works because `InlineRenderers` already stamps `data-mention-url` / `data-citation-url` on the element. -3. **Upgrade to a ref-based lib.** If live repositioning matters, a future iteration of this plan can add an optional `target: HTMLElement` field to the web hover event without breaking back-compat. Out of scope here. - -Same story on native: `rect` is emitted once, and if the user scrolls the surrounding RN `ScrollView` the consumer is responsible for dismissing or re-emitting (typically wired to the `ScrollView`'s `onScroll`). This library does not own the scroll container and has no API for it. - -## Native plan (documented, not implemented) - -Only one change needed on each platform: include `rect` in the existing press event. No new gestures, no new callbacks. - -iOS ([ios/utils/LinkTapUtils.m](ios/utils/LinkTapUtils.m), [ios/EnrichedMarkdownText.mm](ios/EnrichedMarkdownText.mm)): - -- At the point `inlineElementAtTapLocation` resolves a mention or citation, compute the full attribute run's character range (`effectiveRange:` out-param from `attributesAtIndex:`), then call `NSLayoutManager boundingRectForGlyphRange:inTextContainer:` for that glyph range. -- Combine with `lineFragmentRectForGlyphAtIndex:` for the hit glyph when the run is multi-line, so line-height and padding are accounted for correctly (not just the glyph ink extent). -- Convert to the `EnrichedMarkdownText` view's coordinate space by adding the text container inset / the host view's `bounds.origin` offset as used elsewhere in this file. -- Include the rect on the `MentionPressEvent` / `CitationPressEvent` payload emitted from `EnrichedMarkdownText.mm`. - -Android ([android/.../spans/MentionSpan.kt](android/src/main/java/com/swmansion/enriched/markdown/spans/MentionSpan.kt), [android/.../spans/CitationSpan.kt](android/src/main/java/com/swmansion/enriched/markdown/spans/CitationSpan.kt), [android/.../utils/text/view/LinkLongPressMovementMethod.kt](android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt)): - -- In `LinkLongPressMovementMethod.onTouchEvent` where `onMentionTap` / `onCitationTap` currently fire, compute the span's rect from `Layout.getPrimaryHorizontal(spanStart..spanEnd)` + `Layout.getLineTop` / `getLineBottom` for the hit line. -- `Layout` coordinates are text-relative, not view-relative. Before emitting, add `textView.totalPaddingLeft` to x and `textView.totalPaddingTop` to y so the rect is aligned with the `EnrichedMarkdownText` View's origin. -- Thread the rect through `MentionPressEvent.kt` / `CitationPressEvent.kt` to the JS event. - -Both platforms report **container-relative** rects (relative to the `EnrichedMarkdownText` view), documented on `InlineElementRect`. - -Since this only extends an existing optional field, it is fully backward-compatible: consumers already reading `url` / `text` see no changes. - -Hover events on native are explicitly out of scope — mobile has no hover. - -### Consumer guidance on native (to include in JSDoc for `rect`) - -Most RN positioning libraries (e.g. `@floating-ui/react-native`) expect window / global coordinates, not container-relative ones. To convert: - -```tsx -const ref = useRef(null); -const [containerOrigin, setContainerOrigin] = useState({ x: 0, y: 0 }); - - { - ref.current?.measureInWindow((x, y) => setContainerOrigin({ x, y })); - }} - onMentionPress={({ url, text, rect }) => { - const windowRect = rect && { - x: rect.x + containerOrigin.x, - y: rect.y + containerOrigin.y, - width: rect.width, - height: rect.height, - }; - // feed windowRect to Floating UI / custom overlay - }} -/> -``` - -Rationale for keeping the emitted rect container-relative rather than global: the component itself may move (animated transitions, keyboard avoidance, parent re-layout), and a container-relative rect remains valid as long as the consumer re-measures the container on layout changes. - -## Flow diagram - -```mermaid -sequenceDiagram - participant User - participant Renderer as InlineRenderers - participant Consumer as App - - Note over User,Consumer: Hover (web only) - User->>Renderer: pointerenter on mention - Renderer->>Consumer: onMentionHoverChange({hovered:true, url, text, rect}) - Consumer->>Consumer: setState -> render custom tooltip overlay - User->>Renderer: pointerleave - Renderer->>Consumer: onMentionHoverChange({hovered:false, url, text}) - - Note over User,Consumer: Press (all platforms) - User->>Renderer: click / tap on mention - Renderer->>Consumer: onMentionPress({url, text, rect}) -``` - -## Out of scope - -- No long-press events for mention / citation on mobile. -- No tooltip UI shipped with the library — consumers own all rendering / positioning / a11y. -- No markdown syntax changes; no new `MarkdownStyle` slots. - -## Risks / notes - -- Multi-line mention wraps: `getBoundingClientRect()` returns the bounding box across all line fragments on web (one tall rect spanning both lines); native equivalents collapse to the hit line. Documented platform asymmetry. If this becomes visually jarring, a future iteration can switch the web rect to `event.currentTarget.getClientRects()[hitLineIndex]` to anchor to the specific hovered line — non-breaking since `InlineElementRect` shape stays the same. -- Rect is optional on both press and hover event types. Consumers must null-check before using it. -- Rect is a point-in-time snapshot. The library does not track the pill through scrolls / resizes; handling that is the consumer's responsibility (see "Scroll behavior" above). Deliberate choice to keep the API thin and uniform across platforms. - -## Future considerations (not in this pass) - -- **`target: HTMLElement` field on web hover event.** Enables live re-positioning with Floating UI / Popper on scroll/resize without consumer re-querying DOM. Additive, non-breaking. -- **`padding` or `inset` in `InlineElementRect`.** If consumers want the rect slightly expanded (e.g. to allow a fade/slide tooltip entry animation without visual jitter at the edges), a numeric padding can be added to the event payload later. Out of scope for v1 — consumers can expand the rect themselves. -- **Per-line-fragment rect on web** for long multi-line mention wraps (see Risks above). From d1bccf15148010a1e7ec1c1851b70b57a86a33b2 Mon Sep 17 00:00:00 2001 From: Xindi Xu Date: Thu, 23 Apr 2026 00:41:47 -0700 Subject: [PATCH 15/15] fix copy?? --- src/web/EnrichedMarkdownText.tsx | 116 +++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/src/web/EnrichedMarkdownText.tsx b/src/web/EnrichedMarkdownText.tsx index f7d60e8e..082db819 100644 --- a/src/web/EnrichedMarkdownText.tsx +++ b/src/web/EnrichedMarkdownText.tsx @@ -132,55 +132,113 @@ export const EnrichedMarkdownText = ({ // 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