react-native-enriched-markdown 0.1.1 → 0.2.0

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 (127) hide show
  1. package/README.md +80 -8
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +17 -2
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +6 -1
  4. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +9 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +6 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +28 -3
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +225 -1
  8. package/android/src/main/cpp/jni-adapter.cpp +28 -11
  9. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +132 -15
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +1 -16
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +67 -13
  12. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +241 -21
  13. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +279 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLongPressEvent.kt +23 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +2 -0
  16. package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +17 -3
  17. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +13 -18
  18. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +23 -24
  19. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeRenderer.kt +1 -0
  20. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +2 -1
  21. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/EmphasisRenderer.kt +2 -1
  22. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +18 -2
  23. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ImageRenderer.kt +22 -6
  24. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LineBreakRenderer.kt +1 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +3 -2
  26. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +2 -1
  27. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +16 -9
  28. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +5 -1
  29. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +23 -9
  30. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +24 -10
  31. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +1 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrikethroughRenderer.kt +27 -0
  33. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrongRenderer.kt +2 -1
  34. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/TextRenderer.kt +1 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ThematicBreakRenderer.kt +1 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/UnderlineRenderer.kt +27 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +1 -0
  38. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +8 -17
  39. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +19 -5
  40. package/android/src/main/java/com/swmansion/enriched/markdown/spans/MarginBottomSpan.kt +1 -1
  41. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrikethroughSpan.kt +12 -0
  42. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BaseBlockStyle.kt +1 -0
  43. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +3 -0
  44. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +3 -0
  45. package/android/src/main/java/com/swmansion/enriched/markdown/styles/HeadingStyle.kt +5 -1
  46. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +3 -1
  47. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +3 -0
  48. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ParagraphStyle.kt +5 -1
  49. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrikethroughStyle.kt +17 -0
  50. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +32 -1
  51. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleParser.kt +22 -5
  52. package/android/src/main/java/com/swmansion/enriched/markdown/styles/TextAlignment.kt +32 -0
  53. package/android/src/main/java/com/swmansion/enriched/markdown/styles/UnderlineStyle.kt +17 -0
  54. package/android/src/main/java/com/swmansion/enriched/markdown/utils/HTMLGenerator.kt +23 -5
  55. package/android/src/main/java/com/swmansion/enriched/markdown/utils/LinkLongPressMovementMethod.kt +121 -0
  56. package/android/src/main/java/com/swmansion/enriched/markdown/utils/MarkdownExtractor.kt +10 -0
  57. package/android/src/main/java/com/swmansion/enriched/markdown/utils/Utils.kt +58 -56
  58. package/android/src/main/jni/CMakeLists.txt +1 -13
  59. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +0 -13
  60. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +2 -14
  61. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +3 -0
  62. package/cpp/parser/MD4CParser.cpp +21 -8
  63. package/cpp/parser/MD4CParser.hpp +5 -1
  64. package/cpp/parser/MarkdownASTNode.hpp +2 -0
  65. package/ios/EnrichedMarkdownText.mm +356 -29
  66. package/ios/attachments/{ImageAttachment.h → EnrichedMarkdownImageAttachment.h} +1 -1
  67. package/ios/attachments/{ImageAttachment.m → EnrichedMarkdownImageAttachment.m} +4 -4
  68. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.cpp +9 -0
  69. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.h +6 -0
  70. package/ios/generated/EnrichedMarkdownTextSpec/Props.cpp +28 -3
  71. package/ios/generated/EnrichedMarkdownTextSpec/Props.h +225 -1
  72. package/ios/parser/MarkdownASTNode.h +2 -0
  73. package/ios/parser/MarkdownParser.h +9 -0
  74. package/ios/parser/MarkdownParser.mm +31 -2
  75. package/ios/parser/MarkdownParserBridge.mm +13 -3
  76. package/ios/renderer/AttributedRenderer.h +2 -0
  77. package/ios/renderer/AttributedRenderer.m +52 -19
  78. package/ios/renderer/BlockquoteRenderer.m +7 -6
  79. package/ios/renderer/CodeBlockRenderer.m +9 -8
  80. package/ios/renderer/HeadingRenderer.m +31 -24
  81. package/ios/renderer/ImageRenderer.m +31 -10
  82. package/ios/renderer/ListItemRenderer.m +51 -39
  83. package/ios/renderer/ListRenderer.m +21 -18
  84. package/ios/renderer/ParagraphRenderer.m +27 -16
  85. package/ios/renderer/RenderContext.h +17 -0
  86. package/ios/renderer/RenderContext.m +66 -2
  87. package/ios/renderer/RendererFactory.m +6 -0
  88. package/ios/renderer/StrikethroughRenderer.h +6 -0
  89. package/ios/renderer/StrikethroughRenderer.m +40 -0
  90. package/ios/renderer/UnderlineRenderer.h +6 -0
  91. package/ios/renderer/UnderlineRenderer.m +39 -0
  92. package/ios/styles/StyleConfig.h +46 -0
  93. package/ios/styles/StyleConfig.mm +351 -12
  94. package/ios/utils/AccessibilityInfo.h +35 -0
  95. package/ios/utils/AccessibilityInfo.m +24 -0
  96. package/ios/utils/CodeBlockBackground.m +4 -9
  97. package/ios/utils/FontUtils.h +5 -0
  98. package/ios/utils/FontUtils.m +14 -0
  99. package/ios/utils/HTMLGenerator.m +21 -7
  100. package/ios/utils/MarkdownAccessibilityElementBuilder.h +45 -0
  101. package/ios/utils/MarkdownAccessibilityElementBuilder.m +323 -0
  102. package/ios/utils/MarkdownExtractor.m +18 -5
  103. package/ios/utils/ParagraphStyleUtils.h +10 -2
  104. package/ios/utils/ParagraphStyleUtils.m +57 -2
  105. package/ios/utils/PasteboardUtils.h +1 -1
  106. package/ios/utils/PasteboardUtils.m +3 -3
  107. package/lib/module/EnrichedMarkdownText.js +33 -2
  108. package/lib/module/EnrichedMarkdownText.js.map +1 -1
  109. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +83 -3
  110. package/lib/module/index.js +0 -1
  111. package/lib/module/index.js.map +1 -1
  112. package/lib/module/normalizeMarkdownStyle.js +58 -14
  113. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  114. package/lib/typescript/src/EnrichedMarkdownText.d.ts +85 -3
  115. package/lib/typescript/src/EnrichedMarkdownText.d.ts.map +1 -1
  116. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +75 -1
  117. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  118. package/lib/typescript/src/index.d.ts +2 -3
  119. package/lib/typescript/src/index.d.ts.map +1 -1
  120. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  121. package/package.json +1 -1
  122. package/src/EnrichedMarkdownText.tsx +133 -5
  123. package/src/EnrichedMarkdownTextNativeComponent.ts +83 -3
  124. package/src/index.tsx +5 -2
  125. package/src/normalizeMarkdownStyle.ts +46 -0
  126. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.cpp +0 -9
  127. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.h +0 -25
