react-native-enriched-markdown-scaffold 0.7.4-scaffold.1 → 0.7.4-scaffold.3

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.
@@ -848,18 +848,36 @@ class EnrichedMarkdownTextInputView(
848
848
  return true
849
849
  }
850
850
 
851
- private fun computeChipBounds(
851
+ /** Chip glyph rect in [Layout] coordinates — no padding, no scroll offset. */
852
+ private fun chipBoundsInLayout(
852
853
  layout: Layout,
853
854
  start: Int,
854
855
  ): Rect {
855
856
  val line = layout.getLineForOffset(start)
856
- val xStart = (layout.getPrimaryHorizontal(start)).toInt() + totalPaddingLeft - scrollX
857
- val xEnd = (layout.getPrimaryHorizontal(start + 1)).toInt() + totalPaddingLeft - scrollX
858
- val top = layout.getLineTop(line) + totalPaddingTop - scrollY
859
- val bottom = layout.getLineBottom(line) + totalPaddingTop - scrollY
860
- return Rect(xStart, top, xEnd, bottom)
857
+ val xStart = layout.getPrimaryHorizontal(start).toInt()
858
+ val xEnd = layout.getPrimaryHorizontal(start + 1).toInt()
859
+ // Ordered, not raw start/end: in an RTL run xStart > xEnd, which would make an
860
+ // inverted Rect that contains() always rejects.
861
+ val left = xStart.coerceAtMost(xEnd)
862
+ var right = xStart.coerceAtLeast(xEnd)
863
+ if (right <= left) {
864
+ // A chip that is the last character has start + 1 == length, where some layouts
865
+ // report the same horizontal for both offsets. Fall back to the line's content edge
866
+ // so the rect keeps a real width instead of collapsing.
867
+ right = layout.getLineRight(line).toInt()
868
+ }
869
+ return Rect(left, layout.getLineTop(line), right, layout.getLineBottom(line))
861
870
  }
862
871
 
872
+ /** Chip glyph rect in view coordinates, for reporting to JS. */
873
+ private fun computeChipBounds(
874
+ layout: Layout,
875
+ start: Int,
876
+ ): Rect =
877
+ chipBoundsInLayout(layout, start).apply {
878
+ offset(totalPaddingLeft - scrollX, totalPaddingTop - scrollY)
879
+ }
880
+
863
881
  fun mathChipPositionAtTouch(
864
882
  x: Float,
865
883
  y: Float,
@@ -869,13 +887,21 @@ class EnrichedMarkdownTextInputView(
869
887
  val layoutNow = layout ?: return -1
870
888
  val adjustedX = (x - totalPaddingLeft + scrollX).toInt()
871
889
  val adjustedY = (y - totalPaddingTop + scrollY).toInt()
872
- if (adjustedY < 0) return -1
890
+ // getLineForVertical and getOffsetForHorizontal both clamp into the laid-out text, so
891
+ // a touch in the padding or in the blank space below the last line resolves onto the
892
+ // nearest character. Bound the touch to the text box, then require it to land inside
893
+ // the chip's own rect — otherwise a tap beside a chip reads as a tap on it.
894
+ if (adjustedY < 0 || adjustedY > layoutNow.height) return -1
873
895
  val line = layoutNow.getLineForVertical(adjustedY)
874
896
  val offset = layoutNow.getOffsetForHorizontal(line, adjustedX.toFloat())
875
- if (offset < 0 || offset >= editable.length) return -1
897
+ if (offset < 0) return -1
876
898
  val candidates = intArrayOf(offset, offset - 1).filter { it in 0 until editable.length }
877
899
  for (candidate in candidates) {
878
900
  val range = controller.chipRangeAt(editable, candidate) ?: continue
901
+ // An empty rect means the layout could not give us the chip's extent; accept the
902
+ // candidate then, rather than making the chip untappable.
903
+ val bounds = chipBoundsInLayout(layoutNow, range.first)
904
+ if (!bounds.isEmpty && !bounds.contains(adjustedX, adjustedY)) continue
879
905
  return range.first
880
906
  }
881
907
  return -1
@@ -123,6 +123,45 @@ static char kENRMSegmentFadeAnimatorKey;
123
123
  return concreteComponentDescriptorProvider<EnrichedMarkdownComponentDescriptor>();
124
124
  }
125
125
 
126
+ /// Clears every piece of state that `updateProps` derives from props, back to the
127
+ /// values the default-constructed `EnrichedMarkdownProps` describes.
128
+ ///
129
+ /// `updateProps` applies most props as a per-field diff against `_props`, so this
130
+ /// state is only correct while it stays in phase with the `_props` baseline it was
131
+ /// diffed from. `prepareForRecycle` resets `_props`, so it must reset this too —
132
+ /// otherwise every field the next consumer leaves at its default silently inherits
133
+ /// the previous consumer's value. Called from `initWithFrame:` as well, so the two
134
+ /// baselines cannot drift apart.
135
+ ///
136
+ /// Requires `_fontScaleObserver` to already exist.
137
+ - (void)resetPropDerivedState
138
+ {
139
+ // Discarded rather than cleared field-by-field: the style diff can only clear a
140
+ // field when new and old differ, which never holds against a reset baseline.
141
+ _config = nil;
142
+
143
+ _md4cFlags = [ENRMMd4cFlags propsDefaultFlags];
144
+ _maxFontSizeMultiplier = 0;
145
+ _allowTrailingMargin = NO;
146
+ _fontScaleObserver.allowFontScaling = YES;
147
+ _streamingAnimation = NO;
148
+ _tableStreamingMode = ENRMTableStreamingModeProgressive;
149
+ _spoilerOverlay = ENRMSpoilerOverlayParticles;
150
+ _lineBreakStrategy = NSLineBreakStrategyNone;
151
+ _writingDirectionMode = ENRMWritingDirectionModeFirstStrong;
152
+ _contextMenuItemTexts = nil;
153
+ _contextMenuItemIcons = nil;
154
+ _accessibilityLabels = nil;
155
+
156
+ // Written only once a render completes, so stale until the next one lands.
157
+ _renderedStyleFingerprint = 0;
158
+ _pendingStyleFingerprint = 0;
159
+
160
+ _dirtyFlags = ENRMDirtyNone;
161
+ _heightUpdateCounter = 0;
162
+ _state = nullptr;
163
+ }
164
+
126
165
  - (instancetype)initWithFrame:(CGRect)frame
127
166
  {
128
167
  if (self = [super initWithFrame:frame]) {
@@ -131,28 +170,22 @@ static char kENRMSegmentFadeAnimatorKey;
131
170
 
132
171
  self.backgroundColor = [RCTUIColor clearColor];
133
172
  _parser = [[ENRMMarkdownParser alloc] init];
134
- _md4cFlags = [ENRMMd4cFlags defaultFlags];
135
173
  _segmentViews = [NSMutableArray array];
136
174
  _segmentSignatures = [NSMutableArray array];
137
- _dirtyFlags = ENRMDirtyNone;
138
175
  [self configureSegmentViewRegistry];
139
176
 
140
177
  _renderCoordinator =
141
178
  [[ENRMAsyncRenderCoordinator alloc] initWithQueueLabel:"com.swmansion.enriched.markdown.container.render"];
142
179
 
143
- _maxFontSizeMultiplier = 0;
144
- _allowTrailingMargin = NO;
145
180
  _selectable = YES;
146
181
  _enableLinkPreview = YES;
147
- _streamingAnimation = NO;
148
- _tableStreamingMode = ENRMTableStreamingModeProgressive;
149
182
  _selectionMenuConfig = (ENRMSelectionMenuConfig){.copyAsMarkdown = YES, .copyImageURL = YES};
150
- _lineBreakStrategy = NSLineBreakStrategyNone;
151
- _writingDirectionMode = ENRMWritingDirectionModeFirstStrong;
152
183
  _resolvedLayoutDirection =
153
184
  [[RCTI18nUtil sharedInstance] isRTL] ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight;
154
185
 
155
186
  _fontScaleObserver = [[FontScaleObserver alloc] init];
187
+ [self resetPropDerivedState];
188
+
156
189
  __weak EnrichedMarkdown *weakSelf = self;
157
190
  _fontScaleObserver.onChange = ^{
158
191
  EnrichedMarkdown *strongSelf = weakSelf;
@@ -624,7 +657,7 @@ static char kENRMSegmentFadeAnimatorKey;
624
657
  selectionEnd:selectionEnd];
625
658
  });
626
659
  return buildEditMenuForSelection(textView.textStorage, textView.selectedRange, segmentMarkdown, strongSelf->_config,
627
- @[ baseMenu ], customItems, strongSelf -> _selectionMenuConfig);
660
+ @[ baseMenu ], customItems, strongSelf->_selectionMenuConfig);
628
661
  }];
