react-native-enriched-markdown-scaffold 1.0.2-scaffold.3 → 1.0.2-scaffold.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +3 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +1 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +3 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +1 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +48 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +32 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +10 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +122 -0
  9. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +38 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +8 -0
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +37 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +8 -0
  13. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLayoutEvent.kt +36 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +37 -7
  15. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +5 -1
  16. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +13 -99
  17. package/android/src/main/java/com/swmansion/enriched/markdown/spans/InlineChipPainter.kt +140 -0
  18. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkChipSpans.kt +177 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +1 -6
  20. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +36 -1
  21. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkVariantEntry.kt +7 -3
  22. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +14 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt +2 -1
  24. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +6 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLayoutCollector.kt +100 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +4 -2
  27. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +14 -0
  28. package/docs/API_REFERENCE.md +53 -0
  29. package/docs/MENTIONS.md +8 -1
  30. package/docs/STYLES.md +27 -0
  31. package/docs/TEXT.md +2 -0
  32. package/ios/EnrichedMarkdown.mm +48 -0
  33. package/ios/EnrichedMarkdownText.mm +39 -0
  34. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +48 -0
  35. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +32 -0
  36. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +10 -0
  37. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +122 -0
  38. package/ios/internals/MeasurementCache.h +5 -0
  39. package/ios/renderer/LinkRenderer.m +47 -15
  40. package/ios/styles/StyleConfig.h +16 -0
  41. package/ios/styles/StyleConfig.mm +60 -0
  42. package/ios/utils/CodeBackground.m +3 -65
  43. package/ios/utils/ENRMChipDrawing.h +24 -0
  44. package/ios/utils/ENRMChipDrawing.m +83 -0
  45. package/ios/utils/ENRMLinkChip.h +70 -0
  46. package/ios/utils/ENRMLinkChip.m +194 -0
  47. package/ios/utils/ENRMLinkLayout.h +50 -0
  48. package/ios/utils/ENRMLinkLayout.m +45 -0
  49. package/ios/utils/EditMenuUtils+macOS.m +4 -1
  50. package/ios/utils/EditMenuUtils.m +4 -1
  51. package/ios/utils/HTMLGenerator.m +2 -1
  52. package/ios/utils/MarkdownAccessibilityElementBuilder.m +4 -1
  53. package/ios/utils/MarkdownExtractor.m +2 -1
  54. package/ios/utils/RTFExportUtils.m +2 -0
  55. package/ios/utils/StylePropsUtils.h +37 -1
  56. package/ios/utils/TextViewLayoutManager.mm +3 -0
  57. package/ios/views/TableContainerView.h +3 -0
  58. package/ios/views/TableContainerView.m +21 -0
  59. package/lib/module/EnrichedMarkdownNativeComponent.ts +41 -0
  60. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +41 -0
  61. package/lib/module/index.js.map +1 -1
  62. package/lib/module/native/EnrichedMarkdownText.js +96 -7
  63. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  64. package/lib/module/normalizeMarkdownStyle.js +13 -2
  65. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  66. package/lib/module/normalizeMarkdownStyle.web.js +13 -2
  67. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  68. package/lib/module/web/EnrichedMarkdownText.js +6 -3
  69. package/lib/module/web/EnrichedMarkdownText.js.map +1 -1
  70. package/lib/module/web/renderers/InlineRenderers.js +96 -3
  71. package/lib/module/web/renderers/InlineRenderers.js.map +1 -1
  72. package/lib/module/web/styles.js +18 -0
  73. package/lib/module/web/styles.js.map +1 -1
  74. package/lib/module/web/utils.js +13 -0
  75. package/lib/module/web/utils.js.map +1 -1
  76. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +40 -0
  77. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  78. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +40 -0
  79. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  80. package/lib/typescript/src/index.d.ts +1 -1
  81. package/lib/typescript/src/index.d.ts.map +1 -1
  82. package/lib/typescript/src/index.web.d.ts +1 -1
  83. package/lib/typescript/src/index.web.d.ts.map +1 -1
  84. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +3 -3
  85. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  86. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  87. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  88. package/lib/typescript/src/types/MarkdownStyle.d.ts +28 -5
  89. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  90. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +10 -0
  91. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  92. package/lib/typescript/src/types/MarkdownTextProps.d.ts +41 -0
  93. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  94. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts +41 -1
  95. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts.map +1 -1
  96. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts +1 -1
  97. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts.map +1 -1
  98. package/lib/typescript/src/web/renderers/InlineRenderers.d.ts.map +1 -1
  99. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  100. package/lib/typescript/src/web/types.d.ts +4 -0
  101. package/lib/typescript/src/web/types.d.ts.map +1 -1
  102. package/lib/typescript/src/web/utils.d.ts +4 -0
  103. package/lib/typescript/src/web/utils.d.ts.map +1 -1
  104. package/package.json +1 -1
  105. package/src/EnrichedMarkdownNativeComponent.ts +41 -0
  106. package/src/EnrichedMarkdownTextNativeComponent.ts +41 -0
  107. package/src/index.tsx +2 -0
  108. package/src/index.web.tsx +5 -1
  109. package/src/native/EnrichedMarkdownText.tsx +103 -6
  110. package/src/normalizeMarkdownStyle.ts +13 -0
  111. package/src/normalizeMarkdownStyle.web.ts +11 -0
  112. package/src/types/MarkdownStyle.ts +29 -5
  113. package/src/types/MarkdownStyleInternal.ts +10 -0
  114. package/src/types/MarkdownTextProps.ts +43 -0
  115. package/src/types/MarkdownTextProps.web.ts +43 -1
  116. package/src/web/EnrichedMarkdownText.tsx +10 -3
  117. package/src/web/renderers/InlineRenderers.tsx +111 -3
  118. package/src/web/styles.ts +25 -0
  119. package/src/web/types.ts +4 -0
  120. package/src/web/utils.ts +8 -0