@@ -1,17 +1,21 @@
1
1
  #import "EnrichedMarkdownText.h"
2
+ #import "AccessibilityInfo.h"
2
3
  #import "AttributedRenderer.h"
3
4
  #import "CodeBlockBackground.h"
4
5
  #import "EditMenuUtils.h"
6
+ #import "EnrichedMarkdownImageAttachment.h"
5
7
  #import "FontUtils.h"
6
- #import "ImageAttachment.h"
7
8
  #import "LastElementUtils.h"
8
9
  #import "MarkdownASTNode.h"
10
+ #import "MarkdownAccessibilityElementBuilder.h"
9
11
  #import "MarkdownExtractor.h"
10
12
  #import "MarkdownParser.h"
13
+ #import "ParagraphStyleUtils.h"
11
14
  #import "RenderContext.h"
12
15
  #import "RuntimeKeys.h"
13
16
  #import "StyleConfig.h"
14
17
  #import "TextViewLayoutManager.h"
18
+ #import <React/RCTUtils.h>
15
19
  #import <objc/runtime.h>
16
20
 
17
21
  #import <ReactNativeEnrichedMarkdown/EnrichedMarkdownTextComponentDescriptor.h>
@@ -39,6 +43,7 @@ using namespace facebook::react;
39
43
  MarkdownParser *_parser;
40
44
  NSString *_cachedMarkdown;
41
45
  StyleConfig *_config;
46
+ Md4cFlags *_md4cFlags;
42
47
 
43
48
  // Background rendering support
44
49
  dispatch_queue_t _renderQueue;
@@ -47,6 +52,22 @@ using namespace facebook::react;
47
52
 
48
53
  EnrichedMarkdownTextShadowNode::ConcreteState::Shared _state;
49
54
  int _heightUpdateCounter;
55
+
56
+ // Font scale tracking
57
+ CGFloat _currentFontScale;
58
+ BOOL _allowFontScaling;
59
+ CGFloat _maxFontSizeMultiplier;
60
+
61
+ // Last element marginBottom tracking
62
+ CGFloat _lastElementMarginBottom;
63
+ BOOL _allowTrailingMargin;
64
+
65
+ // iOS link preview control
66
+ BOOL _enableLinkPreview;
67
+
68
+ // Accessibility data for VoiceOver
69
+ AccessibilityInfo *_accessibilityInfo;
70
+ NSMutableArray<UIAccessibilityElement *> *_accessibilityElements;
50
71
  }
51
72
 
52
73
  + (ComponentDescriptorProvider)componentDescriptorProvider
@@ -61,37 +82,37 @@ using namespace facebook::react;
61
82
  NSAttributedString *text = _textView.attributedText;
62
83
  CGFloat defaultHeight = [UIFont systemFontOfSize:16.0].lineHeight;
63
84
 
