From 0f9eb540341a8e979b61db7521e03f160ee227e0 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Wed, 17 Jun 2026 13:33:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ios):=20add=20image=20support=20to=20i?= =?UTF-8?q?nput=20=E2=80=94=20store,=20paste,=20serialize,=20and=20style?= =?UTF-8?q?=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/playground/PlaygroundScreen.tsx | 30 ++- .../ios/attachments/ENRMImageAttachment.h | 9 + .../ios/attachments/ENRMImageAttachment.m | 58 ++++- .../ios/input/ENRMEditAdjusting.h | 15 ++ .../ios/input/ENRMFormattingStore.h | 3 +- .../ios/input/ENRMImageStore.h | 41 ++++ .../ios/input/ENRMImageStore.mm | 112 +++++++++ .../ios/input/ENRMInputParser.h | 7 +- .../ios/input/ENRMInputParser.mm | 85 +++++++ .../ios/input/ENRMInputTextView.mm | 101 +++++++- .../ios/input/ENRMMarkdownSerializer.h | 5 + .../ios/input/ENRMMarkdownSerializer.mm | 43 ++++ .../ios/input/ENRMParseResult.h | 18 ++ .../ios/input/EnrichedMarkdownTextInput.h | 5 +- .../ios/input/EnrichedMarkdownTextInput.mm | 217 ++++++++++++++++-- .../ios/utils/ENRMImageDownloader.m | 16 ++ .../src/EnrichedMarkdownTextInput.tsx | 40 ++++ ...nrichedMarkdownTextInputNativeComponent.ts | 24 ++ .../src/index.tsx | 3 + .../src/normalizeMarkdownTextInputStyle.ts | 15 ++ 20 files changed, 811 insertions(+), 36 deletions(-) create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMEditAdjusting.h create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMImageStore.h create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMImageStore.mm create mode 100644 packages/react-native-enriched-markdown/ios/input/ENRMParseResult.h diff --git a/apps/example/src/screens/playground/PlaygroundScreen.tsx b/apps/example/src/screens/playground/PlaygroundScreen.tsx index c3fdeb78..7b4f7212 100644 --- a/apps/example/src/screens/playground/PlaygroundScreen.tsx +++ b/apps/example/src/screens/playground/PlaygroundScreen.tsx @@ -18,6 +18,7 @@ import { EnrichedMarkdownText, type EnrichedMarkdownTextInputInstance, type StyleState, + type PastedImage, } from 'react-native-enriched-markdown'; import { FormattingToolbar } from '../../components/FormattingToolbar'; @@ -75,6 +76,15 @@ export default function PlaygroundScreen() { Alert.alert('Markdown', md ?? '(empty)', [{ text: 'OK' }]); }, []); + const handlePasteImages = useCallback((images: PastedImage[]) => { + for (const img of images) { + inputRef.current?.insertImage(img.uri, { + width: Math.min(img.width, 80), + height: Math.min(img.height, 80), + }); + } + }, []); + return ( { - const current = (await inputRef.current?.getMarkdown()) ?? ''; - const md = current - ? `${current}\n\n![logo](${BLOCK_IMAGE_URI})` - : `![logo](${BLOCK_IMAGE_URI})`; - inputRef.current?.setValue(md); - setMarkdown(md); + onPress={() => { + inputRef.current?.insertImage(BLOCK_IMAGE_URI, { + alt: 'logo', + width: 80, + height: 80, + }); }} testID="insert-image-button" > @@ -154,9 +163,9 @@ export default function PlaygroundScreen() { { - const md = `Enriched Markdown is a library for ![icon](${INLINE_IMAGE_URI}) React Native.`; - inputRef.current?.setValue(md); - setMarkdown(md); + inputRef.current?.insertImage(INLINE_IMAGE_URI, { + alt: 'icon', + }); }} testID="insert-inline-image-button" > @@ -178,6 +187,7 @@ export default function PlaygroundScreen() { onChangeState={setState} onChangeMarkdown={setMarkdown} onChangeSelection={(sel) => setHasSelection(sel.start !== sel.end)} + onPasteImages={handlePasteImages} /> *)originalImageCache; diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m index 68aecf1c..9b3b3a45 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m @@ -25,6 +25,7 @@ @interface ENRMImageAttachment () @property (nonatomic, assign) BOOL isInline; @property (nonatomic, assign) CGFloat cachedHeight; @property (nonatomic, assign) CGFloat cachedBorderRadius; +@property (nonatomic, assign) CGFloat explicitBlockWidth; @property (nonatomic, weak) NSTextContainer *textContainer; @property (nonatomic, weak) ENRMPlatformTextView *textView; @property (nonatomic, strong) RCTUIImage *originalImage; @@ -76,6 +77,49 @@ + (instancetype)attachmentForURL:(NSString *)imageURL config:(StyleConfig *)conf return attachment; } ++ (instancetype)inputAttachmentForURL:(NSString *)imageURL + isInline:(BOOL)isInline + inlineSize:(CGFloat)inlineSize + blockWidth:(CGFloat)blockWidth + blockHeight:(CGFloat)blockHeight + borderRadius:(CGFloat)borderRadius +{ + NSString *key = [NSString stringWithFormat:@"input_%@_%d_%.0f_%.0f", imageURL, isInline, blockWidth, blockHeight]; + ENRMImageAttachment *existing = [[self attachmentRegistry] objectForKey:key]; + if (existing) { + return existing; + } + ENRMImageAttachment *attachment = [[self alloc] initWithImageURL:imageURL + isInline:isInline + inlineSize:inlineSize + blockWidth:blockWidth + blockHeight:blockHeight + borderRadius:borderRadius]; + [[self attachmentRegistry] setObject:attachment forKey:key]; + return attachment; +} + +- (instancetype)initWithImageURL:(NSString *)imageURL + isInline:(BOOL)isInline + inlineSize:(CGFloat)inlineSize + blockWidth:(CGFloat)blockWidth + blockHeight:(CGFloat)blockHeight + borderRadius:(CGFloat)borderRadius +{ + self = [super init]; + if (self) { + _imageURL = imageURL; + _isInline = isInline; + _cachedHeight = isInline ? inlineSize : blockHeight; + _cachedBorderRadius = borderRadius; + _explicitBlockWidth = isInline ? 0 : blockWidth; + + [self setupPlaceholder]; + [self startDownloadingImage]; + } + return self; +} + + (void)clearAttachmentRegistry { [[self attachmentRegistry] removeAllObjects]; @@ -103,7 +147,14 @@ - (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer characterIndex:(NSUInteger)characterIndex { CGFloat height = self.cachedHeight; - CGFloat width = self.isInline ? height : (lineFragment.size.width > 0 ? lineFragment.size.width : height); + CGFloat width; + if (self.isInline) { + width = height; + } else if (self.explicitBlockWidth > 0) { + width = self.explicitBlockWidth; + } else { + width = lineFragment.size.width > 0 ? lineFragment.size.width : height; + } if (self.isInline) { UIFont *appliedFont = nil; @@ -269,6 +320,11 @@ - (void)refreshDisplay } } +- (void)setAssociatedTextView:(ENRMPlatformTextView *)textView +{ + self.textView = textView; +} + - (ENRMPlatformTextView *)fetchAssociatedTextView { if (self.textView) diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMEditAdjusting.h b/packages/react-native-enriched-markdown/ios/input/ENRMEditAdjusting.h new file mode 100644 index 00000000..bc14b58a --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMEditAdjusting.h @@ -0,0 +1,15 @@ +#pragma once +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol ENRMEditAdjusting + +- (void)adjustForEditAtLocation:(NSUInteger)location + deletedLength:(NSUInteger)deletedLength + insertedLength:(NSUInteger)insertedLength; +- (void)clearAll; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h b/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h index f67fb4d2..d0830d92 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h @@ -1,10 +1,11 @@ #pragma once +#import "ENRMEditAdjusting.h" #import "ENRMFormattingRange.h" NS_ASSUME_NONNULL_BEGIN -@interface ENRMFormattingStore : NSObject +@interface ENRMFormattingStore : NSObject @property (nonatomic, readonly) NSArray *allRanges; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMImageStore.h b/packages/react-native-enriched-markdown/ios/input/ENRMImageStore.h new file mode 100644 index 00000000..4d2baf54 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMImageStore.h @@ -0,0 +1,41 @@ +#pragma once + +#import "ENRMEditAdjusting.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ENRMImageEntry : NSObject + +@property (nonatomic, assign) NSUInteger position; +@property (nonatomic, copy) NSString *url; +@property (nonatomic, copy) NSString *alt; +@property (nonatomic, assign) CGFloat width; +@property (nonatomic, assign) CGFloat height; +@property (nonatomic, assign) BOOL isInline; + ++ (instancetype)entryWithPosition:(NSUInteger)position + url:(NSString *)url + alt:(NSString *)alt + width:(CGFloat)width + height:(CGFloat)height + isInline:(BOOL)isInline; + +@end + +@interface ENRMImageStore : NSObject + +@property (nonatomic, readonly) NSArray *allEntries; + +- (void)addEntry:(ENRMImageEntry *)entry; +- (void)removeEntryAtPosition:(NSUInteger)position; +- (nullable ENRMImageEntry *)entryAtPosition:(NSUInteger)position; +- (void)clearAll; + +- (void)adjustForEditAtLocation:(NSUInteger)editLocation + deletedLength:(NSUInteger)deletedLength + insertedLength:(NSUInteger)insertedLength; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMImageStore.mm b/packages/react-native-enriched-markdown/ios/input/ENRMImageStore.mm new file mode 100644 index 00000000..6c5a7318 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMImageStore.mm @@ -0,0 +1,112 @@ +#import "ENRMImageStore.h" + +@implementation ENRMImageEntry + ++ (instancetype)entryWithPosition:(NSUInteger)position + url:(NSString *)url + alt:(NSString *)alt + width:(CGFloat)width + height:(CGFloat)height + isInline:(BOOL)isInline +{ + ENRMImageEntry *entry = [[ENRMImageEntry alloc] init]; + entry.position = position; + entry.url = url; + entry.alt = alt; + entry.width = width; + entry.height = height; + entry.isInline = isInline; + return entry; +} + +@end + +@implementation ENRMImageStore { + NSMutableArray *_entries; +} + +- (instancetype)init +{ + if (self = [super init]) { + _entries = [NSMutableArray array]; + } + return self; +} + +- (NSArray *)allEntries +{ + return [_entries copy]; +} + +- (void)addEntry:(ENRMImageEntry *)entry +{ + NSUInteger insertAt = 0; + for (NSUInteger i = 0; i < _entries.count; i++) { + if (_entries[i].position == entry.position) { + _entries[i] = entry; + return; + } + if (_entries[i].position > entry.position) + break; + insertAt = i + 1; + } + [_entries insertObject:entry atIndex:insertAt]; +} + +- (void)removeEntryAtPosition:(NSUInteger)position +{ + for (NSUInteger i = 0; i < _entries.count; i++) { + if (_entries[i].position == position) { + [_entries removeObjectAtIndex:i]; + return; + } + } +} + +- (nullable ENRMImageEntry *)entryAtPosition:(NSUInteger)position +{ + for (ENRMImageEntry *entry in _entries) { + if (entry.position == position) { + return entry; + } + } + return nil; +} + +- (void)clearAll +{ + [_entries removeAllObjects]; +} + +- (void)adjustForEditAtLocation:(NSUInteger)editLocation + deletedLength:(NSUInteger)deletedLength + insertedLength:(NSUInteger)insertedLength +{ + if (deletedLength == 0 && insertedLength == 0) + return; + + NSUInteger deleteEnd = editLocation + deletedLength; + NSMutableIndexSet *indexesToRemove = [NSMutableIndexSet indexSet]; + + for (NSUInteger i = 0; i < _entries.count; i++) { + ENRMImageEntry *entry = _entries[i]; + NSUInteger pos = entry.position; + + if (deletedLength > 0) { + if (pos >= editLocation && pos < deleteEnd) { + [indexesToRemove addIndex:i]; + } else if (pos >= deleteEnd) { + entry.position = pos - deletedLength + insertedLength; + } + } else { + if (pos >= editLocation) { + entry.position = pos + insertedLength; + } + } + } + + [indexesToRemove enumerateIndexesWithOptions:NSEnumerationReverse + usingBlock:^(NSUInteger idx, BOOL *stop) { [_entries removeObjectAtIndex:idx]; }]; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.h index 3a03e3f1..4cb53e71 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.h @@ -1,16 +1,11 @@ #pragma once -#import "ENRMFormattingRange.h" #import "ENRMInputStyledRange.h" +#import "ENRMParseResult.h" #import NS_ASSUME_NONNULL_BEGIN -@interface ENRMParseResult : NSObject -@property (nonatomic, strong, readonly) NSString *plainText; -@property (nonatomic, strong, readonly) NSArray *formattingRanges; -@end - @interface ENRMInputParser : NSObject - (ENRMParseResult *)parseToPlainTextAndRanges:(NSString *)markdown; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm index 654ee394..ba1aa607 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm @@ -1,5 +1,6 @@ #import "ENRMInputParser.h" #import "ENRMFormattingRange.h" +#import "ENRMImageStore.h" #import "ENRMInputRemend.h" #include "md4c.h" #include @@ -8,9 +9,22 @@ @interface ENRMParseResult () @property (nonatomic, strong, readwrite) NSString *plainText; @property (nonatomic, strong, readwrite) NSArray *formattingRanges; +@property (nonatomic, strong, readwrite) NSArray *imageEntries; @end @implementation ENRMParseResult + ++ (instancetype)resultWithPlainText:(NSString *)plainText + formattingRanges:(NSArray *)formattingRanges + imageEntries:(NSArray *)imageEntries +{ + ENRMParseResult *result = [[ENRMParseResult alloc] init]; + result.plainText = plainText; + result.formattingRanges = formattingRanges; + result.imageEntries = imageEntries; + return result; +} + @end namespace { @@ -306,9 +320,14 @@ - (ENRMParseResult *)parseToPlainTextAndRanges:(NSString *)markdown if (markdown.length == 0) { parseResult.plainText = @""; parseResult.formattingRanges = @[]; + parseResult.imageEntries = @[]; return parseResult; } + NSMutableArray *imageEntries = [NSMutableArray array]; + NSString *preprocessed = [self extractImagesFromMarkdown:markdown entries:imageEntries]; + markdown = preprocessed; + NSArray *styledRanges = [self parse:markdown]; NSUInteger rawLength = markdown.length; @@ -396,7 +415,73 @@ - (ENRMParseResult *)parseToPlainTextAndRanges:(NSString *)markdown parseResult.plainText = plainText; parseResult.formattingRanges = formattingRanges; + + NSMutableArray *finalImageEntries = [NSMutableArray arrayWithCapacity:imageEntries.count]; + for (ENRMImageEntry *entry in imageEntries) { + NSUInteger rawPos = entry.position; + if (rawPos <= rawLength) { + NSUInteger plainPos = rawToPlainMap[rawPos]; + BOOL isInline = (plainPos > 0) && ([plainText characterAtIndex:plainPos - 1] != '\n'); + ENRMImageEntry *adjusted = [ENRMImageEntry entryWithPosition:plainPos + url:entry.url + alt:entry.alt + width:entry.width + height:entry.height + isInline:isInline]; + [finalImageEntries addObject:adjusted]; + } + } + parseResult.imageEntries = finalImageEntries; + return parseResult; } +- (NSString *)extractImagesFromMarkdown:(NSString *)markdown entries:(NSMutableArray *)entries +{ + static NSRegularExpression *imageRegex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + imageRegex = [NSRegularExpression + regularExpressionWithPattern:@"!\\[([^\\]]*)\\]\\(([^()\\s]*(?:\\([^()\\s]*\\)[^()\\s]*)*)\\)" + options:0 + error:nil]; + }); + + NSArray *matches = [imageRegex matchesInString:markdown + options:0 + range:NSMakeRange(0, markdown.length)]; + if (matches.count == 0) { + return markdown; + } + + NSMutableString *result = [NSMutableString stringWithCapacity:markdown.length]; + NSUInteger lastEnd = 0; + + for (NSTextCheckingResult *match in matches) { + NSRange fullMatch = match.range; + NSString *alt = [markdown substringWithRange:[match rangeAtIndex:1]]; + NSString *url = [markdown substringWithRange:[match rangeAtIndex:2]]; + + [result appendString:[markdown substringWithRange:NSMakeRange(lastEnd, fullMatch.location - lastEnd)]]; + + NSUInteger orcPosition = result.length; + unichar orc = 0xFFFC; + [result appendString:[NSString stringWithCharacters:&orc length:1]]; + + CGFloat defaultSize = 80.0; + ENRMImageEntry *entry = [ENRMImageEntry entryWithPosition:orcPosition + url:url + alt:alt.length > 0 ? alt : @"image" + width:defaultSize + height:defaultSize + isInline:NO]; + [entries addObject:entry]; + + lastEnd = NSMaxRange(fullMatch); + } + + [result appendString:[markdown substringFromIndex:lastEnd]]; + return result; +} + @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm index ce0004fe..b0aaea1e 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm @@ -1,4 +1,5 @@ #import "ENRMInputTextView.h" +#import "ENRMParseResult.h" #import "EnrichedMarkdownTextInput.h" #if TARGET_OS_OSX #import "EnrichedMarkdownTextInput+Internal.h" @@ -8,6 +9,7 @@ #if !TARGET_OS_OSX +#import #import @implementation ENRMInputTextView @@ -34,13 +36,21 @@ - (void)copy:(id)sender - (void)cut:(id)sender { [self copy:sender]; - [self.markdownTextInput replaceSelectedTextWith:@"" formattingRanges:@[]]; + [self.markdownTextInput replaceSelectedTextWithParseResult:[ENRMParseResult resultWithPlainText:@"" + formattingRanges:@[] + imageEntries:@[]]]; } - (void)paste:(id)sender { UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; + NSMutableArray *foundImages = [self extractImagesFromPasteboard:pasteboard]; + if (foundImages.count > 0 && self.markdownTextInput != nil) { + [self.markdownTextInput emitOnPasteImagesEvent:foundImages]; + return; + } + NSString *markdown = nil; id markdownValue = [pasteboard valueForPasteboardType:kENRMMarkdownPasteboardType]; if ([markdownValue isKindOfClass:[NSString class]]) { @@ -60,11 +70,94 @@ - (void)paste:(id)sender } } +static NSDictionary *fileInfoForUTType(NSString *type) +{ + static NSDictionary *> *mapping; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + mapping = @{ + UTTypeJPEG.identifier : @[ @"jpg", @"image/jpeg" ], + UTTypePNG.identifier : @[ @"png", @"image/png" ], + UTTypeGIF.identifier : @[ @"gif", @"image/gif" ], + UTTypeHEIC.identifier : @[ @"heic", @"image/heic" ], + UTTypeWebP.identifier : @[ @"webp", @"image/webp" ], + UTTypeTIFF.identifier : @[ @"tiff", @"image/tiff" ], + }; + }); + NSArray *info = mapping[type]; + if (!info) + return nil; + return @{@"ext" : info[0], @"mimeType" : info[1]}; +} + +static NSData *imageDataFromItem(NSDictionary *item, NSString *type, UIPasteboard *pasteboard) +{ + if ([type isEqual:UTTypeWebP.identifier] || [type isEqual:UTTypeGIF.identifier]) { + return [pasteboard dataForPasteboardType:type]; + } + + id value = item[type]; + if ([value isKindOfClass:[NSData class]]) { + return (NSData *)value; + } + if ([value isKindOfClass:[UIImage class]]) { + UIImage *img = (UIImage *)value; + return [type isEqual:UTTypePNG.identifier] ? UIImagePNGRepresentation(img) : UIImageJPEGRepresentation(img, 1.0); + } + return nil; +} + +static NSDictionary *saveImageToTempFile(NSData *data, NSString *ext, NSString *mimeType) +{ + UIImage *image = [UIImage imageWithData:data]; + if (!image) + return nil; + + NSString *fileName = [NSString stringWithFormat:@"%@.%@", [NSUUID UUID].UUIDString, ext]; + NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; + + if (![data writeToFile:filePath atomically:YES]) + return nil; + + return @{ + @"uri" : [NSURL fileURLWithPath:filePath].absoluteString, + @"type" : mimeType, + @"width" : @(image.size.width), + @"height" : @(image.size.height), + }; +} + +- (NSMutableArray *)extractImagesFromPasteboard:(UIPasteboard *)pasteboard +{ + NSMutableArray *foundImages = [NSMutableArray array]; + + for (NSDictionary *item in pasteboard.items) { + for (NSString *type in item.allKeys) { + NSDictionary *info = fileInfoForUTType(type); + if (!info) + continue; + + NSData *data = imageDataFromItem(item, type, pasteboard); + if (!data) + continue; + + NSDictionary *entry = saveImageToTempFile(data, info[@"ext"], info[@"mimeType"]); + if (entry) { + [foundImages addObject:entry]; + break; + } + } + } + + return foundImages; +} + - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { if (action == @selector(paste:)) { UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; - if (pasteboard.hasStrings || [pasteboard containsPasteboardTypes:@[ kENRMMarkdownPasteboardType ]]) { + if (pasteboard.hasStrings || pasteboard.hasImages || + [pasteboard containsPasteboardTypes:@[ kENRMMarkdownPasteboardType ]]) { return YES; } } @@ -111,7 +204,9 @@ - (void)copy:(id)sender - (void)cut:(id)sender { [self copy:sender]; - [self.markdownTextInput replaceSelectedTextWith:@"" formattingRanges:@[]]; + [self.markdownTextInput replaceSelectedTextWithParseResult:[ENRMParseResult resultWithPlainText:@"" + formattingRanges:@[] + imageEntries:@[]]]; } - (void)paste:(id)sender diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.h b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.h index cfc8fd7a..fb2ec8ea 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.h @@ -1,6 +1,7 @@ #pragma once #import "ENRMFormattingRange.h" +#import "ENRMImageStore.h" NS_ASSUME_NONNULL_BEGIN @@ -8,6 +9,10 @@ NS_ASSUME_NONNULL_BEGIN + (NSString *)serializePlainText:(NSString *)text ranges:(NSArray *)ranges; ++ (NSString *)serializePlainText:(NSString *)text + ranges:(NSArray *)ranges + imageEntries:(NSArray *)imageEntries; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm index 27192e9d..1d4d585c 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm @@ -144,6 +144,49 @@ static int compareBoundaryEvents(const void *first, const void *second) @implementation ENRMMarkdownSerializer ++ (NSString *)serializePlainText:(NSString *)text + ranges:(NSArray *)ranges + imageEntries:(NSArray *)imageEntries +{ + NSString *result = [self serializePlainText:text ranges:ranges]; + + if (imageEntries.count == 0) { + return result; + } + + NSArray *sorted = [imageEntries sortedArrayUsingComparator:^NSComparisonResult(ENRMImageEntry *a, + ENRMImageEntry *b) { + return a.position < b.position ? NSOrderedAscending : a.position > b.position ? NSOrderedDescending : NSOrderedSame; + }]; + + NSMutableArray *validated = [NSMutableArray array]; + for (ENRMImageEntry *entry in sorted) { + if (entry.position < text.length && [text characterAtIndex:entry.position] == 0xFFFC) { + [validated addObject:entry]; + } + } + + if (validated.count == 0) { + return result; + } + + NSMutableString *output = [NSMutableString stringWithCapacity:result.length + validated.count * 30]; + NSUInteger entryIndex = 0; + + for (NSUInteger i = 0; i < result.length; i++) { + unichar ch = [result characterAtIndex:i]; + if (ch == 0xFFFC && entryIndex < validated.count) { + ENRMImageEntry *entry = validated[entryIndex++]; + NSString *alt = entry.alt.length > 0 ? entry.alt : @"image"; + [output appendFormat:@"![%@](%@)", alt, entry.url]; + } else { + [output appendFormat:@"%C", ch]; + } + } + + return output; +} + + (NSString *)serializePlainText:(NSString *)text ranges:(NSArray *)ranges { if (ranges.count == 0) { diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMParseResult.h b/packages/react-native-enriched-markdown/ios/input/ENRMParseResult.h new file mode 100644 index 00000000..482d413e --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/ENRMParseResult.h @@ -0,0 +1,18 @@ +#pragma once +#import "ENRMFormattingRange.h" +#import "ENRMImageStore.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ENRMParseResult : NSObject +@property (nonatomic, strong, readonly) NSString *plainText; +@property (nonatomic, strong, readonly) NSArray *formattingRanges; +@property (nonatomic, strong, readonly) NSArray *imageEntries; + ++ (instancetype)resultWithPlainText:(NSString *)plainText + formattingRanges:(NSArray *)formattingRanges + imageEntries:(NSArray *)imageEntries; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.h b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.h index 2e67a50e..4f762c9f 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.h +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.h @@ -4,6 +4,8 @@ #ifndef EnrichedMarkdownTextInput_h #define EnrichedMarkdownTextInput_h +@class ENRMParseResult; + NS_ASSUME_NONNULL_BEGIN @interface EnrichedMarkdownTextInput : RCTViewComponentView @@ -11,7 +13,8 @@ NS_ASSUME_NONNULL_BEGIN - (CGSize)measureSize:(CGFloat)maxWidth; - (nullable NSString *)markdownForSelectedRange; - (void)pasteMarkdown:(NSString *)markdown; -- (void)replaceSelectedTextWith:(NSString *)text formattingRanges:(NSArray *)ranges; +- (void)replaceSelectedTextWithParseResult:(ENRMParseResult *)parseResult; +- (void)emitOnPasteImagesEvent:(NSArray *)images; - (void)scheduleRelayoutIfNeeded; @end diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index e8822b32..78269e80 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -4,6 +4,8 @@ #import "ENRMDetectorPipeline.h" #import "ENRMFormattingRange.h" #import "ENRMFormattingStore.h" +#import "ENRMImageAttachment.h" +#import "ENRMImageStore.h" #import "ENRMInputFormatter.h" #import "ENRMInputLayoutManager.h" #import "ENRMInputLinkPrompt.h" @@ -55,6 +57,9 @@ @implementation EnrichedMarkdownTextInput { ENRMInputFormatter *_formatter; ENRMInputFormatterStyle *_formatterStyle; ENRMFormattingStore *_formattingStore; + ENRMImageStore *_imageStore; + CGFloat _imageInlineSize; + CGFloat _imageBorderRadius; NSMutableSet *_pendingStyles; NSMutableSet *_pendingStyleRemovals; BOOL _isApplyingFormatting; @@ -115,6 +120,9 @@ - (instancetype)initWithFrame:(CGRect)frame _formatter = [[ENRMInputFormatter alloc] init]; _formatterStyle = [[ENRMInputFormatterStyle alloc] init]; _formattingStore = [[ENRMFormattingStore alloc] init]; + _imageStore = [[ENRMImageStore alloc] init]; + _imageInlineSize = 20.0; + _imageBorderRadius = 4.0; _pendingStyles = [NSMutableSet set]; _pendingStyleRemovals = [NSMutableSet set]; _lastTextLength = 0; @@ -367,6 +375,15 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & BOOL styleChanged = applyInputStyleProps(_formatterStyle, newViewProps, oldViewProps); + if (newViewProps.markdownStyle.image.borderRadius != oldViewProps.markdownStyle.image.borderRadius) { + _imageBorderRadius = newViewProps.markdownStyle.image.borderRadius; + styleChanged = YES; + } + if (newViewProps.markdownStyle.inlineImage.size != oldViewProps.markdownStyle.inlineImage.size) { + _imageInlineSize = newViewProps.markdownStyle.inlineImage.size; + styleChanged = YES; + } + BOOL writingDirectionChanged = NO; if (newViewProps.writingDirection != oldViewProps.writingDirection) { NSString *value = [[NSString alloc] initWithUTF8String:newViewProps.writingDirection.c_str()]; @@ -520,6 +537,10 @@ - (void)importMarkdown:(NSString *)markdown _isApplyingFormatting = NO; [_formattingStore setRanges:parsed.formattingRanges]; + [_imageStore clearAll]; + for (ENRMImageEntry *entry in parsed.imageEntries) { + [_imageStore addEntry:entry]; + } _lastTextLength = parsed.plainText.length; _lastSelectedRange = _textView.selectedRange; [self applyFormatting]; @@ -528,10 +549,9 @@ - (void)importMarkdown:(NSString *)markdown _blockEmitting = NO; } -- (void)replaceTextInRange:(NSRange)selection - withText:(NSString *)text - formattingRanges:(NSArray *)ranges +- (void)replaceTextInRange:(NSRange)selection withParseResult:(ENRMParseResult *)parseResult { + NSString *text = parseResult.plainText; NSUInteger editLocation = selection.location; _isApplyingFormatting = YES; @@ -539,12 +559,22 @@ - (void)replaceTextInRange:(NSRange)selection _isApplyingFormatting = NO; [_formattingStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; + [_imageStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; - for (ENRMFormattingRange *range in ranges) { + for (ENRMFormattingRange *range in parseResult.formattingRanges) { NSRange shifted = NSMakeRange(range.range.location + editLocation, range.range.length); [_formattingStore addRange:[ENRMFormattingRange rangeWithType:range.type range:shifted url:range.url]]; } + for (ENRMImageEntry *entry in parseResult.imageEntries) { + [_imageStore addEntry:[ENRMImageEntry entryWithPosition:entry.position + editLocation + url:entry.url + alt:entry.alt + width:entry.width + height:entry.height + isInline:entry.isInline]]; + } + _lastTextLength = ENRMGetPlainText(_textView).length; _lastSelectedRange = _textView.selectedRange; @@ -561,16 +591,16 @@ - (void)replaceTextInRange:(NSRange)selection [self scheduleRelayoutIfNeeded]; } -- (void)replaceSelectedTextWith:(NSString *)text formattingRanges:(NSArray *)ranges +- (void)replaceSelectedTextWithParseResult:(ENRMParseResult *)parseResult { - [self replaceTextInRange:_textView.selectedRange withText:text formattingRanges:ranges]; + [self replaceTextInRange:_textView.selectedRange withParseResult:parseResult]; } - (void)pasteMarkdown:(NSString *)markdown { ENRMInputParser *parser = [[ENRMInputParser alloc] init]; ENRMParseResult *parsed = [parser parseToPlainTextAndRanges:markdown]; - [self replaceSelectedTextWith:parsed.plainText formattingRanges:parsed.formattingRanges]; + [self replaceSelectedTextWithParseResult:parsed]; } #pragma mark - Formatting @@ -596,6 +626,7 @@ - (void)applyFormatting NSRange savedSelection = _textView.selectedRange; [_formatter applyFormattingRanges:_formattingStore.allRanges toTextView:_textView style:_formatterStyle]; + [self applyImageAttachments]; [_detectorPipeline refreshAllStyling]; [self applyWritingDirection]; @@ -607,6 +638,44 @@ - (void)applyFormatting _isApplyingFormatting = NO; } +- (void)applyImageAttachments +{ + NSArray *entries = _imageStore.allEntries; + if (entries.count == 0) { + return; + } + + NSTextStorage *textStorage = _textView.textStorage; + NSUInteger textLength = textStorage.length; + + [textStorage beginEditing]; + for (ENRMImageEntry *entry in entries) { + NSUInteger pos = entry.position; + if (pos >= textLength) { + continue; + } + unichar ch = [textStorage.string characterAtIndex:pos]; + if (ch != kORC) { + continue; + } + + CGFloat inlineSize = _imageInlineSize; + CGFloat borderRadius = _imageBorderRadius; + + ENRMImageAttachment *attachment = [ENRMImageAttachment inputAttachmentForURL:entry.url + isInline:entry.isInline + inlineSize:inlineSize + blockWidth:entry.width + blockHeight:entry.height + borderRadius:borderRadius]; + [attachment setAssociatedTextView:_textView]; + + NSRange orcRange = NSMakeRange(pos, 1); + [textStorage addAttribute:NSAttachmentAttributeName value:attachment range:orcRange]; + } + [textStorage endEditing]; +} + - (void)applyWritingDirection { NSTextStorage *textStorage = _textView.textStorage; @@ -770,7 +839,9 @@ - (void)insertLink:(NSString *)text url:(NSString *)url ENRMFormattingRange *range = [ENRMFormattingRange rangeWithType:ENRMInputStyleTypeLink range:linkRange url:[self sanitizeLinkURL:url]]; - [self replaceSelectedTextWith:displayText formattingRanges:@[ range ]]; + [self replaceSelectedTextWithParseResult:[ENRMParseResult resultWithPlainText:displayText + formattingRanges:@[ range ] + imageEntries:@[]]]; } - (NSString *)sanitizeLinkURL:(NSString *)url @@ -785,7 +856,9 @@ - (void)startMention:(NSString *)indicator return; } - [self replaceSelectedTextWith:indicator formattingRanges:@[]]; + [self replaceSelectedTextWithParseResult:[ENRMParseResult resultWithPlainText:indicator + formattingRanges:@[] + imageEntries:@[]]]; [self updateActiveMention]; } @@ -811,7 +884,10 @@ - (void)insertMention:(NSString *)displayText url:(NSString *)url NSRange mentionRange = _activeMentionRange; [self clearActiveMention:indicator]; - [self replaceTextInRange:mentionRange withText:replacement formattingRanges:@[ linkRange ]]; + [self replaceTextInRange:mentionRange + withParseResult:[ENRMParseResult resultWithPlainText:replacement + formattingRanges:@[ linkRange ] + imageEntries:@[]]]; _textView.selectedRange = NSMakeRange(mentionRange.location + replacement.length, 0); [self emitOnChangeSelection]; } @@ -829,6 +905,75 @@ - (void)removeLink [self emitFormattingChanged]; } +static unichar const kORC = 0xFFFC; + +- (void)insertImage:(NSString *)url alt:(NSString *)alt width:(float)width height:(float)height +{ + NSString *plainText = ENRMGetPlainText(_textView); + NSRange selection = _lastSelectedRange; + NSUInteger cursor = selection.location; + + BOOL isInline = [self isInlinePositionAtCursor:cursor inText:plainText]; + + NSString *textToInsert; + if (isInline) { + textToInsert = [NSString stringWithCharacters:&kORC length:1]; + } else { + NSMutableString *buf = [NSMutableString string]; + if (cursor > 0 && [plainText characterAtIndex:cursor - 1] != '\n') { + [buf appendString:@"\n"]; + } + [buf appendFormat:@"%C", kORC]; + [buf appendString:@"\n"]; + textToInsert = buf; + } + + _isApplyingFormatting = YES; + ENRMReplaceTextInRange(_textView, textToInsert, selection); + _isApplyingFormatting = NO; + + [_formattingStore adjustForEditAtLocation:cursor deletedLength:selection.length insertedLength:textToInsert.length]; + [_imageStore adjustForEditAtLocation:cursor deletedLength:selection.length insertedLength:textToInsert.length]; + + NSUInteger orcPosition = + isInline ? cursor : (cursor > 0 && [plainText characterAtIndex:cursor - 1] != '\n' ? cursor + 1 : cursor); + + CGFloat defaultBlockSize = 80.0; + ENRMImageEntry *entry = + [ENRMImageEntry entryWithPosition:orcPosition + url:url + alt:alt.length > 0 ? alt : @"image" + width:width > 0 ? width + : (isInline ? _imageInlineSize : defaultBlockSize)height:height > 0 + ? height + : (isInline ? _imageInlineSize : defaultBlockSize)isInline:isInline]; + [_imageStore addEntry:entry]; + + _lastTextLength = ENRMGetPlainText(_textView).length; + _lastSelectedRange = _textView.selectedRange; + + [self applyFormatting]; + + [_detectorPipeline processTextChange:ENRMGetPlainText(_textView) + modificationRange:NSMakeRange(cursor, textToInsert.length)]; + + [self updatePlaceholderVisibility]; + [self emitOnChangeText]; + [self emitOnChangeSelection]; + [self emitFormattingChanged]; + [self requestHeightUpdate]; + [self scheduleRelayoutIfNeeded]; +} + +- (BOOL)isInlinePositionAtCursor:(NSUInteger)cursor inText:(NSString *)text +{ + if (cursor == 0 || text.length == 0) { + return NO; + } + unichar preceding = [text characterAtIndex:cursor - 1]; + return (preceding != '\n'); +} + - (void)showLinkPrompt { NSUInteger cursor = _textView.selectedRange.location; @@ -866,7 +1011,20 @@ - (nullable NSString *)markdownForSelectedRange [clippedRanges addObject:[ENRMFormattingRange rangeWithType:range.type range:shifted url:range.url]]; } - return [ENRMMarkdownSerializer serializePlainText:selectedText ranges:clippedRanges]; + NSMutableArray *clippedImages = [NSMutableArray array]; + for (ENRMImageEntry *entry in _imageStore.allEntries) { + if (entry.position >= selection.location && entry.position < selEnd) { + ENRMImageEntry *shifted = [ENRMImageEntry entryWithPosition:entry.position - selection.location + url:entry.url + alt:entry.alt + width:entry.width + height:entry.height + isInline:entry.isInline]; + [clippedImages addObject:shifted]; + } + } + + return [ENRMMarkdownSerializer serializePlainText:selectedText ranges:clippedRanges imageEntries:clippedImages]; } - (void)requestMarkdown:(NSInteger)requestId @@ -876,7 +1034,8 @@ - (void)requestMarkdown:(NSInteger)requestId return; } NSString *markdown = [ENRMMarkdownSerializer serializePlainText:ENRMGetPlainText(_textView) - ranges:[self allRangesIncludingTransient]]; + ranges:[self allRangesIncludingTransient] + imageEntries:_imageStore.allEntries]; emitter->onRequestMarkdownResult({ .requestId = static_cast(requestId), .markdown = std::string([markdown UTF8String] ?: ""), @@ -1068,7 +1227,8 @@ - (BOOL)deleteLinkForReplacementRange:(NSRange)range replacementText:(NSString * return NO; } - [self replaceTextInRange:linkRange.range withText:@"" formattingRanges:@[]]; + [self replaceTextInRange:linkRange.range + withParseResult:[ENRMParseResult resultWithPlainText:@"" formattingRanges:@[] imageEntries:@[]]]; [self clearActiveMention:nil]; return YES; } @@ -1090,7 +1250,8 @@ - (void)emitOnChangeMarkdown return; } NSString *markdown = [ENRMMarkdownSerializer serializePlainText:ENRMGetPlainText(_textView) - ranges:[self allRangesIncludingTransient]]; + ranges:[self allRangesIncludingTransient] + imageEntries:_imageStore.allEntries]; emitter->onChangeMarkdown({.value = std::string([markdown UTF8String] ?: "")}); } @@ -1239,6 +1400,33 @@ - (void)emitOnBlur emitter->onInputBlur({}); } +- (void)emitOnPasteImagesEvent:(NSArray *)images +{ + auto emitter = [self getEventEmitter]; + if (emitter == nullptr) { + return; + } + + std::vector imagesVector; + imagesVector.reserve(images.count); + + for (NSDictionary *img in images) { + NSString *uri = img[@"uri"]; + NSString *type = img[@"type"]; + double width = [img[@"width"] doubleValue]; + double height = [img[@"height"] doubleValue]; + + imagesVector.push_back({ + .uri = std::string([uri UTF8String] ?: ""), + .type = std::string([type UTF8String] ?: ""), + .width = width, + .height = height, + }); + } + + emitter->onPasteImages({.images = imagesVector}); +} + - (void)emitOnLinkDetectedWithText:(NSString *)text url:(NSString *)url range:(NSRange)range { auto emitter = [self getEventEmitter]; @@ -1318,6 +1506,7 @@ - (void)handleTextChanged } [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; + [_imageStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; if (insertedLength > 0) { NSRange insertedRange = NSMakeRange(editLocation, insertedLength); diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m b/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m index 720d864e..7443fae2 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m @@ -71,6 +71,22 @@ - (void)downloadURL:(NSString *)url completion:(ENRMImageDownloadCompletion)comp return; } + if (nsURL.isFileURL) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSData *fileData = [NSData dataWithContentsOfURL:nsURL]; +#if !TARGET_OS_OSX + RCTUIImage *fileImage = fileData ? [RCTUIImage imageWithData:fileData] : nil; +#else + RCTUIImage *fileImage = fileData ? [[RCTUIImage alloc] initWithData:fileData] : nil; +#endif + if (fileImage) { + [[ENRMImageAttachment originalImageCache] setObject:fileImage forKey:url cost:ENRMImageByteCost(fileImage)]; + } + [self dispatchCallbacksForURL:url image:fileImage]; + }); + return; + } + [[_session dataTaskWithURL:nsURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { #if !TARGET_OS_OSX diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx index 017ea3b4..5cf81f9e 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx @@ -21,12 +21,14 @@ import EnrichedMarkdownTextInputNativeComponent, { type OnStartMentionEvent, type OnChangeMentionEvent, type OnEndMentionEvent, + type OnPasteImagesEvent, } from './EnrichedMarkdownTextInputNativeComponent'; export type { OnLinkDetected, OnStartMentionEvent, OnChangeMentionEvent, OnEndMentionEvent, + OnPasteImagesEvent, } from './EnrichedMarkdownTextInputNativeComponent'; import type { HostInstance, @@ -61,6 +63,12 @@ export interface MarkdownTextInputStyle { color?: string; backgroundColor?: string; }; + image?: { + borderRadius?: number; + }; + inlineImage?: { + size?: number; + }; } export interface StyleState { @@ -90,6 +98,19 @@ export interface CaretRect { height: number; } +export interface InsertImageOptions { + alt?: string; + width?: number; + height?: number; +} + +export interface PastedImage { + uri: string; + type: string; + width: number; + height: number; +} + export interface EnrichedMarkdownTextInputInstance { focus: () => void; blur: () => void; @@ -108,6 +129,7 @@ export interface EnrichedMarkdownTextInputInstance { insertMention: (displayText: string, url: string) => void; startMention: (indicator: string) => void; removeLink: () => void; + insertImage: (url: string, options?: InsertImageOptions) => void; getMarkdown: () => Promise; getCaretRect: () => Promise; } @@ -139,6 +161,7 @@ export interface EnrichedMarkdownTextInputProps extends Omit< onStartMention?: (event: OnStartMentionEvent) => void; onChangeMention?: (event: OnChangeMentionEvent) => void; onEndMention?: (event: OnEndMentionEvent) => void; + onPasteImages?: (images: PastedImage[]) => void; onFocus?: () => void; onBlur?: () => void; contextMenuItems?: ContextMenuItem[]; @@ -200,6 +223,7 @@ export const EnrichedMarkdownTextInput = ({ onStartMention, onChangeMention, onEndMention, + onPasteImages, onFocus, onBlur, contextMenuItems, @@ -331,6 +355,13 @@ export const EnrichedMarkdownTextInput = ({ [onEndMention] ); + const handlePasteImages = useCallback( + (e: NativeSyntheticEvent) => { + onPasteImages?.(e.nativeEvent.images as PastedImage[]); + }, + [onPasteImages] + ); + const handleFocus = useCallback(() => { onFocus?.(); }, [onFocus]); @@ -408,6 +439,14 @@ export const EnrichedMarkdownTextInput = ({ Commands.insertMention(commandRef, displayText, url), startMention: (indicator) => Commands.startMention(commandRef, indicator), removeLink: () => Commands.removeLink(commandRef), + insertImage: (url, options) => + Commands.insertImage( + commandRef, + url, + options?.alt ?? 'image', + options?.width ?? 0, + options?.height ?? 0 + ), getMarkdown: () => new Promise((resolve, reject) => { const requestId = nextRequestId.current++; @@ -470,6 +509,7 @@ export const EnrichedMarkdownTextInput = ({ onStartMention={handleStartMention as NativeProps['onStartMention']} onChangeMention={handleChangeMention as NativeProps['onChangeMention']} onEndMention={handleEndMention as NativeProps['onEndMention']} + onPasteImages={handlePasteImages as NativeProps['onPasteImages']} {...rest} /> ); diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts index 041435dd..5acab78b 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts @@ -32,6 +32,12 @@ interface MarkdownTextInputStyleInternal { color: ColorValue; backgroundColor: ColorValue; }; + image: { + borderRadius: CodegenTypes.Float; + }; + inlineImage: { + size: CodegenTypes.Float; + }; } interface TargetedEvent { @@ -108,6 +114,15 @@ export interface OnEndMentionEvent { indicator: string; } +export interface OnPasteImagesEvent { + images: { + uri: string; + type: string; + width: CodegenTypes.Double; + height: CodegenTypes.Double; + }[]; +} + export interface ContextMenuItemConfig { text: string; icon?: string; @@ -237,6 +252,7 @@ export interface NativeProps extends ViewProps { onStartMention?: CodegenTypes.DirectEventHandler; onChangeMention?: CodegenTypes.DirectEventHandler; onEndMention?: CodegenTypes.DirectEventHandler; + onPasteImages?: CodegenTypes.DirectEventHandler; } type ComponentType = HostComponent; @@ -274,6 +290,13 @@ interface NativeCommands { indicator: string ) => void; removeLink: (viewRef: React.ElementRef) => void; + insertImage: ( + viewRef: React.ElementRef, + url: string, + alt: string, + width: CodegenTypes.Float, + height: CodegenTypes.Float + ) => void; requestMarkdown: ( viewRef: React.ElementRef, requestId: CodegenTypes.Int32 @@ -300,6 +323,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'insertMention', 'startMention', 'removeLink', + 'insertImage', 'requestMarkdown', 'requestCaretRect', ], diff --git a/packages/react-native-enriched-markdown/src/index.tsx b/packages/react-native-enriched-markdown/src/index.tsx index b180ff42..80fd688d 100644 --- a/packages/react-native-enriched-markdown/src/index.tsx +++ b/packages/react-native-enriched-markdown/src/index.tsx @@ -24,5 +24,8 @@ export type { OnStartMentionEvent, OnChangeMentionEvent, OnEndMentionEvent, + OnPasteImagesEvent, CaretRect, + InsertImageOptions, + PastedImage, } from './EnrichedMarkdownTextInput'; diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownTextInputStyle.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownTextInputStyle.ts index 7b21d55d..4c8dbb1a 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownTextInputStyle.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownTextInputStyle.ts @@ -30,6 +30,12 @@ interface MarkdownTextInputStyleInternal { color: ColorValue; backgroundColor: ColorValue; }; + image: { + borderRadius: number; + }; + inlineImage: { + size: number; + }; } const DEFAULT_LINK_COLOR = '#2563EB'; @@ -54,6 +60,8 @@ const defaultInternal: MarkdownTextInputStyleInternal = Object.freeze({ color: processColor(DEFAULT_SPOILER_COLOR)!, backgroundColor: processColor(DEFAULT_SPOILER_BG_COLOR)!, }, + image: { borderRadius: 4 }, + inlineImage: { size: 20 }, }); let cachedInput: MarkdownTextInputStyle | undefined; @@ -108,6 +116,13 @@ export const normalizeMarkdownTextInputStyle = ( normalizeColor(style.spoiler?.backgroundColor) ?? defaultInternal.spoiler.backgroundColor, }, + image: { + borderRadius: + style.image?.borderRadius ?? defaultInternal.image.borderRadius, + }, + inlineImage: { + size: style.inlineImage?.size ?? defaultInternal.inlineImage.size, + }, }; cachedInput = style; From 306eb8ae5ae17cae05c7e0af158b4fa9d5b67569 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Wed, 17 Jun 2026 13:47:36 +0200 Subject: [PATCH 2/2] fix(ios): enhance image attachment key generation and improve image entry creation logic --- .../ios/attachments/ENRMImageAttachment.m | 3 ++- .../ios/input/EnrichedMarkdownTextInput.mm | 20 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m index 9b3b3a45..4e6cfdc1 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMImageAttachment.m @@ -84,7 +84,8 @@ + (instancetype)inputAttachmentForURL:(NSString *)imageURL blockHeight:(CGFloat)blockHeight borderRadius:(CGFloat)borderRadius { - NSString *key = [NSString stringWithFormat:@"input_%@_%d_%.0f_%.0f", imageURL, isInline, blockWidth, blockHeight]; + NSString *key = [NSString stringWithFormat:@"input_%@_%d_%.2f_%.2f_%.2f_%.2f", imageURL, isInline, inlineSize, + blockWidth, blockHeight, borderRadius]; ENRMImageAttachment *existing = [[self attachmentRegistry] objectForKey:key]; if (existing) { return existing; diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index cdce511e..ca652de5 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -638,6 +638,8 @@ - (void)applyFormatting _isApplyingFormatting = NO; } +static unichar const kORC = 0xFFFC; + - (void)applyImageAttachments { NSArray *entries = _imageStore.allEntries; @@ -905,8 +907,6 @@ - (void)removeLink [self emitFormattingChanged]; } -static unichar const kORC = 0xFFFC; - - (void)insertImage:(NSString *)url alt:(NSString *)alt width:(float)width height:(float)height { NSString *plainText = ENRMGetPlainText(_textView); @@ -939,14 +939,14 @@ - (void)insertImage:(NSString *)url alt:(NSString *)alt width:(float)width heigh isInline ? cursor : (cursor > 0 && [plainText characterAtIndex:cursor - 1] != '\n' ? cursor + 1 : cursor); CGFloat defaultBlockSize = 80.0; - ENRMImageEntry *entry = - [ENRMImageEntry entryWithPosition:orcPosition - url:url - alt:alt.length > 0 ? alt : @"image" - width:width > 0 ? width - : (isInline ? _imageInlineSize : defaultBlockSize)height:height > 0 - ? height - : (isInline ? _imageInlineSize : defaultBlockSize)isInline:isInline]; + CGFloat finalWidth = width > 0 ? width : (isInline ? _imageInlineSize : defaultBlockSize); + CGFloat finalHeight = height > 0 ? height : (isInline ? _imageInlineSize : defaultBlockSize); + ENRMImageEntry *entry = [ENRMImageEntry entryWithPosition:orcPosition + url:url + alt:alt.length > 0 ? alt : @"image" + width:finalWidth + height:finalHeight + isInline:isInline]; [_imageStore addEntry:entry]; _lastTextLength = ENRMGetPlainText(_textView).length;