react-native-enriched-markdown 1.1.0-nightly-20260930-7bcab4e4a → 1.1.1

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 (34) hide show
  1. package/README.md +2 -1
  2. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +43 -5
  3. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +9 -8
  4. package/docs/API_REFERENCE.md +24 -0
  5. package/docs/BREAKING_CHANGES.md +10 -0
  6. package/ios/EnrichedMarkdown.mm +65 -18
  7. package/ios/EnrichedMarkdownText.mm +61 -33
  8. package/ios/internals/ShadowMeasurementUtils.h +1 -2
  9. package/ios/renderer/ListItemRenderer.m +38 -1
  10. package/ios/segments/ENRMBlockquoteContainerView.h +6 -0
  11. package/ios/segments/ENRMBlockquoteContainerView.mm +28 -0
  12. package/ios/segments/TableContainerView.m +1 -0
  13. package/ios/utils/ENRMCodeBlockContent.mm +2 -0
  14. package/ios/utils/ENRMTextRenderer.m +1 -0
  15. package/ios/utils/MarkdownAccessibilityElementBuilder.m +4 -2
  16. package/ios/utils/ParagraphStyleUtils.h +18 -0
  17. package/ios/utils/ParagraphStyleUtils.m +20 -0
  18. package/ios/utils/TextViewLayoutManager.mm +57 -0
  19. package/lib/module/EnrichedMarkdownTextInput.js +48 -2
  20. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  21. package/lib/module/utils/textInputState.js +4 -3
  22. package/lib/module/utils/textInputState.js.map +1 -1
  23. package/lib/module/utils/usePressability.js +28 -0
  24. package/lib/module/utils/usePressability.js.map +1 -0
  25. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +3 -3
  26. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  27. package/lib/typescript/src/utils/textInputState.d.ts +5 -3
  28. package/lib/typescript/src/utils/textInputState.d.ts.map +1 -1
  29. package/lib/typescript/src/utils/usePressability.d.ts +24 -0
  30. package/lib/typescript/src/utils/usePressability.d.ts.map +1 -0
  31. package/package.json +1 -1
  32. package/src/EnrichedMarkdownTextInput.tsx +66 -4
  33. package/src/utils/textInputState.ts +5 -3
  34. package/src/utils/usePressability.ts +34 -0
package/README.md CHANGED
@@ -262,7 +262,8 @@ We're actively working on expanding the capabilities of `react-native-enriched-m
262
262
  | | 0.82 | 0.83 | 0.84 | 0.85 | 0.86 | 0.87 |
263
263
  | ----------- | :--: | :--: | :--: | :--: | :--: | :--: |
264
264
  | **nightly** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
265
- | **1.0.0** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
265
+ | **1.1.x** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
266
+ | **1.0.x** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
266
267
  | **0.7.0** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
267
268
  | **0.6.0** | ⛔ | ✅ | ✅ | ✅ | ⛔ | ⛔ |
268
269
  | **0.5.0** | ⛔ | ✅ | ✅ | ✅ | ⛔ | ⛔ |
@@ -60,6 +60,8 @@ class EnrichedMarkdownTextInputView(
60
60
  context: Context,
61
61
  ) : AppCompatEditText(context) {
62
62
  private var isComponentReady = false
63
+ private var lastEmittedSelectionStart = -1
64
+ private var lastEmittedSelectionEnd = -1
63
65
 
64
66
  val formattingStore = FormattingStore()
65
67
  val blockStore = BlockStore()
@@ -387,6 +389,7 @@ class EnrichedMarkdownTextInputView(
387
389
  } finally {
388
390
  editSession.exit()
389
391
  }
392
+ emitSelectionIfChanged()
390
393
  }
391
394
 
392
395
  override fun onSelectionChanged(
@@ -416,12 +419,33 @@ class EnrichedMarkdownTextInputView(
416
419
  syncEmptyListAnchor()
417
420
  }
418
421
 
419
- eventEmitter.emitSelection(selStart, selEnd)
422
+ emitSelectionIfChanged()
420
423
  dispatchMentionUpdate()
421
424
  eventEmitter.emitState()
422
425
  eventEmitter.emitCaretRectChangeIfNeeded()
423
426
  }
424
427
 
