react-native-enriched-markdown-scaffold 0.6.0-scaffold.8 → 0.7.4-scaffold.2

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 (150) hide show
  1. package/ReactNativeEnrichedMarkdown.podspec +62 -11
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +3 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +1 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +9 -0
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +3 -0
  6. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +3 -0
  7. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +1 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  9. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +744 -0
  10. package/android/src/main/baseline-prof.txt +75 -2
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +62 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +10 -2
  13. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +10 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +9 -2
  15. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +10 -0
  16. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/AccessibilityLabels.kt +15 -0
  17. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +44 -7
  18. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +52 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +89 -11
  20. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt +19 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +22 -3
  22. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputRemend.kt +1 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +7 -1
  24. package/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt +54 -13
  25. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +17 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +4 -4
  27. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +30 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt +22 -0
  29. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +36 -7
  30. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +73 -3
  31. package/android/src/main/jni/CMakeLists.txt +15 -1
  32. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +24 -2
  33. package/app.plugin.js +2 -1
  34. package/cpp/parser/MD4CParser.cpp +1 -1
  35. package/cpp/wasm/build.sh +9 -9
  36. package/docs/ACCESSIBILITY.md +172 -0
  37. package/docs/API_REFERENCE.md +948 -0
  38. package/docs/COPY_OPTIONS.md +127 -0
  39. package/docs/ELEMENTS_STRUCTURE.md +112 -0
  40. package/docs/IMAGE_CACHING.md +25 -0
  41. package/docs/INPUT.md +233 -0
  42. package/docs/LATEX_MATH.md +136 -0
  43. package/docs/MACOS.md +19 -0
  44. package/docs/MARKDOWN_STREAMING.md +36 -0
  45. package/docs/MENTIONS.md +97 -0
  46. package/docs/RTL.md +82 -0
  47. package/docs/STYLES.md +519 -0
  48. package/docs/TEXT.md +110 -0
  49. package/docs/WEB.md +49 -0
  50. package/ios/EnrichedMarkdown.mm +82 -25
  51. package/ios/EnrichedMarkdownText.mm +37 -5
  52. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  53. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +744 -0
  54. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +15 -0
  55. package/ios/input/ENRMFormattingStore.h +5 -0
  56. package/ios/input/ENRMFormattingStore.mm +34 -0
  57. package/ios/input/ENRMInputTextView.h +2 -0
  58. package/ios/input/ENRMInputTextView.mm +8 -17
  59. package/ios/input/ENRMMarkdownSerializer.mm +1 -1
  60. package/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm +71 -53
  61. package/ios/input/EnrichedMarkdownTextInput+Internal.h +26 -0
  62. package/ios/input/EnrichedMarkdownTextInput.h +1 -0
  63. package/ios/input/EnrichedMarkdownTextInput.mm +230 -29
  64. package/ios/input/styles/ENRMBoldStyleHandler.mm +3 -2
  65. package/ios/input/styles/ENRMItalicStyleHandler.mm +3 -2
  66. package/ios/renderer/AttributedRenderer.m +51 -6
  67. package/ios/renderer/ENRMMathInlineRenderer.m +5 -1
  68. package/ios/styles/ENRMFontSlot.h +1 -1
  69. package/ios/utils/ENRMAccessibilityLabels.h +36 -0
  70. package/ios/utils/ENRMAccessibilityLabels.m +5 -0
  71. package/ios/utils/EditMenuUtils+macOS.m +4 -5
  72. package/ios/utils/EditMenuUtils.h +70 -0
  73. package/ios/utils/EditMenuUtils.m +14 -11
  74. package/ios/utils/LastElementUtils.h +39 -2
  75. package/ios/utils/MarkdownAccessibilityElementBuilder.h +18 -16
  76. package/ios/utils/MarkdownAccessibilityElementBuilder.m +64 -32
  77. package/ios/utils/ParagraphStyleUtils.m +21 -2
  78. package/ios/views/ENRMMathContainerView.h +8 -0
  79. package/ios/views/ENRMMathContainerView.m +7 -5
  80. package/ios/views/ENRMTableIOSGridView.h +40 -0
  81. package/ios/views/ENRMTableIOSGridView.m +216 -0
  82. package/ios/views/EnrichedMarkdownInternalText.h +2 -0
  83. package/ios/views/EnrichedMarkdownInternalText.m +2 -0
  84. package/ios/views/TableContainerView.h +8 -0
  85. package/ios/views/TableContainerView.m +72 -128
  86. package/lib/module/EnrichedMarkdownNativeComponent.ts +37 -0
  87. package/lib/module/EnrichedMarkdownTextInput.js +48 -1
  88. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  89. package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +35 -0
  90. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +37 -0
  91. package/lib/module/accessibilityLabelDefaults.js +64 -0
  92. package/lib/module/accessibilityLabelDefaults.js.map +1 -0
  93. package/lib/module/index.js +1 -0
  94. package/lib/module/index.js.map +1 -1
  95. package/lib/module/native/EnrichedMarkdownText.js +72 -4
  96. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  97. package/lib/module/normalizeMenuItem.js +38 -0
  98. package/lib/module/normalizeMenuItem.js.map +1 -0
  99. package/lib/module/types/AccessibilityLabels.js +2 -0
  100. package/lib/module/types/AccessibilityLabels.js.map +1 -0
  101. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +34 -0
  102. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  103. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +55 -1
  104. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  105. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +30 -0
  106. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
  107. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +34 -0
  108. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  109. package/lib/typescript/src/accessibilityLabelDefaults.d.ts +16 -0
  110. package/lib/typescript/src/accessibilityLabelDefaults.d.ts.map +1 -0
  111. package/lib/typescript/src/index.d.ts +4 -2
  112. package/lib/typescript/src/index.d.ts.map +1 -1
  113. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +3 -3
  114. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  115. package/lib/typescript/src/normalizeMenuItem.d.ts +7 -0
  116. package/lib/typescript/src/normalizeMenuItem.d.ts.map +1 -0
  117. package/lib/typescript/src/types/AccessibilityLabels.d.ts +114 -0
  118. package/lib/typescript/src/types/AccessibilityLabels.d.ts.map +1 -0
  119. package/lib/typescript/src/types/MarkdownTextProps.d.ts +78 -9
  120. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  121. package/package.json +4 -2
  122. package/plugin/build/withAndroidMath.js +19 -0
  123. package/plugin/build/withIosMath.js +28 -0
  124. package/plugin/build/withReactNativeEnrichedMarkdown.js +11 -0
  125. package/plugin/tsconfig.build.json +16 -0
  126. package/src/EnrichedMarkdownNativeComponent.ts +37 -0
  127. package/src/EnrichedMarkdownTextInput.tsx +117 -1
  128. package/src/EnrichedMarkdownTextInputNativeComponent.ts +35 -0
  129. package/src/EnrichedMarkdownTextNativeComponent.ts +37 -0
  130. package/src/accessibilityLabelDefaults.ts +83 -0
  131. package/src/index.tsx +11 -0
  132. package/src/native/EnrichedMarkdownText.tsx +120 -6
  133. package/src/normalizeMenuItem.ts +52 -0
  134. package/src/types/AccessibilityLabels.ts +118 -0
  135. package/src/types/MarkdownTextProps.ts +74 -9
  136. package/lib/module/plugin/withAndroidMath.js +0 -20
  137. package/lib/module/plugin/withAndroidMath.js.map +0 -1
  138. package/lib/module/plugin/withIosMath.js +0 -23
  139. package/lib/module/plugin/withIosMath.js.map +0 -1
  140. package/lib/module/plugin/withReactNativeEnrichedMarkdown.js +0 -16
  141. package/lib/module/plugin/withReactNativeEnrichedMarkdown.js.map +0 -1
  142. package/lib/typescript/src/plugin/withAndroidMath.d.ts +0 -5
  143. package/lib/typescript/src/plugin/withAndroidMath.d.ts.map +0 -1
  144. package/lib/typescript/src/plugin/withIosMath.d.ts +0 -5
  145. package/lib/typescript/src/plugin/withIosMath.d.ts.map +0 -1
  146. package/lib/typescript/src/plugin/withReactNativeEnrichedMarkdown.d.ts +0 -6
  147. package/lib/typescript/src/plugin/withReactNativeEnrichedMarkdown.d.ts.map +0 -1
  148. /package/{src/plugin → plugin/src}/withAndroidMath.ts +0 -0
  149. /package/{src/plugin → plugin/src}/withIosMath.ts +0 -0
  150. /package/{src/plugin → plugin/src}/withReactNativeEnrichedMarkdown.ts +0 -0
