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.
- package/README.md +2 -1
- package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +43 -5
- package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +9 -8
- package/docs/API_REFERENCE.md +24 -0
- package/docs/BREAKING_CHANGES.md +10 -0
- package/ios/EnrichedMarkdown.mm +65 -18
- package/ios/EnrichedMarkdownText.mm +61 -33
- package/ios/internals/ShadowMeasurementUtils.h +1 -2
- package/ios/renderer/ListItemRenderer.m +38 -1
- package/ios/segments/ENRMBlockquoteContainerView.h +6 -0
- package/ios/segments/ENRMBlockquoteContainerView.mm +28 -0
- package/ios/segments/TableContainerView.m +1 -0
- package/ios/utils/ENRMCodeBlockContent.mm +2 -0
- package/ios/utils/ENRMTextRenderer.m +1 -0
- package/ios/utils/MarkdownAccessibilityElementBuilder.m +4 -2
- package/ios/utils/ParagraphStyleUtils.h +18 -0
- package/ios/utils/ParagraphStyleUtils.m +20 -0
- package/ios/utils/TextViewLayoutManager.mm +57 -0
- package/lib/module/EnrichedMarkdownTextInput.js +48 -2
- package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
- package/lib/module/utils/textInputState.js +4 -3
- package/lib/module/utils/textInputState.js.map +1 -1
- package/lib/module/utils/usePressability.js +28 -0
- package/lib/module/utils/usePressability.js.map +1 -0
- package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +3 -3
- package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
- package/lib/typescript/src/utils/textInputState.d.ts +5 -3
- package/lib/typescript/src/utils/textInputState.d.ts.map +1 -1
- package/lib/typescript/src/utils/usePressability.d.ts +24 -0
- package/lib/typescript/src/utils/usePressability.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/EnrichedMarkdownTextInput.tsx +66 -4
- package/src/utils/textInputState.ts +5 -3
- 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.
|
|
265
|
+
| **1.1.x** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
266
|
+
| **1.0.x** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
266
267
|
| **0.7.0** | ⛔ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
267
268
|
| **0.6.0** | ⛔ | ✅ | ✅ | ✅ | ⛔ | ⛔ |
|
|
268
269
|
| **0.5.0** | ⛔ | ✅ | ✅ | ✅ | ⛔ | ⛔ |
|
package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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() {
|
package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt
CHANGED
|
@@ -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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
val
|
|
243
|
-
dispatcher
|
|
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
|
}
|
package/docs/API_REFERENCE.md
CHANGED
|
@@ -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).
|
package/docs/BREAKING_CHANGES.md
CHANGED
|
@@ -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
|
package/ios/EnrichedMarkdown.mm
CHANGED
|
@@ -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 =
|
|
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
|
-
///
|
|
584
|
-
///
|
|
585
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
691
|
+
CGRect contentBounds = [self contentBounds];
|
|
692
|
+
if (contentBounds.size.width > 0) {
|
|
653
693
|
[self setNeedsLayout];
|
|
654
|
-
CGSize measured = [self measureSize:
|
|
655
|
-
if (needsHeightUpdate(measured,
|
|
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
|
-
|
|
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:
|
|
877
|
+
[self computeSegmentLayoutForWidth:contentBounds.size.width applyFrames:YES];
|
|
837
878
|
[self layoutIfNeeded];
|
|
838
879
|
[self requestHeightUpdate];
|
|
839
880
|
} else {
|
|
840
|
-
CGSize measured = [self measureSize:
|
|
841
|
-
if (needsHeightUpdate(measured,
|
|
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
|
|
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
|
-
|
|
1234
|
-
if (
|
|
1235
|
-
[self
|
|
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
|
-
///
|
|
190
|
-
///
|
|
191
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
448
|
-
//
|
|
449
|
-
//
|
|
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
|
-
|
|
510
|
+
CGRect contentBounds = [self contentBounds];
|
|
511
|
+
CGFloat containerWidth = contentBounds.size.width;
|
|
481
512
|
if (containerWidth <= 0) {
|
|
482
|
-
containerWidth =
|
|
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 (
|
|
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:
|
|
517
|
-
if (forceHeightUpdate || needsHeightUpdate(measured,
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
if (
|
|
751
|
-
[self
|
|
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
|
|
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
|
}
|