64
- if (!text || text.length == 0) {
85
+ if (text.length == 0) {
65
86
  return CGSizeMake(maxWidth, defaultHeight);
66
87
  }
67
88
 
68
- // Find last content character (exclude trailing newlines from measurement)
69
- NSRange lastContent = [text.string rangeOfCharacterFromSet:[[NSCharacterSet newlineCharacterSet] invertedSet]
70
- options:NSBackwardsSearch];
71
- if (lastContent.location == NSNotFound) {
72
- return CGSizeMake(maxWidth, defaultHeight);
73
- }
89
+ // Use UITextView's layout manager for measurement to avoid
90
+ // boundingRectWithSize: height discrepancies with NSTextAttachment objects.
91
+ _textView.textContainer.size = CGSizeMake(maxWidth, CGFLOAT_MAX);
92
+ [_textView.layoutManager ensureLayoutForTextContainer:_textView.textContainer];
93
+ CGRect usedRect = [_textView.layoutManager usedRectForTextContainer:_textView.textContainer];
74
94
 
75
- NSAttributedString *contentToMeasure = [text attributedSubstringFromRange:NSMakeRange(0, NSMaxRange(lastContent))];
95
+ CGFloat measuredWidth = ceil(usedRect.size.width);
96
+ CGFloat measuredHeight = usedRect.size.height;
76
97
 
77
- // Use NSStringDrawingUsesDeviceMetrics for tighter bounds (especially for images)
78
- NSStringDrawingOptions options = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
79
- if (isLastElementImage(text)) {
80
- options |= NSStringDrawingUsesDeviceMetrics;
98
+ // When text ends with \n (e.g. code block's bottom padding spacer),
99
+ // TextKit creates an "extra line fragment" after it that adds unwanted height.
100
+ CGRect extraFragment = _textView.layoutManager.extraLineFragmentRect;
101
+ if (!CGRectIsEmpty(extraFragment)) {
102
+ measuredHeight -= extraFragment.size.height;
81
103
  }
82
104
 
83
- CGRect boundingRect = [contentToMeasure boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX)
84
- options:options
85
- context:nil];
86
-
87
- CGFloat measuredHeight = boundingRect.size.height;
88
-
89
- // Compensate for iOS not measuring trailing newlines (code block bottom padding)
105
+ // Code block's bottom padding is a spacer \n with minimumLineHeight = codeBlockPadding.
106
+ // The layout manager may not size it accurately, so add the padding explicitly.
90
107
  if (isLastElementCodeBlock(text)) {
91
108
  measuredHeight += [_config codeBlockPadding];
92
109
  }
93
110
 
94
- return CGSizeMake(maxWidth, ceil(measuredHeight));
111
+ if (_allowTrailingMargin && _lastElementMarginBottom > 0) {
112
+ measuredHeight += _lastElementMarginBottom;
113
+ }
114
+
115
+ return CGSizeMake(measuredWidth, ceil(measuredHeight));
95
116
  }
96
117
 
97
118
  - (void)updateState:(const facebook::react::State::Shared &)state
@@ -124,17 +145,61 @@ using namespace facebook::react;
124
145
 
125
146
  self.backgroundColor = [UIColor clearColor];
126
147
  _parser = [[MarkdownParser alloc] init];
148
+ _md4cFlags = [Md4cFlags defaultFlags];
127
149
 
128
150
  // Serial queue for background rendering
129
151
  _renderQueue = dispatch_queue_create("com.swmansion.enriched.markdown.render", DISPATCH_QUEUE_SERIAL);
130
152
  _currentRenderId = 0;
131
153
 
154
+ // Initialize font scale from current content size category
155
+ _allowFontScaling = YES;
156
+ _maxFontSizeMultiplier = 0;
157
+ _allowTrailingMargin = NO;
158
+ _currentFontScale = RCTFontSizeMultiplier();
159
+ _enableLinkPreview = YES;
160
+
161
+ [[NSNotificationCenter defaultCenter] addObserver:self
162
+ selector:@selector(contentSizeCategoryDidChange:)
163
+ name:UIContentSizeCategoryDidChangeNotification
164
+ object:nil];
165
+
132
166
  [self setupTextView];
133
167
  }
134
168
 
135
169
  return self;
136
170
  }
137
171
 
172
+ - (void)dealloc
173
+ {
174
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:UIContentSizeCategoryDidChangeNotification object:nil];
175
+ }
176
+
177
+ - (CGFloat)effectiveFontScale
178
+ {
179
+ // If font scaling is disabled, always return 1.0 (no scaling)
180
+ return _allowFontScaling ? _currentFontScale : 1.0;
181
+ }
182
+
183
+ - (void)contentSizeCategoryDidChange:(NSNotification *)notification
184
+ {
185
+ if (!_allowFontScaling) {
186
+ return;
187
+ }
188
+
189
+ CGFloat newFontScale = RCTFontSizeMultiplier();
190
+ if (_currentFontScale != newFontScale) {
191
+ _currentFontScale = newFontScale;
192
+
193
+ if (_config != nil) {
194
+ [_config setFontScaleMultiplier:[self effectiveFontScale]];
195
+ }
196
+
197
+ if (_cachedMarkdown != nil && _cachedMarkdown.length > 0) {
198
+ [self renderMarkdownContent:_cachedMarkdown];
199
+ }
200
+ }
201
+ }
202
+
138
203
  - (void)setupTextView