@@ -20,9 +20,12 @@
20
20
  #import "ENRMStyleMergingConfig.h"
21
21
  #import "ENRMUIKit.h"
22
22
  #import "ENRMWordsUtils.h"
23
+ #import "EnrichedMarkdownTextInput+Internal.h"
23
24
  #import "InputStylePropsUtils.h"
24
25
  #import "ParagraphStyleUtils.h"
26
+ #import "PasteboardUtils.h"
25
27
  #import "SelectionColorUtils.h"
28
+ #import <QuartzCore/CABase.h>
26
29
  #import <React/RCTI18nUtil.h>
27
30
  #if TARGET_OS_OSX
28
31
  #import <React/RCTBackedTextInputDelegate.h>
@@ -65,6 +68,7 @@ using namespace facebook::react;
65
68
  BOOL _isApplyingFormatting;
66
69
  BOOL _isTextChanging;
67
70
  BOOL _emitMarkdown;
71
+ CFTimeInterval _lastTextChangeTime;
68
72
 
69
73
  ENRMPlaceholderLabel *_placeholderLabel;
70
74
 
@@ -102,6 +106,20 @@ using namespace facebook::react;
102
106
  BOOL _suppressMathChipClearOnce;
103
107
  /// Character index of the currently selected math chip, or NSNotFound.
104
108
  NSUInteger _activeMathChipIndex;