428
+ /**
429
+ * Sends the caret range to JS when it differs from the last range JS received.
430
+ *
431
+ * [onSelectionChanged] returns immediately while an edit phase is active, so
432
+ * a caret move from setValue, a programmatic insert, or anchor management is
433
+ * not reported there. Those callers invoke this once the edit phase has ended.
434
+ *
435
+ * This function then rereads the latest range at that point before emitting
436
+ * the selection.
437
+ */
438
+ private fun emitSelectionIfChanged() {
439
+ if (!isComponentReady) return
440
+ val start = selectionStart
441
+ val end = selectionEnd
442
+ if (start == lastEmittedSelectionStart && end == lastEmittedSelectionEnd) return
443
+ if (eventEmitter.emitSelection(start, end)) {
444
+ lastEmittedSelectionStart = start
445
+ lastEmittedSelectionEnd = end
446
+ }
447
+ }
448
+
425
449
  /**
426
450
  * Text typed over a non-empty selection inherits the inline styles of the first
427
451
  * selected character (mirrors iOS rebuildFromContext and the range-inheritance
@@ -478,6 +502,7 @@ class EnrichedMarkdownTextInputView(
478
502
  } finally {
479
503
  editSession.exit()
480
504
  }
505
+ emitSelectionIfChanged()
481
506
  }
482
507
 
483
508
  fun applyFormatting() {
@@ -971,6 +996,7 @@ class EnrichedMarkdownTextInputView(
971
996
  } finally {
972
997
  editSession.exit()
973
998
  }
999
+ emitSelectionIfChanged()
974
1000
  }
975
1001
 
976
1002
  override fun setBackgroundColor(color: Int) {
@@ -1036,10 +1062,22 @@ class EnrichedMarkdownTextInputView(
1036
1062
  AutoCapitalizeUtils.apply(this, flagName)
1037
1063
  }
1038
1064
 
1039
- fun requestFocusProgrammatically() {
1040
- requestFocus()
1041
- inputMethodManager?.showSoftInput(this, 0)
1042
- setSelection(selectionStart.coerceAtLeast(0))
1065
+ /**
1066
+ * Programmatic focus for ref.focus() and for the focus command that the JS
1067
+ * pressability onPress sends on finger-up (including after a long-press word
1068
+ * select).
1069
+ *
1070
+ * Matches ReactEditText.requestFocusProgrammatically: request focus and show
1071
+ * the keyboard without touching the selection, so a long-press word selection
1072
+ * isn't collapsed:
1073
+ * https://github.com/react/react-native/blob/v0.86.2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L396-L402
1074
+ */
1075
+ fun requestFocusProgrammatically(): Boolean {
1076
+ val focused = super.requestFocus(FOCUS_DOWN, null)
1077
+ if (isInTouchMode && showSoftInputOnFocus) {
1078
+ inputMethodManager?.showSoftInput(this, 0)
1079
+ }
1080
+ return focused
1043
1081
  }
1044
1082
 
