react-native-jet-markdown 0.2.0-beta.7

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 (157) hide show
  1. package/JetMarkdown.podspec +42 -0
  2. package/LICENSE +20 -0
  3. package/README.md +247 -0
  4. package/android/CMakeLists.txt +52 -0
  5. package/android/build.gradle +55 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/JniBindings.cpp +243 -0
  8. package/android/src/main/java/com/jetmarkdown/JetMarkdownNative.kt +114 -0
  9. package/android/src/main/java/com/jetmarkdown/JetMarkdownPackage.kt +22 -0
  10. package/android/src/main/java/com/jetmarkdown/JetMarkdownView.kt +235 -0
  11. package/android/src/main/java/com/jetmarkdown/JetMarkdownViewManager.kt +75 -0
  12. package/android/src/main/java/com/jetmarkdown/editor/EditorSpans.kt +217 -0
  13. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorManager.kt +224 -0
  14. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorView.kt +1736 -0
  15. package/android/src/main/java/com/jetmarkdown/measure/MarkdownMeasurer.kt +54 -0
  16. package/android/src/main/java/com/jetmarkdown/parser/AstDecoder.kt +59 -0
  17. package/android/src/main/java/com/jetmarkdown/parser/MdNode.kt +43 -0
  18. package/android/src/main/java/com/jetmarkdown/render/ContentCache.kt +53 -0
  19. package/android/src/main/java/com/jetmarkdown/render/RenderedContent.kt +318 -0
  20. package/android/src/main/java/com/jetmarkdown/render/SpannableRenderer.kt +658 -0
  21. package/android/src/main/java/com/jetmarkdown/render/spans/ChipSpan.kt +17 -0
  22. package/android/src/main/java/com/jetmarkdown/render/spans/InteractiveSpans.kt +11 -0
  23. package/android/src/main/java/com/jetmarkdown/render/spans/MarkdownLineHeightSpan.kt +59 -0
  24. package/android/src/main/java/com/jetmarkdown/render/spans/RunSpan.kt +40 -0
  25. package/android/src/main/java/com/jetmarkdown/style/Fonts.kt +34 -0
  26. package/android/src/main/java/com/jetmarkdown/style/LayoutStyleSpec.kt +71 -0
  27. package/android/src/main/java/com/jetmarkdown/style/PlatformColorResolver.kt +77 -0
  28. package/android/src/main/java/com/jetmarkdown/style/StyleConfig.kt +80 -0
  29. package/android/src/main/java/com/jetmarkdown/style/TextStyleSpec.kt +61 -0
  30. package/android/src/main/java/com/jetmarkdown/views/BlockStackView.kt +292 -0
  31. package/android/src/main/java/com/jetmarkdown/views/BlockTextView.kt +378 -0
  32. package/android/src/main/java/com/jetmarkdown/views/BoxDrawing.kt +50 -0
  33. package/android/src/main/java/com/jetmarkdown/views/MarkdownHost.kt +12 -0
  34. package/android/src/main/java/com/jetmarkdown/views/MarkdownImageView.kt +113 -0
  35. package/android/src/main/java/com/jetmarkdown/views/NestedHorizontalScrollView.kt +87 -0
  36. package/android/src/main/java/com/jetmarkdown/views/TableBlockView.kt +139 -0
  37. package/cpp/core/Ast.h +85 -0
  38. package/cpp/core/AstJson.cpp +129 -0
  39. package/cpp/core/AstJson.h +12 -0
  40. package/cpp/core/AstSerializer.cpp +40 -0
  41. package/cpp/core/AstSerializer.h +21 -0
  42. package/cpp/core/AstToMarkdown.cpp +549 -0
  43. package/cpp/core/AstToMarkdown.h +16 -0
  44. package/cpp/core/EditorRuns.cpp +640 -0
  45. package/cpp/core/EditorRuns.h +94 -0
  46. package/cpp/core/EditorText.cpp +15 -0
  47. package/cpp/core/EditorText.h +18 -0
  48. package/cpp/core/InlineExtensions.cpp +305 -0
  49. package/cpp/core/InlineExtensions.h +17 -0
  50. package/cpp/core/Parser.cpp +470 -0
  51. package/cpp/core/Parser.h +16 -0
  52. package/cpp/core/Preprocess.cpp +181 -0
  53. package/cpp/core/Preprocess.h +14 -0
  54. package/cpp/md4c/LICENSE +22 -0
  55. package/cpp/md4c/VERSION +1 -0
  56. package/cpp/md4c/md4c.c +6462 -0
  57. package/cpp/md4c/md4c.h +407 -0
  58. package/cpp/react/JetMarkdownEditorShadowNode.cpp +54 -0
  59. package/cpp/react/JetMarkdownEditorShadowNode.h +55 -0
  60. package/cpp/react/JetMarkdownEditorState.h +35 -0
  61. package/cpp/react/JetMarkdownMeasurer.cpp +32 -0
  62. package/cpp/react/JetMarkdownMeasurer.h +41 -0
  63. package/cpp/react/JetMarkdownShadowNode.cpp +100 -0
  64. package/cpp/react/JetMarkdownShadowNode.h +53 -0
  65. package/cpp/react/JetMarkdownState.h +52 -0
  66. package/cpp/react/override/react/renderer/components/JetMarkdownViewSpec/ComponentDescriptors.h +21 -0
  67. package/cpp/tests/parser_tests.cpp +662 -0
  68. package/cpp/tests/run_tests.sh +25 -0
  69. package/ios/JetMarkdownView.h +14 -0
  70. package/ios/JetMarkdownView.mm +468 -0
  71. package/ios/editor/JetMarkdownEditor.h +12 -0
  72. package/ios/editor/JetMarkdownEditor.mm +2002 -0
  73. package/ios/measure/JMDMarkdownMeasurer.h +22 -0
  74. package/ios/measure/JMDMarkdownMeasurer.mm +38 -0
  75. package/ios/render/JMDBlock.h +97 -0
  76. package/ios/render/JMDBlock.m +28 -0
  77. package/ios/render/JMDBlockRenderer.h +17 -0
  78. package/ios/render/JMDBlockRenderer.mm +852 -0
  79. package/ios/render/JMDContentCache.h +17 -0
  80. package/ios/render/JMDContentCache.mm +42 -0
  81. package/ios/render/JMDRenderedContent.h +35 -0
  82. package/ios/render/JMDRenderedContent.mm +274 -0
  83. package/ios/style/JMDFontScale.h +33 -0
  84. package/ios/style/JMDLayoutStyle.h +37 -0
  85. package/ios/style/JMDLayoutStyle.mm +68 -0
  86. package/ios/style/JMDStyleConfig.h +39 -0
  87. package/ios/style/JMDStyleConfig.mm +111 -0
  88. package/ios/style/JMDTextStyle.h +25 -0
  89. package/ios/style/JMDTextStyle.mm +68 -0
  90. package/ios/views/JMDBlockStackView.h +18 -0
  91. package/ios/views/JMDBlockStackView.m +97 -0
  92. package/ios/views/JMDBlockTextView.h +24 -0
  93. package/ios/views/JMDBlockTextView.m +340 -0
  94. package/ios/views/JMDBoxViews.h +33 -0
  95. package/ios/views/JMDBoxViews.m +292 -0
  96. package/ios/views/JMDImageView.h +19 -0
  97. package/ios/views/JMDImageView.m +68 -0
  98. package/ios/views/JMDMarkdownHost.h +16 -0
  99. package/ios/views/JMDTableView.h +16 -0
  100. package/ios/views/JMDTableView.m +172 -0
  101. package/lib/module/JetMarkdownEditor.js +85 -0
  102. package/lib/module/JetMarkdownEditor.js.map +1 -0
  103. package/lib/module/JetMarkdownEditor.native.js +244 -0
  104. package/lib/module/JetMarkdownEditor.native.js.map +1 -0
  105. package/lib/module/JetMarkdownEditorNativeComponent.ts +173 -0
  106. package/lib/module/JetMarkdownView.js +19 -0
  107. package/lib/module/JetMarkdownView.js.map +1 -0
  108. package/lib/module/JetMarkdownView.native.js +44 -0
  109. package/lib/module/JetMarkdownView.native.js.map +1 -0
  110. package/lib/module/JetMarkdownViewNativeComponent.ts +35 -0
  111. package/lib/module/defaultStyles.js +122 -0
  112. package/lib/module/defaultStyles.js.map +1 -0
  113. package/lib/module/index.js +9 -0
  114. package/lib/module/index.js.map +1 -0
  115. package/lib/module/package.json +1 -0
  116. package/lib/module/serializeStyles.js +259 -0
  117. package/lib/module/serializeStyles.js.map +1 -0
  118. package/lib/module/types.js +4 -0
  119. package/lib/module/types.js.map +1 -0
  120. package/lib/module/useJetMarkdownEditor.js +41 -0
  121. package/lib/module/useJetMarkdownEditor.js.map +1 -0
  122. package/lib/typescript/package.json +1 -0
  123. package/lib/typescript/src/JetMarkdownEditor.d.ts +6 -0
  124. package/lib/typescript/src/JetMarkdownEditor.d.ts.map +1 -0
  125. package/lib/typescript/src/JetMarkdownEditor.native.d.ts +6 -0
  126. package/lib/typescript/src/JetMarkdownEditor.native.d.ts.map +1 -0
  127. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts +105 -0
  128. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts.map +1 -0
  129. package/lib/typescript/src/JetMarkdownView.d.ts +3 -0
  130. package/lib/typescript/src/JetMarkdownView.d.ts.map +1 -0
  131. package/lib/typescript/src/JetMarkdownView.native.d.ts +3 -0
  132. package/lib/typescript/src/JetMarkdownView.native.d.ts.map +1 -0
  133. package/lib/typescript/src/JetMarkdownViewNativeComponent.d.ts +28 -0
  134. package/lib/typescript/src/JetMarkdownViewNativeComponent.d.ts.map +1 -0
  135. package/lib/typescript/src/defaultStyles.d.ts +20 -0
  136. package/lib/typescript/src/defaultStyles.d.ts.map +1 -0
  137. package/lib/typescript/src/index.d.ts +7 -0
  138. package/lib/typescript/src/index.d.ts.map +1 -0
  139. package/lib/typescript/src/serializeStyles.d.ts +34 -0
  140. package/lib/typescript/src/serializeStyles.d.ts.map +1 -0
  141. package/lib/typescript/src/types.d.ts +352 -0
  142. package/lib/typescript/src/types.d.ts.map +1 -0
  143. package/lib/typescript/src/useJetMarkdownEditor.d.ts +36 -0
  144. package/lib/typescript/src/useJetMarkdownEditor.d.ts.map +1 -0
  145. package/package.json +150 -0
  146. package/react-native.config.js +11 -0
  147. package/src/JetMarkdownEditor.native.tsx +301 -0
  148. package/src/JetMarkdownEditor.tsx +90 -0
  149. package/src/JetMarkdownEditorNativeComponent.ts +173 -0
  150. package/src/JetMarkdownView.native.tsx +67 -0
  151. package/src/JetMarkdownView.tsx +16 -0
  152. package/src/JetMarkdownViewNativeComponent.ts +35 -0
  153. package/src/defaultStyles.ts +102 -0
  154. package/src/index.tsx +34 -0
  155. package/src/serializeStyles.ts +369 -0
  156. package/src/types.ts +438 -0
  157. package/src/useJetMarkdownEditor.ts +50 -0