139
204
  {
140
205
  _textView = [[UITextView alloc] init];
@@ -145,15 +210,19 @@ using namespace facebook::react;
145
210
  _textView.editable = NO;
146
211
  _textView.delegate = self;
147
212
  _textView.scrollEnabled = NO;
213
+ _textView.showsVerticalScrollIndicator = NO;
214
+ _textView.showsHorizontalScrollIndicator = NO;
148
215
  _textView.textContainerInset = UIEdgeInsetsZero;
149
216
  _textView.textContainer.lineFragmentPadding = 0;
150
217
  // Disable UITextView's default link styling - we handle it directly in attributed strings
151
218
  _textView.linkTextAttributes = @{};
152
- // isSelectable controls text selection and link previews
219
+ // selectable controls text selection and link previews
153
220
  // Default to YES to match the prop default
154
221
  _textView.selectable = YES;
155
222
  // Hide initially to prevent flash before content is rendered
156
223
  _textView.hidden = YES;
224
+ // Disable textView's built-in accessibility - we provide custom elements with proper traits
225
+ _textView.accessibilityElementsHidden = YES;
157
226
 
158
227
  // Add tap gesture recognizer
159
228
  UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
@@ -218,14 +287,19 @@ using namespace facebook::react;
218
287
  // Capture state needed for background rendering
219
288
  StyleConfig *config = [_config copy];
220
289
  MarkdownParser *parser = _parser;
290
+ Md4cFlags *md4cFlags = [_md4cFlags copy];
221
291
  NSUInteger inputLength = markdownString.length;
222
292
  NSDate *scheduleStart = [NSDate date];
223
293
 
294
+ // Capture font scaling settings
295
+ BOOL allowFontScaling = _allowFontScaling;
296
+ CGFloat maxFontSizeMultiplier = _maxFontSizeMultiplier;
297
+
224
298
  // Dispatch heavy work to background queue
225
299
  dispatch_async(_renderQueue, ^{
226
300
  // 1. Parse Markdown → AST (C++ md4c parser)
227
301
  NSDate *parseStart = [NSDate date];
228
- MarkdownASTNode *ast = [parser parseMarkdown:markdownString];
302
+ MarkdownASTNode *ast = [parser parseMarkdown:markdownString flags:md4cFlags];
229
303
  if (!ast) {
230
304
  return;
231
305
  }
@@ -235,9 +309,14 @@ using namespace facebook::react;
235
309
  // 2. Render AST → NSAttributedString
236
310
  NSDate *renderStart = [NSDate date];
237
311
  AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:config];
312
+ [renderer setAllowTrailingMargin:self->_allowTrailingMargin];
238
313
  RenderContext *context = [RenderContext new];
314
+ context.allowFontScaling = allowFontScaling;
315
+ context.maxFontSizeMultiplier = maxFontSizeMultiplier;
239
316
  NSMutableAttributedString *attributedText = [renderer renderRoot:ast context:context];
240
317
 
318
+ self->_lastElementMarginBottom = [renderer getLastElementMarginBottom];
319
+
241
320
  // Add link attributes
242
321
  for (NSUInteger i = 0; i < context.linkRanges.count; i++) {
243
322
  NSValue *rangeValue = context.linkRanges[i];
@@ -245,6 +324,10 @@ using namespace facebook::react;
245
324
  NSString *url = context.linkURLs[i];
246
325
  [attributedText addAttribute:@"linkURL" value:url range:range];
247
326
  }
327
+
328
+ // Capture accessibility info
329
+ AccessibilityInfo *accessibilityInfo = [AccessibilityInfo infoFromContext:context];
330
+
248
331
  NSTimeInterval renderTime = [[NSDate date] timeIntervalSinceDate:renderStart] * 1000;
249
332
  NSUInteger styledLength = attributedText.length;
250
333
 
@@ -255,6 +338,9 @@ using namespace facebook::react;
255
338
  return;
256
339
  }
257
340
 
341
+ // Store accessibility info
342
+ self->_accessibilityInfo = accessibilityInfo;
343
+
258
344
  [self applyRenderedText:attributedText];
259
345
 
260
346
  NSTimeInterval totalTime = [[NSDate date] timeIntervalSinceDate:scheduleStart] * 1000;
@@ -279,15 +365,20 @@ using namespace facebook::react;
279
365
  _blockAsyncRender = YES;
280
366
  _cachedMarkdown = [markdownString copy];
281
367
 
282
- MarkdownASTNode *ast = [_parser parseMarkdown:markdownString];
368
+ MarkdownASTNode *ast = [_parser parseMarkdown:markdownString flags:_md4cFlags];
283
369
  if (!ast) {
284
370
  return;
285
371
  }
286
372
 
287
373
  AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:_config];
374
+ [renderer setAllowTrailingMargin:_allowTrailingMargin];
288
375
  RenderContext *context = [RenderContext new];
376
+ context.allowFontScaling = _allowFontScaling;
377
+ context.maxFontSizeMultiplier = _maxFontSizeMultiplier;
289
378
  NSMutableAttributedString *attributedText = [renderer renderRoot:ast context:context];
290
379
 
380
+ _lastElementMarginBottom = [renderer getLastElementMarginBottom];
381
+
291
382
  for (NSUInteger i = 0; i < context.linkRanges.count; i++) {
292
383
  NSValue *rangeValue = context.linkRanges[i];
293
384
  NSRange range = [rangeValue rangeValue];
@@ -295,6 +386,9 @@ using namespace facebook::react;
295
386
  [attributedText addAttribute:@"linkURL" value:url range:range];
296
387
  }
297
388
 
389
+ // Store accessibility info
390
+ _accessibilityInfo = [AccessibilityInfo infoFromContext:context];
391
+
298
392
  _textView.attributedText = attributedText;
299
393
  }
300
394
 
@@ -323,6 +417,9 @@ using namespace facebook::react;
323
417
  // Request height recalculation from shadow node FIRST
324
418
  [self requestHeightUpdate];
325
419
 
420
+ // Build accessibility elements after layout is complete
421
+ [self buildAccessibilityElements];
422
+
326
423
  // Show text view on next run loop, after layout has settled
327
424
  if (_textView.hidden) {
328
425
  dispatch_async(dispatch_get_main_queue(), ^{ self->_textView.hidden = NO; });
@@ -338,6 +435,7 @@ using namespace facebook::react;
338
435
 
339
436
  if (_config == nil) {
340
437
  _config = [[StyleConfig alloc] init];
438
+ [_config setFontScaleMultiplier:[self effectiveFontScale]];
341
439
  }
342
440
 
343
441
  // Paragraph style
@@ -378,6 +476,11 @@ using namespace facebook::react;
378
476
  stylePropChanged = YES;
379
477
  }
