react-native-enriched-markdown 0.3.0 → 0.4.0-nightly-20260228-6747e87e4

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.
@@ -24,35 +24,18 @@ data class BlockStyle(
24
24
  val color: Int,
25
25
  )
26
26
 
27
- private class MutableBlockStyle {
28
- var fontSize: Float = 0f
29
- var fontFamily: String = ""
30
- var fontWeight: String = ""
31
- var color: Int = 0
32
- var isDirty: Boolean = false
33
-
34
- fun updateFrom(style: BaseBlockStyle) {
35
- fontSize = style.fontSize
36
- fontFamily = style.fontFamily
37
- fontWeight = style.fontWeight
38
- color = style.color
39
- isDirty = true
40
- }
41
-
42
- fun toImmutable(): BlockStyle = BlockStyle(fontSize, fontFamily, fontWeight, color)
43
-
44
- fun clear() {
45
- isDirty = false
46
- }
47
- }
27
+ private data class BlockStyleEntry(
28
+ val blockType: BlockType,
29
+ val blockStyle: BlockStyle,
30
+ val headingLevel: Int,
31
+ )
48
32
 
49
33
  class BlockStyleContext {
50
34
  var currentBlockType = BlockType.NONE
51
35
  private set
52
36
 
53
- private val mutableBlockStyle = MutableBlockStyle()
54
- private var cachedBlockStyle: BlockStyle? = null
55
37
  private var currentHeadingLevel = 0
38
+ private val blockStyleStack = ArrayDeque<BlockStyleEntry>()
56
39
 
57
40
  var blockquoteDepth = 0
58
41
  var listDepth = 0
@@ -64,37 +47,58 @@ class BlockStyleContext {
64
47
 
65
48
  enum class ListType { UNORDERED, ORDERED }
66
49
 
67
- private fun updateBlockStyle(
50
+ private fun pushBlockStyle(
68
51
  type: BlockType,
69
52
  style: BaseBlockStyle,
70
53
  headingLevel: Int = 0,
71
54
  ) {
55
+ val entry =
56
+ BlockStyleEntry(
57
+ blockType = type,
58
+ blockStyle = BlockStyle(style.fontSize, style.fontFamily, style.fontWeight, style.color),
59
+ headingLevel = headingLevel,
60
+ )
61
+
62
+ blockStyleStack.addLast(entry)
72
63
  currentBlockType = type
73
64
  currentHeadingLevel = headingLevel
74
- mutableBlockStyle.updateFrom(style)
75
- cachedBlockStyle = null
76
65
  }
77
66
 
78
- fun setParagraphStyle(style: ParagraphStyle) = updateBlockStyle(BlockType.PARAGRAPH, style)
67
+ fun popBlockStyle() {
68
+ if (blockStyleStack.isNotEmpty()) {
69
+ blockStyleStack.removeLast()
70
+ }
71
+
72
+ val parentStyle = blockStyleStack.lastOrNull()
73
+ if (parentStyle != null) {
74
+ currentBlockType = parentStyle.blockType
75
+ currentHeadingLevel = parentStyle.headingLevel
76
+ } else {
77
+ currentBlockType = BlockType.NONE
78
+ currentHeadingLevel = 0
79
+ }
80
+ }
81
+
82
+ fun setParagraphStyle(style: ParagraphStyle) = pushBlockStyle(BlockType.PARAGRAPH, style)
79
83
 
80
84
  fun setHeadingStyle(
81
85
  style: HeadingStyle,
82
86
  level: Int,
83
- ) = updateBlockStyle(BlockType.HEADING, style, level)
87
+ ) = pushBlockStyle(BlockType.HEADING, style, level)
84
88
 
85
- fun setBlockquoteStyle(style: BlockquoteStyle) = updateBlockStyle(BlockType.BLOCKQUOTE, style)
89
+ fun setBlockquoteStyle(style: BlockquoteStyle) = pushBlockStyle(BlockType.BLOCKQUOTE, style)
86
90
 
87
91
  fun setUnorderedListStyle(style: ListStyle) {
88
92
  listType = ListType.UNORDERED
89
- updateBlockStyle(BlockType.UNORDERED_LIST, style)
93
+ pushBlockStyle(BlockType.UNORDERED_LIST, style)
90
94
  }
91
95
 
92
96
  fun setOrderedListStyle(style: ListStyle) {
93
97
  listType = ListType.ORDERED
94
- updateBlockStyle(BlockType.ORDERED_LIST, style)
98
+ pushBlockStyle(BlockType.ORDERED_LIST, style)
95
99
  }
96
100
 
97
- fun setCodeBlockStyle(style: CodeBlockStyle) = updateBlockStyle(BlockType.CODE_BLOCK, style)
101
+ fun setCodeBlockStyle(style: CodeBlockStyle) = pushBlockStyle(BlockType.CODE_BLOCK, style)
98
102
 
99
103
  fun isInsideBlockElement(): Boolean = blockquoteDepth > 0 || listDepth > 0
100
104
 
@@ -117,39 +121,26 @@ class BlockStyleContext {
117
121
  }
118
122
 
119
123
  fun clearListStyle() {
124
+ popBlockStyle()
125
+
120
126
  if (listDepth == 0) {
121
- reset()
127
+ listType = null
128
+ listItemNumber = 0
129
+ orderedListItemNumbers.clear()
122
130
  }
123
131
  }
124
132
 
125
- private fun reset() {
126
- clearBlockStyle()
127
- listType = null
128
- listItemNumber = 0
129
- orderedListItemNumbers.clear()
130
- }
131
-
132
133
  fun requireBlockStyle(): BlockStyle {
133
- if (!mutableBlockStyle.isDirty) {
134
- throw IllegalStateException(
134
+ val entry = blockStyleStack.lastOrNull()
135
+ return entry?.blockStyle
136
+ ?: throw IllegalStateException(
135
137
  "BlockStyle is null. Inline renderers must be used within a block context.",
136
138
  )
137
- }
138
-
139
- return cachedBlockStyle ?: mutableBlockStyle.toImmutable().also { cachedBlockStyle = it }
140
- }
141
-
142
- fun clearBlockStyle() {
143
- currentBlockType = BlockType.NONE
144
- mutableBlockStyle.clear()
145
- cachedBlockStyle = null
146
- currentHeadingLevel = 0
147
139
  }
148
140
 
149
141
  fun resetForNewRender() {
142
+ blockStyleStack.clear()
150
143
  currentBlockType = BlockType.NONE
151
- mutableBlockStyle.clear()
152
- cachedBlockStyle = null
153
144
  currentHeadingLevel = 0
154
145
  blockquoteDepth = 0
155
146
  listDepth = 0
@@ -30,7 +30,7 @@ class BlockquoteRenderer(
30
30
  try {
31
31
  factory.renderChildren(node, builder, onLinkPress, onLinkLongPress)
32
32
  } finally {
33
- context.clearBlockStyle()
33
+ context.popBlockStyle()
34
34
  context.blockquoteDepth = depth
35
35
  }
36
36
 
@@ -34,7 +34,7 @@ class CodeBlockRenderer(
34
34
  try {
35
35
  factory.renderChildren(node, builder, onLinkPress, onLinkLongPress)
36
36
  } finally {
37
- context.clearBlockStyle()
37
+ context.popBlockStyle()
38
38
  }
39
39
 
40
40
  if (builder.length == contentStart) return
@@ -23,12 +23,13 @@ class HeadingRenderer(
23
23
  val start = builder.length
24
24
 
25
25
  val headingStyle = config.style.headingStyles[level]!!
26
- factory.blockStyleContext.setHeadingStyle(headingStyle, level)
26
+ val blockStyleContext = factory.blockStyleContext
27
+ blockStyleContext.setHeadingStyle(headingStyle, level)
27
28
 
28
29
  try {
29
30
  factory.renderChildren(node, builder, onLinkPress, onLinkLongPress)
30
31
  } finally {
31
- factory.blockStyleContext.clearBlockStyle()
32
+ blockStyleContext.popBlockStyle()
32
33
  }
33
34
 
34
35
  val end = builder.length
@@ -1,7 +1,6 @@
1
1
  package com.swmansion.enriched.markdown.renderer
2
2
 
3
3
  import com.swmansion.enriched.markdown.styles.ListStyle
4
- import com.swmansion.enriched.markdown.styles.StyleConfig
5
4
 
6
5
  /**
7
6
  * Manages list context transitions (entering/exiting lists, handling nesting, etc.).
@@ -16,7 +15,6 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
16
15
  */
17
16
  class ListContextManager(
18
17
  private val context: BlockStyleContext,
19
- private val styleConfig: StyleConfig,
20
18
  ) {
21
19
  /**
22
20
  * Captures the state when entering a list, needed for proper restoration when exiting.
@@ -37,7 +35,7 @@ class ListContextManager(
37
35
  */
38
36
  fun enterList(
39
37
  listType: BlockStyleContext.ListType,
40
- style: Any,
38
+ style: ListStyle,
41
39
  ): ListEntryState {
42
40
  val previousDepth = context.listDepth
43
41
  val isNested = previousDepth > 0
@@ -53,11 +51,11 @@ class ListContextManager(
53
51
  context.listDepth = previousDepth + 1
54
52
  when (listType) {
55
53
  BlockStyleContext.ListType.ORDERED -> {
56
- context.setOrderedListStyle(style as ListStyle)
54
+ context.setOrderedListStyle(style)
57
55
  }
58
56
 
59
57
  BlockStyleContext.ListType.UNORDERED -> {
60
- context.setUnorderedListStyle(style as ListStyle)
58
+ context.setUnorderedListStyle(style)
61
59
  }
62
60
  }
63
61
  context.resetListItemNumber()
@@ -71,35 +69,21 @@ class ListContextManager(
71
69
 
72
70
  /**
73
71
  * Exits a list context. Handles:
74
- * - Clearing list style (only if top-level, depth == 0)
72
+ * - Popping current list block style from stack
75
73
  * - Decrementing list depth back to previousDepth
76
74
  * - Restoring parent list item numbers from stack (if applicable)
77
- * - Restoring parent list context (if nested) so subsequent parent items render correctly
75
+ * - Restoring parent list metadata (if nested) so subsequent parent items render correctly
78
76
  */
79
77
  fun exitList(entryState: ListEntryState) {
80
- context.clearListStyle()
81
78
  context.listDepth = entryState.previousDepth
79
+ context.clearListStyle()
82
80
 
83
81
  if (entryState.wasNestedInOrderedList) {
84
82
  context.popOrderedListItemNumber()
85
83
  }
86
84
 
87
85
  if (entryState.previousDepth > 0) {
88
- restoreParentListContext(entryState.parentListType)
89
- }
90
- }
91
-
92
- private fun restoreParentListContext(parentListType: BlockStyleContext.ListType?) {
93
- when (parentListType) {
94
- BlockStyleContext.ListType.UNORDERED -> {
95
- context.setUnorderedListStyle(styleConfig.listStyle)
96
- }
97
-
98
- BlockStyleContext.ListType.ORDERED -> {
99
- context.setOrderedListStyle(styleConfig.listStyle)
100
- }
101
-
102
- null -> {}
86
+ context.listType = entryState.parentListType
103
87
  }
104
88
  }
105
89
  }
@@ -22,7 +22,7 @@ class ListRenderer(
22
22
  val listStyle = config.style.listStyle
23
23
  val listType = if (isOrdered) BlockStyleContext.ListType.ORDERED else BlockStyleContext.ListType.UNORDERED
24
24
 
25
- val contextManager = ListContextManager(factory.blockStyleContext, config.style)
25
+ val contextManager = ListContextManager(factory.blockStyleContext)
26
26
  val entryState = contextManager.enterList(listType, listStyle)
27
27
 
28
28
  // For top-level lists, insert marginTop spacer before rendering content
@@ -36,7 +36,7 @@ class ParagraphRenderer(
36
36
  try {
37
37
  factory.renderChildren(node, builder, onLinkPress, onLinkLongPress)
38
38
  } finally {
39
- context.clearBlockStyle()
39
+ context.popBlockStyle()
40
40
  }
41
41
 
42
42
  if (builder.length > start) {
@@ -2,9 +2,9 @@
2
2
  #import "AccessibilityInfo.h"
3
3
  #import "AttributedRenderer.h"
4
4
  #import "CodeBlockBackground.h"
5
+ #import "ENRMImageAttachment.h"
5
6
  #import "ENRMMarkdownParser.h"
6
7
  #import "EditMenuUtils.h"
7
- #import "EnrichedMarkdownImageAttachment.h"
8
8
  #import "FontScaleObserver.h"
9
9
  #import "FontUtils.h"
10
10
  #import "LastElementUtils.h"
@@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
11
11
  * Images are loaded asynchronously and scaled dynamically based on text container width.
12
12
  * Supports inline and block images with custom height and border radius from config.
13
13
  */
14
- @interface EnrichedMarkdownImageAttachment : NSTextAttachment
14
+ @interface ENRMImageAttachment : NSTextAttachment
15
15
 
16
16
  @property (nonatomic, readonly) NSString *imageURL;
17
17
  @property (nonatomic, readonly) BOOL isInline;
@@ -0,0 +1,271 @@
1
+ #import "ENRMImageAttachment.h"
2
+ #import "RuntimeKeys.h"
3
+ #import "StyleConfig.h"
4
+ #import <React/RCTLog.h>
5
+ #import <objc/runtime.h>
6
+
7
+ @interface ENRMImageAttachment ()
8
+
9
+ @property (nonatomic, readwrite) NSString *imageURL;
10
+ @property (nonatomic, weak) StyleConfig *styleConfiguration;
11
+ @property (nonatomic, assign) BOOL isInline;
12
+ @property (nonatomic, assign) CGFloat cachedHeight;
13
+ @property (nonatomic, assign) CGFloat cachedBorderRadius;
14
+ @property (nonatomic, weak) NSTextContainer *textContainer;
15
+ @property (nonatomic, weak) UITextView *textView;
16
+ @property (nonatomic, strong) UIImage *originalImage;
17
+ @property (nonatomic, strong) UIImage *loadedImage;
18
+ @property (nonatomic, strong) NSURLSessionDataTask *loadingTask;
19
+
20
+ @end
21
+
22
+ @implementation ENRMImageAttachment
23
+
24
+ - (instancetype)initWithImageURL:(NSString *)imageURL config:(StyleConfig *)config isInline:(BOOL)isInline
25
+ {
26
+ self = [super init];
27
+ if (self) {
28
+ _imageURL = imageURL;
29
+ _styleConfiguration = config;
30
+ _isInline = isInline;
31
+
32
+ _cachedHeight = isInline ? [config inlineImageSize] : [config imageHeight];
33
+ _cachedBorderRadius = [config imageBorderRadius];
34
+
35
+ [self setupPlaceholder];
36
+ [self startDownloadingImage];
37
+ }
38
+ return self;
39
+ }
40
+
41
+ - (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer
42
+ proposedLineFragment:(CGRect)lineFragment
43
+ glyphPosition:(CGPoint)position
44
+ characterIndex:(NSUInteger)characterIndex
45
+ {
46
+ CGFloat height = self.cachedHeight;
47
+ CGFloat width = self.isInline ? height : (lineFragment.size.width > 0 ? lineFragment.size.width : height);
48
+
49
+ if (self.isInline) {
50
+ UIFont *appliedFont = nil;
51
+ NSLayoutManager *layoutManager = textContainer.layoutManager;
52
+ NSTextStorage *textStorage = layoutManager.textStorage;
53
+
54
+ if (textStorage && characterIndex < textStorage.length) {
55
+ appliedFont = [textStorage attribute:NSFontAttributeName atIndex:characterIndex effectiveRange:NULL];
56
+ }
57
+
58
+ // Determine the vertical alignment:
59
+ // Center against the font's Capital Height if available,
60
+ // otherwise center within the line fragment.
61
+ CGFloat verticalOffset;
62
+ if (appliedFont) {
63
+ verticalOffset = (appliedFont.capHeight - height) / 2.0;
64
+ } else {
65
+ verticalOffset = (lineFragment.size.height - height) / 2.0;
66
+ }
67
+
68
+ return CGRectMake(0, verticalOffset, width, height);
69
+ }
70
+
71
+ return CGRectMake(0, 0, width, height);
72
+ }
73
+
74
+ - (UIImage *)imageForBounds:(CGRect)imageBounds
75
+ textContainer:(NSTextContainer *)textContainer
76
+ characterIndex:(NSUInteger)characterIndex
77
+ {
78
+ self.textContainer = textContainer;
79
+
80
+ if (self.originalImage && imageBounds.size.width > 0) {
81
+ CGFloat currentWidth = imageBounds.size.width;
82
+
83
+ BOOL isFirstLoad = (self.loadedImage == nil);
84
+ BOOL hasWidthChanged = !self.isInline && self.loadedImage && fabs(self.loadedImage.size.width - currentWidth) > 1.0;
85
+
86
+ if (isFirstLoad || hasWidthChanged) {
87
+ self.bounds = imageBounds;
88
+ [self processAndApplyImage:self.originalImage withTargetWidth:currentWidth];
89
+ }
90
+ }
91
+
92
+ return self.loadedImage ?: self.image;
93
+ }
94
+
95
+ - (void)handleLoadedImage:(UIImage *)image
96
+ {
97
+ if (!image) {
98
+ return;
99
+ }
100
+
101
+ self.originalImage = image;
102
+ CGFloat targetWidth = self.isInline ? self.cachedHeight : self.bounds.size.width;
103
+
104
+ // If bounds width isn't known yet (image loaded before layout), defer scaling
105
+ // until imageForBounds: provides the real width via the text system.
106
+ if (!self.isInline && targetWidth <= self.cachedHeight) {
107
+ return;
108
+ }
109
+
110
+ [self processAndApplyImage:image withTargetWidth:targetWidth];
111
+ }
112
+
113
+ - (void)processAndApplyImage:(UIImage *)image withTargetWidth:(CGFloat)targetWidth
114
+ {
115
+ if (targetWidth <= 0) {
116
+ return;
117
+ }
118
+
119
+ __weak typeof(self) weakSelf = self;
120
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
121
+ __strong typeof(weakSelf) strongSelf = weakSelf;
122
+ if (!strongSelf)
123
+ return;
124
+
125
+ UIImage *processedImage = [strongSelf createScaledImage:image
126
+ toWidth:targetWidth
127
+ height:strongSelf.cachedHeight
128
+ borderRadius:strongSelf.cachedBorderRadius];
129
+
130
+ dispatch_async(dispatch_get_main_queue(), ^{
131
+ strongSelf.loadedImage = processedImage;
132
+
133
+ if (strongSelf.isInline) {
134
+ strongSelf.image = processedImage;
135
+ strongSelf.bounds = CGRectMake(0, 0, strongSelf.cachedHeight, strongSelf.cachedHeight);
136
+ } else {
137
+ strongSelf.image = image;
138
+ }
139
+
140
+ [strongSelf refreshDisplay];
141
+ });
142
+ });
143
+ }
144
+
145
+ - (UIImage *)createScaledImage:(UIImage *)image
146
+ toWidth:(CGFloat)targetWidth
147
+ height:(CGFloat)targetHeight
148
+ borderRadius:(CGFloat)radius
149
+ {
150
+ CGFloat sourceWidth = image.size.width;
151
+ CGFloat sourceHeight = image.size.height;
152
+
153
+ CGFloat drawingWidth, drawingHeight;
154
+
155
+ if (!self.isInline && sourceWidth > 0 && sourceHeight > 0) {
156
+ CGFloat aspectRatioScale = targetWidth / sourceWidth;
157
+ drawingWidth = targetWidth;
158
+ drawingHeight = sourceHeight * aspectRatioScale;
159
+ } else {
160
+ drawingWidth = targetWidth;
161
+ drawingHeight = targetHeight;
162
+ }
163
+
164
+ CGFloat xOffset = (targetWidth - drawingWidth) / 2.0;
165
+ CGFloat yOffset = (targetHeight - drawingHeight) / 2.0;
166
+ CGRect drawingRect = CGRectMake(xOffset, yOffset, drawingWidth, drawingHeight);
167
+
168
+ UIGraphicsImageRenderer *renderer =
169
+ [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(targetWidth, targetHeight)];
170
+
171
+ return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
172
+ if (radius > 0) {
173
+ CGRect clippingRect = CGRectIntersection(CGRectMake(0, 0, targetWidth, targetHeight), drawingRect);
174
+ UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:clippingRect cornerRadius:radius];
175
+ [path addClip];
176
+ }
177
+ [image drawInRect:drawingRect];
178
+ }];
179
+ }
180
+
181
+ - (void)refreshDisplay
182
+ {
183
+ UITextView *textView = [self fetchAssociatedTextView];
184
+ if (!textView) {
185
+ return;
186
+ }
187
+
188
+ NSRange attachmentRange = [self findAttachmentRangeInText:textView.attributedText];
189
+ if (attachmentRange.location == NSNotFound) {
190
+ return;
191
+ }
192
+
193
+ [textView.layoutManager invalidateDisplayForCharacterRange:attachmentRange];
194
+ if (!self.isInline) {
195
+ [textView.layoutManager invalidateLayoutForCharacterRange:attachmentRange actualCharacterRange:NULL];
196
+ }
197
+ }
198
+
199
+ - (UITextView *)fetchAssociatedTextView
200
+ {
201
+ if (self.textView) {
202
+ return self.textView;
203
+ }
204
+
205
+ if (!self.textContainer) {
206
+ return nil;
207
+ }
208
+
209
+ // Look up the text view via the associated object key stored on the container
210
+ self.textView = objc_getAssociatedObject(self.textContainer, kTextViewKey);
211
+ return self.textView;
212
+ }
213
+
214
+ - (void)setupPlaceholder
215
+ {
216
+ CGFloat size = self.cachedHeight;
217
+ self.bounds = CGRectMake(0, 0, size, size);
218
+
219
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(size, size)];
220
+ self.image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context){
221
+ // Generates an empty transparent placeholder
222
+ }];
223
+ }
224
+
225
+ - (void)startDownloadingImage
226
+ {
227
+ if (self.imageURL.length == 0) {
228
+ return;
229
+ }
230
+
231
+ NSURL *url = [NSURL URLWithString:self.imageURL];
232
+ if (!url) {
233
+ return;
234
+ }
235
+
236
+ __weak typeof(self) weakSelf = self;
237
+ self.loadingTask = [[NSURLSession sharedSession]
238
+ dataTaskWithURL:url
239
+ completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
240
+ if (data && !error) {
241
+ UIImage *downloadedImage = [UIImage imageWithData:data];
242
+ dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf handleLoadedImage:downloadedImage]; });
243
+ }
244
+ }];
245
+
246
+ [self.loadingTask resume];
247
+ }
248
+
249
+ - (NSRange)findAttachmentRangeInText:(NSAttributedString *)attributedString
250
+ {
251
+ __block NSRange foundRange = NSMakeRange(NSNotFound, 0);
252
+
253
+ [attributedString enumerateAttribute:NSAttachmentAttributeName
254
+ inRange:NSMakeRange(0, attributedString.length)
255
+ options:0
256
+ usingBlock:^(id value, NSRange range, BOOL *stop) {
257
+ if (value == self) {
258
+ foundRange = range;
259
+ *stop = YES;
260
+ }
261
+ }];
262
+
263
+ return foundRange;
264
+ }
265
+
266
+ - (void)dealloc
267
+ {
268
+ [_loadingTask cancel];
269
+ }
270
+
271
+ @end
@@ -3,7 +3,7 @@
3
3
 
4
4
  NS_ASSUME_NONNULL_BEGIN
5
5
 
6
- @interface ImageRenderer : NSObject <NodeRenderer>
6
+ @interface ENRMImageRenderer : NSObject <NodeRenderer>
7
7
 
8
8
  - (instancetype)initWithRendererFactory:(id)rendererFactory config:(id)config;
9
9
 
@@ -1,5 +1,5 @@
1
- #import "ImageRenderer.h"
2
- #import "EnrichedMarkdownImageAttachment.h"
1
+ #import "ENRMImageRenderer.h"
2
+ #import "ENRMImageAttachment.h"
3
3
  #import "MarkdownASTNode.h"
4
4
  #import "RenderContext.h"
5
5
  #import "RendererFactory.h"
@@ -8,7 +8,7 @@
8
8
  static const unichar kLineBreak = '\n';
9
9
  static const unichar kZeroWidthSpace = 0x200B;
10
10
 
11
- @implementation ImageRenderer {
11
+ @implementation ENRMImageRenderer {
12
12
  RendererFactory *_rendererFactory;
13
13
  StyleConfig *_config;
14
14
  }
@@ -22,8 +22,6 @@ static const unichar kZeroWidthSpace = 0x200B;
22
22
  return self;
23
23
  }
24
24
 
25
- #pragma mark - Rendering
26
-
27
25
  - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context
28
26
  {
29
27
  NSString *imageURL = node.attributes[@"url"];
@@ -32,9 +30,9 @@ static const unichar kZeroWidthSpace = 0x200B;
32
30
  }
33
31
 
34
32
  BOOL isInline = [self isInlineImageInOutput:output];
35
- EnrichedMarkdownImageAttachment *attachment = [[EnrichedMarkdownImageAttachment alloc] initWithImageURL:imageURL
36
- config:_config
37
- isInline:isInline];
33
+ ENRMImageAttachment *attachment = [[ENRMImageAttachment alloc] initWithImageURL:imageURL
34
+ config:_config
35
+ isInline:isInline];
38
36
 
39
37
  NSUInteger startIndex = output.length;
40
38
 
@@ -47,8 +45,6 @@ static const unichar kZeroWidthSpace = 0x200B;
47
45
  [context registerImageRange:imageRange altText:altText url:imageURL];
48
46
  }
49
47
 
50
- #pragma mark - Private Helpers
51
-
52
48
  - (NSString *)extractTextFromNode:(MarkdownASTNode *)node
53
49
  {
54
50
  if (!node)
@@ -2,9 +2,9 @@
2
2
  #import "BlockquoteRenderer.h"
3
3
  #import "CodeBlockRenderer.h"
4
4
  #import "CodeRenderer.h"
5
+ #import "ENRMImageRenderer.h"
5
6
  #import "EmphasisRenderer.h"
6
7
  #import "HeadingRenderer.h"
7
- #import "ImageRenderer.h"
8
8
  #import "LinkRenderer.h"
9
9
  #import "ListItemRenderer.h"
10
10
  #import "ListRenderer.h"
@@ -80,7 +80,7 @@
80
80
  case MarkdownNodeTypeCode:
81
81
  return [[CodeRenderer alloc] initWithRendererFactory:self config:_config];
82
82
  case MarkdownNodeTypeImage:
83
- return [[ImageRenderer alloc] initWithRendererFactory:self config:_config];
83
+ return [[ENRMImageRenderer alloc] initWithRendererFactory:self config:_config];
84
84
  case MarkdownNodeTypeBlockquote:
85
85
  return [[BlockquoteRenderer alloc] initWithRendererFactory:self config:_config];
86
86
  case MarkdownNodeTypeListItem:
@@ -1,7 +1,7 @@
1
1
  #import "HTMLGenerator.h"
2
2
  #import "BlockquoteBorder.h"
3
3
  #import "CodeBackground.h"
4
- #import "EnrichedMarkdownImageAttachment.h"
4
+ #import "ENRMImageAttachment.h"
5
5
  #import "LastElementUtils.h"
6
6
  #import "ListItemRenderer.h"
7
7
  #import "ParagraphStyleUtils.h"
@@ -456,8 +456,8 @@ static void generateInlineHTML(NSMutableString *html, NSAttributedString *attrib
456
456
 
457
457
  if ([text containsString:kObjectReplacementChar]) {
458
458
  id attachment = attrs[NSAttachmentAttributeName];
459
- if ([attachment isKindOfClass:[EnrichedMarkdownImageAttachment class]]) {
460
- EnrichedMarkdownImageAttachment *img = (EnrichedMarkdownImageAttachment *)attachment;
459
+ if ([attachment isKindOfClass:[ENRMImageAttachment class]]) {
460
+ ENRMImageAttachment *img = (ENRMImageAttachment *)attachment;
461
461
  if (img.imageURL) {
462
462
  if (img.isInline) {
463
463
  [html appendFormat:
@@ -1,6 +1,6 @@
1
1
  #import "MarkdownExtractor.h"
2
2
  #import "BlockquoteBorder.h"
3
- #import "EnrichedMarkdownImageAttachment.h"
3
+ #import "ENRMImageAttachment.h"
4
4
  #import "LastElementUtils.h"
5
5
  #import "ListItemRenderer.h"
6
6
  #import "RuntimeKeys.h"
@@ -136,8 +136,8 @@ NSString *_Nullable extractMarkdownFromAttributedString(NSAttributedString *attr
136
136
 
137
137
  // Images and Thematic Breaks
138
138
  NSTextAttachment *attachment = attrs[NSAttachmentAttributeName];
139
- if ([attachment isKindOfClass:[EnrichedMarkdownImageAttachment class]]) {
140
- EnrichedMarkdownImageAttachment *img = (EnrichedMarkdownImageAttachment *)attachment;
139
+ if ([attachment isKindOfClass:[ENRMImageAttachment class]]) {
140
+ ENRMImageAttachment *img = (ENRMImageAttachment *)attachment;
141
141
  if (!img.imageURL)
142
142
  return;
143
143
 
@@ -25,7 +25,7 @@ NSString *_Nullable markdownForRange(NSAttributedString *attributedText, NSRange
25
25
  NSString *_Nullable cachedMarkdown);
26
26
 
27
27
  /**
28
- * Returns remote image URLs (http/https only) from EnrichedMarkdownImageAttachments in the given range.
28
+ * Returns remote image URLs (http/https only) from ENRMImageAttachments in the given range.
29
29
  */
30
30
  NSArray<NSString *> *imageURLsInRange(NSAttributedString *attributedText, NSRange range);
31
31
 
@@ -1,5 +1,5 @@
1
1
  #import "PasteboardUtils.h"
2
- #import "EnrichedMarkdownImageAttachment.h"
2
+ #import "ENRMImageAttachment.h"
3
3
  #import "HTMLGenerator.h"
4
4
  #import "MarkdownExtractor.h"
5
5
  #import "RTFExportUtils.h"
@@ -121,10 +121,10 @@ NSArray<NSString *> *imageURLsInRange(NSAttributedString *attributedText, NSRang
121
121
  inRange:range
122
122
  options:0
123
123
  usingBlock:^(id value, NSRange r, BOOL *stop) {
124
- if (![value isKindOfClass:[EnrichedMarkdownImageAttachment class]])
124
+ if (![value isKindOfClass:[ENRMImageAttachment class]])
125
125
  return;
126
126
 
127
- NSString *url = ((EnrichedMarkdownImageAttachment *)value).imageURL;
127
+ NSString *url = ((ENRMImageAttachment *)value).imageURL;
128
128
  if ([url hasPrefix:@"http://"] || [url hasPrefix:@"https://"]) {
129
129
  [urls addObject:url];
130
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-enriched-markdown",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-nightly-20260228-6747e87e4",
4
4
  "description": "Markdown Text component for React Native",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -1,185 +0,0 @@
1
- #import "EnrichedMarkdownImageAttachment.h"
2
- #import "RuntimeKeys.h"
3
- #import "StyleConfig.h"
4
- #import <React/RCTLog.h>
5
- #import <objc/runtime.h>
6
-
7
- @interface EnrichedMarkdownImageAttachment ()
8
- @property (nonatomic, readwrite) NSString *imageURL;
9
- @property (nonatomic, weak) StyleConfig *config;
10
- @property (nonatomic, assign) BOOL isInline;
11
- @property (nonatomic, assign) CGFloat cachedHeight;
12
- @property (nonatomic, assign) CGFloat cachedBorderRadius;
13
- @property (nonatomic, weak) NSTextContainer *textContainer;
14
- @property (nonatomic, weak) UITextView *textView;
15
- @property (nonatomic, strong) UIImage *originalImage;
16
- @property (nonatomic, strong) UIImage *loadedImage;
17
- @property (nonatomic, strong) NSURLSessionDataTask *loadingTask;
18
- @end
19
-
20
- @implementation EnrichedMarkdownImageAttachment
21
-
22
- - (instancetype)initWithImageURL:(NSString *)imageURL config:(StyleConfig *)config isInline:(BOOL)isInline
23
- {
24
- if (self = [super init]) {
25
- _imageURL = imageURL;
26
- _config = config;
27
- _isInline = isInline;
28
- _cachedHeight = isInline ? [config inlineImageSize] : [config imageHeight];
29
- _cachedBorderRadius = [config imageBorderRadius];
30
-
31
- [self setupPlaceholder];
32
- [self loadImage];
33
- }
34
- return self;
35
- }
36
-
37
- - (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer
38
- proposedLineFragment:(CGRect)lineFrag
39
- glyphPosition:(CGPoint)position
40
- characterIndex:(NSUInteger)charIndex
41
- {
42
- CGFloat height = self.cachedHeight;
43
- CGFloat width = self.isInline ? height : (lineFrag.size.width > 0 ? lineFrag.size.width : height);
44
- return CGRectMake(0, 0, width, height);
45
- }
46
-
47
- - (UIImage *)imageForBounds:(CGRect)imageBounds
48
- textContainer:(NSTextContainer *)textContainer
49
- characterIndex:(NSUInteger)charIndex
50
- {
51
- self.textContainer = textContainer;
52
-
53
- // If width is now known but we haven't scaled yet, trigger it
54
- if (self.originalImage && !self.loadedImage && imageBounds.size.width > 0) {
55
- self.bounds = imageBounds;
56
- [self handleLoadedImage:self.originalImage];
57
- }
58
-
59
- return self.loadedImage ?: self.image;
60
- }
61
-
62
- - (void)handleLoadedImage:(UIImage *)image
63
- {
64
- if (!image)
65
- return;
66
- self.originalImage = image;
67
-
68
- __weak typeof(self) weakSelf = self;
69
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
70
- __strong typeof(weakSelf) strongSelf = weakSelf;
71
- if (!strongSelf)
72
- return;
73
-
74
- CGFloat targetWidth = strongSelf.isInline ? strongSelf.cachedHeight : strongSelf.bounds.size.width;
75
- if (targetWidth <= 0)
76
- return;
77
-
78
- UIImage *scaled = [strongSelf scaleImage:image
79
- toWidth:targetWidth
80
- height:strongSelf.cachedHeight
81
- borderRadius:strongSelf.cachedBorderRadius];
82
-
83
- dispatch_async(dispatch_get_main_queue(), ^{
84
- strongSelf.loadedImage = scaled;
85
-
86
- if (strongSelf.isInline) {
87
- // Use scaled image for RTF export (RTF uses self.image dimensions)
88
- strongSelf.image = scaled;
89
- strongSelf.bounds = CGRectMake(0, 0, strongSelf.cachedHeight, strongSelf.cachedHeight);
90
- } else {
91
- // Use original for "Save to Camera Roll" (no baked-in rounded corners)
92
- strongSelf.image = image;
93
- }
94
-
95
- [strongSelf updateUI];
96
- });
97
- });
98
- }
99
-
100
- - (void)updateUI
101
- {
102
- UITextView *textView = [self getTextView];
103
- if (!textView)
104
- return;
105
-
106
- NSRange range = [self findAttachmentRangeInText:textView.attributedText];
107
- if (range.location == NSNotFound)
108
- return;
109
-
110
- [textView.layoutManager invalidateDisplayForCharacterRange:range];
111
- if (!self.isInline) {
112
- [textView.layoutManager invalidateLayoutForCharacterRange:range actualCharacterRange:NULL];
113
- }
114
- }
115
-
116
- - (UITextView *)getTextView
117
- {
118
- if (self.textView)
119
- return self.textView;
120
- if (!self.textContainer)
121
- return nil;
122
- self.textView = objc_getAssociatedObject(self.textContainer, kTextViewKey);
123
- return self.textView;
124
- }
125
-
126
- - (void)setupPlaceholder
127
- {
128
- CGFloat size = self.cachedHeight;
129
- self.bounds = CGRectMake(0, 0, size, size);
130
- UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(size, size)];
131
- self.image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx){}];
132
- }
133
-
134
- - (void)loadImage
135
- {
136
- if (self.imageURL.length == 0)
137
- return;
138
- NSURL *url = [NSURL URLWithString:self.imageURL];
139
- if (!url)
140
- return;
141
-
142
- __weak typeof(self) weakSelf = self;
143
- self.loadingTask =
144
- [[NSURLSession sharedSession] dataTaskWithURL:url
145
- completionHandler:^(NSData *data, NSURLResponse *res, NSError *err) {
146
- if (data) {
147
- UIImage *img = [UIImage imageWithData:data];
148
- dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf handleLoadedImage:img]; });
149
- }
150
- }];
151
- [self.loadingTask resume];
152
- }
153
-
154
- - (UIImage *)scaleImage:(UIImage *)image toWidth:(CGFloat)w height:(CGFloat)h borderRadius:(CGFloat)r
155
- {
156
- UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(w, h)];
157
- return [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
158
- if (r > 0) {
159
- [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, w, h) cornerRadius:r] addClip];
160
- }
161
- [image drawInRect:CGRectMake(0, 0, w, h)];
162
- }];
163
- }
164
-
165
- - (NSRange)findAttachmentRangeInText:(NSAttributedString *)text
166
- {
167
- __block NSRange found = NSMakeRange(NSNotFound, 0);
168
- [text enumerateAttribute:NSAttachmentAttributeName
169
- inRange:NSMakeRange(0, text.length)
170
- options:0
171
- usingBlock:^(id val, NSRange range, BOOL *stop) {
172
- if (val == self) {
173
- found = range;
174
- *stop = YES;
175
- }
176
- }];
177
- return found;
178
- }
179
-
180
- - (void)dealloc
181
- {
182
- [_loadingTask cancel];
183
- }
184
-
185
- @end