109
+
110
+ ENRMInputSelectionMenuConfig _inputSelectionMenuConfig;
111
+ ENRMFormatMenuConfig _formatMenuConfig;
112
+
113
+ // Strong owners for the strings the two config structs above reference via
114
+ // `__unsafe_unretained` pointers.
115
+ NSString *_inputSelectionMenuFormatLabel;
116
+ NSString *_inputSelectionMenuCopyAsMarkdownLabel;
117
+ NSString *_formatMenuBoldLabel;
118
+ NSString *_formatMenuItalicLabel;
119
+ NSString *_formatMenuUnderlineLabel;
120
+ NSString *_formatMenuStrikethroughLabel;
121
+ NSString *_formatMenuSpoilerLabel;
122
+ NSString *_formatMenuLinkLabel;
105
123
  }
106
124
 
107
125
  #pragma mark - Fabric lifecycle
@@ -139,6 +157,9 @@ using namespace facebook::react;
139
157
  _writingDirectionMode = ENRMWritingDirectionModeFirstStrong;
140
158
  _resolvedLayoutDirection =
141
159
  [[RCTI18nUtil sharedInstance] isRTL] ? NSWritingDirectionRightToLeft : NSWritingDirectionLeftToRight;
160
+ _inputSelectionMenuConfig = (ENRMInputSelectionMenuConfig){.format = YES, .copyAsMarkdown = YES};
161
+ _formatMenuConfig = (ENRMFormatMenuConfig){
162
+ .bold = YES, .italic = YES, .underline = YES, .strikethrough = YES, .spoiler = YES, .link = YES};
142
163
 
143
164
  _suppressMathChipClearOnce = NO;
144
165
  _activeMathChipIndex = NSNotFound;
@@ -232,6 +253,20 @@ using namespace facebook::react;
232
253
 
233
254
  #if ENRICHED_MARKDOWN_MATH && !TARGET_OS_OSX
234
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
+
235
270
  - (void)handleTapForMathChip:(UITapGestureRecognizer *)gesture
236
271
  {
237
272
  if (gesture.state != UIGestureRecognizerStateRecognized)
@@ -244,23 +279,28 @@ using namespace facebook::react;
244
279
  if (idx < 0)
245
280
  return;
246
281
  NSTextStorage *storage = _textView.textStorage;
247
- // 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.
248
288
  ENRMMathInlineAttachment *att = nil;
249
289
  NSUInteger chipIdx = NSNotFound;
250
- if ((NSUInteger)idx < storage.length) {
251
- id v = [storage attribute:NSAttachmentAttributeName atIndex:(NSUInteger)idx effectiveRange:NULL];
252
- if ([v isKindOfClass:[ENRMMathInlineAttachment class]]) {
253
- att = v;
254
- chipIdx = (NSUInteger)idx;
255
- }
256
- }
257
- if (!att && idx > 0) {
258
- NSUInteger probe = (NSUInteger)idx - 1;
259
- id v = [storage attribute:NSAttachmentAttributeName atIndex:probe effectiveRange:NULL];
260
- if ([v isKindOfClass:[ENRMMathInlineAttachment class]]) {
261
- att = v;
262
- chipIdx = probe;
263
- }
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;
264
304
  }
265
305
  if (!att)
266
306
  return;
@@ -269,7 +309,6 @@ using namespace facebook::react;
269
309
  [self clearMathChipSelectionsExcept:chipRange];
270
310
  att.isSelected = YES;
271
311
  [att invalidateRender];
272
- NSLayoutManager *lm = storage.layoutManagers.firstObject;
273
312
  if (lm) {
274
313
  [lm invalidateLayoutForCharacterRange:chipRange actualCharacterRange:NULL];
275
314
  [lm ensureLayoutForCharacterRange:chipRange];
@@ -285,10 +324,8 @@ using namespace facebook::react;
285
324
  _suppressMathChipClearOnce = YES;
286
325
  _textView.selectedRange = NSMakeRange(caretIndex, 0);
287
326
 
288
- NSRange glyphRange = [lm glyphRangeForCharacterRange:chipRange actualCharacterRange:NULL];
289
- CGRect glyphRect = [lm boundingRectForGlyphRange:glyphRange inTextContainer:_textView.textContainer];
290
- glyphRect.origin.x += _textView.textContainerInset.left;
291
- 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];
292
329
  CGRect rectInSelf = [self.contentView convertRect:glyphRect fromView:_textView];
293
330
 
294
331
  [self emitOnMathPressWithLatex:att.latex start:chipIdx end:chipIdx + 1 displayMode:att.displayMode rect:rectInSelf];
@@ -463,6 +500,44 @@ using namespace facebook::react;
463
500
  _contextMenuItemIcons = ENRMContextMenuIconsFromItems(newViewProps.contextMenuItems);
464
501
  }
465
502
 