380
478
 
479
+ if (newViewProps.markdownStyle.paragraph.marginTop != oldViewProps.markdownStyle.paragraph.marginTop) {
480
+ [_config setParagraphMarginTop:newViewProps.markdownStyle.paragraph.marginTop];
481
+ stylePropChanged = YES;
482
+ }
483
+
381
484
  if (newViewProps.markdownStyle.paragraph.marginBottom != oldViewProps.markdownStyle.paragraph.marginBottom) {
382
485
  [_config setParagraphMarginBottom:newViewProps.markdownStyle.paragraph.marginBottom];
383
486
  stylePropChanged = YES;
@@ -388,6 +491,11 @@ using namespace facebook::react;
388
491
  stylePropChanged = YES;
389
492
  }
390
493
 
494
+ if (newViewProps.markdownStyle.paragraph.textAlign != oldViewProps.markdownStyle.paragraph.textAlign) {
495
+ [_config setParagraphTextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.paragraph.textAlign.c_str()))];
496
+ stylePropChanged = YES;
497
+ }
498
+
391
499
  // H1 style
392
500
  if (newViewProps.markdownStyle.h1.fontSize != oldViewProps.markdownStyle.h1.fontSize) {
393
501
  [_config setH1FontSize:newViewProps.markdownStyle.h1.fontSize];
@@ -424,6 +532,11 @@ using namespace facebook::react;
424
532
  stylePropChanged = YES;
425
533
  }
426
534
 
535
+ if (newViewProps.markdownStyle.h1.marginTop != oldViewProps.markdownStyle.h1.marginTop) {
536
+ [_config setH1MarginTop:newViewProps.markdownStyle.h1.marginTop];
537
+ stylePropChanged = YES;
538
+ }
539
+
427
540
  if (newViewProps.markdownStyle.h1.marginBottom != oldViewProps.markdownStyle.h1.marginBottom) {
428
541
  [_config setH1MarginBottom:newViewProps.markdownStyle.h1.marginBottom];
429
542
  stylePropChanged = YES;
@@ -434,6 +547,11 @@ using namespace facebook::react;
434
547
  stylePropChanged = YES;
435
548
  }
436
549
 
550
+ if (newViewProps.markdownStyle.h1.textAlign != oldViewProps.markdownStyle.h1.textAlign) {
551
+ [_config setH1TextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.h1.textAlign.c_str()))];
552
+ stylePropChanged = YES;
553
+ }
554
+
437
555
  // H2 style
438
556
  if (newViewProps.markdownStyle.h2.fontSize != oldViewProps.markdownStyle.h2.fontSize) {
439
557
  [_config setH2FontSize:newViewProps.markdownStyle.h2.fontSize];
@@ -470,6 +588,11 @@ using namespace facebook::react;
470
588
  stylePropChanged = YES;
471
589
  }
472
590
 
591
+ if (newViewProps.markdownStyle.h2.marginTop != oldViewProps.markdownStyle.h2.marginTop) {
592
+ [_config setH2MarginTop:newViewProps.markdownStyle.h2.marginTop];
593
+ stylePropChanged = YES;
594
+ }
595
+
473
596
  if (newViewProps.markdownStyle.h2.marginBottom != oldViewProps.markdownStyle.h2.marginBottom) {
474
597
  [_config setH2MarginBottom:newViewProps.markdownStyle.h2.marginBottom];
475
598
  stylePropChanged = YES;
@@ -480,6 +603,11 @@ using namespace facebook::react;
480
603
  stylePropChanged = YES;
481
604
  }
482
605
 
606
+ if (newViewProps.markdownStyle.h2.textAlign != oldViewProps.markdownStyle.h2.textAlign) {
607
+ [_config setH2TextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.h2.textAlign.c_str()))];
608
+ stylePropChanged = YES;
609
+ }
610
+
483
611
  // H3 style
484
612
  if (newViewProps.markdownStyle.h3.fontSize != oldViewProps.markdownStyle.h3.fontSize) {
485
613
  [_config setH3FontSize:newViewProps.markdownStyle.h3.fontSize];
@@ -516,6 +644,11 @@ using namespace facebook::react;
516
644
  stylePropChanged = YES;
517
645
  }
518
646
 
647
+ if (newViewProps.markdownStyle.h3.marginTop != oldViewProps.markdownStyle.h3.marginTop) {
648
+ [_config setH3MarginTop:newViewProps.markdownStyle.h3.marginTop];
649
+ stylePropChanged = YES;
650
+ }
651
+
519
652
  if (newViewProps.markdownStyle.h3.marginBottom != oldViewProps.markdownStyle.h3.marginBottom) {
520
653
  [_config setH3MarginBottom:newViewProps.markdownStyle.h3.marginBottom];
521
654
  stylePropChanged = YES;
@@ -526,6 +659,11 @@ using namespace facebook::react;
526
659
  stylePropChanged = YES;
527
660
  }
528
661
 
662
+ if (newViewProps.markdownStyle.h3.textAlign != oldViewProps.markdownStyle.h3.textAlign) {
663
+ [_config setH3TextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.h3.textAlign.c_str()))];
664
+ stylePropChanged = YES;
665
+ }
666
+
529
667
  // H4 style
530
668
  if (newViewProps.markdownStyle.h4.fontSize != oldViewProps.markdownStyle.h4.fontSize) {
531
669
  [_config setH4FontSize:newViewProps.markdownStyle.h4.fontSize];
@@ -562,6 +700,11 @@ using namespace facebook::react;
562
700
  stylePropChanged = YES;
563
701
  }