@@ -0,0 +1,2002 @@
1
+ #import "JetMarkdownEditor.h"
2
+
3
+ #import <React/RCTConversions.h>
4
+ #import <react/renderer/components/JetMarkdownViewSpec/EventEmitters.h>
5
+ #import <react/renderer/components/JetMarkdownViewSpec/Props.h>
6
+ #import <react/renderer/components/JetMarkdownViewSpec/RCTComponentViewHelpers.h>
7
+ #import <react/renderer/core/ConcreteComponentDescriptor.h>
8
+
9
+ #import <vector>
10
+
11
+ #import "../../cpp/core/EditorRuns.h"
12
+ #import "../../cpp/react/JetMarkdownEditorShadowNode.h"
13
+ #import "../style/JMDFontScale.h"
14
+ #import "../style/JMDStyleConfig.h"
15
+ #import "../style/JMDTextStyle.h"
16
+
17
+ using namespace facebook::react;
18
+
19
+ // Imported directly (like the viewer) so the descriptor binds to the custom
20
+ // measurable shadow node regardless of include order.
21
+ using JMDEditorComponentDescriptor =
22
+ ConcreteComponentDescriptor<JetMarkdownEditorShadowNode>;
23
+
24
+ // Source of truth for inline marks: a bitmask of jetmarkdown::EditorMark
25
+ // stored as a custom attribute. Display attributes (fonts, strikethrough,
26
+ // backgrounds) are always derived from it.
27
+ static NSAttributedStringKey const JMDEditorMarksAttribute = @"JMDEditorMarks";
28
+
29
+ // Source of truth for the line's block type: (EditorBlockType << 8) | level,
30
+ // stored on every character of the line (and carried by the newline).
31
+ static NSAttributedStringKey const JMDEditorBlockAttribute = @"JMDEditorBlock";
32
+
33
+ // Linked ranges: the URL string. Mentions are links with app-scheme URLs
34
+ // plus the atomic flag (the token edits as one unit).
35
+ static NSAttributedStringKey const JMDEditorLinkAttribute = @"JMDEditorLink";
36
+ static NSAttributedStringKey const JMDEditorAtomicAttribute = @"JMDEditorAtomic";
37
+
38
+ static NSString *JMDStringFromCpp(const std::string &value) {
39
+ return [[NSString alloc] initWithBytes:value.data()
40
+ length:value.size()
41
+ encoding:NSUTF8StringEncoding]
42
+ ?: @"";
43
+ }
44
+
45
+ static uint32_t JMDFlagsFromValue(id value) {
46
+ return value == nil ? 0 : [(NSNumber *)value unsignedIntValue];
47
+ }
48
+
49
+ static uint32_t JMDPackBlock(jetmarkdown::EditorBlockType type, uint8_t level) {
50
+ return (static_cast<uint32_t>(type) << 8) | level;
51
+ }
52
+
53
+ static jetmarkdown::EditorBlockType JMDBlockType(uint32_t packed) {
54
+ return static_cast<jetmarkdown::EditorBlockType>(packed >> 8);
55
+ }
56
+
57
+ static BOOL JMDBlockIsList(uint32_t packed) {
58
+ const auto type = JMDBlockType(packed);
59
+ return type == jetmarkdown::EditorBlockType::Bullet ||
60
+ type == jetmarkdown::EditorBlockType::Ordered;
61
+ }
62
+
63
+ @class JetMarkdownEditor;
64
+
65
+ // Draws list markers and quote bars in the gutter created by the line
66
+ // blocks' paragraph indents. Sits above the text view; never interactive.
67
+ @interface JMDEditorMarkerView : UIView
68
+ @property (nonatomic, weak) JetMarkdownEditor *editor;
69
+ @end
70
+
71
+ // Draws the full-width background stripe behind code-block lines. Sits
72
+ // below the text view so glyphs stay crisp.
73
+ @interface JMDEditorCodeBackgroundView : UIView
74
+ @property (nonatomic, weak) JetMarkdownEditor *editor;
75
+ @end
76
+
77
+ @protocol JMDEditorTextViewActions <NSObject>
78
+ - (void)editorTextViewDidPaste;
79
+ - (void)editorTextViewShortcut:(uint32_t)mark;
80
+ // Backspace with the caret at the very start of the document: UIKit has
81
+ // nothing to delete, but a formatted first line should shed its block.
82
+ - (BOOL)editorTextViewHandleDeleteAtDocumentStart;
83
+ @end
84
+
85
+ // Intercepts paste (the clipboard is reported to JS, which owns the
86
+ // default insertion) and adds hardware keyboard formatting shortcuts.
87
+ @interface JMDEditorTextView : UITextView
88
+ @property (nonatomic, weak) id<JMDEditorTextViewActions> actionDelegate;
89
+ @end
90
+
91
+ @implementation JMDEditorTextView
92
+
93
+ - (void)paste:(id)sender {
94
+ [self.actionDelegate editorTextViewDidPaste];
95
+ }
96
+
97
+ - (void)deleteBackward {
98
+ if (self.selectedRange.location == 0 && self.selectedRange.length == 0 &&
99
+ [self.actionDelegate editorTextViewHandleDeleteAtDocumentStart]) {
100
+ return;
101
+ }
102
+ [super deleteBackward];
103
+ }
104
+
105
+ - (NSArray<UIKeyCommand *> *)keyCommands {
106
+ UIKeyCommand *bold = [UIKeyCommand keyCommandWithInput:@"b"
107
+ modifierFlags:UIKeyModifierCommand
108
+ action:@selector(jmdToggleBold:)];
109
+ UIKeyCommand *italic = [UIKeyCommand keyCommandWithInput:@"i"
110
+ modifierFlags:UIKeyModifierCommand
111
+ action:@selector(jmdToggleItalic:)];
112
+ UIKeyCommand *strike = [UIKeyCommand
113
+ keyCommandWithInput:@"x"
114
+ modifierFlags:UIKeyModifierCommand | UIKeyModifierShift
115
+ action:@selector(jmdToggleStrikethrough:)];
116
+ if (@available(iOS 15.0, *)) {
117
+ bold.wantsPriorityOverSystemBehavior = YES;
118
+ italic.wantsPriorityOverSystemBehavior = YES;
119
+ strike.wantsPriorityOverSystemBehavior = YES;
120
+ }
121
+ return @[ bold, italic, strike ];
122
+ }
123
+
124
+ - (void)jmdToggleBold:(UIKeyCommand *)command {
125
+ [self.actionDelegate editorTextViewShortcut:jetmarkdown::MarkBold];
126
+ }
127
+
128
+ - (void)jmdToggleItalic:(UIKeyCommand *)command {
129
+ [self.actionDelegate editorTextViewShortcut:jetmarkdown::MarkItalic];
130
+ }
131
+
132
+ - (void)jmdToggleStrikethrough:(UIKeyCommand *)command {
133
+ [self.actionDelegate editorTextViewShortcut:jetmarkdown::MarkStrikethrough];
134
+ }
135
+
136
+ @end
137
+
138
+ @interface JetMarkdownEditor () <UITextViewDelegate,
139
+ JMDEditorTextViewActions,
140
+ RCTJetMarkdownEditorViewProtocol>
141
+ - (void)drawMarkersInContext:(CGContextRef)context view:(JMDEditorMarkerView *)view;
142
+ - (void)drawCodeBackgroundsInView:(JMDEditorCodeBackgroundView *)view;
143
+ @end
144
+
145
+ @implementation JMDEditorMarkerView
146
+
147
+ - (instancetype)initWithFrame:(CGRect)frame {
148
+ if (self = [super initWithFrame:frame]) {
149
+ self.userInteractionEnabled = NO;
150
+ self.backgroundColor = UIColor.clearColor;
151
+ self.contentMode = UIViewContentModeRedraw;
152
+ }
153
+ return self;
154
+ }
155
+
156
+ - (void)drawRect:(CGRect)rect {
157
+ [self.editor drawMarkersInContext:UIGraphicsGetCurrentContext() view:self];
158
+ }
159
+
160
+ @end
161
+
162
+ @implementation JMDEditorCodeBackgroundView
163
+
164
+ - (instancetype)initWithFrame:(CGRect)frame {
165
+ if (self = [super initWithFrame:frame]) {
166
+ self.userInteractionEnabled = NO;
167
+ self.backgroundColor = UIColor.clearColor;
168
+ self.contentMode = UIViewContentModeRedraw;
169
+ }
170
+ return self;
171
+ }
172
+
173
+ - (void)drawRect:(CGRect)rect {
174
+ [self.editor drawCodeBackgroundsInView:self];
175
+ }
176
+
177
+ @end
178
+
179
+ @implementation JetMarkdownEditor {
180
+ JMDEditorTextView *_textView;
181
+ UILabel *_placeholderLabel;
182
+ JMDEditorMarkerView *_markerView;
183
+ JMDEditorCodeBackgroundView *_codeBackgroundView;
184
+ NSString *_stylesJson;
185
+ BOOL _defaultValueApplied;
186
+ BOOL _autoFocusHandled;
187
+ BOOL _multiline;
188
+ BOOL _propScrollEnabled;
189
+ // Autogrow cap; 0 = unbounded. Past it the text view scrolls internally.
190
+ CGFloat _maxHeight;
191
+ CGFloat _lastPublishedHeight;
192
+ UIFont *_baseFont;
193
+ UIColor *_baseColor;
194
+ // Resolved lineHeight per context; 0 = natural. Headings and code use
195
+ // their own element style, everything else the base/paragraph cascade.
196
+ CGFloat _lineHeight;
197
+ CGFloat _headingLineHeights[7];
198
+ CGFloat _codeLineHeight;
199
+ // Marks armed for text typed at the collapsed cursor. Explicit while the
200
+ // user has toggled at this caret position; re-derived from the character
201
+ // before the caret whenever the selection moves.
202
+ uint32_t _typingFlags;
203
+ // Block armed for the caret's line (empty lines carry no characters, so
204
+ // the attribute alone cannot represent them).
205
+ uint32_t _typingBlock;
206
+ BOOL _paragraphAfterNewline;
207
+ // Autocorrect/QuickType replace a whole word ("Ab" → "An"), rebuilding it
208
+ // with attributes that drop the custom mark keys. The replaced range's
209
+ // per-character flags are captured in shouldChangeTextInRange and
210
+ // restored onto the committed text in textViewDidChange.
211
+ std::vector<uint32_t> _replacedCharFlags;
212
+ NSRange _markRestoreRange;
213
+ BOOL _pendingMarkRestore;
214
+ BOOL _markdownEmitScheduled;
215
+ // Autocorrect/autocapitalize from props; suppressed while the caret is in
216
+ // a code context (code block line or armed inline-code mark).
217
+ BOOL _propAutoCorrect;
218
+ UITextAutocapitalizationType _propAutoCapitalize;
219
+ BOOL _allowFontScaling;
220
+ // Dynamic Type multiplier applied to font sizes and line heights; 1 when
221
+ // allowFontScaling is off. Must match the shadow node's fontSizeMultiplier.
222
+ CGFloat _fontScale;
223
+ BOOL _suppressFocusEvents;
224
+ UIColor *_linkColor;
225
+ NSArray<NSString *> *_mentionTriggers;
226
+ BOOL _mentionActive;
227
+ NSString *_mentionTrigger;
228
+ NSUInteger _mentionStart;
229
+ // Dedupes onMentionChange: typing fires both didChangeSelection and
230
+ // didChange, which each re-evaluate the session.
231
+ NSString *_lastMentionQuery;
232
+ NSRange _lastSelection;
233
+ uint64_t _lastStateKey;
234
+ BOOL _stateEmitted;
235
+ JetMarkdownEditorShadowNode::ConcreteState::Shared _state;
236
+ }
237
+
238
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
239
+ return concreteComponentDescriptorProvider<JMDEditorComponentDescriptor>();
240
+ }
241
+
242
+ - (instancetype)initWithFrame:(CGRect)frame {
243
+ if (self = [super initWithFrame:frame]) {
244
+ static const auto defaultProps = std::make_shared<const JetMarkdownEditorProps>();
245
+ _props = defaultProps;
246
+ _stylesJson = @"";
247
+ _multiline = YES;
248
+ _allowFontScaling = YES;
249
+ _fontScale = JMDFontSizeMultiplier();
250
+
251
+ [NSNotificationCenter.defaultCenter
252
+ addObserver:self
253
+ selector:@selector(jmdContentSizeCategoryDidChange)
254
+ name:UIContentSizeCategoryDidChangeNotification
255
+ object:nil];
256
+ _lastPublishedHeight = 0;
257
+ _baseFont = [UIFont systemFontOfSize:16];
258
+ _baseColor = UIColor.blackColor;
259
+ _linkColor = UIColor.systemBlueColor;
260
+ _mentionTriggers = @[];
261
+ _lastSelection = NSMakeRange(0, 0);
262
+
263
+ _codeBackgroundView = [[JMDEditorCodeBackgroundView alloc] initWithFrame:CGRectZero];
264
+ _codeBackgroundView.editor = self;
265
+ [self addSubview:_codeBackgroundView];
266
+
267
+ _textView = [[JMDEditorTextView alloc] initWithFrame:CGRectZero];
268
+ _textView.actionDelegate = self;
269
+ _textView.backgroundColor = UIColor.clearColor;
270
+ _textView.delegate = self;
271
+ _textView.scrollEnabled = NO;
272
+ _textView.textContainer.lineFragmentPadding = 0;
273
+ _textView.textContainerInset = UIEdgeInsetsZero;
274
+ [self addSubview:_textView];
275
+
276
+ _markerView = [[JMDEditorMarkerView alloc] initWithFrame:CGRectZero];
277
+ _markerView.editor = self;
278
+ [self addSubview:_markerView];
279
+
280
+ _placeholderLabel = [[UILabel alloc] initWithFrame:CGRectZero];
281
+ _placeholderLabel.numberOfLines = 1;
282
+ _placeholderLabel.userInteractionEnabled = NO;
283
+ [self addSubview:_placeholderLabel];
284
+
285
+ [self applyTextStyles];
286
+ }
287
+ return self;
288
+ }
289
+
290
+ #pragma mark - Styles
291
+
292
+ // Root text attributes come from the same cascade the viewer uses:
293
+ // base (style prop text keys) then paragraph, floored at 16pt black.
294
+ - (void)applyTextStyles {
295
+ JMDStyleConfig *styles = [JMDStyleConfig configWithJson:_stylesJson];
296
+
297
+ CGFloat fontSize = 16;
298
+ NSString *fontFamily = nil;
299
+ UIColor *color = UIColor.blackColor;
300
+ CGFloat lineHeight = 0;
301
+ for (NSString *key in @[ @"base", @"paragraph" ]) {
302
+ JMDTextStyle *style = [styles textStyleFor:key];
303
+ if (style.fontSize != nil) {
304
+ fontSize = style.fontSize.doubleValue;
305
+ }
306
+ if (style.fontFamily != nil) {
307
+ fontFamily = style.fontFamily;
308
+ }
309
+ if (style.color != nil) {
310
+ color = style.color;
311
+ }
312
+ if (style.lineHeight != nil) {
313
+ lineHeight = style.lineHeight.doubleValue;
314
+ }
315
+ }
316
+ _fontScale = _allowFontScaling ? JMDFontSizeMultiplier() : 1.0;
317
+ fontSize *= _fontScale;
318
+ lineHeight *= _fontScale;
319
+
320
+ _lineHeight = lineHeight;
321
+ for (uint8_t level = 1; level <= 6; level++) {
322
+ JMDTextStyle *heading =
323
+ [styles textStyleFor:[NSString stringWithFormat:@"h%d", level]];
324
+ _headingLineHeights[level] =
325
+ (heading.lineHeight != nil ? heading.lineHeight.doubleValue : 0) *
326
+ _fontScale;
327
+ }
328
+ JMDTextStyle *codeStyle = [styles textStyleFor:@"codeBlock"];
329
+ _codeLineHeight = codeStyle.lineHeight != nil
330
+ ? codeStyle.lineHeight.doubleValue * _fontScale
331
+ : lineHeight;
332
+
333
+ UIFont *font = nil;
334
+ if (fontFamily != nil) {
335
+ font = [UIFont fontWithName:fontFamily size:fontSize];
336
+ }
337
+ if (font == nil) {
338
+ font = [UIFont systemFontOfSize:fontSize];
339
+ }
340
+
341
+ _baseFont = font;
342
+ _baseColor = color;
343
+ _linkColor = [styles textStyleFor:@"link"].color ?: UIColor.systemBlueColor;
344
+ _textView.font = font;
345
+ _textView.textColor = color;
346
+ _textView.textContainerInset = UIEdgeInsetsMake(
347
+ styles.paddingTop, styles.paddingLeft, styles.paddingBottom, styles.paddingRight);
348
+ self.backgroundColor = styles.backgroundColor ?: UIColor.clearColor;
349
+
350
+ [self refreshDisplayAttributesInRange:NSMakeRange(0, _textView.textStorage.length)];
351
+ [self applyTypingAttributes];
352
+
353
+ _placeholderLabel.font = font;
354
+ [self setNeedsLayout];
355
+ [self invalidateDecorations];
356
+ [self publishHeight];
357
+ }
358
+
359
+ #pragma mark - Attributes
360
+
361
+ - (NSDictionary<NSAttributedStringKey, id> *)attributesForFlags:(uint32_t)flags
362
+ block:(uint32_t)block {
363
+ return [self attributesForFlags:flags block:block link:nil atomic:NO];
364
+ }
365
+
366
+ - (NSDictionary<NSAttributedStringKey, id> *)attributesForFlags:(uint32_t)flags
367
+ block:(uint32_t)block
368
+ link:(NSString *)link
369
+ atomic:(BOOL)atomic {
370
+ const auto blockType = JMDBlockType(block);
371
+ const uint8_t level = block & 0xFF;
372
+ const BOOL isCodeBlock = blockType == jetmarkdown::EditorBlockType::Code;
373
+ const BOOL isHeading = blockType == jetmarkdown::EditorBlockType::Heading;
374
+ const BOOL isCode = isCodeBlock || (flags & jetmarkdown::MarkInlineCode) != 0;
375
+ const BOOL isSuper = (flags & jetmarkdown::MarkSuperscript) != 0;
376
+ const BOOL isSub = (flags & jetmarkdown::MarkSubscript) != 0;
377
+
378
+ static const CGFloat headingScale[7] = {1, 2.0, 1.5, 1.25, 1.125, 1.0, 0.875};
379
+ CGFloat size = _baseFont.pointSize;
380
+ if (isHeading) {
381
+ size *= headingScale[MIN(level, (uint8_t)6)];
382
+ }
383
+ // Sup/sub match the viewer's 0.7 scaling.
384
+ if (isSuper || isSub) {
385
+ size *= 0.7;
386
+ }
387
+
388
+ UIFont *font = isCode
389
+ ? [UIFont monospacedSystemFontOfSize:size weight:UIFontWeightRegular]
390
+ : [_baseFont fontWithSize:size];
391
+
392
+ UIFontDescriptorSymbolicTraits traits = font.fontDescriptor.symbolicTraits;
393
+ if ((flags & jetmarkdown::MarkBold) != 0 || isHeading) {
394
+ traits |= UIFontDescriptorTraitBold;
395
+ }
396
+ if ((flags & jetmarkdown::MarkItalic) != 0) {
397
+ traits |= UIFontDescriptorTraitItalic;
398
+ }
399
+ UIFontDescriptor *descriptor =
400
+ [font.fontDescriptor fontDescriptorWithSymbolicTraits:traits];
401
+ if (descriptor != nil) {
402
+ font = [UIFont fontWithDescriptor:descriptor size:size];
403
+ }
404
+
405
+ NSMutableDictionary<NSAttributedStringKey, id> *attributes =
406
+ [NSMutableDictionary dictionary];
407
+ attributes[NSFontAttributeName] = font;
408
+ attributes[NSForegroundColorAttributeName] = _baseColor;
409
+ if ((flags & jetmarkdown::MarkStrikethrough) != 0) {
410
+ attributes[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle);
411
+ }
412
+ // Inline code gets a per-run background; code BLOCK lines get a
413
+ // full-width stripe from the background view instead.
414
+ if ((flags & jetmarkdown::MarkInlineCode) != 0) {
415
+ attributes[NSBackgroundColorAttributeName] =
416
+ [UIColor colorWithWhite:0.5 alpha:0.15];
417
+ }
418
+ if ((flags & jetmarkdown::MarkSpoiler) != 0) {
419
+ attributes[NSBackgroundColorAttributeName] =
420
+ [UIColor colorWithWhite:0.35 alpha:0.25];
421
+ }
422
+ CGFloat baselineOffset = 0;
423
+ if (isSuper) {
424
+ baselineOffset = _baseFont.pointSize * 0.33;
425
+ } else if (isSub) {
426
+ baselineOffset = -_baseFont.pointSize * 0.15;
427
+ }
428
+
429
+ // Line height: headings/code use their element style, everything else
430
+ // the base/paragraph cascade (0 = natural). Glyphs center in the line
431
+ // box, matching React Native.
432
+ CGFloat lineHeight = _lineHeight;
433
+ if (isHeading) {
434
+ lineHeight = _headingLineHeights[MIN(level, (uint8_t)6)];
435
+ } else if (isCodeBlock) {
436
+ lineHeight = _codeLineHeight;
437
+ }
438
+
439
+ NSMutableParagraphStyle *paragraph = [self paragraphStyleForBlock:block];
440
+ if (lineHeight > 0) {
441
+ if (paragraph == nil) {
442
+ paragraph = [[NSMutableParagraphStyle alloc] init];
443
+ }
444
+ paragraph.minimumLineHeight = lineHeight;
445
+ paragraph.maximumLineHeight = lineHeight;
446
+ const CGFloat delta = lineHeight - font.lineHeight;
447
+ if (delta > 0) {
448
+ baselineOffset += delta / 2;
449
+ }
450
+ }
451
+ if (paragraph != nil) {
452
+ attributes[NSParagraphStyleAttributeName] = paragraph;
453
+ }
454
+ if (baselineOffset != 0) {
455
+ attributes[NSBaselineOffsetAttributeName] = @(baselineOffset);
456
+ }
457
+
458
+ if (link.length > 0) {
459
+ attributes[NSForegroundColorAttributeName] = _linkColor;
460
+ attributes[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle);
461
+ attributes[JMDEditorLinkAttribute] = link;
462
+ if (atomic) {
463
+ attributes[JMDEditorAtomicAttribute] = @YES;
464
+ }
465
+ }
466
+ if (flags != 0) {
467
+ attributes[JMDEditorMarksAttribute] = @(flags);
468
+ }
469
+ if (block != 0) {
470
+ attributes[JMDEditorBlockAttribute] = @(block);
471
+ }
472
+ return attributes;
473
+ }
474
+
475
+ - (NSMutableParagraphStyle *)paragraphStyleForBlock:(uint32_t)block {
476
+ const auto blockType = JMDBlockType(block);
477
+ if (blockType != jetmarkdown::EditorBlockType::Quote && !JMDBlockIsList(block)) {
478
+ return nil;
479
+ }
480
+ NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
481
+ const CGFloat indent =
482
+ blockType == jetmarkdown::EditorBlockType::Quote ? 16 : 28;
483
+ paragraph.firstLineHeadIndent = indent;
484
+ paragraph.headIndent = indent;
485
+ return paragraph;
486
+ }
487
+
488
+ // Full attributes for a run described by an existing attribute dictionary.
489
+ - (NSDictionary<NSAttributedStringKey, id> *)attributesFromExisting:
490
+ (NSDictionary *)attrs
491
+ withFlags:(uint32_t)flags
492
+ block:(uint32_t)block {
493
+ return [self attributesForFlags:flags
494
+ block:block
495
+ link:attrs[JMDEditorLinkAttribute]
496
+ atomic:[attrs[JMDEditorAtomicAttribute] boolValue]];
497
+ }
498
+
499
+ // Rebuilds display attributes from the data attributes (marks + block).
500
+ - (void)refreshDisplayAttributesInRange:(NSRange)range {
501
+ if (range.length == 0) {
502
+ return;
503
+ }
504
+ NSTextStorage *storage = _textView.textStorage;
505
+ [storage beginEditing];
506
+ [storage enumerateAttributesInRange:range
507
+ options:0
508
+ usingBlock:^(NSDictionary *attrs, NSRange runRange, BOOL *stop) {
509
+ const uint32_t flags =
510
+ JMDFlagsFromValue(attrs[JMDEditorMarksAttribute]);
511
+ const uint32_t block =
512
+ JMDFlagsFromValue(attrs[JMDEditorBlockAttribute]);
513
+ [storage setAttributes:[self attributesFromExisting:attrs
514
+ withFlags:flags
515
+ block:block]
516
+ range:runRange];
517
+ }];
518
+ [storage endEditing];
519
+ }
520
+
521
+ - (void)applyTypingAttributes {
522
+ _textView.typingAttributes = [self attributesForFlags:_typingFlags
523
+ block:_typingBlock];
524
+ }
525
+
526
+ // Autocorrect/autocapitalize/spellcheck follow the caret: suppressed in
527
+ // code contexts (`let` must not become `Let`), restored from props outside.
528
+ - (void)updateInputTraits {
529
+ const BOOL inCode =
530
+ JMDBlockType(_typingBlock) == jetmarkdown::EditorBlockType::Code ||
531
+ (_typingFlags & jetmarkdown::MarkInlineCode) != 0;
532
+ const UITextAutocorrectionType correction = (!inCode && _propAutoCorrect)
533
+ ? UITextAutocorrectionTypeDefault
534
+ : UITextAutocorrectionTypeNo;
535
+ const UITextAutocapitalizationType capitalization =
536
+ inCode ? UITextAutocapitalizationTypeNone : _propAutoCapitalize;
537
+ const UITextSpellCheckingType spelling =
538
+ inCode ? UITextSpellCheckingTypeNo : UITextSpellCheckingTypeDefault;
539
+ if (_textView.autocorrectionType == correction &&
540
+ _textView.autocapitalizationType == capitalization &&
541
+ _textView.spellCheckingType == spelling) {
542
+ return;
543
+ }
544
+ _textView.autocorrectionType = correction;
545
+ _textView.autocapitalizationType = capitalization;
546
+ _textView.spellCheckingType = spelling;
547
+ if (_textView.isFirstResponder && self.window != nil) {
548
+ // reloadInputViews alone leaves an already-latched shift key engaged
549
+ // (the first code character would still capitalize); cycling the
550
+ // responder resets the keyboard's state. Focus/blur events are
551
+ // suppressed — JS must not see a blip.
552
+ _suppressFocusEvents = YES;
553
+ __block BOOL refocused = NO;
554
+ [UIView performWithoutAnimation:^{
555
+ [self->_textView resignFirstResponder];
556
+ refocused = [self->_textView becomeFirstResponder];
557
+ }];
558
+ _suppressFocusEvents = NO;
559
+ if (!refocused) {
560
+ // The keyboard is gone for real; JS must not believe the editor is
561
+ // still focused.
562
+ if (const auto *emitter = [self editorEventEmitter]) {
563
+ emitter->onEditorBlur({});
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ #pragma mark - Lines
570
+
571
+ - (NSRange)contentRangeOfLineAt:(NSUInteger)location {
572
+ NSString *text = _textView.text;
573
+ NSUInteger start = 0;
574
+ NSUInteger contentsEnd = 0;
575
+ const NSRange probe = NSMakeRange(MIN(location, text.length), 0);
576
+ [text getLineStart:&start end:nil contentsEnd:&contentsEnd forRange:probe];
577
+ return NSMakeRange(start, contentsEnd - start);
578
+ }
579
+
580
+ // Line-iteration primitive safe for every terminator (\n, \r, \r\n):
581
+ // reports the line STARTING at `location` and where the next line begins.
582
+ // Returns NO when `location` is not a line start (iteration is done). The
583
+ // old "NSMaxRange(content) + 1" advance assumed 1-char terminators and
584
+ // looped forever on \r\n.
585
+ - (BOOL)lineStartingAt:(NSUInteger)location
586
+ content:(NSRange *)outContent
587
+ nextLine:(NSUInteger *)outNext {
588
+ NSString *text = _textView.text;
589
+ if (location > text.length) {
590
+ return NO;
591
+ }
592
+ NSUInteger start = 0;
593
+ NSUInteger end = 0;
594
+ NSUInteger contentsEnd = 0;
595
+ [text getLineStart:&start
596
+ end:&end
597
+ contentsEnd:&contentsEnd
598
+ forRange:NSMakeRange(location, 0)];
599
+ if (start != location) {
600
+ return NO;
601
+ }
602
+ *outContent = NSMakeRange(start, contentsEnd - start);
603
+ *outNext = end;
604
+ return YES;
605
+ }
606
+
607
+ - (uint32_t)blockOfLineAt:(NSUInteger)location {
608
+ const NSRange content = [self contentRangeOfLineAt:location];
609
+ if (content.length == 0) {
610
+ return 0;
611
+ }
612
+ return JMDFlagsFromValue([_textView.textStorage attribute:JMDEditorBlockAttribute
613
+ atIndex:content.location
614
+ effectiveRange:nil]);
615
+ }
616
+
617
+ // Sets the block on every line the range touches, preserving per-character
618
+ // marks.
619
+ - (void)applyBlock:(uint32_t)block toLinesInRange:(NSRange)range {
620
+ NSString *text = _textView.text;
621
+ const NSRange lines = [text lineRangeForRange:range];
622
+ if (lines.length == 0) {
623
+ return;
624
+ }
625
+ NSTextStorage *storage = _textView.textStorage;
626
+ // A code fence carries raw text only: marks and links on lines converted
627
+ // to a code block would be dropped by the serializer, so shed them now.
628
+ const BOOL toCode = JMDBlockType(block) == jetmarkdown::EditorBlockType::Code;
629
+ [storage beginEditing];
630
+ [storage enumerateAttributesInRange:lines
631
+ options:0
632
+ usingBlock:^(NSDictionary *attrs, NSRange runRange, BOOL *stop) {
633
+ const uint32_t flags =
634
+ JMDFlagsFromValue(attrs[JMDEditorMarksAttribute]);
635
+ NSDictionary *next = toCode
636
+ ? [self attributesForFlags:0 block:block]
637
+ : [self attributesFromExisting:attrs
638
+ withFlags:flags
639
+ block:block];
640
+ [storage setAttributes:next range:runRange];
641
+ }];
642
+ [storage endEditing];
643
+ }
644
+
645
+ - (void)toggleBlock:(jetmarkdown::EditorBlockType)type level:(uint8_t)level {
646
+ const uint32_t target = JMDPackBlock(type, level);
647
+ const NSRange selection = _textView.selectedRange;
648
+ NSString *text = _textView.text;
649
+ const NSRange lines =
650
+ text.length == 0 ? NSMakeRange(0, 0) : [text lineRangeForRange:selection];
651
+
652
+ BOOL allMatch = YES;
653
+ if (lines.length == 0) {
654
+ allMatch = _typingBlock == target;
655
+ } else {
656
+ NSUInteger cursor = lines.location;
657
+ NSRange content;
658
+ NSUInteger nextLine;
659
+ while (cursor < NSMaxRange(lines) &&
660
+ [self lineStartingAt:cursor content:&content nextLine:&nextLine]) {
661
+ if (content.length > 0 && [self blockOfLineAt:cursor] != target) {
662
+ allMatch = NO;
663
+ break;
664
+ }
665
+ if (content.length == 0 && _typingBlock != target) {
666
+ allMatch = NO;
667
+ break;
668
+ }
669
+ if (nextLine == cursor) {
670
+ break;
671
+ }
672
+ cursor = nextLine;
673
+ }
674
+ }
675
+
676
+ const uint32_t next = allMatch ? 0 : target;
677
+ if (lines.length > 0) {
678
+ [self applyBlock:next toLinesInRange:lines];
679
+ _textView.selectedRange = selection;
680
+ }
681
+ _typingBlock = next;
682
+ if (JMDBlockType(next) == jetmarkdown::EditorBlockType::Code) {
683
+ // Armed marks cannot survive inside a code fence.
684
+ _typingFlags = 0;
685
+ }
686
+ [self applyTypingAttributes];
687
+ [self updateInputTraits];
688
+ [self invalidateDecorations];
689
+ [self textContentChanged];
690
+ [self emitState];
691
+ }
692
+
693
+ #pragma mark - Markers
694
+
695
+ - (void)invalidateDecorations {
696
+ [_markerView setNeedsDisplay];
697
+ [_codeBackgroundView setNeedsDisplay];
698
+ }
699
+
700
+ // The block shown for a line: stored attribute for content lines; for the
701
+ // EMPTY caret line, the armed typing block (immediate feedback on toggle).
702
+ - (uint32_t)displayBlockForLineContent:(NSRange)content {
703
+ if (content.length > 0) {
704
+ return [self blockOfLineAt:content.location];
705
+ }
706
+ const NSRange selection = _textView.selectedRange;
707
+ if (selection.length == 0 && selection.location == content.location) {
708
+ return _typingBlock;
709
+ }
710
+ return 0;
711
+ }
712
+
713
+ - (CGRect)rectForLineContent:(NSRange)content {
714
+ if (content.length > 0) {
715
+ NSLayoutManager *layoutManager = _textView.layoutManager;
716
+ const NSRange glyphs =
717
+ [layoutManager glyphRangeForCharacterRange:content actualCharacterRange:nil];
718
+ CGRect rect = [layoutManager boundingRectForGlyphRange:glyphs
719
+ inTextContainer:_textView.textContainer];
720
+ rect.origin.x += _textView.textContainerInset.left;
721
+ rect.origin.y += _textView.textContainerInset.top;
722
+ return rect;
723
+ }
724
+ UITextPosition *position =
725
+ [_textView positionFromPosition:_textView.beginningOfDocument
726
+ offset:(NSInteger)content.location];
727
+ if (position == nil) {
728
+ return CGRectZero;
729
+ }
730
+ return [_textView caretRectForPosition:position];
731
+ }
732
+
733
+ - (void)drawMarkersInContext:(CGContextRef)context view:(JMDEditorMarkerView *)view {
734
+ if (context == nil) {
735
+ return;
736
+ }
737
+ NSString *text = _textView.text;
738
+ const UIEdgeInsets inset = _textView.textContainerInset;
739
+ UIColor *markerColor = [_baseColor colorWithAlphaComponent:0.6];
740
+
741
+ NSDictionary *markerAttributes = @{
742
+ NSFontAttributeName : _baseFont,
743
+ NSForegroundColorAttributeName : markerColor,
744
+ };
745
+ NSUInteger location = 0;
746
+ NSInteger orderedNumber = 0;
747
+ NSRange content;
748
+ NSUInteger nextLine;
749
+ while ([self lineStartingAt:location content:&content nextLine:&nextLine]) {
750
+ const uint32_t block = [self displayBlockForLineContent:content];
751
+ const auto type = JMDBlockType(block);
752
+
753
+ if (type == jetmarkdown::EditorBlockType::Ordered) {
754
+ orderedNumber += 1;
755
+ } else {
756
+ orderedNumber = 0;
757
+ }
758
+
759
+ if (block != 0) {
760
+ const CGRect lineRect = [self rectForLineContent:content];
761
+ if (!CGRectIsEmpty(lineRect)) {
762
+ const CGFloat top = lineRect.origin.y;
763
+
764
+ if (type == jetmarkdown::EditorBlockType::Quote) {
765
+ [markerColor setFill];
766
+ UIRectFill(CGRectMake(inset.left + 4, top, 3, lineRect.size.height));
767
+ } else if (type == jetmarkdown::EditorBlockType::Bullet ||
768
+ type == jetmarkdown::EditorBlockType::Ordered) {
769
+ NSString *marker = type == jetmarkdown::EditorBlockType::Bullet
770
+ ? @"•"
771
+ : [NSString stringWithFormat:@"%ld.", (long)orderedNumber];
772
+ const CGSize size = [marker sizeWithAttributes:markerAttributes];
773
+ [marker drawAtPoint:CGPointMake(inset.left + 24 - size.width - 6, top)
774
+ withAttributes:markerAttributes];
775
+ }
776
+ }
777
+ }
778
+
779
+ if (nextLine == location) {
780
+ break;
781
+ }
782
+ location = nextLine;
783
+ }
784
+ }
785
+
786
+ - (void)drawCodeBackgroundsInView:(JMDEditorCodeBackgroundView *)view {
787
+ NSString *text = _textView.text;
788
+ const UIEdgeInsets inset = _textView.textContainerInset;
789
+ const CGFloat left = MAX(inset.left - 6, 0);
790
+ const CGFloat width = view.bounds.size.width - left - MAX(inset.right - 6, 0);
791
+ UIColor *fill = [UIColor colorWithWhite:0.5 alpha:0.1];
792
+
793
+ // Contiguous code lines merge into one rounded stripe.
794
+ CGFloat groupTop = 0;
795
+ CGFloat groupBottom = 0;
796
+ BOOL inGroup = NO;
797
+ const auto flush = [&]() {
798
+ if (inGroup) {
799
+ UIBezierPath *path = [UIBezierPath
800
+ bezierPathWithRoundedRect:CGRectMake(left, groupTop - 2, width,
801
+ groupBottom - groupTop + 4)
802
+ cornerRadius:6];
803
+ [fill setFill];
804
+ [path fill];
805
+ inGroup = NO;
806
+ }
807
+ };
808
+
809
+ NSUInteger location = 0;
810
+ NSRange content;
811
+ NSUInteger nextLine;
812
+ while ([self lineStartingAt:location content:&content nextLine:&nextLine]) {
813
+ const uint32_t block = [self displayBlockForLineContent:content];
814
+ const CGRect lineRect = JMDBlockType(block) == jetmarkdown::EditorBlockType::Code
815
+ ? [self rectForLineContent:content]
816
+ : CGRectZero;
817
+
818
+ if (!CGRectIsEmpty(lineRect)) {
819
+ if (!inGroup) {
820
+ inGroup = YES;
821
+ groupTop = lineRect.origin.y;
822
+ }
823
+ groupBottom = CGRectGetMaxY(lineRect);
824
+ } else {
825
+ flush();
826
+ }
827
+
828
+ if (nextLine == location) {
829
+ break;
830
+ }
831
+ location = nextLine;
832
+ }
833
+ flush();
834
+ }
835
+
836
+ #pragma mark - Marks
837
+
838
+ // Marks present across the ENTIRE range (the AND), which drives both toggle
839
+ // direction and the reported selection state.
840
+ - (uint32_t)commonFlagsInRange:(NSRange)range {
841
+ __block uint32_t common = ~0u;
842
+ [_textView.textStorage enumerateAttribute:JMDEditorMarksAttribute
843
+ inRange:range
844
+ options:0
845
+ usingBlock:^(id value, NSRange runRange, BOOL *stop) {
846
+ common &= JMDFlagsFromValue(value);
847
+ }];
848
+ return common == ~0u ? 0 : common;
849
+ }
850
+
851
+ - (uint32_t)flagsBeforeCaret:(NSUInteger)location {
852
+ NSTextStorage *storage = _textView.textStorage;
853
+ if (storage.length == 0) {
854
+ return 0;
855
+ }
856
+ const NSUInteger probe = location > 0 ? location - 1 : 0;
857
+ if (probe >= storage.length) {
858
+ return 0;
859
+ }
860
+ // Marks end at the paragraph break: a newline never carries them forward
861
+ // (otherwise a mark armed once would leak into every following line).
862
+ if ([_textView.text characterAtIndex:probe] == '\n') {
863
+ return 0;
864
+ }
865
+ return JMDFlagsFromValue([storage attribute:JMDEditorMarksAttribute
866
+ atIndex:probe
867
+ effectiveRange:nil]);
868
+ }
869
+
870
+ // YES when the caret's armed block or any line the range touches is a code
871
+ // block.
872
+ - (BOOL)selectionTouchesCodeBlock:(NSRange)range {
873
+ if (JMDBlockType(_typingBlock) == jetmarkdown::EditorBlockType::Code) {
874
+ return YES;
875
+ }
876
+ NSString *text = _textView.text;
877
+ NSUInteger lineStart = 0;
878
+ [text getLineStart:&lineStart
879
+ end:nil
880
+ contentsEnd:nil
881
+ forRange:NSMakeRange(MIN(range.location, text.length), 0)];
882
+ NSUInteger location = lineStart;
883
+ const NSUInteger max = NSMaxRange(range);
884
+ NSRange content;
885
+ NSUInteger nextLine;
886
+ while ([self lineStartingAt:location content:&content nextLine:&nextLine]) {
887
+ if (JMDBlockType([self blockOfLineAt:location]) ==
888
+ jetmarkdown::EditorBlockType::Code) {
889
+ return YES;
890
+ }
891
+ if (nextLine == location || nextLine > max) {
892
+ break;
893
+ }
894
+ location = nextLine;
895
+ }
896
+ return NO;
897
+ }
898
+
899
+ - (void)toggleMark:(uint32_t)mark {
900
+ const NSRange selection = _textView.selectedRange;
901
+ // A code fence carries raw text only — marks applied there would render
902
+ // in the editor but silently vanish from the markdown, so refuse them.
903
+ if ([self selectionTouchesCodeBlock:selection]) {
904
+ return;
905
+ }
906
+ // Superscript and subscript are mutually exclusive: a glyph cannot sit
907
+ // above and below the baseline, and combined they serialize to nested
908
+ // ^~…~^ that does not round-trip.
909
+ uint32_t exclusive = 0;
910
+ if (mark == jetmarkdown::MarkSuperscript) {
911
+ exclusive = jetmarkdown::MarkSubscript;
912
+ } else if (mark == jetmarkdown::MarkSubscript) {
913
+ exclusive = jetmarkdown::MarkSuperscript;
914
+ }
915
+ if (selection.length == 0) {
916
+ _typingFlags ^= mark;
917
+ if ((_typingFlags & mark) != 0) {
918
+ _typingFlags &= ~exclusive;
919
+ }
920
+ [self applyTypingAttributes];
921
+ [self updateInputTraits];
922
+ [self emitState];
923
+ return;
924
+ }
925
+
926
+ const BOOL allHave = ([self commonFlagsInRange:selection] & mark) != 0;
927
+ NSTextStorage *storage = _textView.textStorage;
928
+ [storage beginEditing];
929
+ [storage enumerateAttributesInRange:selection
930
+ options:0
931
+ usingBlock:^(NSDictionary *attrs, NSRange runRange, BOOL *stop) {
932
+ const uint32_t flags =
933
+ JMDFlagsFromValue(attrs[JMDEditorMarksAttribute]);
934
+ const uint32_t block =
935
+ JMDFlagsFromValue(attrs[JMDEditorBlockAttribute]);
936
+ const uint32_t next = allHave
937
+ ? (flags & ~mark)
938
+ : ((flags | mark) & ~exclusive);
939
+ [storage setAttributes:[self attributesFromExisting:attrs
940
+ withFlags:next
941
+ block:block]
942
+ range:runRange];
943
+ }];
944
+ [storage endEditing];
945
+ _textView.selectedRange = selection;
946
+ [self textContentChanged];
947
+ [self emitState];
948
+ }
949
+
950
+ #pragma mark - Fabric plumbing
951
+
952
+ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState {
953
+ _state = std::static_pointer_cast<const JetMarkdownEditorShadowNode::ConcreteState>(state);
954
+ }
955
+
956
+ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
957
+ const auto &newProps = *std::static_pointer_cast<JetMarkdownEditorProps const>(props);
958
+ const auto &prevProps = *std::static_pointer_cast<JetMarkdownEditorProps const>(_props);
959
+
960
+ NSString *stylesJson = JMDStringFromCpp(newProps.stylesJson);
961
+ if (![stylesJson isEqualToString:_stylesJson]) {
962
+ _stylesJson = stylesJson;
963
+ [self applyTextStyles];
964
+ }
965
+
966
+ if (!_defaultValueApplied) {
967
+ _defaultValueApplied = YES;
968
+ if (!newProps.defaultValue.empty()) {
969
+ [self applyMarkdownValue:newProps.defaultValue];
970
+ }
971
+ }
972
+
973
+ _textView.editable = newProps.editable;
974
+ _propScrollEnabled = newProps.scrollEnabled;
975
+ _maxHeight = newProps.maxHeight;
976
+ _multiline = newProps.multiline;
977
+ [self publishHeight];
978
+
979
+ if (newProps.allowFontScaling != _allowFontScaling) {
980
+ _allowFontScaling = newProps.allowFontScaling;
981
+ [self applyTextStyles];
982
+ }
983
+
984
+ _propAutoCorrect = newProps.autoCorrect;
985
+ switch (newProps.autoCapitalize) {
986
+ case JetMarkdownEditorAutoCapitalize::None:
987
+ _propAutoCapitalize = UITextAutocapitalizationTypeNone;
988
+ break;
989
+ case JetMarkdownEditorAutoCapitalize::Words:
990
+ _propAutoCapitalize = UITextAutocapitalizationTypeWords;
991
+ break;
992
+ case JetMarkdownEditorAutoCapitalize::Characters:
993
+ _propAutoCapitalize = UITextAutocapitalizationTypeAllCharacters;
994
+ break;
995
+ case JetMarkdownEditorAutoCapitalize::Sentences:
996
+ _propAutoCapitalize = UITextAutocapitalizationTypeSentences;
997
+ break;
998
+ }
999
+ [self updateInputTraits];
1000
+
1001
+ // UIKit shares one tint for the caret and the selection highlight;
1002
+ // selectionColor wins when both are set, nil restores the system tint.
1003
+ // SharedColor carries platform colors (PlatformColor/DynamicColorIOS)
1004
+ // through as dynamic-provider UIColors.
1005
+ UIColor *selectionColor = RCTUIColorFromSharedColor(newProps.selectionColor);
1006
+ UIColor *cursorColor = RCTUIColorFromSharedColor(newProps.cursorColor);
1007
+ _textView.tintColor = selectionColor ?: cursorColor;
1008
+
1009
+ NSMutableArray<NSString *> *triggers = [NSMutableArray array];
1010
+ for (const auto &trigger : newProps.mentionTriggers) {
1011
+ NSString *value = JMDStringFromCpp(trigger);
1012
+ if (value.length > 0) {
1013
+ [triggers addObject:[value substringWithRange:
1014
+ [value rangeOfComposedCharacterSequenceAtIndex:0]]];
1015
+ }
1016
+ }
1017
+ _mentionTriggers = triggers;
1018
+
1019
+ NSString *placeholder = JMDStringFromCpp(newProps.placeholder);
1020
+ if (![placeholder isEqualToString:_placeholderLabel.text]) {
1021
+ _placeholderLabel.text = placeholder;
1022
+ _textView.accessibilityLabel = placeholder;
1023
+ [self setNeedsLayout];
1024
+ }
1025
+ UIColor *placeholderColor =
1026
+ RCTUIColorFromSharedColor(newProps.placeholderTextColor);
1027
+ _placeholderLabel.textColor =
1028
+ placeholderColor ?: [UIColor colorWithWhite:0 alpha:0.3];
1029
+
1030
+ if (newProps.autoFocus && !prevProps.autoFocus) {
1031
+ _autoFocusHandled = NO;
1032
+ }
1033
+
1034
+ [super updateProps:props oldProps:oldProps];
1035
+ [self refreshPlaceholderVisibility];
1036
+ }
1037
+
1038
+ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
1039
+ [super traitCollectionDidChange:previousTraitCollection];
1040
+ if ([self.traitCollection
1041
+ hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
1042
+ // Marker/stripe decorations draw with (possibly dynamic) UIColors;
1043
+ // re-resolve them under the new appearance.
1044
+ [self invalidateDecorations];
1045
+ }
1046
+ }
1047
+
1048
+ - (void)didMoveToWindow {
1049
+ [super didMoveToWindow];
1050
+ const auto &props = *std::static_pointer_cast<JetMarkdownEditorProps const>(_props);
1051
+ if (self.window != nil && props.autoFocus && !_autoFocusHandled) {
1052
+ _autoFocusHandled = YES;
1053
+ [_textView becomeFirstResponder];
1054
+ }
1055
+ }
1056
+
1057
+ - (void)dealloc {
1058
+ [NSNotificationCenter.defaultCenter removeObserver:self];
1059
+ }
1060
+
1061
+ - (void)jmdContentSizeCategoryDidChange {
1062
+ if (_allowFontScaling) {
1063
+ [self applyTextStyles];
1064
+ }
1065
+ }
1066
+
1067
+ - (void)prepareForRecycle {
1068
+ [super prepareForRecycle];
1069
+ _textView.text = @"";
1070
+ _stylesJson = @"";
1071
+ _defaultValueApplied = NO;
1072
+ _autoFocusHandled = NO;
1073
+ _lastPublishedHeight = 0;
1074
+ _typingFlags = 0;
1075
+ _typingBlock = 0;
1076
+ _paragraphAfterNewline = NO;
1077
+ _mentionActive = NO;
1078
+ _lastMentionQuery = nil;
1079
+ _lastSelection = NSMakeRange(0, 0);
1080
+ _stateEmitted = NO;
1081
+ _state = nullptr;
1082
+ [self applyTextStyles];
1083
+ [self refreshPlaceholderVisibility];
1084
+ }
1085
+
1086
+ - (void)layoutSubviews {
1087
+ [super layoutSubviews];
1088
+ _textView.frame = self.bounds;
1089
+ _markerView.frame = self.bounds;
1090
+ _codeBackgroundView.frame = self.bounds;
1091
+
1092
+ const UIEdgeInsets inset = _textView.textContainerInset;
1093
+ const CGSize placeholderSize = [_placeholderLabel sizeThatFits:CGSizeMake(
1094
+ self.bounds.size.width - inset.left - inset.right, CGFLOAT_MAX)];
1095
+ _placeholderLabel.frame = CGRectMake(
1096
+ inset.left, inset.top, placeholderSize.width, placeholderSize.height);
1097
+
1098
+ [self invalidateDecorations];
1099
+ [self publishHeight];
1100
+ }
1101
+
1102
+ #pragma mark - Autogrow
1103
+
1104
+ - (void)publishHeight {
1105
+ const CGFloat width = self.bounds.size.width;
1106
+ if (width <= 0 || _state == nullptr) {
1107
+ return;
1108
+ }
1109
+ const CGSize size = [_textView sizeThatFits:CGSizeMake(width, CGFLOAT_MAX)];
1110
+ CGFloat height = size.height;
1111
+ BOOL exceedsMax = NO;
1112
+ if (_maxHeight > 0 && height > _maxHeight) {
1113
+ height = _maxHeight;
1114
+ exceedsMax = YES;
1115
+ }
1116
+ // Grow-then-scroll: once content passes maxHeight the text view scrolls
1117
+ // internally like a textarea.
1118
+ const BOOL wantScroll = exceedsMax || _propScrollEnabled;
1119
+ if (_textView.scrollEnabled != wantScroll) {
1120
+ _textView.scrollEnabled = wantScroll;
1121
+ }
1122
+ if (fabs(height - _lastPublishedHeight) < 0.5) {
1123
+ return;
1124
+ }
1125
+ _lastPublishedHeight = height;
1126
+ _state->updateState(JetMarkdownEditorState(height));
1127
+ }
1128
+
1129
+ #pragma mark - Events
1130
+
1131
+ - (const JetMarkdownEditorEventEmitter *)editorEventEmitter {
1132
+ if (!_eventEmitter) {
1133
+ return nullptr;
1134
+ }
1135
+ return static_cast<const JetMarkdownEditorEventEmitter *>(_eventEmitter.get());
1136
+ }
1137
+
1138
+ - (std::string)serializedMarkdown {
1139
+ NSTextStorage *storage = _textView.textStorage;
1140
+ NSString *text = _textView.text;
1141
+ const std::string utf8(text.UTF8String ?: "");
1142
+
1143
+ __block std::vector<jetmarkdown::StyledRun> runs;
1144
+ [storage enumerateAttribute:JMDEditorMarksAttribute
1145
+ inRange:NSMakeRange(0, storage.length)
1146
+ options:0
1147
+ usingBlock:^(id value, NSRange runRange, BOOL *stop) {
1148
+ const uint32_t flags = JMDFlagsFromValue(value);
1149
+ if (flags != 0) {
1150
+ runs.push_back(
1151
+ {static_cast<uint32_t>(runRange.location),
1152
+ static_cast<uint32_t>(NSMaxRange(runRange)),
1153
+ flags});
1154
+ }
1155
+ }];
1156
+
1157
+ std::vector<jetmarkdown::EditorLine> lines;
1158
+ NSUInteger location = 0;
1159
+ NSRange content;
1160
+ NSUInteger nextLine;
1161
+ while ([self lineStartingAt:location content:&content nextLine:&nextLine]) {
1162
+ const uint32_t block =
1163
+ content.length > 0 ? [self blockOfLineAt:content.location] : 0;
1164
+ lines.push_back(
1165
+ {JMDBlockType(block), static_cast<uint8_t>(block & 0xFF)});
1166
+ if (nextLine == location) {
1167
+ break;
1168
+ }
1169
+ location = nextLine;
1170
+ }
1171
+ if (lines.empty()) {
1172
+ lines.push_back({});
1173
+ }
1174
+
1175
+ __block std::vector<jetmarkdown::LinkRun> links;
1176
+ [storage enumerateAttribute:JMDEditorLinkAttribute
1177
+ inRange:NSMakeRange(0, storage.length)
1178
+ options:0
1179
+ usingBlock:^(id value, NSRange runRange, BOOL *stop) {
1180
+ NSString *url = (NSString *)value;
1181
+ if (url.length > 0) {
1182
+ links.push_back(
1183
+ {static_cast<uint32_t>(runRange.location),
1184
+ static_cast<uint32_t>(NSMaxRange(runRange)),
1185
+ std::string(url.UTF8String ?: "")});
1186
+ }
1187
+ }];
1188
+
1189
+ return jetmarkdown::markdownFromEditor(utf8, runs, lines, links);
1190
+ }
1191
+
1192
+ - (void)textContentChanged {
1193
+ [self refreshPlaceholderVisibility];
1194
+ [self publishHeight];
1195
+ [self invalidateDecorations];
1196
+ if (const auto *emitter = [self editorEventEmitter]) {
1197
+ const std::string text(_textView.text.UTF8String ?: "");
1198
+ emitter->onEditorChangeText({.text = text});
1199
+ }
1200
+ // Serializing the whole document per keystroke is the expensive half of
1201
+ // this pipeline; coalesce bursts to one emission per runloop turn.
1202
+ if (!_markdownEmitScheduled) {
1203
+ _markdownEmitScheduled = YES;
1204
+ __weak __typeof(self) weakSelf = self;
1205
+ dispatch_async(dispatch_get_main_queue(), ^{
1206
+ __typeof(self) strongSelf = weakSelf;
1207
+ if (strongSelf == nil) {
1208
+ return;
1209
+ }
1210
+ strongSelf->_markdownEmitScheduled = NO;
1211
+ if (const auto *emitter = [strongSelf editorEventEmitter]) {
1212
+ emitter->onEditorChangeMarkdown({.markdown = [strongSelf serializedMarkdown]});
1213
+ }
1214
+ });
1215
+ }
1216
+ }
1217
+
1218
+ - (void)emitState {
1219
+ const NSRange selection = _textView.selectedRange;
1220
+ const uint32_t flags = selection.length == 0
1221
+ ? _typingFlags
1222
+ : [self commonFlagsInRange:selection];
1223
+ const NSRange caretLine = [self contentRangeOfLineAt:selection.location];
1224
+ const uint32_t block =
1225
+ caretLine.length > 0 ? [self blockOfLineAt:caretLine.location] : _typingBlock;
1226
+ const uint64_t stateKey = (static_cast<uint64_t>(block) << 32) | flags;
1227
+ if (_stateEmitted && stateKey == _lastStateKey) {
1228
+ return;
1229
+ }
1230
+ _lastStateKey = stateKey;
1231
+ _stateEmitted = YES;
1232
+ if (const auto *emitter = [self editorEventEmitter]) {
1233
+ const auto type = JMDBlockType(block);
1234
+ emitter->onEditorChangeState({
1235
+ .headingLevel = type == jetmarkdown::EditorBlockType::Heading
1236
+ ? static_cast<int>(block & 0xFF)
1237
+ : 0,
1238
+ .isBlockQuote = type == jetmarkdown::EditorBlockType::Quote,
1239
+ .isBold = (flags & jetmarkdown::MarkBold) != 0,
1240
+ .isCodeBlock = type == jetmarkdown::EditorBlockType::Code,
1241
+ .isInlineCode = (flags & jetmarkdown::MarkInlineCode) != 0,
1242
+ .isItalic = (flags & jetmarkdown::MarkItalic) != 0,
1243
+ .isOrderedList = type == jetmarkdown::EditorBlockType::Ordered,
1244
+ .isSpoiler = (flags & jetmarkdown::MarkSpoiler) != 0,
1245
+ .isStrikethrough = (flags & jetmarkdown::MarkStrikethrough) != 0,
1246
+ .isSubscript = (flags & jetmarkdown::MarkSubscript) != 0,
1247
+ .isSuperscript = (flags & jetmarkdown::MarkSuperscript) != 0,
1248
+ .isUnorderedList = type == jetmarkdown::EditorBlockType::Bullet,
1249
+ });
1250
+ }
1251
+ }
1252
+
1253
+ - (void)refreshPlaceholderVisibility {
1254
+ _placeholderLabel.hidden = _textView.text.length > 0;
1255
+ }
1256
+
1257
+ #pragma mark - Links & mentions
1258
+
1259
+ static BOOL JMDIsWordBreak(unichar c) {
1260
+ return c == ' ' || c == '\t' || c == '\n';
1261
+ }
1262
+
1263
+ // The atomic token range containing (or abutting) the position, if any.
1264
+ - (NSRange)atomicRangeAt:(NSUInteger)location {
1265
+ NSTextStorage *storage = _textView.textStorage;
1266
+ if (storage.length == 0 || location >= storage.length) {
1267
+ return NSMakeRange(NSNotFound, 0);
1268
+ }
1269
+ NSRange effective = NSMakeRange(NSNotFound, 0);
1270
+ id value = [storage attribute:JMDEditorAtomicAttribute
1271
+ atIndex:location
1272
+ longestEffectiveRange:&effective
1273
+ inRange:NSMakeRange(0, storage.length)];
1274
+ return [value boolValue] ? effective : NSMakeRange(NSNotFound, 0);
1275
+ }
1276
+
1277
+ - (void)endMentionSession {
1278
+ if (!_mentionActive) {
1279
+ return;
1280
+ }
1281
+ _mentionActive = NO;
1282
+ _lastMentionQuery = nil;
1283
+ if (const auto *emitter = [self editorEventEmitter]) {
1284
+ emitter->onEditorMentionEnd(
1285
+ {.trigger = std::string(_mentionTrigger.UTF8String ?: "")});
1286
+ }
1287
+ }
1288
+
1289
+ // Runs after every content or caret change: starts, updates, or ends the
1290
+ // mention session based on the text between the trigger and the caret.
1291
+ - (void)updateMentionSession {
1292
+ if (_mentionTriggers.count == 0) {
1293
+ return;
1294
+ }
1295
+ NSString *text = _textView.text;
1296
+ const NSRange selection = _textView.selectedRange;
1297
+ if (selection.length != 0) {
1298
+ [self endMentionSession];
1299
+ return;
1300
+ }
1301
+ const NSUInteger caret = selection.location;
1302
+
1303
+ if (_mentionActive) {
1304
+ BOOL valid = _mentionStart < text.length && caret > _mentionStart &&
1305
+ caret <= text.length;
1306
+ if (valid) {
1307
+ NSString *trigger = [text substringWithRange:NSMakeRange(_mentionStart, 1)];
1308
+ valid = [trigger isEqualToString:_mentionTrigger];
1309
+ }
1310
+ NSString *query = @"";
1311
+ if (valid) {
1312
+ query = [text substringWithRange:NSMakeRange(
1313
+ _mentionStart + 1, caret - _mentionStart - 1)];
1314
+ for (NSUInteger i = 0; i < query.length; i++) {
1315
+ if (JMDIsWordBreak([query characterAtIndex:i])) {
1316
+ valid = NO;
1317
+ break;
1318
+ }
1319
+ }
1320
+ }
1321
+ if (!valid) {
1322
+ [self endMentionSession];
1323
+ return;
1324
+ }
1325
+ if ([query isEqualToString:_lastMentionQuery]) {
1326
+ return;
1327
+ }
1328
+ _lastMentionQuery = query;
1329
+ if (const auto *emitter = [self editorEventEmitter]) {
1330
+ emitter->onEditorMentionChange({
1331
+ .query = std::string(query.UTF8String ?: ""),
1332
+ .trigger = std::string(_mentionTrigger.UTF8String ?: ""),
1333
+ });
1334
+ }
1335
+ return;
1336
+ }
1337
+
1338
+ // A trigger character at a word start (directly before the caret) opens
1339
+ // a session.
1340
+ if (caret == 0 || caret > text.length) {
1341
+ return;
1342
+ }
1343
+ NSString *last = [text substringWithRange:NSMakeRange(caret - 1, 1)];
1344
+ if (![_mentionTriggers containsObject:last]) {
1345
+ return;
1346
+ }
1347
+ if (caret >= 2 && !JMDIsWordBreak([text characterAtIndex:caret - 2])) {
1348
+ return;
1349
+ }
1350
+ _mentionActive = YES;
1351
+ _mentionTrigger = last;
1352
+ _mentionStart = caret - 1;
1353
+ if (const auto *emitter = [self editorEventEmitter]) {
1354
+ emitter->onEditorMentionStart(
1355
+ {.trigger = std::string(last.UTF8String ?: "")});
1356
+ }
1357
+ }
1358
+
1359
+ // After a word break is typed, reports a bare URL the word forms (the app
1360
+ // decides whether to call insertLink).
1361
+ - (void)detectLinkBefore:(NSUInteger)location {
1362
+ NSString *text = _textView.text;
1363
+ if (location > text.length) {
1364
+ return;
1365
+ }
1366
+ NSUInteger wordStart = location;
1367
+ while (wordStart > 0 &&
1368
+ !JMDIsWordBreak([text characterAtIndex:wordStart - 1])) {
1369
+ wordStart--;
1370
+ }
1371
+ if (wordStart >= location) {
1372
+ return;
1373
+ }
1374
+ NSString *word = [text substringWithRange:NSMakeRange(wordStart, location - wordStart)];
1375
+ if (![word hasPrefix:@"http://"] && ![word hasPrefix:@"https://"]) {
1376
+ return;
1377
+ }
1378
+ if ([word isEqualToString:@"http://"] || [word isEqualToString:@"https://"]) {
1379
+ return;
1380
+ }
1381
+ id linked = [_textView.textStorage attribute:JMDEditorLinkAttribute
1382
+ atIndex:wordStart
1383
+ effectiveRange:nil];
1384
+ if (linked != nil) {
1385
+ return;
1386
+ }
1387
+ // Linkify in place: a bare URL re-parses as an autolink in any markdown
1388
+ // renderer, so the editor must show it as a link too (WYSIWYG). The app
1389
+ // can still restyle or remove it from the onLinkDetected callback.
1390
+ const NSRange wordRange = NSMakeRange(wordStart, location - wordStart);
1391
+ // textViewDidChange serializes right after this returns, so no extra
1392
+ // textContentChanged is needed here.
1393
+ if (JMDBlockType([self blockOfLineAt:wordStart]) !=
1394
+ jetmarkdown::EditorBlockType::Code) {
1395
+ [self applyLink:word atomic:NO inRange:wordRange];
1396
+ }
1397
+ if (const auto *emitter = [self editorEventEmitter]) {
1398
+ emitter->onEditorLinkDetected({.url = std::string(word.UTF8String ?: "")});
1399
+ }
1400
+ }
1401
+
1402
+ - (void)applyLink:(NSString *)url atomic:(BOOL)atomic inRange:(NSRange)range {
1403
+ NSTextStorage *storage = _textView.textStorage;
1404
+ [storage beginEditing];
1405
+ [storage enumerateAttributesInRange:range
1406
+ options:0
1407
+ usingBlock:^(NSDictionary *attrs, NSRange runRange, BOOL *stop) {
1408
+ const uint32_t flags =
1409
+ JMDFlagsFromValue(attrs[JMDEditorMarksAttribute]);
1410
+ const uint32_t block =
1411
+ JMDFlagsFromValue(attrs[JMDEditorBlockAttribute]);
1412
+ [storage setAttributes:[self attributesForFlags:flags
1413
+ block:block
1414
+ link:url
1415
+ atomic:atomic]
1416
+ range:runRange];
1417
+ }];
1418
+ [storage endEditing];
1419
+ }
1420
+
1421
+ #pragma mark - UITextViewDelegate
1422
+
1423
+ - (BOOL)textView:(UITextView *)textView
1424
+ shouldChangeTextInRange:(NSRange)range
1425
+ replacementText:(NSString *)text {
1426
+ if (!_multiline && [text containsString:@"\n"]) {
1427
+ [textView resignFirstResponder];
1428
+ return NO;
1429
+ }
1430
+
1431
+ // Normalize CR line endings at the door (system drag-and-drop and some
1432
+ // input methods deliver them); the whole line model assumes "\n".
1433
+ if ([text rangeOfString:@"\r"].location != NSNotFound) {
1434
+ NSString *sanitized =
1435
+ [[text stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]
1436
+ stringByReplacingOccurrencesOfString:@"\r"
1437
+ withString:@"\n"];
1438
+ [_textView.textStorage replaceCharactersInRange:range withString:sanitized];
1439
+ _textView.selectedRange = NSMakeRange(range.location + sanitized.length, 0);
1440
+ [self textViewDidChange:_textView];
1441
+ return NO;
1442
+ }
1443
+
1444
+ // Deleting into an atomic token removes the whole token.
1445
+ if (text.length == 0 && range.length > 0) {
1446
+ NSRange expanded = range;
1447
+ const NSRange headToken = [self atomicRangeAt:range.location];
1448
+ if (headToken.location != NSNotFound) {
1449
+ expanded = NSUnionRange(expanded, headToken);
1450
+ }
1451
+ if (range.length > 1) {
1452
+ const NSRange tailToken = [self atomicRangeAt:NSMaxRange(range) - 1];
1453
+ if (tailToken.location != NSNotFound) {
1454
+ expanded = NSUnionRange(expanded, tailToken);
1455
+ }
1456
+ }
1457
+ if (!NSEqualRanges(expanded, range)) {
1458
+ [_textView.textStorage replaceCharactersInRange:expanded withString:@""];
1459
+ _textView.selectedRange = NSMakeRange(expanded.location, 0);
1460
+ [self textContentChanged];
1461
+ return NO;
1462
+ }
1463
+ }
1464
+
1465
+ // Typing strictly inside an atomic token demotes it to plain text.
1466
+ if (text.length > 0 && range.length == 0 && range.location > 0) {
1467
+ const NSRange token = [self atomicRangeAt:range.location - 1];
1468
+ if (token.location != NSNotFound && range.location > token.location &&
1469
+ range.location < NSMaxRange(token)) {
1470
+ [self applyLink:nil atomic:NO inRange:token];
1471
+ }
1472
+ }
1473
+
1474
+ if ([text isEqualToString:@"\n"] && range.length == 0) {
1475
+ const NSRange content = [self contentRangeOfLineAt:range.location];
1476
+ const uint32_t block =
1477
+ content.length > 0 ? [self blockOfLineAt:content.location] : _typingBlock;
1478
+ if (block != 0 && content.length == 0) {
1479
+ // Enter on any empty formatted line (list item, quote, code block,
1480
+ // heading) exits the block instead of continuing it.
1481
+ _typingBlock = 0;
1482
+ [self applyTypingAttributes];
1483
+ [self updateInputTraits];
1484
+ [self invalidateDecorations];
1485
+ [self emitState];
1486
+ return NO;
1487
+ }
1488
+ if (JMDBlockType(block) == jetmarkdown::EditorBlockType::Heading) {
1489
+ // A heading does not continue onto the next line.
1490
+ _paragraphAfterNewline = YES;
1491
+ }
1492
+ }
1493
+
1494
+ // A growing word replacement is an autocorrect/QuickType commit; snapshot
1495
+ // the replaced characters' marks so they survive the rebuild (the size
1496
+ // cap keeps select-all replacements out of this path).
1497
+ _pendingMarkRestore = NO;
1498
+ if (range.length > 0 && range.length <= 512 && text.length >= range.length &&
1499
+ NSMaxRange(range) <= _textView.textStorage.length) {
1500
+ _replacedCharFlags.clear();
1501
+ NSTextStorage *storage = _textView.textStorage;
1502
+ for (NSUInteger i = 0; i < range.length; i++) {
1503
+ _replacedCharFlags.push_back(JMDFlagsFromValue(
1504
+ [storage attribute:JMDEditorMarksAttribute
1505
+ atIndex:range.location + i
1506
+ effectiveRange:nil]));
1507
+ }
1508
+ _markRestoreRange = NSMakeRange(range.location, text.length);
1509
+ _pendingMarkRestore = YES;
1510
+ }
1511
+
1512
+ // Backspace at the start of a formatted line clears the block first.
1513
+ if (text.length == 0 && range.length == 1 &&
1514
+ [_textView.text characterAtIndex:range.location] == '\n') {
1515
+ const NSUInteger lineStart = range.location + 1;
1516
+ const uint32_t block = [self blockOfLineAt:lineStart];
1517
+ if (block != 0) {
1518
+ [self applyBlock:0
1519
+ toLinesInRange:NSMakeRange(lineStart, 0)];
1520
+ _typingBlock = 0;
1521
+ [self applyTypingAttributes];
1522
+ [self invalidateDecorations];
1523
+ [self textContentChanged];
1524
+ [self emitState];
1525
+ return NO;
1526
+ }
1527
+ }
1528
+
1529
+ return YES;
1530
+ }
1531
+
1532
+ - (void)textViewDidChange:(UITextView *)textView {
1533
+ if (_pendingMarkRestore) {
1534
+ _pendingMarkRestore = NO;
1535
+ NSTextStorage *storage = _textView.textStorage;
1536
+ const NSUInteger location = _markRestoreRange.location;
1537
+ const NSUInteger count =
1538
+ MIN(_replacedCharFlags.size(), _markRestoreRange.length);
1539
+ if (location + count <= storage.length) {
1540
+ [storage beginEditing];
1541
+ for (NSUInteger i = 0; i < count; i++) {
1542
+ const NSUInteger position = location + i;
1543
+ NSDictionary *attrs = [storage attributesAtIndex:position
1544
+ effectiveRange:nil];
1545
+ const uint32_t existing =
1546
+ JMDFlagsFromValue(attrs[JMDEditorMarksAttribute]);
1547
+ const uint32_t desired = _replacedCharFlags[i];
1548
+ if (existing == desired) {
1549
+ continue;
1550
+ }
1551
+ const uint32_t block = JMDFlagsFromValue(attrs[JMDEditorBlockAttribute]);
1552
+ [storage setAttributes:[self attributesForFlags:desired
1553
+ block:block
1554
+ link:attrs[JMDEditorLinkAttribute]
1555
+ atomic:[attrs[JMDEditorAtomicAttribute]
1556
+ boolValue]]
1557
+ range:NSMakeRange(position, 1)];
1558
+ }
1559
+ [storage endEditing];
1560
+ }
1561
+ _replacedCharFlags.clear();
1562
+ }
1563
+
1564
+ if (_paragraphAfterNewline) {
1565
+ _paragraphAfterNewline = NO;
1566
+ _typingBlock = 0;
1567
+ [self applyTypingAttributes];
1568
+ }
1569
+
1570
+ // A typed newline inherits the previous line's attributes, and TextKit
1571
+ // sizes the trailing empty line fragment from them — after a heading the
1572
+ // caret would stay heading-sized. Normalize the newline: base font and
1573
+ // no marks, keeping the block attr for list/quote continuation (headings
1574
+ // never continue, so theirs is dropped).
1575
+ {
1576
+ const NSRange caret = textView.selectedRange;
1577
+ if (caret.length == 0 && caret.location > 0 &&
1578
+ caret.location <= textView.text.length &&
1579
+ [textView.text characterAtIndex:caret.location - 1] == '\n') {
1580
+ NSTextStorage *storage = textView.textStorage;
1581
+ const NSRange newline = NSMakeRange(caret.location - 1, 1);
1582
+ NSDictionary *attrs = [storage attributesAtIndex:newline.location
1583
+ effectiveRange:nil];
1584
+ uint32_t block = JMDFlagsFromValue(attrs[JMDEditorBlockAttribute]);
1585
+ if (JMDBlockType(block) == jetmarkdown::EditorBlockType::Heading) {
1586
+ block = 0;
1587
+ }
1588
+ [storage setAttributes:[self attributesForFlags:0 block:block]
1589
+ range:newline];
1590
+ }
1591
+ }
1592
+
1593
+ const NSRange selection = textView.selectedRange;
1594
+ if (selection.length == 0 && selection.location > 0 &&
1595
+ selection.location <= textView.text.length) {
1596
+ const unichar last = [textView.text characterAtIndex:selection.location - 1];
1597
+ if (JMDIsWordBreak(last)) {
1598
+ [self detectLinkBefore:selection.location - 1];
1599
+ }
1600
+ }
1601
+ [self updateMentionSession];
1602
+ [self textContentChanged];
1603
+ // The heading-to-paragraph reset above changes the caret context after
1604
+ // didChangeSelection already emitted; re-emit so toolbars never show a
1605
+ // stale block.
1606
+ [self emitState];
1607
+ }
1608
+
1609
+ - (void)textViewDidChangeSelection:(UITextView *)textView {
1610
+ const NSRange selection = textView.selectedRange;
1611
+ const BOOL moved = !NSEqualRanges(selection, _lastSelection);
1612
+ _lastSelection = selection;
1613
+ if (moved && selection.length == 0) {
1614
+ // Sticky typing state: inherit the marks of the character before the
1615
+ // caret (which is the just-typed character while typing).
1616
+ _typingFlags = [self flagsBeforeCaret:selection.location];
1617
+ const NSRange content = [self contentRangeOfLineAt:selection.location];
1618
+ if (content.length > 0) {
1619
+ _typingBlock = [self blockOfLineAt:content.location];
1620
+ } else if (selection.location > 0 &&
1621
+ selection.location <= _textView.textStorage.length) {
1622
+ // Empty line: inherit the block carried by the preceding newline so
1623
+ // lists continue across Enter.
1624
+ _typingBlock = JMDFlagsFromValue([_textView.textStorage
1625
+ attribute:JMDEditorBlockAttribute
1626
+ atIndex:selection.location - 1
1627
+ effectiveRange:nil]);
1628
+ } else {
1629
+ _typingBlock = 0;
1630
+ }
1631
+ [self applyTypingAttributes];
1632
+ [self updateInputTraits];
1633
+ }
1634
+ if (moved) {
1635
+ [self updateMentionSession];
1636
+ // The empty caret line renders its armed block (marker/stripe), so a
1637
+ // caret move can change what the decorations show.
1638
+ [self invalidateDecorations];
1639
+ }
1640
+ [self emitState];
1641
+ if (moved) {
1642
+ if (const auto *emitter = [self editorEventEmitter]) {
1643
+ emitter->onEditorChangeSelection({
1644
+ .start = static_cast<int>(selection.location),
1645
+ .end = static_cast<int>(selection.location + selection.length),
1646
+ });
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ - (void)textViewDidBeginEditing:(UITextView *)textView {
1652
+ if (_suppressFocusEvents) {
1653
+ return;
1654
+ }
1655
+ if (const auto *emitter = [self editorEventEmitter]) {
1656
+ emitter->onEditorFocus({});
1657
+ }
1658
+ }
1659
+
1660
+ - (void)textViewDidEndEditing:(UITextView *)textView {
1661
+ if (_suppressFocusEvents) {
1662
+ return;
1663
+ }
1664
+ if (const auto *emitter = [self editorEventEmitter]) {
1665
+ emitter->onEditorBlur({});
1666
+ }
1667
+ }
1668
+
1669
+ #pragma mark - Commands
1670
+
1671
+ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
1672
+ RCTJetMarkdownEditorHandleCommand(self, commandName, args);
1673
+ }
1674
+
1675
+ - (void)focus {
1676
+ [_textView becomeFirstResponder];
1677
+ }
1678
+
1679
+ - (void)blur {
1680
+ [_textView resignFirstResponder];
1681
+ }
1682
+
1683
+ - (NSMutableAttributedString *)attributedContentFromMarkdown:(const std::string &)markdown {
1684
+ const auto document = jetmarkdown::editorFromMarkdown(markdown);
1685
+ NSString *text = JMDStringFromCpp(document.text);
1686
+ NSMutableAttributedString *attributed = [[NSMutableAttributedString alloc]
1687
+ initWithString:text
1688
+ attributes:[self attributesForFlags:0 block:0]];
1689
+
1690
+ // Line blocks first (line granularity), then mark runs refine spans.
1691
+ NSUInteger location = 0;
1692
+ size_t lineIndex = 0;
1693
+ while (location <= text.length && lineIndex < document.lines.size()) {
1694
+ NSUInteger start = 0;
1695
+ NSUInteger contentsEnd = 0;
1696
+ NSUInteger end = 0;
1697
+ [text getLineStart:&start
1698
+ end:&end
1699
+ contentsEnd:&contentsEnd
1700
+ forRange:NSMakeRange(MIN(location, text.length), 0)];
1701
+ const auto &line = document.lines[lineIndex];
1702
+ const uint32_t block = JMDPackBlock(line.type, line.level);
1703
+ if (block != 0 && contentsEnd > start) {
1704
+ [attributed setAttributes:[self attributesForFlags:0 block:block]
1705
+ range:NSMakeRange(start, contentsEnd - start)];
1706
+ }
1707
+ if (end <= location || end > text.length) {
1708
+ break;
1709
+ }
1710
+ location = end;
1711
+ lineIndex++;
1712
+ if (contentsEnd == end) {
1713
+ break;
1714
+ }
1715
+ }
1716
+
1717
+ for (const auto &run : document.runs) {
1718
+ const NSRange range = NSMakeRange(run.start, run.end - run.start);
1719
+ if (NSMaxRange(range) <= attributed.length) {
1720
+ uint32_t block = 0;
1721
+ if (range.location < attributed.length) {
1722
+ block = JMDFlagsFromValue([attributed attribute:JMDEditorBlockAttribute
1723
+ atIndex:range.location
1724
+ effectiveRange:nil]);
1725
+ }
1726
+ [attributed setAttributes:[self attributesForFlags:run.flags block:block]
1727
+ range:range];
1728
+ }
1729
+ }
1730
+
1731
+ for (const auto &link : document.links) {
1732
+ const NSRange range = NSMakeRange(link.start, link.end - link.start);
1733
+ if (NSMaxRange(range) <= attributed.length && range.length > 0) {
1734
+ [attributed enumerateAttributesInRange:range
1735
+ options:0
1736
+ usingBlock:^(NSDictionary *attrs, NSRange runRange, BOOL *stop) {
1737
+ [attributed setAttributes:
1738
+ [self attributesForFlags:JMDFlagsFromValue(
1739
+ attrs[JMDEditorMarksAttribute])
1740
+ block:JMDFlagsFromValue(
1741
+ attrs[JMDEditorBlockAttribute])
1742
+ link:JMDStringFromCpp(link.url)
1743
+ atomic:NO]
1744
+ range:runRange];
1745
+ }];
1746
+ }
1747
+ }
1748
+
1749
+ return attributed;
1750
+ }
1751
+
1752
+ - (void)applyMarkdownValue:(const std::string &)markdown {
1753
+ _textView.attributedText = [self attributedContentFromMarkdown:markdown];
1754
+ _typingFlags = 0;
1755
+ _typingBlock = 0;
1756
+ [self applyTypingAttributes];
1757
+ [self textContentChanged];
1758
+ }
1759
+
1760
+ - (void)setValue:(NSString *)value {
1761
+ [self applyMarkdownValue:std::string(value.UTF8String ?: "")];
1762
+ }
1763
+
1764
+ - (void)insertMarkdown:(NSString *)value {
1765
+ const std::string markdown(value.UTF8String ?: "");
1766
+ if (markdown.empty()) {
1767
+ return;
1768
+ }
1769
+ NSAttributedString *content = [self attributedContentFromMarkdown:markdown];
1770
+ const NSRange selection = _textView.selectedRange;
1771
+ [_textView.textStorage replaceCharactersInRange:selection
1772
+ withAttributedString:content];
1773
+ _textView.selectedRange = NSMakeRange(selection.location + content.length, 0);
1774
+ [self textContentChanged];
1775
+ [self emitState];
1776
+ }
1777
+
1778
+ #pragma mark - JMDEditorTextViewActions
1779
+
1780
+ - (void)editorTextViewDidPaste {
1781
+ UIPasteboard *pasteboard = UIPasteboard.generalPasteboard;
1782
+ const std::string text(pasteboard.string.UTF8String ?: "");
1783
+
1784
+ if (!pasteboard.hasImages) {
1785
+ if (const auto *emitter = [self editorEventEmitter]) {
1786
+ emitter->onEditorPaste({.images = {}, .text = text});
1787
+ }
1788
+ return;
1789
+ }
1790
+
1791
+ // PNG-encoding pasted photos synchronously would freeze the main thread
1792
+ // for the whole encode + disk write; do it off-main and emit when done.
1793
+ NSArray<UIImage *> *pastedImages = pasteboard.images;
1794
+ __weak __typeof(self) weakSelf = self;
1795
+ dispatch_async(
1796
+ dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
1797
+ auto images =
1798
+ std::vector<JetMarkdownEditorEventEmitter::OnEditorPasteImages>();
1799
+ for (UIImage *image in pastedImages) {
1800
+ NSData *data = UIImagePNGRepresentation(image);
1801
+ if (data == nil) {
1802
+ continue;
1803
+ }
1804
+ NSString *path = [NSTemporaryDirectory()
1805
+ stringByAppendingPathComponent:
1806
+ [NSString
1807
+ stringWithFormat:@"jmd-paste-%@.png", NSUUID.UUID.UUIDString]];
1808
+ if (![data writeToFile:path atomically:YES]) {
1809
+ continue;
1810
+ }
1811
+ images.push_back({
1812
+ .height = image.size.height,
1813
+ .url = std::string([NSString stringWithFormat:@"file://%@", path].UTF8String),
1814
+ .width = image.size.width,
1815
+ });
1816
+ }
1817
+ auto shared =
1818
+ std::make_shared<std::vector<JetMarkdownEditorEventEmitter::OnEditorPasteImages>>(
1819
+ std::move(images));
1820
+ dispatch_async(dispatch_get_main_queue(), ^{
1821
+ __typeof(self) strongSelf = weakSelf;
1822
+ if (strongSelf == nil) {
1823
+ return;
1824
+ }
1825
+ if (const auto *emitter = [strongSelf editorEventEmitter]) {
1826
+ emitter->onEditorPaste({.images = std::move(*shared), .text = text});
1827
+ }
1828
+ });
1829
+ });
1830
+ }
1831
+
1832
+ - (void)editorTextViewShortcut:(uint32_t)mark {
1833
+ [self toggleMark:mark];
1834
+ }
1835
+
1836
+ - (BOOL)editorTextViewHandleDeleteAtDocumentStart {
1837
+ const uint32_t block = [self blockOfLineAt:0] ?: _typingBlock;
1838
+ if (block == 0) {
1839
+ return NO;
1840
+ }
1841
+ [self applyBlock:0 toLinesInRange:NSMakeRange(0, 0)];
1842
+ _typingBlock = 0;
1843
+ [self applyTypingAttributes];
1844
+ [self updateInputTraits];
1845
+ [self invalidateDecorations];
1846
+ [self textContentChanged];
1847
+ [self emitState];
1848
+ return YES;
1849
+ }
1850
+
1851
+ - (void)setSelection:(NSInteger)start end:(NSInteger)end {
1852
+ const NSInteger length = (NSInteger)_textView.text.length;
1853
+ const NSInteger clampedStart = MAX(0, MIN(start, length));
1854
+ const NSInteger clampedEnd = MAX(clampedStart, MIN(end, length));
1855
+ _textView.selectedRange = NSMakeRange(clampedStart, clampedEnd - clampedStart);
1856
+ }
1857
+
1858
+ - (void)toggleBold {
1859
+ [self toggleMark:jetmarkdown::MarkBold];
1860
+ }
1861
+
1862
+ - (void)toggleCode {
1863
+ [self toggleMark:jetmarkdown::MarkInlineCode];
1864
+ }
1865
+
1866
+ - (void)toggleItalic {
1867
+ [self toggleMark:jetmarkdown::MarkItalic];
1868
+ }
1869
+
1870
+ - (void)toggleSpoiler {
1871
+ [self toggleMark:jetmarkdown::MarkSpoiler];
1872
+ }
1873
+
1874
+ - (void)toggleStrikethrough {
1875
+ [self toggleMark:jetmarkdown::MarkStrikethrough];
1876
+ }
1877
+
1878
+ - (void)toggleSubscript {
1879
+ [self toggleMark:jetmarkdown::MarkSubscript];
1880
+ }
1881
+
1882
+ - (void)toggleSuperscript {
1883
+ [self toggleMark:jetmarkdown::MarkSuperscript];
1884
+ }
1885
+
1886
+ - (void)toggleBlockQuote {
1887
+ [self toggleBlock:jetmarkdown::EditorBlockType::Quote level:0];
1888
+ }
1889
+
1890
+ - (void)toggleCodeBlock {
1891
+ [self toggleBlock:jetmarkdown::EditorBlockType::Code level:0];
1892
+ }
1893
+
1894
+ - (void)toggleHeading:(NSInteger)level {
1895
+ [self toggleBlock:jetmarkdown::EditorBlockType::Heading
1896
+ level:(uint8_t)MAX(1, MIN(level, 6))];
1897
+ }
1898
+
1899
+ - (void)toggleOrderedList {
1900
+ [self toggleBlock:jetmarkdown::EditorBlockType::Ordered level:0];
1901
+ }
1902
+
1903
+ - (void)toggleUnorderedList {
1904
+ [self toggleBlock:jetmarkdown::EditorBlockType::Bullet level:0];
1905
+ }
1906
+
1907
+ - (NSString *)singleLine:(NSString *)value {
1908
+ NSString *flattened =
1909
+ [[value stringByReplacingOccurrencesOfString:@"\r" withString:@" "]
1910
+ stringByReplacingOccurrencesOfString:@"\n"
1911
+ withString:@" "];
1912
+ return flattened;
1913
+ }
1914
+
1915
+ - (void)insertLink:(NSString *)url label:(NSString *)label {
1916
+ if (url.length == 0) {
1917
+ return;
1918
+ }
1919
+ label = [self singleLine:label ?: @""];
1920
+ const NSRange selection = _textView.selectedRange;
1921
+ // A code fence carries raw text only — a link there would render in the
1922
+ // editor but vanish from the markdown.
1923
+ if ([self selectionTouchesCodeBlock:selection]) {
1924
+ return;
1925
+ }
1926
+ if (selection.length > 0) {
1927
+ [self applyLink:url atomic:NO inRange:selection];
1928
+ _textView.selectedRange = NSMakeRange(NSMaxRange(selection), 0);
1929
+ } else {
1930
+ NSString *content = label.length > 0 ? label : url;
1931
+ NSAttributedString *linked = [[NSAttributedString alloc]
1932
+ initWithString:content
1933
+ attributes:[self attributesForFlags:_typingFlags
1934
+ block:_typingBlock
1935
+ link:url
1936
+ atomic:NO]];
1937
+ [_textView.textStorage insertAttributedString:linked
1938
+ atIndex:selection.location];
1939
+ _textView.selectedRange = NSMakeRange(selection.location + content.length, 0);
1940
+ }
1941
+ [self applyTypingAttributes];
1942
+ [self textContentChanged];
1943
+ }
1944
+
1945
+ - (void)removeLink {
1946
+ const NSRange selection = _textView.selectedRange;
1947
+ NSRange target = selection;
1948
+ if (selection.length == 0) {
1949
+ NSTextStorage *storage = _textView.textStorage;
1950
+ const NSUInteger probe =
1951
+ selection.location > 0 ? selection.location - 1 : 0;
1952
+ if (storage.length == 0 || probe >= storage.length) {
1953
+ return;
1954
+ }
1955
+ NSRange effective = NSMakeRange(NSNotFound, 0);
1956
+ id value = [storage attribute:JMDEditorLinkAttribute
1957
+ atIndex:probe
1958
+ longestEffectiveRange:&effective
1959
+ inRange:NSMakeRange(0, storage.length)];
1960
+ if (value == nil) {
1961
+ return;
1962
+ }
1963
+ target = effective;
1964
+ }
1965
+ [self applyLink:nil atomic:NO inRange:target];
1966
+ [self textContentChanged];
1967
+ }
1968
+
1969
+ - (void)insertMention:(NSString *)trigger label:(NSString *)label url:(NSString *)url {
1970
+ if (label.length == 0 || url.length == 0) {
1971
+ return;
1972
+ }
1973
+ label = [self singleLine:label];
1974
+ if ([self selectionTouchesCodeBlock:_textView.selectedRange]) {
1975
+ return;
1976
+ }
1977
+ // Replaces the active mention query (trigger included), or inserts at
1978
+ // the caret. A trailing space keeps typing outside the token.
1979
+ NSRange target = _textView.selectedRange;
1980
+ if (_mentionActive) {
1981
+ target = NSMakeRange(_mentionStart, target.location - _mentionStart);
1982
+ }
1983
+ NSString *token = [NSString stringWithFormat:@"%@%@", trigger ?: @"", label];
1984
+ NSMutableAttributedString *inserted = [[NSMutableAttributedString alloc]
1985
+ initWithString:token
1986
+ attributes:[self attributesForFlags:0
1987
+ block:_typingBlock
1988
+ link:url
1989
+ atomic:YES]];
1990
+ [inserted appendAttributedString:[[NSAttributedString alloc]
1991
+ initWithString:@" "
1992
+ attributes:[self attributesForFlags:0 block:_typingBlock]]];
1993
+ [_textView.textStorage replaceCharactersInRange:target
1994
+ withAttributedString:inserted];
1995
+ _textView.selectedRange = NSMakeRange(target.location + inserted.length, 0);
1996
+ [self endMentionSession];
1997
+ _typingFlags = 0;
1998
+ [self applyTypingAttributes];
1999
+ [self textContentChanged];
2000
+ }
2001
+
2002
+ @end