503
+ // Coalesce against `initWithUTF8String:` returning nil on malformed UTF-8 —
504
+ // UIMenu / UIAction / NSMenuItem titles are _Nonnull and would crash.
505
+ _inputSelectionMenuFormatLabel =
506
+ [[NSString alloc] initWithUTF8String:newViewProps.selectionMenuConfig.formatLabel.c_str()] ?: @"";
507
+ _inputSelectionMenuCopyAsMarkdownLabel =
508
+ [[NSString alloc] initWithUTF8String:newViewProps.selectionMenuConfig.copyAsMarkdownLabel.c_str()] ?: @"";
509
+ _inputSelectionMenuConfig = (ENRMInputSelectionMenuConfig){
510
+ .format = newViewProps.selectionMenuConfig.format,
511
+ .formatLabel = _inputSelectionMenuFormatLabel,
512
+ .copyAsMarkdown = newViewProps.selectionMenuConfig.copyAsMarkdown,
513
+ .copyAsMarkdownLabel = _inputSelectionMenuCopyAsMarkdownLabel,
514
+ };
515
+
516
+ _formatMenuBoldLabel = [[NSString alloc] initWithUTF8String:newViewProps.formatMenuConfig.boldLabel.c_str()] ?: @"";
517
+ _formatMenuItalicLabel =
518
+ [[NSString alloc] initWithUTF8String:newViewProps.formatMenuConfig.italicLabel.c_str()] ?: @"";
519
+ _formatMenuUnderlineLabel =
520
+ [[NSString alloc] initWithUTF8String:newViewProps.formatMenuConfig.underlineLabel.c_str()] ?: @"";
521
+ _formatMenuStrikethroughLabel =
522
+ [[NSString alloc] initWithUTF8String:newViewProps.formatMenuConfig.strikethroughLabel.c_str()] ?: @"";
523
+ _formatMenuSpoilerLabel =
524
+ [[NSString alloc] initWithUTF8String:newViewProps.formatMenuConfig.spoilerLabel.c_str()] ?: @"";
525
+ _formatMenuLinkLabel = [[NSString alloc] initWithUTF8String:newViewProps.formatMenuConfig.linkLabel.c_str()] ?: @"";
526
+ _formatMenuConfig = (ENRMFormatMenuConfig){
527
+ .bold = newViewProps.formatMenuConfig.bold,
528
+ .boldLabel = _formatMenuBoldLabel,
529
+ .italic = newViewProps.formatMenuConfig.italic,
530
+ .italicLabel = _formatMenuItalicLabel,
531
+ .underline = newViewProps.formatMenuConfig.underline,
532
+ .underlineLabel = _formatMenuUnderlineLabel,
533
+ .strikethrough = newViewProps.formatMenuConfig.strikethrough,
534
+ .strikethroughLabel = _formatMenuStrikethroughLabel,
535
+ .spoiler = newViewProps.formatMenuConfig.spoiler,
536
+ .spoilerLabel = _formatMenuSpoilerLabel,
537
+ .link = newViewProps.formatMenuConfig.link,
538
+ .linkLabel = _formatMenuLinkLabel,
539
+ };
540
+
466
541
  if (newViewProps.mentionIndicators != oldViewProps.mentionIndicators) {
467
542
  NSMutableArray<NSString *> *indicators = [NSMutableArray array];
468
543
  for (const auto &indicator : newViewProps.mentionIndicators) {
@@ -964,6 +1039,7 @@ using namespace facebook::react;
964
1039
  }
965
1040
 
966
1041
  [self applyFormatting];
1042
+ [self syncTypingAttributesWithPendingStyles];
967
1043
  [self emitFormattingChanged];
968
1044
  }
969
1045
 
@@ -1221,6 +1297,21 @@ using namespace facebook::react;
1221
1297
  return [ENRMMarkdownSerializer serializeAttributedString:selectedAttr ranges:clippedRanges];
1222
1298
  }
1223
1299
 
1300
+ - (void)copyToClipboard
1301
+ {
1302
+ NSString *plainText = ENRMGetPlainText(_textView);
1303
+ if (plainText.length == 0) {
1304
+ return;
1305
+ }
1306
+ NSString *markdown = [ENRMMarkdownSerializer serializePlainText:plainText ranges:[self allRangesIncludingTransient]];
1307
+ NSMutableDictionary *items = [NSMutableDictionary dictionary];
1308
+ items[kUTIPlainText] = plainText;
1309
+ if (markdown.length > 0) {
1310
+ items[kENRMMarkdownPasteboardType] = markdown;
1311
+ }
1312
+ copyItemsToPasteboard(items);
1313
+ }
1314
+
1224
1315
  - (void)requestMarkdown:(NSInteger)requestId
1225
1316
  {
1226
1317
  auto emitter = [self getEventEmitter];
@@ -1293,6 +1384,57 @@ using namespace facebook::react;
1293
1384
  return inRange;
1294
1385
  }
1295
1386
 