564
702
 
703
+ if (newViewProps.markdownStyle.h4.marginTop != oldViewProps.markdownStyle.h4.marginTop) {
704
+ [_config setH4MarginTop:newViewProps.markdownStyle.h4.marginTop];
705
+ stylePropChanged = YES;
706
+ }
707
+
565
708
  if (newViewProps.markdownStyle.h4.marginBottom != oldViewProps.markdownStyle.h4.marginBottom) {
566
709
  [_config setH4MarginBottom:newViewProps.markdownStyle.h4.marginBottom];
567
710
  stylePropChanged = YES;
@@ -572,6 +715,11 @@ using namespace facebook::react;
572
715
  stylePropChanged = YES;
573
716
  }
574
717
 
718
+ if (newViewProps.markdownStyle.h4.textAlign != oldViewProps.markdownStyle.h4.textAlign) {
719
+ [_config setH4TextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.h4.textAlign.c_str()))];
720
+ stylePropChanged = YES;
721
+ }
722
+
575
723
  // H5 style
576
724
  if (newViewProps.markdownStyle.h5.fontSize != oldViewProps.markdownStyle.h5.fontSize) {
577
725
  [_config setH5FontSize:newViewProps.markdownStyle.h5.fontSize];
@@ -608,6 +756,11 @@ using namespace facebook::react;
608
756
  stylePropChanged = YES;
609
757
  }
610
758
 
759
+ if (newViewProps.markdownStyle.h5.marginTop != oldViewProps.markdownStyle.h5.marginTop) {
760
+ [_config setH5MarginTop:newViewProps.markdownStyle.h5.marginTop];
761
+ stylePropChanged = YES;
762
+ }
763
+
611
764
  if (newViewProps.markdownStyle.h5.marginBottom != oldViewProps.markdownStyle.h5.marginBottom) {
612
765
  [_config setH5MarginBottom:newViewProps.markdownStyle.h5.marginBottom];
613
766
  stylePropChanged = YES;
@@ -618,6 +771,11 @@ using namespace facebook::react;
618
771
  stylePropChanged = YES;
619
772
  }
620
773
 
774
+ if (newViewProps.markdownStyle.h5.textAlign != oldViewProps.markdownStyle.h5.textAlign) {
775
+ [_config setH5TextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.h5.textAlign.c_str()))];
776
+ stylePropChanged = YES;
777
+ }
778
+
621
779
  // H6 style
622
780
  if (newViewProps.markdownStyle.h6.fontSize != oldViewProps.markdownStyle.h6.fontSize) {
623
781
  [_config setH6FontSize:newViewProps.markdownStyle.h6.fontSize];
@@ -654,6 +812,11 @@ using namespace facebook::react;
654
812
  stylePropChanged = YES;
655
813
  }
656
814
 
815
+ if (newViewProps.markdownStyle.h6.marginTop != oldViewProps.markdownStyle.h6.marginTop) {
816
+ [_config setH6MarginTop:newViewProps.markdownStyle.h6.marginTop];
817
+ stylePropChanged = YES;
818
+ }
819
+
657
820
  if (newViewProps.markdownStyle.h6.marginBottom != oldViewProps.markdownStyle.h6.marginBottom) {
658
821
  [_config setH6MarginBottom:newViewProps.markdownStyle.h6.marginBottom];
659
822
  stylePropChanged = YES;
@@ -664,6 +827,11 @@ using namespace facebook::react;
664
827
  stylePropChanged = YES;
665
828
  }
666
829
 
830
+ if (newViewProps.markdownStyle.h6.textAlign != oldViewProps.markdownStyle.h6.textAlign) {
831
+ [_config setH6TextAlign:textAlignmentFromString(@(newViewProps.markdownStyle.h6.textAlign.c_str()))];
832
+ stylePropChanged = YES;
833
+ }
834
+
667
835
  // Blockquote style
668
836
  if (newViewProps.markdownStyle.blockquote.fontSize != oldViewProps.markdownStyle.blockquote.fontSize) {
669
837
  [_config setBlockquoteFontSize:newViewProps.markdownStyle.blockquote.fontSize];
@@ -690,6 +858,11 @@ using namespace facebook::react;
690
858
  stylePropChanged = YES;
691
859
  }
692
860
 
861
+ if (newViewProps.markdownStyle.blockquote.marginTop != oldViewProps.markdownStyle.blockquote.marginTop) {
862
+ [_config setBlockquoteMarginTop:newViewProps.markdownStyle.blockquote.marginTop];
863
+ stylePropChanged = YES;
864
+ }
865
+
693
866
  if (newViewProps.markdownStyle.blockquote.marginBottom != oldViewProps.markdownStyle.blockquote.marginBottom) {
694
867
  [_config setBlockquoteMarginBottom:newViewProps.markdownStyle.blockquote.marginBottom];
695
868
  stylePropChanged = YES;
@@ -754,6 +927,18 @@ using namespace facebook::react;
754
927
  stylePropChanged = YES;
755
928
  }
756
929
 