629
662
  #endif
630
663
 
@@ -949,9 +982,8 @@ static char kENRMSegmentFadeAnimatorKey;
949
982
 
950
983
  _cachedMarkdown = nil;
951
984
  _renderedMarkdown = nil;
952
- _streamingAnimation = NO;
953
- _tableStreamingMode = ENRMTableStreamingModeProgressive;
954
- _dirtyFlags = ENRMDirtyNone;
985
+
986
+ [self resetPropDerivedState];
955
987
 
956
988
  [super prepareForRecycle];
957
989
  }
@@ -46,6 +46,7 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
46
46
  };
47
47
 
48
48
  @interface EnrichedMarkdownText () <RCTEnrichedMarkdownTextViewProtocol, UITextViewDelegate>
49
+ - (void)resetPropDerivedState;
49
50
  - (void)setupTextView;
50
51
  - (void)renderMarkdownContent:(NSString *)markdownString;
51
52
  - (void)applyRenderedText:(NSMutableAttributedString *)attributedText;
@@ -177,6 +178,46 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
177
178
  ENRMRequestHeightUpdate<EnrichedMarkdownTextState>(_state, _heightUpdateCounter, self);
178
179
  }
179
180
 
181
+ /// Clears every piece of state that `updateProps` derives from props, back to the
182
+ /// values the default-constructed `EnrichedMarkdownTextProps` describes.
183
+ ///
184
+ /// `updateProps` applies most props as a per-field diff against `_props`, so this
185
+ /// state is only correct while it stays in phase with the `_props` baseline it was
186
+ /// diffed from. `prepareForRecycle` resets `_props`, so it must reset this too —
187
+ /// otherwise every field the next consumer leaves at its default silently inherits
188
+ /// the previous consumer's value. Called from `initWithFrame:` as well, so the two
189
+ /// baselines cannot drift apart.
190
+ ///
191
+ /// Requires `_fontScaleObserver` to already exist.
192
+ - (void)resetPropDerivedState
193
+ {
194
+ // Discarded rather than cleared field-by-field: the style diff can only clear a
195
+ // field when new and old differ, which never holds against a reset baseline.
196
+ _config = nil;
197
+ _spoilerManager = nil;
198
+
199
+ _md4cFlags = [ENRMMd4cFlags propsDefaultFlags];
200
+ _maxFontSizeMultiplier = 0;
201
+ _allowTrailingMargin = NO;
202
+ _fontScaleObserver.allowFontScaling = YES;
203
+ _streamingAnimation = NO;
204
+ _lineBreakStrategy = NSLineBreakStrategyNone;
205
+ _writingDirectionMode = ENRMWritingDirectionModeFirstStrong;
206
+ _contextMenuItemTexts = nil;
207
+ _contextMenuItemIcons = nil;
208
+ _accessibilityLabels = nil;
209
+
210
+ // Written only once a render completes, so stale until the next one lands.
211
+ _renderedStyleFingerprint = 0;
212
+ _pendingStyleFingerprint = 0;
213
+ _lastElementMarginBottom = 0;
214
+
215
+ _dirtyFlags = ENRMDirtyNone;
216
+ _forceHeightUpdateOnNextRender = NO;
217
+ _heightUpdateCounter = 0;
218
+ _state = nullptr;
219
+ }
220
+
180
221
  - (instancetype)initWithFrame:(CGRect)frame