1387
+ - (void)syncTypingAttributesWithPendingStyles
1388
+ {
1389
+ UIFontDescriptorSymbolicTraits traits = 0;
1390
+ if ([_pendingStyles containsObject:@(ENRMInputStyleTypeStrong)]) {
1391
+ traits |= UIFontDescriptorTraitBold;
1392
+ }
1393
+ if ([_pendingStyles containsObject:@(ENRMInputStyleTypeEmphasis)]) {
1394
+ traits |= UIFontDescriptorTraitItalic;
1395
+ }
1396
+
1397
+ NSMutableDictionary *attrs = [_textView.typingAttributes mutableCopy];
1398
+ attrs[NSFontAttributeName] = [_formatterStyle fontForTraits:traits];
1399
+ _textView.typingAttributes = attrs;
1400
+ }
1401
+
1402
+ - (void)resetPendingStylesForSelectionChange
1403
+ {
1404
+ // Skip system-driven selection adjustments (e.g., predictive text) that fire
1405
+ // immediately after a text edit.
1406
+ static const CFTimeInterval kPostEditGracePeriod = 0.1;
1407
+ BOOL isPostEditAdjustment =
1408
+ (_lastTextChangeTime > 0 && (CACurrentMediaTime() - _lastTextChangeTime) < kPostEditGracePeriod);
1409
+ if (isPostEditAdjustment) {
1410
+ return;
1411
+ }
1412
+ [_pendingStyles removeAllObjects];
1413
+ [_pendingStyleRemovals removeAllObjects];
1414
+ [self rebuildPendingStylesFromContext];
1415
+ [self syncTypingAttributesWithPendingStyles];
1416
+ }
1417
+
1418
+ - (void)rebuildPendingStylesFromContext
1419
+ {
1420
+ NSUInteger cursor = _textView.selectedRange.location;
1421
+ if (_textView.selectedRange.length > 0 || cursor == 0) {
1422
+ return;
1423
+ }
1424
+
1425
+ static const ENRMInputStyleType inlineStyles[] = {
1426
+ ENRMInputStyleTypeStrong, ENRMInputStyleTypeEmphasis, ENRMInputStyleTypeUnderline,
1427
+ ENRMInputStyleTypeStrikethrough, ENRMInputStyleTypeSpoiler,
1428
+ };
1429
+
1430
+ for (NSUInteger i = 0; i < sizeof(inlineStyles) / sizeof(inlineStyles[0]); i++) {
1431
+ ENRMInputStyleType type = inlineStyles[i];
1432
+ if ([_formattingStore isStyleAdjacentBefore:type position:cursor]) {
1433
+ [_pendingStyles addObject:@(type)];
1434
+ }
1435
+ }
1436
+ }
1437
+
1296
1438
  #pragma mark - Event emitters
1297
1439
 
1298
1440
  - (void)emitFormattingChanged
@@ -1531,6 +1673,16 @@ using namespace facebook::react;
1531
1673
  return _contextMenuItemIcons ?: @[];
1532
1674
  }
1533
1675
 
1676
+ - (ENRMInputSelectionMenuConfig)inputSelectionMenuConfig
1677
+ {
1678
+ return _inputSelectionMenuConfig;
1679
+ }
1680
+
1681
+ - (ENRMFormatMenuConfig)formatMenuConfig
1682
+ {
1683
+ return _formatMenuConfig;
1684
+ }
1685
+
1534
1686
  - (void)emitContextMenuItemPress:(NSString *)itemText
1535
1687
  {
1536
1688
  auto eventEmitter = [self getEventEmitter];
@@ -1674,10 +1826,27 @@ using namespace facebook::react;
1674
1826
  if (insertedLength > 0) {
1675
1827
  NSRange insertedRange = NSMakeRange(editLocation, insertedLength);
1676
1828
 
1677
- for (NSNumber *styleNum in _pendingStyles) {
1678
- ENRMFormattingRange *newRange = [ENRMFormattingRange rangeWithType:(ENRMInputStyleType)styleNum.integerValue
1679
- range:insertedRange];
1680
- [_formattingStore addRange:newRange];
1829
+ // Skip applying pending styles when the insertion is only line breaks —
1830
+ // a phantom range over a bare newline corrupts isStyleActive() at the boundary.
1831
+ NSString *plainText = ENRMGetPlainText(_textView);
1832
+ NSUInteger insertedEnd = NSMaxRange(insertedRange);
1833
+ BOOL insertedHasGlyphContent = NO;
1834
+ if (insertedEnd <= plainText.length) {
1835
+ NSCharacterSet *newlines = [NSCharacterSet newlineCharacterSet];
1836
+ for (NSUInteger i = insertedRange.location; i < insertedEnd; i++) {
1837
+ if (![newlines characterIsMember:[plainText characterAtIndex:i]]) {
1838
+ insertedHasGlyphContent = YES;
1839
+ break;
1840
+ }
1841
+ }
1842
+ }
1843
+
1844
+ if (insertedHasGlyphContent) {
1845
+ for (NSNumber *styleNum in _pendingStyles) {
1846
+ ENRMFormattingRange *newRange = [ENRMFormattingRange rangeWithType:(ENRMInputStyleType)styleNum.integerValue
1847
+ range:insertedRange];
1848
+ [_formattingStore addRange:newRange];
1849
+ }
1681
1850
  }
1682
1851
 
1683
1852
  // adjustForEditAtLocation may have expanded an existing range to cover
@@ -1781,6 +1950,7 @@ using namespace facebook::react;
1781
1950
  }
1782
1951
  [self handleTextChanged];
1783
1952
  _isTextChanging = NO;
1953
+ _lastTextChangeTime = CACurrentMediaTime();
1784
1954
  _lastSelectedRange = textView.selectedRange;
1785
1955
  }
1786
1956
 
@@ -1798,6 +1968,15 @@ using namespace facebook::react;
1798
1968
  - (void)textViewDidChangeSelection:(UITextView *)textView
1799
1969
  {
1800
1970
  NSRange newSelection = textView.selectedRange;
1971
+ // Links (e.g. mentions) are atomic: snap a partial selection to the whole link, and a caret inside
1972
+ // a link to its end. Returning hands the adjusted selection to the recursive change (no double emit).
1973
+ if (!_isApplyingFormatting && !_isTextChanging && !ENRMHasMarkedText(_textView)) {
1974
+ NSRange adjusted = [_formattingStore selectionAdjustedForAtomicLinks:newSelection];
1975
+ if (!NSEqualRanges(adjusted, newSelection)) {
1976
+ _textView.selectedRange = adjusted;
1977
+ return;
1978
+ }
1979
+ }
1801
1980
  NSRange previousSelection = _lastSelectedRange;
1802
1981
  _lastSelectedRange = newSelection;
1803
1982
 
@@ -1805,6 +1984,10 @@ using namespace facebook::react;
1805
1984
  return;
1806
1985
  }
1807
1986
 
1987
+ if (ENRMHasMarkedText(_textView)) {
1988
+ return;
1989
+ }
1990
+
1808
1991
  #if ENRICHED_MARKDOWN_MATH
1809
1992
  if (_suppressMathChipClearOnce) {
1810
1993
  // Caret update was triggered by handleTapForMathChip — keep that chip
@@ -1820,8 +2003,7 @@ using namespace facebook::react;
1820
2003
  newSelection.location != previousSelection.location || newSelection.length != previousSelection.length;
1821
2004
 
1822
2005
  if (selectionMoved) {
1823
- [_pendingStyles removeAllObjects];
1824
- [_pendingStyleRemovals removeAllObjects];
2006
+ [self resetPendingStylesForSelectionChange];
1825
2007
  }
1826
2008
 
1827
2009
  [self manageSelectionBasedChanges];
@@ -1928,19 +2110,38 @@ using namespace facebook::react;
1928
2110
  }
1929
2111
  [self handleTextChanged];
1930
2112
  _isTextChanging = NO;
2113
+ _lastTextChangeTime = CACurrentMediaTime();
1931
2114
  _lastSelectedRange = _textView.selectedRange;
1932
2115
  }