930
+ if (newViewProps.markdownStyle.strikethrough.color != oldViewProps.markdownStyle.strikethrough.color) {
931
+ UIColor *strikethroughColor = RCTUIColorFromSharedColor(newViewProps.markdownStyle.strikethrough.color);
932
+ [_config setStrikethroughColor:strikethroughColor];
933
+ stylePropChanged = YES;
934
+ }
935
+
936
+ if (newViewProps.markdownStyle.underline.color != oldViewProps.markdownStyle.underline.color) {
937
+ UIColor *underlineColor = RCTUIColorFromSharedColor(newViewProps.markdownStyle.underline.color);
938
+ [_config setUnderlineColor:underlineColor];
939
+ stylePropChanged = YES;
940
+ }
941
+
757
942
  if (newViewProps.markdownStyle.code.color != oldViewProps.markdownStyle.code.color) {
758
943
  if (newViewProps.markdownStyle.code.color) {
759
944
  UIColor *codeColor = RCTUIColorFromSharedColor(newViewProps.markdownStyle.code.color);
@@ -794,6 +979,11 @@ using namespace facebook::react;
794
979
  stylePropChanged = YES;
795
980
  }
796
981
 
982
+ if (newViewProps.markdownStyle.image.marginTop != oldViewProps.markdownStyle.image.marginTop) {
983
+ [_config setImageMarginTop:newViewProps.markdownStyle.image.marginTop];
984
+ stylePropChanged = YES;
985
+ }
986
+
797
987
  if (newViewProps.markdownStyle.image.marginBottom != oldViewProps.markdownStyle.image.marginBottom) {
798
988
  [_config setImageMarginBottom:newViewProps.markdownStyle.image.marginBottom];
799
989
  stylePropChanged = YES;
@@ -828,6 +1018,11 @@ using namespace facebook::react;
828
1018
  stylePropChanged = YES;
829
1019
  }
830
1020
 
1021
+ if (newViewProps.markdownStyle.list.marginTop != oldViewProps.markdownStyle.list.marginTop) {
1022
+ [_config setListStyleMarginTop:newViewProps.markdownStyle.list.marginTop];
1023
+ stylePropChanged = YES;
1024
+ }
1025
+
831
1026
  if (newViewProps.markdownStyle.list.marginBottom != oldViewProps.markdownStyle.list.marginBottom) {
832
1027
  [_config setListStyleMarginBottom:newViewProps.markdownStyle.list.marginBottom];
833
1028
  stylePropChanged = YES;
@@ -898,6 +1093,11 @@ using namespace facebook::react;
898
1093
  stylePropChanged = YES;
899
1094
  }
900
1095
 
1096
+ if (newViewProps.markdownStyle.codeBlock.marginTop != oldViewProps.markdownStyle.codeBlock.marginTop) {
1097
+ [_config setCodeBlockMarginTop:newViewProps.markdownStyle.codeBlock.marginTop];
1098
+ stylePropChanged = YES;
1099
+ }
1100
+
901
1101
  if (newViewProps.markdownStyle.codeBlock.marginBottom != oldViewProps.markdownStyle.codeBlock.marginBottom) {
902
1102
  [_config setCodeBlockMarginBottom:newViewProps.markdownStyle.codeBlock.marginBottom];
903
1103
  stylePropChanged = YES;
@@ -967,16 +1167,51 @@ using namespace facebook::react;
967
1167
  }
968
1168
  }
969
1169
 
970
- // Control text selection and link previews via isSelectable property
971
- // According to Apple docs, isSelectable controls whether text selection and link previews work
1170
+ // Control text selection and link previews via selectable property
1171
+ // According to Apple docs, selectable controls whether text selection and link previews work
972
1172
  // https://developer.apple.com/documentation/uikit/uitextview/isselectable
973
- if (_textView.selectable != newViewProps.isSelectable) {
974
- _textView.selectable = newViewProps.isSelectable;
1173
+ if (_textView.selectable != newViewProps.selectable) {
1174
+ _textView.selectable = newViewProps.selectable;
1175
+ }
1176
+
1177
+ if (newViewProps.allowFontScaling != oldViewProps.allowFontScaling) {
1178
+ _allowFontScaling = newViewProps.allowFontScaling;
1179
+
1180
+ if (_config != nil) {
1181
+ [_config setFontScaleMultiplier:[self effectiveFontScale]];
1182
+ }
1183
+
1184
+ stylePropChanged = YES;
1185
+ }
1186
+
1187
+ if (newViewProps.maxFontSizeMultiplier != oldViewProps.maxFontSizeMultiplier) {
1188
+ _maxFontSizeMultiplier = newViewProps.maxFontSizeMultiplier;
1189
+
1190
+ if (_config != nil) {
1191
+ [_config setMaxFontSizeMultiplier:_maxFontSizeMultiplier];
1192
+ }
1193
+
1194
+ stylePropChanged = YES;
1195
+ }
1196
+
1197
+ // Update allowTrailingMargin
1198
+ if (newViewProps.allowTrailingMargin != oldViewProps.allowTrailingMargin) {
1199
+ _allowTrailingMargin = newViewProps.allowTrailingMargin;
1200
+ }
1201
+
1202
+ // Update md4cFlags
1203
+ BOOL md4cFlagsChanged = NO;
1204
+ if (newViewProps.md4cFlags.underline != oldViewProps.md4cFlags.underline) {
1205
+ _md4cFlags.underline = newViewProps.md4cFlags.underline;
1206
+ md4cFlagsChanged = YES;
975
1207
  }
976
1208
 
977
1209
  BOOL markdownChanged = oldViewProps.markdown != newViewProps.markdown;
1210
+ BOOL allowTrailingMarginChanged = newViewProps.allowTrailingMargin != oldViewProps.allowTrailingMargin;
978
1211
 
979
- if (markdownChanged || stylePropChanged) {
1212
+ _enableLinkPreview = newViewProps.enableLinkPreview;
1213
+
1214
+ if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged) {
980
1215
  NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()];
981
1216
  [self renderMarkdownContent:markdownString];
982
1217
  }