@@ -1,6 +1,9 @@
1
- import { useMemo, useCallback, useRef, useEffect } from 'react';
1
+ import { useMemo, useCallback, useRef, useEffect, useState } from 'react';
2
2
  import EnrichedMarkdownTextNativeComponent from '../EnrichedMarkdownTextNativeComponent';
3
- import type { MarkdownStyleInternal } from '../EnrichedMarkdownTextNativeComponent';
3
+ import type {
4
+ LinkLayoutEvent,
5
+ MarkdownStyleInternal,
6
+ } from '../EnrichedMarkdownTextNativeComponent';
4
7
  import EnrichedMarkdownNativeComponent from '../EnrichedMarkdownNativeComponent';
5
8
  import { normalizeMarkdownStyle } from '../normalizeMarkdownStyle';
6
9
  import { resolveAccessibilityLabels } from '../accessibilityLabelDefaults';
@@ -8,7 +11,8 @@ import {
8
11
  normalizeMenuItem,
9
12
  normalizeLegacyBooleanMenuItem,
10
13
  } from '../normalizeMenuItem';
11
- import type { NativeSyntheticEvent } from 'react-native';
14
+ import { StyleSheet, View } from 'react-native';
15
+ import type { LayoutChangeEvent, NativeSyntheticEvent } from 'react-native';
12
16
  import type { MarkdownStyle, Md4cFlags } from '../types/MarkdownStyle';