1933
2116
 
1934
2117
  - (void)textInputDidChangeSelection
1935
2118
  {
1936
- _lastSelectedRange = _textView.selectedRange;
2119
+ NSRange newSelection = _textView.selectedRange;
2120
+ // Atomic link snapping — same logic as textViewDidChangeSelection: (iOS).
2121
+ if (!_isApplyingFormatting && !_isTextChanging && !ENRMHasMarkedText(_textView)) {
2122
+ NSRange adjusted = [_formattingStore selectionAdjustedForAtomicLinks:newSelection];
2123
+ if (!NSEqualRanges(adjusted, newSelection)) {
2124
+ _textView.selectedRange = adjusted;
2125
+ return;
2126
+ }
2127
+ }
2128
+ NSRange previousSelection = _lastSelectedRange;
2129
+ _lastSelectedRange = newSelection;
1937
2130
 
1938
2131
  if (_isApplyingFormatting || _isTextChanging) {
1939
2132
  return;
1940
2133
  }
1941
2134
 
1942
- [_pendingStyles removeAllObjects];
1943
- [_pendingStyleRemovals removeAllObjects];
2135
+ if (ENRMHasMarkedText(_textView)) {
2136
+ return;
2137
+ }
2138
+
2139
+ BOOL selectionMoved =
2140
+ newSelection.location != previousSelection.location || newSelection.length != previousSelection.length;
2141
+
2142
+ if (selectionMoved) {
2143
+ [self resetPendingStylesForSelectionChange];
2144
+ }
1944
2145
 
1945
2146
  [self emitOnChangeSelection];
1946
2147
  [self updateActiveMention];
@@ -22,8 +22,9 @@
22
22
  formattingRange:(ENRMFormattingRange *)formattingRange
23
23
  style:(ENRMInputFormatterStyle *)style
24
24
  {
25
- RCTUIColor *color = style.boldColor ?: style.baseTextColor;
26
- [storage addAttribute:NSForegroundColorAttributeName value:color range:range];
25
+ if (style.boldColor) {
26
+ [storage addAttribute:NSForegroundColorAttributeName value:style.boldColor range:range];
27
+ }
27
28
  }
28
29
 
29
30
  @end
@@ -22,8 +22,9 @@
22
22
  formattingRange:(ENRMFormattingRange *)formattingRange
23
23
  style:(ENRMInputFormatterStyle *)style
24
24
  {
25
- RCTUIColor *color = style.italicColor ?: style.baseTextColor;
26
- [storage addAttribute:NSForegroundColorAttributeName value:color range:range];
25
+ if (style.italicColor) {
26
+ [storage addAttribute:NSForegroundColorAttributeName value:style.italicColor range:range];
27
+ }
27
28
  }
28
29
 
29
30
  @end
@@ -3,6 +3,7 @@
3
3
  #import "LastElementUtils.h"
4
4
  #import "MarkdownASTNode.h"
5
5
  #import "NodeRenderer.h"
6
+ #import "ParagraphStyleUtils.h"
6
7
  #import "RenderContext.h"
7
8
  #import "RendererFactory.h"
8
9
  #import "StyleConfig.h"
@@ -58,7 +59,32 @@
58
59
  return output;
59
60
  }