181
222
  {
182
223
  if (self = [super initWithFrame:frame]) {
@@ -185,23 +226,18 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
185
226
 
186
227
  self.backgroundColor = [RCTUIColor clearColor];
187
228
  _parser = [[ENRMMarkdownParser alloc] init];
188
- _md4cFlags = [ENRMMd4cFlags defaultFlags];
189
229
 
190
230
  _renderCoordinator =
191
231
  [[ENRMAsyncRenderCoordinator alloc] initWithQueueLabel:"com.swmansion.enriched.markdown.render"];
192
232
 
193
- _maxFontSizeMultiplier = 0;
194
- _allowTrailingMargin = NO;
195
233
  _enableLinkPreview = YES;
196
- _forceHeightUpdateOnNextRender = NO;
197
234
  _selectionMenuConfig = (ENRMSelectionMenuConfig){.copyAsMarkdown = YES, .copyImageURL = YES};
198
- _lineBreakStrategy = NSLineBreakStrategyNone;
199
- _writingDirectionMode = ENRMWritingDirectionModeFirstStrong;
200
235
  _resolvedLayoutDirection =
201
236
  [[RCTI18nUtil sharedInstance] isRTL] ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight;
202
- _dirtyFlags = ENRMDirtyNone;
203
237
 
204
238
  _fontScaleObserver = [[FontScaleObserver alloc] init];
239
+ [self resetPropDerivedState];
240
+
205
241
  __weak EnrichedMarkdownText *weakSelf = self;
206
242
  _fontScaleObserver.onChange = ^{
207
243
  EnrichedMarkdownText *strongSelf = weakSelf;
@@ -253,8 +289,7 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
253
289
  selectionEnd:selectionEnd];
254
290
  });