@@ -1061,6 +1296,36 @@ Class<RCTComponentViewProtocol> EnrichedMarkdownTextCls(void)
1061
1296
  }
1062
1297
  }
1063
1298
 
1299
+ #pragma mark - UITextViewDelegate (Link Interaction)
1300
+
1301
+ - (BOOL)textView:(UITextView *)textView
1302
+ shouldInteractWithURL:(NSURL *)URL
1303
+ inRange:(NSRange)characterRange
1304
+ interaction:(UITextItemInteraction)interaction
1305
+ {
1306
+ // Only intercept long-press interactions
1307
+ if (interaction != UITextItemInteractionPresentActions) {
1308
+ return YES;
1309
+ }
1310
+
1311
+ // Safely extract the custom URL attribute
1312
+ NSString *urlString = [textView.attributedText attribute:@"linkURL"
1313
+ atIndex:characterRange.location
1314
+ effectiveRange:NULL];
1315
+
1316
+ // If link preview is enabled or no URL found, allow default system behavior
1317
+ if (!urlString || _enableLinkPreview) {
1318
+ return YES;
1319
+ }
1320
+
1321
+ // System preview disabled — emit onLinkLongPress event to React Native
1322
+ auto eventEmitter = std::static_pointer_cast<EnrichedMarkdownTextEventEmitter const>(_eventEmitter);
1323
+ if (eventEmitter) {
1324
+ eventEmitter->onLinkLongPress({.url = std::string([urlString UTF8String])});
1325
+ }
1326
+ return NO;
1327
+ }
1328
+
1064
1329
  #pragma mark - UITextViewDelegate (Edit Menu)
1065
1330
 
1066
1331
  // Customizes the edit menu
@@ -1071,4 +1336,66 @@ Class<RCTComponentViewProtocol> EnrichedMarkdownTextCls(void)
1071
1336
  return buildEditMenuForSelection(_textView.attributedText, range, _cachedMarkdown, _config, suggestedActions);
1072
1337
  }
1073
1338
 
1339
+ #pragma mark - Accessibility (VoiceOver Navigation)
1340
+
1341
+ - (void)buildAccessibilityElements
1342
+ {
1343
+ _accessibilityElements = [MarkdownAccessibilityElementBuilder buildElementsForTextView:_textView
1344
+ info:_accessibilityInfo
1345
+ container:self];
1346
+ }
1347
+
1348
+ - (BOOL)isAccessibilityElement
1349
+ {
1350
+ return NO; // This is a container, not a single element
1351
+ }
1352
+
1353
+ - (NSInteger)accessibilityElementCount
1354
+ {
1355
+ return _accessibilityElements.count;
1356
+ }
1357
+
1358
+ - (id)accessibilityElementAtIndex:(NSInteger)index
1359
+ {
1360
+ if (index < 0 || index >= (NSInteger)_accessibilityElements.count) {
1361
+ return nil;
1362
+ }
1363
+ return _accessibilityElements[index];
1364
+ }
1365
+
1366
+ - (NSInteger)indexOfAccessibilityElement:(id)element
1367
+ {
1368
+ return [_accessibilityElements indexOfObject:element];
1369
+ }
1370
+
1371
+ - (NSArray *)accessibilityElements
1372
+ {
1373
+ return _accessibilityElements;
1374
+ }
1375
+
1376
+ - (NSArray<UIAccessibilityCustomRotor *> *)accessibilityCustomRotors
1377
+ {
1378
+ NSMutableArray<UIAccessibilityCustomRotor *> *rotors = [NSMutableArray array];
1379
+
1380
+ NSArray<UIAccessibilityElement *> *headingElements =
1381
+ [MarkdownAccessibilityElementBuilder filterHeadingElements:_accessibilityElements];
1382
+ if (headingElements.count > 0) {
1383
+ [rotors addObject:[MarkdownAccessibilityElementBuilder createHeadingRotorWithElements:headingElements]];
1384
+ }
1385
+
1386
+ NSArray<UIAccessibilityElement *> *linkElements =
1387
+ [MarkdownAccessibilityElementBuilder filterLinkElements:_accessibilityElements];
1388
+ if (linkElements.count > 0) {
1389
+ [rotors addObject:[MarkdownAccessibilityElementBuilder createLinkRotorWithElements:linkElements]];
1390
+ }
1391
+
1392
+ NSArray<UIAccessibilityElement *> *imageElements =
1393
+ [MarkdownAccessibilityElementBuilder filterImageElements:_accessibilityElements];
1394
+ if (imageElements.count > 0) {
1395
+ [rotors addObject:[MarkdownAccessibilityElementBuilder createImageRotorWithElements:imageElements]];
1396
+ }
1397
+
1398
+ return rotors;
1399
+ }
1400
+
1074
1401
  @end
@@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
11
11
  * Images are loaded asynchronously and scaled dynamically based on text container width.
12
12
  * Supports inline and block images with custom height and border radius from config.
13
13
  */
14
- @interface ImageAttachment : NSTextAttachment
14
+ @interface EnrichedMarkdownImageAttachment : NSTextAttachment
15
15
 
16
16
  @property (nonatomic, readonly) NSString *imageURL;
17
17
  @property (nonatomic, readonly) BOOL isInline;