13
17
  import type {
14
18
  EnrichedMarkdownTextProps,
@@ -16,6 +20,8 @@ import type {
16
20
  ContextMenuItem,
17
21
  SelectionMenuConfig,
18
22
  SelectionMenuPluralLabels,
23
+ LinkDecorationInfo,
24
+ LinkRect,
19
25
  } from '../types/MarkdownTextProps';
20
26
  import type {
21
27
  LinkPressEvent,
@@ -33,6 +39,8 @@ export type {
33
39
  ContextMenuItem,
34
40
  SelectionMenuConfig,
35
41
  SelectionMenuPluralLabels,
42
+ LinkDecorationInfo,
43
+ LinkRect,
36
44
  };
37
45
  export type {
38
46
  LinkPressEvent,
@@ -103,6 +111,34 @@ const buildPluralTemplates = (
103
111
  return templates;
104
112
  };
105
113
 
114
+ type LinkLayoutFragment = LinkLayoutEvent['fragments'][number];
115
+
116
+ /** Groups the flat per-line fragments native reports into one entry per link. */
117
+ const groupLinkFragments = (
118
+ fragments: ReadonlyArray<LinkLayoutFragment>
119
+ ): LinkDecorationInfo[] => {
120
+ const byIndex = new Map<number, LinkDecorationInfo>();
121
+ for (const { linkIndex, url, text, x, y, width, height } of fragments) {
122
+ const rect: LinkRect = { x, y, width, height };
123
+ const info = byIndex.get(linkIndex);
124
+ if (info) {
125
+ info.rects.push(rect);
126
+ info.rect = rect;
127
+ } else {
128
+ byIndex.set(linkIndex, {
129
+ index: linkIndex,
130
+ url,
131
+ text,
132
+ rect,
133
+ rects: [rect],
134
+ });
135
+ }
136
+ }
137
+ return [...byIndex.values()];
138
+ };
139
+
140
+ const ZERO_OFFSET = { x: 0, y: 0 };
141
+
106
142
  const defaultMd4cFlags: Md4cFlags = {
107
143
  underline: false,
108
144
  superscript: false,
@@ -142,6 +178,8 @@ export const EnrichedMarkdownText = ({
142
178
  textBreakStrategy,
143
179
  lineBreakStrategyIOS,
144
180
  writingDirection = 'first-strong',
181
+ renderLinkDecoration,
182
+ onLayout,
145
183
  ...rest
146
184
  }: EnrichedMarkdownTextProps) => {
147
185
  const normalizedStyleRef = useRef<MarkdownStyleInternal | null>(null);
@@ -260,6 +298,23 @@ export const EnrichedMarkdownText = ({
260
298
  );
261
299
  const interceptCopy = onCopy != null;
262
300
 
301
+ const decorated = renderLinkDecoration != null;
302
+ const [linkLayout, setLinkLayout] = useState<LinkDecorationInfo[]>([]);
303
+ // Where the native view sits inside the wrapper (the wrapper's padding).
304
+ const [nativeOffset, setNativeOffset] = useState(ZERO_OFFSET);
305
+
306
+ const handleLinkLayout = useCallback(
307
+ (e: NativeSyntheticEvent<LinkLayoutEvent>) => {
308
+ setLinkLayout(groupLinkFragments(e.nativeEvent.fragments));
309
+ },
310
+ []
311
+ );
312
+
313
+ const handleNativeLayout = useCallback((e: LayoutChangeEvent) => {
314
+ const { x, y } = e.nativeEvent.layout;
315
+ setNativeOffset((prev) => (prev.x === x && prev.y === y ? prev : { x, y }));
316
+ }, []);
317
+
263
318
  const tableMode = streamingConfig?.tableMode ?? 'progressive';
264
319
  const codeBlockMode = streamingConfig?.codeBlockMode ?? 'progressive';
265
320
  const normalizedStreamingConfig = useMemo(
@@ -365,11 +420,53 @@ export const EnrichedMarkdownText = ({
365
420
  ...rest,
366
421
  };
367
422
 
368
- if (flavor === 'github') {
369
- return <EnrichedMarkdownNativeComponent {...sharedProps} />;
423
+ const NativeComponent =
424
+ flavor === 'github'
425
+ ? EnrichedMarkdownNativeComponent
426
+ : EnrichedMarkdownTextNativeComponent;
427
+
428
+ // Without decorations the native view is returned as-is (no wrapper, no
429
+ // layout events) so existing layouts are untouched.
430
+ if (!decorated) {
431
+ return <NativeComponent {...sharedProps} onLayout={onLayout} />;
370
432
  }
371
433
 
372
- return <EnrichedMarkdownTextNativeComponent {...sharedProps} />;
434
+ return (
435
+ <View style={containerStyle} onLayout={onLayout}>
436
+ <NativeComponent
437
+ {...sharedProps}
438
+ style={undefined}
439
+ emitLinkLayout
440
+ onLinkLayout={handleLinkLayout}
441
+ onLayout={handleNativeLayout}
442
+ />
443
+ {linkLayout.map((info) => {
444
+ const decoration = renderLinkDecoration(info);
445
+ if (decoration == null) return null;
446
+ return (
447
+ <View
448
+ key={info.index}
449
+ pointerEvents="box-none"
450
+ style={[
451
+ styles.decoration,
452
+ {
453
+ left: nativeOffset.x + info.rect.x,
454
+ top: nativeOffset.y + info.rect.y,
455
+ width: info.rect.width,
456
+ height: info.rect.height,
457
+ },
458
+ ]}
459
+ >
460
+ {decoration}
461
+ </View>
462
+ );
463
+ })}
464
+ </View>
465
+ );
373
466
  };
374
467
 
468
+ const styles = StyleSheet.create({
469
+ decoration: { position: 'absolute' },
470
+ });
471
+
375
472
  export default EnrichedMarkdownText;
@@ -183,6 +183,11 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({
183
183
  color: normalizeColor('#2563EB')!,
184
184
  underline: true,
185
185
  backgroundColor: normalizeColor('transparent')!,
186
+ borderRadius: 0,
187
+ paddingHorizontal: 0,
188
+ paddingVertical: 0,
189
+ borderColor: normalizeColor('transparent')!,
190
+ borderWidth: 0,
186
191
  },
187
192
  linkVariants: [],
188
193
  strong: { fontFamily: '', fontWeight: 'bold', color: undefined },
@@ -338,9 +343,17 @@ export const normalizeMarkdownStyle = (
338
343
  color: ((override.color ? normalizeColor(override.color) : null) ??
339
344
  linkBase.color) as string,
340
345
  underline: override.underline ?? linkBase.underline,
346
+ // Chip ("box") properties never inherit from the base link style.
341
347
  backgroundColor: (override.backgroundColor
342
348
  ? (normalizeColor(override.backgroundColor) ?? transparent)
343
349
  : transparent) as string,
350
+ borderRadius: override.borderRadius ?? 0,
351
+ paddingHorizontal: override.paddingHorizontal ?? 0,
352
+ paddingVertical: override.paddingVertical ?? 0,
353
+ borderColor: (override.borderColor
354
+ ? (normalizeColor(override.borderColor) ?? transparent)
355
+ : transparent) as string,
356
+ borderWidth: override.borderWidth ?? 0,
344
357
  };
345
358
  }
346
359
  );
@@ -147,6 +147,11 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({
147
147
  color: '#2563EB',
148
148
  underline: true,
149
149
  backgroundColor: 'transparent',
150
+ borderRadius: 0,
151
+ paddingHorizontal: 0,
152
+ paddingVertical: 0,
153
+ borderColor: 'transparent',
154
+ borderWidth: 0,
150
155
  },
151
156
  linkVariants: [],
152
157
  strong: { fontFamily: '', fontWeight: 'bold', color: undefined },
@@ -289,7 +294,13 @@ export const normalizeMarkdownStyle = (
289
294
  pattern,
290
295
  color: override.color ?? linkBase.color,
291
296
  underline: override.underline ?? linkBase.underline,
297
+ // Chip ("box") properties never inherit from the base link style.
292
298
  backgroundColor: override.backgroundColor ?? 'transparent',
299
+ borderRadius: override.borderRadius ?? 0,
300
+ paddingHorizontal: override.paddingHorizontal ?? 0,
301
+ paddingVertical: override.paddingVertical ?? 0,
302
+ borderColor: override.borderColor ?? 'transparent',
303
+ borderWidth: override.borderWidth ?? 0,
293
304
  };
294
305
  }
295
306
  );
@@ -84,17 +84,39 @@ interface CodeBlockStyle extends BaseBlockStyle {
84
84
  syntaxColors?: CodeBlockSyntaxColors;
85
85
  }
86
86
 
87
- export interface LinkStyle {
87
+ /**
88
+ * Chip ("box") properties shared by `link` and `linkVariants`. Unlike `color`
89
+ * and `underline`, these never inherit from the base `link` style: a variant
90
+ * that omits them renders without a chip (`0` / `transparent`).
91
+ */
92
+ interface LinkChipStyle {
93
+ backgroundColor?: string;
94
+ /** Corner radius of the chip, in dp. */
95
+ borderRadius?: number;
96
+ /**
97
+ * Horizontal padding, in dp. Reserves real space in the line on both outer
98
+ * edges of the link (affects measurement and wrapping).
99
+ */
100
+ paddingHorizontal?: number;
101
+ /**
102
+ * Vertical padding, in dp. Only affects the drawn chip (it bleeds into the
103
+ * surrounding line spacing); the line box is unchanged.
104
+ */
105
+ paddingVertical?: number;
106
+ borderColor?: string;
107
+ /** Border width, in dp. Drawn inset, within the chip bounds. */
108
+ borderWidth?: number;
109
+ }
110
+
111
+ export interface LinkStyle extends LinkChipStyle {
88
112
  fontFamily?: string;
89
113
  color?: string;
90
114
  underline?: boolean;
91
- backgroundColor?: string;
92
115
  }
93
116
 
94
- export interface LinkVariantStyle {
117
+ export interface LinkVariantStyle extends LinkChipStyle {
95
118
  color?: string;
96
119
  underline?: boolean;
97
- backgroundColor?: string;
98
120
  }
99
121
 
100
122
  interface StrongStyle {
@@ -350,7 +372,9 @@ export interface MarkdownStyle {
350
372
  * specific patterns take precedence.
351
373
  *
352
374
  * `color` and `underline` inherit from the base `link` style when omitted.
353
- * `backgroundColor` defaults to `transparent`.
375
+ * Chip properties (`backgroundColor`, `borderRadius`, `paddingHorizontal`,
376
+ * `paddingVertical`, `borderColor`, `borderWidth`) do not inherit: when
377
+ * omitted they default to `0` / `transparent`.
354
378
  * `fontFamily` always follows the base `link` style and cannot be overridden per-variant.
355
379
  *
356
380
  * @example
@@ -88,6 +88,11 @@ interface LinkStyleInternal {
88
88
  color: string;
89
89
  underline: boolean;
90
90
  backgroundColor: string;
91
+ borderRadius: number;
92
+ paddingHorizontal: number;
93
+ paddingVertical: number;
94
+ borderColor: string;
95
+ borderWidth: number;
91
96
  }
92
97
 
93
98
  export interface LinkVariantEntryInternal {
@@ -95,6 +100,11 @@ export interface LinkVariantEntryInternal {
95
100
  color: string;
96
101
  underline: boolean;
97
102
  backgroundColor: string;
103
+ borderRadius: number;
104
+ paddingHorizontal: number;
105
+ paddingVertical: number;
106
+ borderColor: string;
107
+ borderWidth: number;
98
108
  }
99
109
 
100
110
  interface StrongStyleInternal {
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react';
1
2
  import type { ColorValue, ViewProps, ViewStyle, TextStyle } from 'react-native';
2
3
  import type { MarkdownStyle, Md4cFlags } from './MarkdownStyle';
3
4
  import type { AccessibilityLabels } from './AccessibilityLabels';
@@ -9,6 +10,33 @@ import type {
9
10
  CopyEvent,
10
11
  } from './events';
11
12
 
13
+ /**
14
+ * A rect in dp, relative to the component's content box (inside any
15
+ * `containerStyle` padding).
16
+ */
17
+ export interface LinkRect {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ /** Layout of one rendered link, passed to `renderLinkDecoration`. */
25
+ export interface LinkDecorationInfo {
26
+ /** Position of the link in the document (0-based) — stable key for a link. */
27
+ index: number;
28
+ url: string;
29
+ /** The link's visible text. */
30
+ text: string;
31
+ /**
32
+ * Anchor rect. Native: the drawn chip (glyphs + padding) of the link's last
33
+ * line fragment. Web: the link's inline box.
34
+ */
35
+ rect: LinkRect;
36
+ /** Every line fragment of the link (several when it wraps). Web: `[rect]`. */
37
+ rects: LinkRect[];
38
+ }
39
+
12
40
  /**
13
41
  * Public context menu item. Each item includes a JS-side `onPress` callback
14
42
  * that is called when the user taps the item in the selection context menu.
@@ -178,6 +206,21 @@ export interface EnrichedMarkdownTextProps extends Omit<ViewProps, 'style'> {
178
206
  * @platform ios, android, web
179
207
  */
180
208
  onLinkLongPress?: (event: LinkLongPressEvent) => void;
209
+ /**
210
+ * Renders a decoration (e.g. a badge or icon) over each link. The returned
211
+ * node is placed in an absolutely positioned box matching `info.rect` (the
212
+ * link's last line fragment, including chip padding) with
213
+ * `pointerEvents="box-none"`; position content relative to it (negative
214
+ * offsets overflow the box). Return `null` to leave a link undecorated.
215
+ *
216
+ * Native caveats: rects arrive from native after layout, so decorations
217
+ * appear one frame after the text; links inside tables (`flavor="github"`)
218
+ * are not reported; content overflowing the component bounds does not
219
+ * receive touches on Android. Toggling this prop between set and unset
220
+ * remounts the native view.
221
+ * @platform ios, android, web
222
+ */
223
+ renderLinkDecoration?: (info: LinkDecorationInfo) => ReactNode | null;
181
224
  /**
182
225
  * Callback fired when a task list checkbox is tapped.
183
226
  *
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, HTMLAttributes } from 'react';
1
+ import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
2
2
  import type { MarkdownStyle, Md4cFlags } from './MarkdownStyle';
3
3
  import type {
4
4
  LinkPressEvent,
@@ -6,6 +6,33 @@ import type {
6
6
  TaskListItemPressEvent,
7
7
  } from './events';
8
8
 
9
+ /**
10
+ * A rect in dp, relative to the component's content box (inside any
11
+ * `containerStyle` padding).
12
+ */
13
+ export interface LinkRect {
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ /** Layout of one rendered link, passed to `renderLinkDecoration`. */
21
+ export interface LinkDecorationInfo {
22
+ /** Position of the link in the document (0-based) — stable key for a link. */
23
+ index: number;
24
+ url: string;
25
+ /** The link's visible text. */
26
+ text: string;
27
+ /**
28
+ * Anchor rect. Native: the drawn chip (glyphs + padding) of the link's last
29
+ * line fragment. Web: the link's inline box.
30
+ */
31
+ rect: LinkRect;
32
+ /** Every line fragment of the link (several when it wraps). Web: `[rect]`. */
33
+ rects: LinkRect[];
34
+ }
35
+
9
36
  export interface EnrichedMarkdownTextProps extends Omit<
10
37
  HTMLAttributes<HTMLDivElement>,
11
38
  'style' | 'dir'
@@ -47,6 +74,21 @@ export interface EnrichedMarkdownTextProps extends Omit<
47
74
  * @platform ios, android, web
48
75
  */
49
76
  onLinkLongPress?: (event: LinkLongPressEvent) => void;
77
+ /**
78
+ * Renders a decoration (e.g. a badge or icon) over each link. The returned
79
+ * node is placed in an absolutely positioned box matching `info.rect` (the
80
+ * link's last line fragment, including chip padding) with
81
+ * `pointerEvents="box-none"`; position content relative to it (negative
82
+ * offsets overflow the box). Return `null` to leave a link undecorated.
83
+ *
84
+ * Native caveats: rects arrive from native after layout, so decorations
85
+ * appear one frame after the text; links inside tables (`flavor="github"`)
86
+ * are not reported; content overflowing the component bounds does not
87
+ * receive touches on Android. Toggling this prop between set and unset
88
+ * remounts the native view.
89
+ * @platform ios, android, web
90
+ */
91
+ renderLinkDecoration?: (info: LinkDecorationInfo) => ReactNode | null;
50
92
  /**
51
93
  * Callback fired when a task list checkbox is tapped.
52
94
  *
@@ -9,7 +9,7 @@ import {
9
9
  import { parseMarkdown } from './parseMarkdown';
10
10
  import { RenderNode } from './renderers';
11
11
  import type { ASTNode, RendererCallbacks, RenderCapabilities } from './types';
12
- import { indexTaskItems, markInlineImages } from './utils';
12
+ import { indexLinks, indexTaskItems, markInlineImages } from './utils';
13
13
  import { loadKaTeX } from './katex';
14
14
  import type { KaTeXInstance } from './katex';
15
15
  import { ENRM_TEXT_CLASS, ENRM_SELECTION_BG_VAR } from './globalStyles';
@@ -27,6 +27,7 @@ export const EnrichedMarkdownText = ({
27
27
  selectable = true,
28
28
  dir,
29
29
  selectionColor,
30
+ renderLinkDecoration,
30
31
  ...rest
31
32
  }: EnrichedMarkdownTextProps) => {
32
33
  const normalizedStyle = useMemo(
@@ -66,6 +67,7 @@ export const EnrichedMarkdownText = ({
66
67
  .then(([result, katexInstance]) => {
67
68
  if (!cancelled) {
68
69
  indexTaskItems(result);
70
+ indexLinks(result);
69
71
  markInlineImages(result);
70
72
 
71
73
  setParseError(false);
@@ -99,8 +101,13 @@ export const EnrichedMarkdownText = ({
99
101
  ]);
100
102
 
101
103
  const callbacks = useMemo<RendererCallbacks>(
102
- () => ({ onLinkPress, onLinkLongPress, onTaskListItemPress }),
103
- [onLinkPress, onLinkLongPress, onTaskListItemPress]
104
+ () => ({
105
+ onLinkPress,
106
+ onLinkLongPress,
107
+ onTaskListItemPress,
108
+ renderLinkDecoration,
109
+ }),
110
+ [onLinkPress, onLinkLongPress, onTaskListItemPress, renderLinkDecoration]
104
111
  );
105
112
 
106
113
  const capabilities = useMemo<RenderCapabilities>(
@@ -1,8 +1,16 @@
1
- import type { MouseEvent } from 'react';
1
+ import {
2
+ useLayoutEffect,
3
+ useRef,
4
+ useState,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import type { LinkRect } from '../../types/MarkdownTextProps.web';
2
9
  import type { RendererProps, RendererMap } from '../types';
3
10
  import { extractNodeText } from '../utils';
4
11
  import { KaTeXRenderer } from './KaTeXRenderer';
5
12
  import { linkStyleForUrl } from '../styles';
13
+ import type { CSSProperties } from 'react';
6
14
 
7
15
  function TextRenderer({ node }: RendererProps) {
8
16
  return <>{node.content ?? ''}</>;
@@ -78,16 +86,116 @@ function LinkRenderer({
78
86
  }
79
87
  };
80
88
 
89
+ const linkStyle = linkStyleForUrl(style, url);
90
+ const children = renderChildren(node);
91
+
92
+ if (callbacks.renderLinkDecoration) {
93
+ return (
94
+ <DecoratedLink
95
+ url={url}
96
+ index={node.attributes?.linkIndex ?? 0}
97
+ text={extractNodeText(node)}
98
+ linkStyle={linkStyle}
99
+ renderLinkDecoration={callbacks.renderLinkDecoration}
100
+ onClick={handleClick}
101
+ onContextMenu={handleContextMenu}
102
+ >
103
+ {children}
104
+ </DecoratedLink>
105
+ );
106
+ }
107
+
81
108
  return (
82
109
  <a
83
110
  href={url}
84
- style={linkStyleForUrl(style, url)}
111
+ style={linkStyle}
85
112
  target="_blank"
86
113
  rel="noopener noreferrer"
87
114
  onClick={handleClick}
88
115
  onContextMenu={handleContextMenu}
89
116
  >
90
- {renderChildren(node)}
117
+ {children}
118
+ </a>
119
+ );
120
+ }
121
+
122
+ const EMPTY_RECT: LinkRect = { x: 0, y: 0, width: 0, height: 0 };
123
+
124
+ const decorationLayerStyle: CSSProperties = {
125
+ position: 'absolute',
126
+ inset: 0,
127
+ pointerEvents: 'none',
128
+ };
129
+
130
+ const decorationContentStyle: CSSProperties = { pointerEvents: 'auto' };
131
+
132
+ /**
133
+ * A link with a `renderLinkDecoration` overlay. Unlike native, the overlay
134
+ * anchors to the link's whole inline box (not the last line fragment of a
135
+ * wrapping link), and `rect` is that box relative to itself.
136
+ */
137
+ function DecoratedLink({
138
+ url,
139
+ index,
140
+ text,
141
+ linkStyle,
142
+ renderLinkDecoration,
143
+ onClick,
144
+ onContextMenu,
145
+ children,
146
+ }: {
147
+ url: string;
148
+ index: number;
149
+ text: string;
150
+ linkStyle: ReturnType<typeof linkStyleForUrl>;
151
+ renderLinkDecoration: NonNullable<
152
+ RendererProps['callbacks']['renderLinkDecoration']
153
+ >;
154
+ onClick: (event: MouseEvent) => void;
155
+ onContextMenu: (event: MouseEvent) => void;
156
+ children: ReactNode;
157
+ }) {
158
+ const anchorRef = useRef<HTMLAnchorElement>(null);
159
+ const [rect, setRect] = useState<LinkRect>(EMPTY_RECT);
160
+
161
+ // The overlay itself is sized by CSS (inset: 0); `rect` is informational, so
162
+ // re-measuring when the content or chip style changes is enough.
163
+ useLayoutEffect(() => {
164
+ const anchor = anchorRef.current;
165
+ if (!anchor) return;
166
+ const { width, height } = anchor.getBoundingClientRect();
167
+ setRect((prev) =>
168
+ prev.width === width && prev.height === height
169
+ ? prev
170
+ : { x: 0, y: 0, width, height }
171
+ );
172
+ }, [text, linkStyle]);
173
+
174
+ const decoration = renderLinkDecoration({
175
+ index,
176
+ url,
177
+ text,
178
+ rect,
179
+ rects: [rect],
180
+ });
181
+
182
+ return (
183
+ <a
184
+ ref={anchorRef}
185
+ href={url}
186
+ style={{ ...linkStyle, position: 'relative' }} // eslint-disable-line react-native/no-inline-styles
187
+ target="_blank"
188
+ rel="noopener noreferrer"
189
+ onClick={onClick}
190
+ onContextMenu={onContextMenu}
191
+ >
192
+ {children}
193
+ {decoration != null && (
194
+ <span aria-hidden style={decorationLayerStyle}>
195
+ {/* Mirrors native pointerEvents="box-none": only the decoration itself is hit-testable. */}
196
+ <span style={decorationContentStyle}>{decoration}</span>
197
+ </span>
198
+ )}
91
199
  </a>
92
200
  );
93
201
  }
package/src/web/styles.ts CHANGED
@@ -323,6 +323,12 @@ export function linkStyleForUrl(
323
323
  variantIndex !== -1 ? style.linkVariants[variantIndex]! : base;
324
324
 
325
325
  const backgroundColor = resolved.backgroundColor;
326
+ const hasBorder =
327
+ resolved.borderWidth > 0 &&
328
+ !!resolved.borderColor &&
329
+ resolved.borderColor !== 'transparent';
330
+ const hasPadding =
331
+ resolved.paddingHorizontal > 0 || resolved.paddingVertical > 0;
326
332
  return {
327
333
  color: resolved.color,
328
334
  fontFamily: normalizeFontFamily(base.fontFamily),
@@ -330,6 +336,25 @@ export function linkStyleForUrl(
330
336
  ...(backgroundColor && backgroundColor !== 'transparent'
331
337
  ? { backgroundColor }
332
338
  : undefined),
339
+ // Chip: inline padding reserves horizontal space like native; vertical
340
+ // padding only paints (inline boxes don't grow the line box). `clone`
341
+ // rounds and pads each fragment of a wrapping link.
342
+ ...(hasPadding
343
+ ? {
344
+ padding: `${resolved.paddingVertical}px ${resolved.paddingHorizontal}px`,
345
+ }
346
+ : undefined),
347
+ ...(resolved.borderRadius > 0
348
+ ? { borderRadius: resolved.borderRadius }
349
+ : undefined),
350
+ ...(hasBorder
351
+ ? {
352
+ border: `${resolved.borderWidth}px solid ${resolved.borderColor}`,
353
+ }
354
+ : undefined),
355
+ ...(hasPadding || hasBorder || resolved.borderRadius > 0
356
+ ? { boxDecorationBreak: 'clone', WebkitBoxDecorationBreak: 'clone' }
357
+ : undefined),
333
358
  };
334
359
  }
335
360
 
package/src/web/types.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  LinkLongPressEvent,
7
7
  TaskListItemPressEvent,
8
8
  } from '../types/events';
9
+ import type { LinkDecorationInfo } from '../types/MarkdownTextProps.web';
9
10
  import type { KaTeXInstance } from './katex';
10
11
 
11
12
  export type NodeType =
@@ -52,6 +53,8 @@ export interface NodeAttributes {
52
53
  taskIndex?: number;
53
54
  /** Stamped by markInlineImages() — not present in the raw WASM output. */
54
55
  isInline?: boolean;
56
+ /** Stamped by indexLinks() — document-order link index, not in the raw WASM output. */
57
+ linkIndex?: number;
55
58
  colCount?: string;
56
59
  headRowCount?: string;
57
60
  bodyRowCount?: string;
@@ -72,6 +75,7 @@ export interface RendererCallbacks {
72
75
  onLinkPress?: (event: LinkPressEvent) => void;
73
76
  onLinkLongPress?: (event: LinkLongPressEvent) => void;
74
77
  onTaskListItemPress?: (event: TaskListItemPressEvent) => void;
78
+ renderLinkDecoration?: (info: LinkDecorationInfo) => ReactNode | null;
75
79
  }
76
80
 
77
81
  export interface RenderCapabilities {
package/src/web/utils.ts CHANGED
@@ -30,6 +30,14 @@ export function indexTaskItems(node: ASTNode, counter = { value: 0 }): void {
30
30
  node.children?.forEach((child) => indexTaskItems(child, counter));
31
31
  }
32
32
 
33
+ /** Stamps Link nodes with their document-order index (the `renderLinkDecoration` key). */
34
+ export function indexLinks(node: ASTNode, counter = { value: 0 }): void {
35
+ if (node.type === 'Link') {
36
+ node.attributes = { ...node.attributes, linkIndex: counter.value++ };
37
+ }
38
+ node.children?.forEach((child) => indexLinks(child, counter));
39
+ }
40
+
33
41
  /**
34
42
  * Stamps Image nodes with `isInline` when they appear inside a paragraph
35
43
  * that also contains other content (text, links, etc.).