60
61
 
61
- /// Removes trailing margin spacing while preserving code block padding
62
+ /**
63
+ * Trims trailing newlines after the last rendered block, with special handling for code blocks.
64
+ *
65
+ * For non-code-block tail elements: deletes all trailing newlines after the last non-newline
66
+ * character and zeroes the last element's paragraph spacing so it doesn't render a margin past
67
+ * the content.
68
+ *
69
+ * For code blocks (`isLastBlockACodeBlock` == YES): keeps the bottom padding spacer in place
70
+ * AND preserves a single trailing newline immediately after it (typically the
71
+ * codeBlockMarginBottom spacer that CodeBlockRenderer already appended; if none exists, one is
72
+ * synthesized here). That extra newline matters because of an iOS layout quirk:
73
+ *
74
+ * - iOS treats the trailing `\n` of the storage as a paragraph terminator and reports the
75
+ * line fragment for the paragraph after it via `extraLineFragmentRect`.
76
+ * - `boundingRectForGlyphRange:` excludes that extra line, and the graphics-context clip
77
+ * passed to `drawBackgroundForGlyphRange:` is tiled to the laid-out content area (rounded
78
+ * up to 128pt boundaries) which also excludes it.
79
+ * - If the bottom padding spacer were the trailing `\n`, its 16/19pt line fragment would
80
+ * fall into that excluded zone — the background rect would have to be manually extended
81
+ * past the clip and the bottom rounded corner would get sliced off at the tile boundary
82
+ * (the original visual bug in #354).
83
+ *
84
+ * By keeping a 1pt-tall tail newline beyond the bottom padding spacer, iOS lays the spacer
85
+ * out as a normal interior line fragment included in usedRect/boundingRect and inside the
86
+ * drawing tiles, so the corner renders intact and no rect extension is needed.
87
+ */
62
88
  - (void)removeTrailingSpacing:(NSMutableAttributedString *)output
63
89
  {
64
90
  if (output.length == 0)
@@ -83,17 +109,36 @@
83
109
 
84
110
  // 2. Trim trailing characters
85
111
  NSUInteger logicalEnd = NSMaxRange(lastContent);
86
- BOOL isCodeBlock = isLastElementCodeBlock(output);
112
+ BOOL isCodeBlock = isLastBlockACodeBlock(output);
113
+ NSRange codeRange = NSMakeRange(0, 0);
87
114
  if (isCodeBlock) {
88
- NSRange codeRange;
89
- [output attribute:CodeBlockAttributeName atIndex:lastContent.location effectiveRange:&codeRange];
90
- logicalEnd = NSMaxRange(codeRange);
115
+ [output attribute:CodeBlockAttributeName
116
+ atIndex:lastContent.location
117
+ longestEffectiveRange:&codeRange
118
+ inRange:NSMakeRange(0, output.length)];
119
+ NSUInteger codeEnd = NSMaxRange(codeRange);
120
+ if (codeEnd >= output.length) {
121
+ [output appendAttributedString:kNewlineAttributedString];
122
+ }
123
+ logicalEnd = codeEnd + 1;
91
124
  }
92
125
 
93
126
  if (logicalEnd < output.length) {
94
127
  [output deleteCharactersInRange:NSMakeRange(logicalEnd, output.length - logicalEnd)];
95
128
  }
96
129
 
130
+ if (isCodeBlock && NSMaxRange(codeRange) < output.length) {
131
+ NSUInteger tailIdx = NSMaxRange(codeRange);
132
+ [output removeAttribute:CodeBlockAttributeName range:NSMakeRange(tailIdx, 1)];
133
+ NSParagraphStyle *style = [output attribute:NSParagraphStyleAttributeName atIndex:tailIdx effectiveRange:NULL];
134
+ NSMutableParagraphStyle *mutableStyle = style ? [style mutableCopy] : [[NSMutableParagraphStyle alloc] init];
135
+ mutableStyle.paragraphSpacing = 0;
136
+ mutableStyle.paragraphSpacingBefore = 0;
137
+ mutableStyle.minimumLineHeight = 1;
138
+ mutableStyle.maximumLineHeight = 1;
139
+ [output addAttribute:NSParagraphStyleAttributeName value:mutableStyle range:NSMakeRange(tailIdx, 1)];
140
+ }
141
+
97
142
  // 3. Zero out internal spacing for the last element (if not a code block)
98
143
  if (!isCodeBlock) {
99
144
  NSRange styleRange;
@@ -150,4 +195,4 @@
150
195
  }
151
196
  }
152
197
 
153
- @end
198
+ @end
@@ -32,7 +32,11 @@
32
32
  [attachment renderForMacOS];
33
33
  #endif
34
34
 
