Skip to content

Commit 6747e87

Browse files
authored
fix(ios): solve block image aspect ratio issue (#118)
* fix(ios): solve block image aspect ratio issue * refactor(ios): use ENRM prefix naming for images
1 parent edb07c5 commit 6747e87

11 files changed

Lines changed: 292 additions & 210 deletions

ios/EnrichedMarkdownText.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
#import "AccessibilityInfo.h"
33
#import "AttributedRenderer.h"
44
#import "CodeBlockBackground.h"
5+
#import "ENRMImageAttachment.h"
56
#import "ENRMMarkdownParser.h"
67
#import "EditMenuUtils.h"
7-
#import "EnrichedMarkdownImageAttachment.h"
88
#import "FontScaleObserver.h"
99
#import "FontUtils.h"
1010
#import "LastElementUtils.h"

ios/attachments/EnrichedMarkdownImageAttachment.h renamed to ios/attachments/ENRMImageAttachment.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
1111
* Images are loaded asynchronously and scaled dynamically based on text container width.
1212
* Supports inline and block images with custom height and border radius from config.
1313
*/
14-
@interface EnrichedMarkdownImageAttachment : NSTextAttachment
14+
@interface ENRMImageAttachment : NSTextAttachment
1515

1616
@property (nonatomic, readonly) NSString *imageURL;
1717
@property (nonatomic, readonly) BOOL isInline;
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)