1045
1083
  private fun showAutoFocusKeyboardIfPending() {
@@ -48,9 +48,7 @@ class InputEventEmitter(
48
48
  fun emitSelection(
49
49
  start: Int,
50
50
  end: Int,
51
- ) {
52
- dispatch(OnChangeSelectionEvent(surfaceId(), view.id, start, end))
53
- }
51
+ ): Boolean = dispatch(OnChangeSelectionEvent(surfaceId(), view.id, start, end))
54
52
 
55
53
  fun emitState() {
56
54
  val pos = view.selectionStart
@@ -236,10 +234,13 @@ class InputEventEmitter(
236
234
  return UIManagerHelper.getSurfaceId(reactContext)
237
235
  }
238
236
 
239
- private fun dispatch(event: Event<*>) {
240
- if (view.editSession.shouldSuppressEvents) return
241
- val reactContext = view.context as? ReactContext ?: return
242
- val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
243
- dispatcher?.dispatchEvent(event)
237
+ /** Returns true when [event] was handed to the React event dispatcher */
238
+ private fun dispatch(event: Event<*>): Boolean {
239
+ if (view.editSession.shouldSuppressEvents) return false
240
+ val reactContext = view.context as? ReactContext ?: return false
241
+ val dispatcher =
242
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id) ?: return false
243
+ dispatcher.dispatchEvent(event)
244
+ return true
244
245
  }
245
246
  }
@@ -935,6 +935,30 @@ Fires when the input loses focus.
935
935
 
936
936
  The input participates in React Native's text-input focus tracking (`TextInput.State`), so blur also happens through the platform's standard keyboard-dismiss paths: taps outside the input inside a `ScrollView` (per its `keyboardShouldPersistTaps` setting) and `Keyboard.dismiss()`. See [Keyboard Dismissal](INPUT.md#keyboard-dismissal).
937
937
 
938
+ ### `onPress`, `onPressIn`, `onPressOut`
939
+
940
+ Fire for taps on the input, like React Native `TextInput`. The input claims the JS touch responder, so a tap that focuses it does not also fire the `onPress` of an ancestor `Pressable`. After `onPress` runs, the input focuses itself (unless `editable` is `false`) without changing the current selection. As on `TextInput`, responder props passed through `ViewProps` (`onStartShouldSetResponder`, `onResponderGrant`, and the rest) are overridden by the input's press handling.
941
+
942
+ | Type | Default Value | Platform |
943
+ | ------------------------------------------- | ------------- | -------- |
944
+ | `(event: GestureResponderEvent) => unknown` | - | Both |
945
+
946
+ ### `hitSlop`
947
+
948
+ Extends the touchable area for the press handlers above. Same as `hitSlop` on React Native `TextInput`.
949
+
950
+ | Type | Default Value | Platform |
951
+ | ---------------------- | ------------- | -------- |
952
+ | `ViewProps['hitSlop']` | - | Both |
953
+
954
+ ### `rejectResponderTermination`
955
+
956
+ When `true`, a parent scroll view or pressable can't take over the touch responder while the input is being pressed. Same as React Native `TextInput`.
957
+
958
+ | Type | Default Value | Platform |
959
+ | --------- | ------------- | -------- |
960
+ | `boolean` | `true` | iOS |
961
+
938
962
  ### `onStartMention`
939
963
 
940
964
  Fires when a new mention flow starts. See [Mentions](MENTIONS.md#events).
@@ -5,6 +5,16 @@ This file tracks breaking changes and how to migrate for each. Entries are newes
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## v1.1.1
9
+
10
+ No breaking changes. v1.1.1 is a patch release over v1.1.0 - every public prop, method, event and style
11
+ key from v1.1.0 keeps its name and behaviour, and no deprecation was introduced or removed.
12
+
13
+ ## v1.1.0
14
+
15
+ No breaking changes. v1.1.0 is additive over v1.0.2 - every public prop, method, event and style key
16
+ from v1.0.2 keeps its name and behaviour, and no deprecation was introduced or removed.
17
+
8
18
  ## v1.0.2
9
19
 
10
20
  ### The Expo config plugin was removed
@@ -377,6 +377,7 @@ static char kENRMSegmentFadeAnimatorKey;
377
377
  view.allowFontScaling = _fontScaleObserver.allowFontScaling;
378
378
  view.lineBreakStrategy = _lineBreakStrategy;
379
379
  view.dynamicProps = _dynamicBlockProps;
380
+ view.accessibilityLabels = _accessibilityLabels;
380
381
 
381
382
  __weak EnrichedMarkdown *weakSelf = self;
382
383
  view.onCopyPress = ^(NSString *code, NSString *language) {
@@ -404,6 +405,32 @@ static char kENRMSegmentFadeAnimatorKey;
404
405
  return view;
405
406
  }
406
407
 
408
+ /// The box the segments actually live in: the component frame inset by the
409
+ /// `containerStyle` border and padding (`LayoutMetrics::getContentFrame`).
410
+ ///
411
+ /// Yoga hands `measureContent` an available size that already excludes those
412
+ /// insets and then adds them back to the frame it commits, so laying the
413
+ /// segments out against `self.bounds` would stack them over the padding and
414
+ /// across the border while the space Yoga reserved for the content stays blank
415
+ /// at the bottom. Unlike `EnrichedMarkdownText`, the segments are attached to
416
+ /// the component view itself rather than to `contentView`, so nothing applies
417
+ /// the insets for us.
418
+ ///
419
+ /// Taken from `_layoutMetrics` rather than recomputed off `self.bounds`:
420
+ /// `prepareForRecycle` resets the metrics but leaves the bounds behind, so the
421
+ /// two do not always agree. The box is empty both before the first layout
422
+ /// metrics arrive and when the insets consume the whole component width; neither
423
+ /// may fall back to `self.bounds`, which is the padded border box the segments
424
+ /// must not be drawn across, so an over-constrained box clamps to zero and
425
+ /// callers skip laying out at all.
426
+ - (CGRect)contentBounds
427
+ {
428
+ CGRect box = RCTCGRectFromRect(_layoutMetrics.getContentFrame());
429
+ box.size.width = MAX(box.size.width, 0);
430
+ box.size.height = MAX(box.size.height, 0);
431
+ return box;
432
+ }
433
+
407
434
  - (CGSize)computeSegmentLayoutForWidth:(CGFloat)width applyFrames:(BOOL)applyFrames
408
435
  {
409
436
  if (_segmentViews.count == 0)
@@ -435,6 +462,10 @@ static char kENRMSegmentFadeAnimatorKey;
435
462
  __block CGFloat yOffset = 0.0;
436
463
  __block CGFloat maxContentWidth = 0.0;
437
464
  const NSUInteger lastIndex = _segmentViews.count - 1;
465
+ // Frames are stacked in the component's own coordinate space, so the content
466
+ // origin (containerStyle border + padding) shifts every segment. yOffset stays
467
+ // content-relative because it doubles as the measured content height.
468
+ const CGPoint contentOrigin = applyFrames ? [self contentBounds].origin : CGPointZero;
438
469
 
439
470
  [_segmentViews enumerateObjectsUsingBlock:^(RCTUIView *segment, NSUInteger i, BOOL *stop) {
440
471
  const BOOL isLast = (i == lastIndex);
@@ -480,16 +511,16 @@ static char kENRMSegmentFadeAnimatorKey;
480
511
  #endif
481
512
 
482
513
  if (applyFrames) {
483
- CGFloat segmentX = 0;
514
+ CGFloat segmentX = contentOrigin.x;
484
515
  CGFloat segmentWidth = width;
485
516
  if (isTable) {
486
517
  CGFloat overhang = MAX(_config.tableHorizontalOverflow, 0);
487
518
  if (overhang > 0) {
488
- segmentX = -overhang;
519
+ segmentX = contentOrigin.x - overhang;
489
520
  segmentWidth = width + overhang * 2;
490
521
  }
491
522
  }
492
- CGRect segmentFrame = CGRectMake(segmentX, yOffset, segmentWidth, segmentHeight);
523
+ CGRect segmentFrame = CGRectMake(segmentX, contentOrigin.y + yOffset, segmentWidth, segmentHeight);
493
524
  segment.frame = segmentFrame;
494
525
  #if TARGET_OS_OSX
495
526
  if ([segment isKindOfClass:[EnrichedMarkdownInternalText class]]) {
@@ -580,15 +611,21 @@ static char kENRMSegmentFadeAnimatorKey;
580
611
  }
581
612
  }
582
613
 
583
- /// Yoga-resolved direction inherited from any ancestor `direction` style.
584
- /// In FirstStrong mode this feeds the neutral-paragraph fallback, so a change
585
- /// requires segment recreation.
614
+ /// Publishes the committed size to the streaming fast path's mailbox, and picks
615
+ /// up the Yoga-resolved direction inherited from any ancestor `direction` style.
616
+ /// In FirstStrong mode the direction feeds the neutral-paragraph fallback, so a
617
+ /// change requires segment recreation.
618
+ ///
619
+ /// The mailbox holds a content size, not the border box: `measureContent`
620
+ /// returns it straight to Yoga, which adds the `containerStyle` insets back on
621
+ /// top of whatever it gets, so storing `frame.size` would double-count them.
586
622
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
587
623
  oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics
588
624
  {
589
625
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
590
626
 
591
- _lastCommittedSize.store(CGSizeMake(layoutMetrics.frame.size.width, layoutMetrics.frame.size.height));
627
+ const auto contentSize = layoutMetrics.getContentFrame().size;
628
+ _lastCommittedSize.store(CGSizeMake(contentSize.width, contentSize.height));
592
629
 
593
630
  NSWritingDirection resolved = _resolvedLayoutDirection;
594
631
  if (layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) {
@@ -625,6 +662,8 @@ static char kENRMSegmentFadeAnimatorKey;
625
662
  ((EnrichedMarkdownInternalText *)segment).accessibilityLabels = _accessibilityLabels;
626
663
  } else if ([segment isKindOfClass:[TableContainerView class]]) {
627
664
  ((TableContainerView *)segment).accessibilityLabels = _accessibilityLabels;
665
+ } else if ([segment isKindOfClass:[ENRMBlockquoteContainerView class]]) {
666
+ ((ENRMBlockquoteContainerView *)segment).accessibilityLabels = _accessibilityLabels;
628
667
  }
629
668
  #if ENRICHED_MARKDOWN_MATH
630
669
  else if ([segment isKindOfClass:[ENRMMathContainerView class]]) {
@@ -649,10 +688,11 @@ static char kENRMSegmentFadeAnimatorKey;
649
688
  MeasurementCache::shared().removeMatchingMarkdown(std::string(_renderedMarkdown.UTF8String));
650
689
  }
651
690
 
652
- if (self.bounds.size.width > 0) {
691
+ CGRect contentBounds = [self contentBounds];
692
+ if (contentBounds.size.width > 0) {
653
693
  [self setNeedsLayout];
654
- CGSize measured = [self measureSize:self.bounds.size.width];
655
- if (needsHeightUpdate(measured, self.bounds)) {
694
+ CGSize measured = [self measureSize:contentBounds.size.width];
695
+ if (needsHeightUpdate(measured, contentBounds)) {
656
696
  [self requestHeightUpdate];
657
697
  }
658
698
  }
@@ -829,16 +869,17 @@ static char kENRMSegmentFadeAnimatorKey;
829
869
  }
830
870
  }];
831
871
 
832
- if (self.bounds.size.width > 0) {
872
+ CGRect contentBounds = [self contentBounds];
873
+ if (contentBounds.size.width > 0) {
833
874
  [self setNeedsLayout];
834
875
 
835
876
  if (forceHeightUpdate || segmentTopologyChanged) {
836
- [self computeSegmentLayoutForWidth:self.bounds.size.width applyFrames:YES];
877
+ [self computeSegmentLayoutForWidth:contentBounds.size.width applyFrames:YES];
837
878
  [self layoutIfNeeded];
838
879
  [self requestHeightUpdate];
839
880
  } else {
840
- CGSize measured = [self measureSize:self.bounds.size.width];
841
- if (needsHeightUpdate(measured, self.bounds)) {
881
+ CGSize measured = [self measureSize:contentBounds.size.width];
882
+ if (needsHeightUpdate(measured, contentBounds)) {
842
883
  [self requestHeightUpdate];
843
884
  }
844
885
  }
@@ -1012,7 +1053,10 @@ static char kENRMSegmentFadeAnimatorKey;
1012
1053
  - (void)layoutSubviews
1013
1054
  {
1014
1055
  [super layoutSubviews];
1015
- [self computeSegmentLayoutForWidth:self.bounds.size.width applyFrames:YES];
1056
+ CGFloat contentWidth = [self contentBounds].size.width;
1057
+ if (contentWidth > 0) {
1058
+ [self computeSegmentLayoutForWidth:contentWidth applyFrames:YES];
1059
+ }
1016
1060
  }
1017
1061
 
1018
1062
  - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
@@ -1230,9 +1274,12 @@ static char kENRMSegmentFadeAnimatorKey;
1230
1274
  }
1231
1275
  }
1232
1276
 
1233
- CGSize measured = [self measureSize:self.bounds.size.width];
1234
- if (needsHeightUpdate(measured, self.bounds)) {
1235
- [self requestHeightUpdate];
1277
+ CGRect contentBounds = [self contentBounds];
1278
+ if (contentBounds.size.width > 0) {
1279
+ CGSize measured = [self measureSize:contentBounds.size.width];
1280
+ if (needsHeightUpdate(measured, contentBounds)) {
1281
+ [self requestHeightUpdate];
1282
+ }
1236
1283
  }
1237
1284
  }
1238
1285
  }
@@ -151,6 +151,34 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
151
151
 
152
152
  #pragma mark - Measuring and State
153
153
 
154
+ /// The box the text actually lives in: `self.bounds` inset by the
155
+ /// `containerStyle` border and padding.
156
+ ///
157
+ /// Yoga hands `measureContent` an available size that already excludes those
158
+ /// insets, and `RCTViewComponentView` sizes `contentView` (our text view) to
159
+ /// `layoutMetrics.getContentFrame()`, so the shadow node's height and the text
160
+ /// view's frame are both content-box quantities. Measuring at `self.bounds`
161
+ /// instead overshoots by the horizontal insets, and because
162
+ /// `ENRMMeasureTextLayout` measures by resizing the live display container and
163
+ /// leaves it there (UIKit never re-syncs it - `widthTracksTextView` does not
164
+ /// apply with `scrollEnabled = NO`), the overshoot becomes the layout the user
165
+ /// sees: glyphs spill past the text view and get clipped, while the taller
166
+ /// content-width height Yoga committed shows up as blank space at the bottom.
167
+ ///
168
+ /// The text view's frame is authoritative, so this is zero-sized both before the
169
+ /// first layout metrics arrive and when the insets consume the whole component
170
+ /// width. Neither may fall back to `self.bounds`: that is the padded border box,
171
+ /// and in the second case it is a real layout the text would then be drawn
172
+ /// across. Callers skip measuring and laying out at a zero width instead.
173
+ ///
174
+ /// The frame, not the bounds: a `UITextView` is a scroll view, so its
175
+ /// `bounds.origin` is the content offset rather than the inset the segments of
176
+ /// `EnrichedMarkdown` get from the matching method.
177
+ - (CGRect)contentBounds
178
+ {
179
+ return _textView.frame;
180
+ }
181
+
154
182
  - (CGSize)measureSize:(CGFloat)maxWidth
155
183
  {
156
184
  CGSize size = ENRMMeasureMarkdownText(_textView, maxWidth, _config, _allowTrailingMargin, _lastElementMarginBottom);
@@ -186,15 +214,21 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
186
214
  }
187
215
  }
188
216
 
189
- /// Yoga-resolved direction inherited from any ancestor `direction` style.
190
- /// In FirstStrong mode this feeds the neutral-paragraph fallback, so a change
191
- /// requires a re-render of the cached markdown.
217
+ /// Publishes the committed size to the streaming fast path's mailbox, and picks
218
+ /// up the Yoga-resolved direction inherited from any ancestor `direction` style.
219
+ /// In FirstStrong mode the direction feeds the neutral-paragraph fallback, so a
220
+ /// change requires a re-render of the cached markdown.
221
+ ///
222
+ /// The mailbox holds a content size, not the border box: `measureContent`
223
+ /// returns it straight to Yoga, which adds the `containerStyle` insets back on
224
+ /// top of whatever it gets, so storing `frame.size` would double-count them.
192
225
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
193
226
  oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics
194
227
  {
195
228
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
196
229
 
197
- _lastCommittedSize.store(CGSizeMake(layoutMetrics.frame.size.width, layoutMetrics.frame.size.height));
230
+ const auto contentSize = layoutMetrics.getContentFrame().size;
231
+ _lastCommittedSize.store(CGSizeMake(contentSize.width, contentSize.height));
198
232
 
199
233
  NSWritingDirection resolved = _resolvedLayoutDirection;
200
234
  if (layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) {
@@ -225,9 +259,10 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
225
259
  facebook::react::MeasurementCache::shared().removeMatchingMarkdown(std::string(_renderedMarkdown.UTF8String));
226
260
  }
227
261
 
228
- if (self.bounds.size.width > 0) {
229
- CGSize measured = [self measureSize:self.bounds.size.width];
230
- if (needsHeightUpdate(measured, self.bounds)) {
262
+ CGRect contentBounds = [self contentBounds];
263
+ if (contentBounds.size.width > 0) {
264
+ CGSize measured = [self measureSize:contentBounds.size.width];
265
+ if (needsHeightUpdate(measured, contentBounds)) {
231
266
  [self requestHeightUpdate];
232
267
  }
233
268
  }
@@ -444,17 +479,12 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
444
479
  }
445
480
 
446
481
  // Kept in sync with the view-free measurement so the rendered line count matches
447
- // the measured height. The clamp must be computed at the padding-inset content
448
- // width (the text view's own width) rather than the full component bounds, or a
449
- // full-width measurement pass leaves the truncation laid out too wide and fewer
450
- // lines render than were measured. numberOfLines == 0 restores the unlimited default.
482
+ // the measured height: both clamp a layout performed at the content width (see
483
+ // contentBounds), so the truncation falls on the same word.
484
+ // numberOfLines == 0 restores the unlimited default.
451
485
  - (void)applyLineClampToTextContainer
452
486
  {
453
487
  if (_numberOfLines > 0) {
454
- CGFloat contentWidth = _textView.bounds.size.width;
455
- if (contentWidth > 0) {
456
- _textView.textContainer.size = CGSizeMake(contentWidth, CGFLOAT_MAX);
457
- }
458
488
  _textView.textContainer.maximumNumberOfLines = _numberOfLines;
459
489
  _textView.textContainer.lineBreakMode = _ellipsizeLineBreakMode;
460
490
  } else {
@@ -477,9 +507,10 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
477
507
  // Ensure the text container has unlimited height before setting content.
478
508
  // updateLayoutMetrics may have shrunk the frame (and thus the text container)
479
509
  // from a previous layout pass, which would clip the new attributed text.
480
- CGFloat containerWidth = _textView.textContainer.size.width;
510
+ CGRect contentBounds = [self contentBounds];
511
+ CGFloat containerWidth = contentBounds.size.width;
481
512
  if (containerWidth <= 0) {
482
- containerWidth = self.bounds.size.width;
513
+ containerWidth = _textView.textContainer.size.width;
483
514
  }
484
515
  _textView.textContainer.size = CGSizeMake(containerWidth, CGFLOAT_MAX);
485
516
  [self applyLineClampToTextContainer];
@@ -499,7 +530,7 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
499
530
  // that corrupts the height sent to Yoga.
500
531
  [_spoilerManager setNeedsUpdate];
501
532
 
502
- if (self.bounds.size.width > 0) {
533
+ if (contentBounds.size.width > 0) {
503
534
  // Font/style changes can produce the same measured size before UIKit has
504
535
  // fully refreshed layout, so force one Yoga update after those renders.
505
536
  BOOL forceHeightUpdate = _forceHeightUpdateOnNextRender;
@@ -513,19 +544,10 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
513
544
 
514
545
  [_spoilerManager updateIfNeeded];
515
546
 
516
- CGSize measured = [self measureSize:self.bounds.size.width];
517
- if (forceHeightUpdate || needsHeightUpdate(measured, self.bounds)) {
547
+ CGSize measured = [self measureSize:contentBounds.size.width];
548
+ if (forceHeightUpdate || needsHeightUpdate(measured, contentBounds)) {
518
549
  [self requestHeightUpdate];
519
550
  }
520
-
521
- // measureSize lays the shared display container out at the full bounds width;
522
- // re-pin the clamp to the content width so the visible truncation matches the
523
- // content-width line count the shadow node measured.
524
- if (_numberOfLines > 0) {
525
- [self applyLineClampToTextContainer];
526
- [_textView.layoutManager ensureLayoutForTextContainer:_textView.textContainer];
527
- ENRMSetNeedsDisplay(_textView);
528
- }
529
551
  }
530
552
 
531
553
  if (_textView.hidden) {
@@ -741,14 +763,20 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
741
763
 
742
764
  if (self.window && _renderedMarkdown != nil) {
743
765
  _textView.hidden = NO;
744
- ENRMRefreshTextViewAfterWindowAttach(_textView, self.bounds);
766
+ // Refresh in place: RCTViewComponentView owns the text view's frame and has
767
+ // already set it to the content frame, so re-assigning self.bounds here would
768
+ // drop the containerStyle insets (see contentBounds).
769
+ ENRMRefreshTextViewLayout(_textView);
745
770
 
746
771
  [_spoilerManager setNeedsUpdate];
747
772
  [_spoilerManager updateIfNeeded];
748
773
 
749
- CGSize measured = [self measureSize:self.bounds.size.width];
750
- if (needsHeightUpdate(measured, self.bounds)) {
751
- [self requestHeightUpdate];
774
+ CGRect contentBounds = [self contentBounds];
775
+ if (contentBounds.size.width > 0) {
776
+ CGSize measured = [self measureSize:contentBounds.size.width];
777
+ if (needsHeightUpdate(measured, contentBounds)) {
778
+ [self requestHeightUpdate];
779
+ }
752
780
  }
753
781
  }
754
782
  }
@@ -70,8 +70,7 @@ static inline bool ENRMPropsNeedExactStreamingMeasurement(const PropsT &oldProps
70
70
  oldProps.md4cFlags.highlight != newProps.md4cFlags.highlight ||
71
71
  oldProps.md4cFlags.hardSoftBreaks != newProps.md4cFlags.hardSoftBreaks ||
72
72
  oldProps.md4cFlags.preserveBlankLines != newProps.md4cFlags.preserveBlankLines ||
73
- oldProps.isGFM != newProps.isGFM ||
74
- oldProps.md4cFlags.admonitions != newProps.md4cFlags.admonitions ||
73
+ oldProps.isGFM != newProps.isGFM || oldProps.md4cFlags.admonitions != newProps.md4cFlags.admonitions ||
75
74
  computeStyleFingerprint(oldProps.markdownStyle) != computeStyleFingerprint(newProps.markdownStyle);
76
75
  }
77
76
 
@@ -19,6 +19,41 @@ NSString *const TaskIndexAttribute = @"TaskIndex";
19
19
  @implementation ENRMListMarkerDescriptor
20
20
  @end
21
21
 
22
+ // NSArray's -hash is its count, so plain one-marker arrays all collide in the
23
+ // table that uniques attribute dictionaries, and each list item probes past
24
+ // every earlier one. Hashing by the item's own marker keeps rendering linear.
25
+ @interface ENRMListMarkerArray : NSArray
26
+ @end
27
+
28
+ @implementation ENRMListMarkerArray {
29
+ NSArray<ENRMListMarkerDescriptor *> *_markers;
30
+ }
31
+
32
+ - (instancetype)initWithMarkers:(NSArray<ENRMListMarkerDescriptor *> *)markers
33
+ {
34
+ if (self = [super init]) {
35
+ _markers = [markers copy];
36
+ }
37
+ return self;
38
+ }
39
+
40
+ - (NSUInteger)count
41
+ {
42
+ return _markers.count;
43
+ }
44
+
45
+ - (id)objectAtIndex:(NSUInteger)index
46
+ {
47
+ return _markers[index];
48
+ }
49
+
50
+ - (NSUInteger)hash
51
+ {
52
+ return _markers.lastObject.hash;
53
+ }
54
+
55
+ @end
56
+
22
57
  @interface ListItemRenderer ()
23
58
  - (void)applyCheckedDecorationsTo:(NSMutableAttributedString *)output
24
59
  range:(NSRange)range
@@ -184,7 +219,9 @@ NSString *const TaskIndexAttribute = @"TaskIndex";
184
219
  NSArray *existingMarkers = [output attribute:ListItemMarkerStartAttribute atIndex:anchorLocation effectiveRange:NULL];
185
220
  NSArray *markers =
186
221
  [existingMarkers isKindOfClass:[NSArray class]] ? [existingMarkers arrayByAddingObject:marker] : @[ marker ];
187
- [output addAttribute:ListItemMarkerStartAttribute value:markers range:anchorRange];
222
+ [output addAttribute:ListItemMarkerStartAttribute
223
+ value:[[ENRMListMarkerArray alloc] initWithMarkers:markers]
224
+ range:anchorRange];
188
225
 
189
226
  if (isTask && isChecked) {
190
227
  [self applyCheckedDecorationsTo:output range:itemRange nestingLevel:nestingLevel];
@@ -5,6 +5,7 @@
5
5
  #import "StyleConfig.h"
6
6
 
7
7
  @class MarkdownASTNode;
8
+ @class ENRMAccessibilityLabels;
8
9
 
9
10
  NS_ASSUME_NONNULL_BEGIN
10
11
 
@@ -79,6 +80,11 @@ typedef void (^ENRMBlockquoteLinkBlock)(NSString *url);
79
80
  // a toggle is born current with no per-toggle push. See ENRMDynamicBlockProps.
80
81
  @property (nonatomic, strong) ENRMDynamicBlockProps *dynamicProps;
81
82
 
83
+ // Resolved VoiceOver strings (list, quote, table and math announcements). Set by
84
+ // the host at creation and pushed to this quote's children, recursing into
85
+ // nested quotes, so they are labelled the same way top-level segments are.
86
+ @property (nonatomic, strong, nullable) ENRMAccessibilityLabels *accessibilityLabels;
87
+
82
88
  @end
83
89
 
84
90
  NS_ASSUME_NONNULL_END
@@ -152,6 +152,30 @@ static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config)
152
152
  #endif
153
153
  }
154
154
 
155
+ - (void)setAccessibilityLabels:(ENRMAccessibilityLabels *)accessibilityLabels
156
+ {
157
+ if (_accessibilityLabels == accessibilityLabels) {
158
+ return;
159
+ }
160
+ _accessibilityLabels = accessibilityLabels;
161
+ // Children created before this point (or before a labels prop change) still
162
+ // hold the previous labels; nested quotes forward them to their own children.
163
+ for (RCTUIView *child in self.subviews) {
164
+ if ([child isKindOfClass:[EnrichedMarkdownInternalText class]]) {
165
+ ((EnrichedMarkdownInternalText *)child).accessibilityLabels = accessibilityLabels;
166
+ } else if ([child isKindOfClass:[TableContainerView class]]) {
167
+ ((TableContainerView *)child).accessibilityLabels = accessibilityLabels;
168
+ } else if ([child isKindOfClass:[ENRMBlockquoteContainerView class]]) {
169
+ ((ENRMBlockquoteContainerView *)child).accessibilityLabels = accessibilityLabels;
170
+ }
171
+ #if ENRICHED_MARKDOWN_MATH
172
+ else if ([child isKindOfClass:[ENRMMathContainerView class]]) {
173
+ ((ENRMMathContainerView *)child).accessibilityLabels = accessibilityLabels;
174
+ }
175
+ #endif
176
+ }
177
+ }
178
+
155
179
  // Child registry for this quote's own content. It reuses static creators for
156
180
  // every kind and, for a nested Blockquote, creates another
157
181
  // ENRMBlockquoteContainerView (the recursion). Streaming is static: no
@@ -172,6 +196,7 @@ static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config)
172
196
  EnrichedMarkdownInternalText *view = [[EnrichedMarkdownInternalText alloc] initWithConfig:config];
173
197
  view.lastElementMarginBottom = segment.textResult.lastElementMarginBottom;
174
198
  view.accessibilityInfo = segment.textResult.accessibilityInfo;
199
+ view.accessibilityLabels = strongSelf.accessibilityLabels;
175
200
  [view applyAttributedText:segment.textResult.attributedText context:segment.textResult.context];
176
201
  if (strongSelf) {
177
202
  [strongSelf attachLinkTapToTextView:view];
@@ -195,6 +220,7 @@ static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config)
195
220
  ENRMBlockquoteContainerView *strongSelf = weakSelf;
196
221
  if (strongSelf) {
197
222
  view.dynamicProps = strongSelf.dynamicProps;
223
+ view.accessibilityLabels = strongSelf.accessibilityLabels;
198
224
  view.onLinkPress = ^(NSString *url) {
199
225
  ENRMBlockquoteContainerView *s = weakSelf;
200
226
  if (s.onLinkPress && url)
@@ -252,6 +278,7 @@ static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config)
252
278
  view.allowFontScaling = strongSelf.allowFontScaling;
253
279
  view.lineBreakStrategy = strongSelf.lineBreakStrategy;
254
280
  view.dynamicProps = strongSelf.dynamicProps;
281
+ view.accessibilityLabels = strongSelf.accessibilityLabels;
255
282
  view.onCopyPress = strongSelf.onCopyPress;
256
283
  view.onCodeBlockPress = strongSelf.onCodeBlockPress;
257
284
  view.onLinkPress = ^(NSString *url) {
@@ -283,6 +310,7 @@ static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config)
283
310
  ENRMMathContainerView *view = [[ENRMMathContainerView alloc] initWithConfig:config];
284
311
  ENRMBlockquoteContainerView *strongSelf = weakSelf;
285
312
  view.dynamicProps = strongSelf.dynamicProps;
313
+ view.accessibilityLabels = strongSelf.accessibilityLabels;
286
314
  [view applyLatex:segment.mathSegment.latex];
287
315
  return view;
288
316
  }