35
- NSAttributedString *attachmentString = [NSAttributedString attributedStringWithAttachment:attachment];
35
+ NSMutableAttributedString *attachmentString =
36
+ [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy];
37
+ if (currentFont) {
38
+ [attachmentString addAttribute:NSFontAttributeName value:currentFont range:NSMakeRange(0, attachmentString.length)];
39
+ }
36
40
  [output appendAttributedString:attachmentString];
37
41
  }
38
42
 
@@ -1,5 +1,5 @@
1
1
  #pragma once
2
- #import <UIKit/UIKit.h>
2
+ #import "ENRMUIKit.h"
3
3
 
4
4
  NS_ASSUME_NONNULL_BEGIN
5
5
 
@@ -0,0 +1,36 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ @interface ENRMAccessibilityLabels : NSObject
6
+
7
+ @property (nonatomic, copy) NSString *bulletPoint;
8
+ @property (nonatomic, copy) NSString *nestedBulletPoint;
9
+ @property (nonatomic, copy) NSString *orderedItem;
10
+ @property (nonatomic, copy) NSString *nestedOrderedItem;
11
+ @property (nonatomic, copy) NSString *blockquote;
12
+ @property (nonatomic, copy) NSString *nestedBlockquote;
13
+ @property (nonatomic, copy) NSString *tableRow;
14
+ @property (nonatomic, copy) NSString *mathEquation;
15
+ @property (nonatomic, copy) NSString *rotorHeadings;
16
+ @property (nonatomic, copy) NSString *rotorLinks;
17
+ @property (nonatomic, copy) NSString *rotorImages;
18
+
19
+ @end
20
+
21
+ NS_ASSUME_NONNULL_END
22
+
23
+ #ifdef __cplusplus
24
+ template <typename T> static bool ENRMAccessibilityLabelsChanged(const T &oldLabels, const T &newLabels)
25
+ {
26
+ return oldLabels.list.bulletPoint != newLabels.list.bulletPoint ||
27
+ oldLabels.list.nestedBulletPoint != newLabels.list.nestedBulletPoint ||
28
+ oldLabels.list.orderedItem != newLabels.list.orderedItem ||
29
+ oldLabels.list.nestedOrderedItem != newLabels.list.nestedOrderedItem ||
30
+ oldLabels.blockquote.quote != newLabels.blockquote.quote ||
31
+ oldLabels.blockquote.nestedQuote != newLabels.blockquote.nestedQuote ||
32
+ oldLabels.table.row != newLabels.table.row || oldLabels.math.equation != newLabels.math.equation ||
33
+ oldLabels.rotor.headings != newLabels.rotor.headings || oldLabels.rotor.links != newLabels.rotor.links ||
34
+ oldLabels.rotor.images != newLabels.rotor.images;
35
+ }
36
+ #endif
@@ -0,0 +1,5 @@
1
+ #import "ENRMAccessibilityLabels.h"
2
+
3
+ @implementation ENRMAccessibilityLabels
4
+
5
+ @end
@@ -25,8 +25,9 @@ NSMenu *_Nullable buildEditMenuForSelection(NSAttributedString *attributedText,
25
25
 
26
26
  // Replace the system Copy item with our enhanced version (copies RTF/HTML/Markdown).
27
27
  // This mirrors the iOS behaviour where we replace the standard-edit Copy action.
28
+ NSString *copyTitle = selectionMenuConfig.copyLabel;
28
29
  NSMenuItem *enhancedCopy =
29
- ENRMCreateMenuItem(@"Copy", ^{ copyAttributedStringToPasteboard(selectedText, markdown, styleConfig); });
30
+ ENRMCreateMenuItem(copyTitle, ^{ copyAttributedStringToPasteboard(selectedText, markdown, styleConfig); });
30
31
  NSInteger systemCopyIndex = [menu indexOfItemWithTarget:nil andAction:@selector(copy:)];
31
32
  if (systemCopyIndex != NSNotFound) {
32
33
  [menu removeItemAtIndex:systemCopyIndex];
@@ -39,13 +40,11 @@ NSMenu *_Nullable buildEditMenuForSelection(NSAttributedString *attributedText,
39
40
  }
40
41
 
41
42
  if (selectionMenuConfig.copyAsMarkdown && markdown.length > 0) {
42
- [menu addItem:ENRMCreateMenuItem(@"Copy as Markdown", ^{ copyStringToPasteboard(markdown); })];
43
+ [menu addItem:ENRMCreateMenuItem(selectionMenuConfig.copyAsMarkdownLabel, ^{ copyStringToPasteboard(markdown); })];
43
44
  }
44
45
 
45
46
  if (selectionMenuConfig.copyImageURL && imageURLs.count > 0) {
46
- NSString *title = (imageURLs.count == 1)
47
- ? @"Copy Image URL"
48
- : [NSString stringWithFormat:@"Copy %lu Image URLs", (unsigned long)imageURLs.count];
47
+ NSString *title = ENRMResolveImageURLsTitle(selectionMenuConfig, imageURLs.count);
49
48
  [menu addItem:ENRMCreateMenuItem(title, ^{
50
49
  NSString *urlsToCopy = [imageURLs componentsJoinedByString:@"\n"];
51
50
  copyStringToPasteboard(urlsToCopy);