255
291
  return buildEditMenuForSelection(textView.textStorage, textView.selectedRange, strongSelf->_cachedMarkdown,
256
- strongSelf->_config, @[ baseMenu ], customItems,
257
- strongSelf -> _selectionMenuConfig);
292
+ strongSelf->_config, @[ baseMenu ], customItems, strongSelf->_selectionMenuConfig);
258
293
  };
259
294
  #endif
260
295
 
@@ -636,14 +671,15 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) {
636
671
  [_fadeAnimator cancel];
637
672
  _fadeAnimator = nil;
638
673
  _previousTextLength = 0;
639
- _streamingAnimation = NO;
640
- _forceHeightUpdateOnNextRender = NO;
641
674
  _cachedMarkdown = nil;
642
675
  _renderedMarkdown = nil;
643
676
  _accessibilityElements = nil;
644
677
  _accessibilityInfo = nil;
645
678
  _accessibilityNeedsRebuild = NO;
646
679
  [_spoilerManager removeAllOverlays];
680
+
681
+ [self resetPropDerivedState];
682
+
647
683
  if (_textView != nil) {
648
684
  ENRMSetAttributedText(_textView, [[NSAttributedString alloc] initWithString:@""]);
649
685
  _textView.hidden = YES;
@@ -253,6 +253,20 @@ using namespace facebook::react;
253
253
 
254
254
  #if ENRICHED_MARKDOWN_MATH && !TARGET_OS_OSX
255
255
 
256
+ // Glyph rect of the chip at `index`, in _textView coordinates (the same space
257
+ // `locationInView:_textView` reports). Single source of truth for both the tap
258
+ // hit-test and the rect reported to JS.
259
+ - (CGRect)mathChipRectInTextViewAtIndex:(NSUInteger)index layoutManager:(NSLayoutManager *)lm
260
+ {
261
+ if (lm == nil)
262
+ return CGRectNull;
263
+ NSRange glyphRange = [lm glyphRangeForCharacterRange:NSMakeRange(index, 1) actualCharacterRange:NULL];
264
+ CGRect rect = [lm boundingRectForGlyphRange:glyphRange inTextContainer:_textView.textContainer];
265
+ rect.origin.x += _textView.textContainerInset.left;
266
+ rect.origin.y += _textView.textContainerInset.top;
267
+ return rect;
268
+ }
269
+
256
270
  - (void)handleTapForMathChip:(UITapGestureRecognizer *)gesture
257
271
  {
258
272
  if (gesture.state != UIGestureRecognizerStateRecognized)
@@ -265,23 +279,28 @@ using namespace facebook::react;
265
279
  if (idx < 0)
266
280
  return;
267
281
  NSTextStorage *storage = _textView.textStorage;
268
- // The closest position can land just past the chip; probe both `idx` and `idx-1`.
282
+ NSLayoutManager *lm = storage.layoutManagers.firstObject;
283
+ // The closest position can land just past the chip, so probe both `idx` and `idx-1`.
284
+ // `closestPositionToPoint:` has no distance bound — it clamps a tap anywhere in the
285
+ // text view (blank space past the text, textContainerInset padding) onto the nearest
286
+ // character. Requiring the touch to fall inside the chip's own glyph rect is what
287
+ // keeps a tap beside a chip from reading as a tap on it.
269
288
  ENRMMathInlineAttachment *att = nil;
270
289
  NSUInteger chipIdx = NSNotFound;
271
- if ((NSUInteger)idx < storage.length) {
272
- id v = [storage attribute:NSAttachmentAttributeName atIndex:(NSUInteger)idx effectiveRange:NULL];
273
- if ([v isKindOfClass:[ENRMMathInlineAttachment class]]) {
274
- att = v;
275
- chipIdx = (NSUInteger)idx;
276
- }
277
- }
278
- if (!att && idx > 0) {
279
- NSUInteger probe = (NSUInteger)idx - 1;
280
- id v = [storage attribute:NSAttachmentAttributeName atIndex:probe effectiveRange:NULL];
281
- if ([v isKindOfClass:[ENRMMathInlineAttachment class]]) {
282
- att = v;
283
- chipIdx = probe;
284
- }
290
+ for (NSInteger probe = idx; probe >= idx - 1; probe--) {
291
+ if (probe < 0 || (NSUInteger)probe >= storage.length)
292
+ continue;
293
+ id v = [storage attribute:NSAttachmentAttributeName atIndex:(NSUInteger)probe effectiveRange:NULL];
294
+ if (![v isKindOfClass:[ENRMMathInlineAttachment class]])
295
+ continue;
296
+ // An empty rect means the layout could not give us the chip's extent; accept the
297
+ // candidate then, rather than making the chip untappable.
298
+ CGRect rect = [self mathChipRectInTextViewAtIndex:(NSUInteger)probe layoutManager:lm];
299
+ if (!CGRectIsEmpty(rect) && !CGRectContainsPoint(rect, loc))
300
+ continue;
301
+ att = v;
302
+ chipIdx = (NSUInteger)probe;
303
+ break;
285
304
  }
286
305
  if (!att)
287
306
  return;
@@ -290,7 +309,6 @@ using namespace facebook::react;
290
309
  [self clearMathChipSelectionsExcept:chipRange];
291
310
  att.isSelected = YES;
292
311
  [att invalidateRender];
293
- NSLayoutManager *lm = storage.layoutManagers.firstObject;
294
312
  if (lm) {
295
313
  [lm invalidateLayoutForCharacterRange:chipRange actualCharacterRange:NULL];
296
314
  [lm ensureLayoutForCharacterRange:chipRange];
@@ -306,10 +324,8 @@ using namespace facebook::react;
306
324
  _suppressMathChipClearOnce = YES;
307
325
  _textView.selectedRange = NSMakeRange(caretIndex, 0);
308
326
 
309
- NSRange glyphRange = [lm glyphRangeForCharacterRange:chipRange actualCharacterRange:NULL];
310
- CGRect glyphRect = [lm boundingRectForGlyphRange:glyphRange inTextContainer:_textView.textContainer];
311
- glyphRect.origin.x += _textView.textContainerInset.left;
312
- glyphRect.origin.y += _textView.textContainerInset.top;
327
+ // Recomputed rather than reused from the hit-test: the layout was invalidated above.
328
+ CGRect glyphRect = [self mathChipRectInTextViewAtIndex:chipIdx layoutManager:lm];
313
329
  CGRect rectInSelf = [self.contentView convertRect:glyphRect fromView:_textView];
314
330
 
315
331
  [self emitOnMathPressWithLatex:att.latex start:chipIdx end:chipIdx + 1 displayMode:att.displayMode rect:rectInSelf];
@@ -11,6 +11,14 @@
11
11
 
12
12
  + (instancetype)defaultFlags;
13
13
 
14
+ /// Flags matching the codegen `md4cFlags` Props defaults (every flag off).
15
+ ///
16
+ /// Components mirror `md4cFlags` into an ivar with a per-field diff against the
17
+ /// props baseline, so the ivar has to start from the same baseline the props do.
18
+ /// `defaultFlags` enables `latexMath`, which is the right default for the
19
+ /// standalone parser API but would make a `latexMath: false` prop a no-op.
20
+ + (instancetype)propsDefaultFlags;
21
+
14
22
  @end
15
23
 
16
24
  @interface ENRMMarkdownParser : NSObject
@@ -22,6 +22,17 @@ extern MarkdownASTNode *parseMarkdownWithCppParser(NSString *markdown, ENRMMd4cF
22
22
  return [[ENRMMd4cFlags alloc] init];
23
23
  }
24
24
 
25
+ + (instancetype)propsDefaultFlags
26
+ {
27
+ ENRMMd4cFlags *flags = [[ENRMMd4cFlags alloc] init];
28
+ flags.underline = NO;
29
+ flags.latexMath = NO;
30
+ flags.superscript = NO;
31
+ flags.subscript = NO;
32
+ flags.highlight = NO;
33
+ return flags;
34
+ }
35
+
25
36
  - (id)copyWithZone:(NSZone *)zone
26
37
  {
27
38
  ENRMMd4cFlags *copy = [[ENRMMd4cFlags allocWithZone:zone] init];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-enriched-markdown-scaffold",
3
- "version": "0.7.4-scaffold.1",
3
+ "version": "0.7.4-scaffold.3",
4
4
  "description": "Markdown Text component for React Native",
5
5
  "main": "./lib/module/index",
6
6
  "module": "./lib/module/index",