diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_table_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_table_combo_display.png index 3c56e38a..aacb0d64 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_table_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_table_combo_display.png differ diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.h b/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.h new file mode 100644 index 00000000..26db4294 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#if !TARGET_OS_OSX + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^ENRMTableIOSLinkBlock)(NSString *url); + +@interface ENRMTableIOSRowData : NSObject +@property (nonatomic, strong) NSArray *cellTexts; +@property (nonatomic, strong) UIColor *backgroundColor; +@end + +/// Draws the entire table grid in a single drawRect: pass, avoiding +/// per-cell UITextView allocation and the TextKit layout storms that +/// cause multi-second main-thread hangs on large tables. +@interface ENRMTableIOSGridView : UIView + +@property (nonatomic, copy, nullable) ENRMTableIOSLinkBlock onLinkTap; +@property (nonatomic, copy, nullable) ENRMTableIOSLinkBlock onLinkLongTap; + +- (void)updateWithRows:(NSArray *)rows + columnWidths:(NSArray *)columnWidths + rowHeights:(NSArray *)rowHeights + borderColor:(UIColor *)borderColor + borderWidth:(CGFloat)borderWidth + horizontalCellPadding:(CGFloat)horizontalCellPadding + verticalCellPadding:(CGFloat)verticalCellPadding + cornerRadius:(CGFloat)cornerRadius; + +- (void)fadeInRowsFrom:(NSUInteger)startRow duration:(NSTimeInterval)duration; + +@end + +NS_ASSUME_NONNULL_END + +#endif // !TARGET_OS_OSX diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.m b/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.m new file mode 100644 index 00000000..13843503 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.m @@ -0,0 +1,216 @@ +#import "ENRMTableIOSGridView.h" + +#if !TARGET_OS_OSX + +@implementation ENRMTableIOSRowData +@end + +@implementation ENRMTableIOSGridView { + NSArray *_tableRows; + NSArray *_columnWidths; + NSArray *_rowHeights; + UIColor *_borderColor; + CGFloat _borderWidth; + CGFloat _horizontalCellPadding; + CGFloat _verticalCellPadding; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + self.backgroundColor = [UIColor clearColor]; + self.contentMode = UIViewContentModeRedraw; + self.opaque = NO; + self.accessibilityElementsHidden = YES; + + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)]; + [self addGestureRecognizer:tap]; + + UILongPressGestureRecognizer *longPress = + [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)]; + [self addGestureRecognizer:longPress]; + } + return self; +} + +#pragma mark - Data + +- (void)updateWithRows:(NSArray *)rows + columnWidths:(NSArray *)columnWidths + rowHeights:(NSArray *)rowHeights + borderColor:(UIColor *)borderColor + borderWidth:(CGFloat)borderWidth + horizontalCellPadding:(CGFloat)horizontalCellPadding + verticalCellPadding:(CGFloat)verticalCellPadding + cornerRadius:(CGFloat)cornerRadius +{ + _tableRows = [rows copy]; + _columnWidths = [columnWidths copy]; + _rowHeights = [rowHeights copy]; + _borderColor = borderColor; + _borderWidth = borderWidth; + _horizontalCellPadding = horizontalCellPadding; + _verticalCellPadding = verticalCellPadding; + [self setNeedsDisplay]; +} + +- (void)fadeInRowsFrom:(NSUInteger)startRow duration:(NSTimeInterval)duration +{ + if (startRow >= _tableRows.count) + return; + + [UIView transitionWithView:self + duration:duration + options:UIViewAnimationOptionTransitionCrossDissolve + animations:^{} + completion:nil]; +} + +#pragma mark - Drawing + +- (void)drawRect:(CGRect)dirtyRect +{ + if (!_tableRows.count || !_columnWidths.count || !_rowHeights.count) + return; + + CGFloat yOffset = 0; + for (NSUInteger r = 0; r < _tableRows.count; r++) { + ENRMTableIOSRowData *rowData = _tableRows[r]; + CGFloat rowHeight = [_rowHeights[r] doubleValue]; + CGFloat xOffset = 0; + + NSUInteger colCount = MIN(rowData.cellTexts.count, _columnWidths.count); + for (NSUInteger c = 0; c < colCount; c++) { + CGFloat columnWidth = [_columnWidths[c] doubleValue]; + CGRect cellRect = CGRectMake(xOffset, yOffset, columnWidth + _borderWidth, rowHeight + _borderWidth); + + [rowData.backgroundColor setFill]; + UIRectFill(cellRect); + + [_borderColor setStroke]; + UIBezierPath *border = [UIBezierPath bezierPathWithRect:cellRect]; + border.lineWidth = _borderWidth; + [border stroke]; + + NSAttributedString *text = rowData.cellTexts[c]; + if (text.length > 0) { + CGRect textRect = CGRectMake(xOffset + _horizontalCellPadding, yOffset + _verticalCellPadding, + columnWidth - _horizontalCellPadding * 2, rowHeight - _verticalCellPadding * 2); + [text drawWithRect:textRect + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + context:nil]; + } + + xOffset += columnWidth; + } + yOffset += rowHeight; + } +} + +#pragma mark - Link hit-testing + +- (BOOL)cellAtPoint:(CGPoint)point + rowOrigin:(out CGFloat *)outRowY + rowHeight:(out CGFloat *)outRowH + colOrigin:(out CGFloat *)outColX + colWidth:(out CGFloat *)outColW + cellText:(out NSAttributedString *__autoreleasing *)outText +{ + CGFloat rowY = 0; + for (NSUInteger r = 0; r < _tableRows.count; r++) { + CGFloat rh = [_rowHeights[r] doubleValue]; + if (point.y >= rowY && point.y < rowY + rh) { + CGFloat colX = 0; + for (NSUInteger c = 0; c < _columnWidths.count; c++) { + CGFloat cw = [_columnWidths[c] doubleValue]; + if (point.x >= colX && point.x < colX + cw) { + ENRMTableIOSRowData *rowData = _tableRows[r]; + if (c >= rowData.cellTexts.count) + return NO; + *outRowY = rowY; + *outRowH = rh; + *outColX = colX; + *outColW = cw; + *outText = rowData.cellTexts[c]; + return YES; + } + colX += cw; + } + return NO; + } + rowY += rh; + } + return NO; +} + +static NSString *linkInAttributedString(NSAttributedString *text, CGRect textRect, CGPoint point) +{ + if (text.length == 0) + return nil; + + NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:text]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:textRect.size]; + textContainer.lineFragmentPadding = 0; + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + + CGPoint local = CGPointMake(point.x - textRect.origin.x, point.y - textRect.origin.y); + CGFloat fraction = 0; + NSUInteger glyphIndex = [layoutManager glyphIndexForPoint:local + inTextContainer:textContainer + fractionOfDistanceThroughGlyph:&fraction]; + if (glyphIndex >= layoutManager.numberOfGlyphs) + return nil; + NSUInteger charIndex = [layoutManager characterIndexForGlyphAtIndex:glyphIndex]; + if (charIndex >= text.length) + return nil; + + return [text attribute:@"linkURL" atIndex:charIndex effectiveRange:NULL]; +} + +- (NSString *)linkURLAtPoint:(CGPoint)point +{ + CGFloat rowY, rowH, colX, colW; + NSAttributedString *text; + if (![self cellAtPoint:point rowOrigin:&rowY rowHeight:&rowH colOrigin:&colX colWidth:&colW cellText:&text]) + return nil; + + CGRect textRect = CGRectMake(colX + _horizontalCellPadding, rowY + _verticalCellPadding, + colW - _horizontalCellPadding * 2, rowH - _verticalCellPadding * 2); + if (!CGRectContainsPoint(textRect, point)) + return nil; + + return linkInAttributedString(text, textRect, point); +} + +#pragma mark - Gesture handlers + +- (void)handleLinkGesture:(UIGestureRecognizer *)recognizer block:(ENRMTableIOSLinkBlock)block +{ + CGPoint point = [recognizer locationInView:self]; + NSString *url = [self linkURLAtPoint:point]; + if (url && block) { + block(url); + } +} + +- (void)handleTap:(UITapGestureRecognizer *)recognizer +{ + if (recognizer.state == UIGestureRecognizerStateEnded) { + [self handleLinkGesture:recognizer block:self.onLinkTap]; + } +} + +- (void)handleLongPress:(UILongPressGestureRecognizer *)recognizer +{ + if (recognizer.state == UIGestureRecognizerStateBegan) { + [self handleLinkGesture:recognizer block:self.onLinkLongTap]; + } +} + +@end + +#endif // !TARGET_OS_OSX diff --git a/packages/react-native-enriched-markdown/ios/views/TableContainerView.m b/packages/react-native-enriched-markdown/ios/views/TableContainerView.m index d4600090..98657b00 100644 --- a/packages/react-native-enriched-markdown/ios/views/TableContainerView.m +++ b/packages/react-native-enriched-markdown/ios/views/TableContainerView.m @@ -12,6 +12,8 @@ #if TARGET_OS_OSX #import "ENRMMenuAction.h" #import "ENRMTableGridView.h" +#else +#import "ENRMTableIOSGridView.h" #endif @interface TableCellData : NSObject @@ -26,7 +28,7 @@ @implementation TableCellData @end #if !TARGET_OS_OSX -@interface TableContainerView () +@interface TableContainerView () @end #else @interface TableContainerView () @@ -94,7 +96,19 @@ - (void)setupScrollView _gridContainer = gridView; [(NSScrollView *)_scrollView setDocumentView:_gridContainer]; #else - _gridContainer = [[RCTUIView alloc] init]; + ENRMTableIOSGridView *iosGridView = [[ENRMTableIOSGridView alloc] initWithFrame:CGRectZero]; + __weak TableContainerView *weakSelf = self; + iosGridView.onLinkTap = ^(NSString *url) { + TableContainerView *strongSelf = weakSelf; + if (strongSelf && strongSelf.onLinkPress) + strongSelf.onLinkPress(url); + }; + iosGridView.onLinkLongTap = ^(NSString *url) { + TableContainerView *strongSelf = weakSelf; + if (strongSelf && strongSelf.onLinkLongPress) + strongSelf.onLinkLongPress(url); + }; + _gridContainer = iosGridView; [_scrollView addSubview:_gridContainer]; UIContextMenuInteraction *contextMenu = [[UIContextMenuInteraction alloc] initWithDelegate:self]; [_gridContainer addInteraction:contextMenu]; @@ -180,24 +194,7 @@ - (void)animateNewRowsFromPreviousCount:(NSUInteger)previousRowCount duration:(N if (self.rowCount <= previousRowCount) { return; } - - NSArray *subviews = _gridContainer.subviews; - NSUInteger childCount = subviews.count; - if (childCount == 0 || self.rowCount == 0) { - return; - } - - NSUInteger colCount = childCount / self.rowCount; - if (colCount == 0) { - return; - } - - NSUInteger firstNewCellIndex = previousRowCount * colCount; - for (NSUInteger i = firstNewCellIndex; i < childCount; i++) { - RCTUIView *cellView = subviews[i]; - cellView.alpha = 0.0; - [UIView animateWithDuration:duration animations:^{ cellView.alpha = 1.0; }]; - } + [(ENRMTableIOSGridView *)_gridContainer fadeInRowsFrom:previousRowCount duration:duration]; } #else - (void)animateNewRowsFromPreviousCount:(NSUInteger)previousRowCount duration:(NSTimeInterval)duration @@ -318,6 +315,16 @@ - (RCTUIColor *)backgroundColorForRowIsHeader:(BOOL)isHeader bodyRowIndex:(NSUIn return (bodyRowIndex % 2 == 0) ? self.config.tableRowEvenBackgroundColor : self.config.tableRowOddBackgroundColor; } +- (NSArray *)attributedTextsForRow:(NSArray *)rowCells +{ + NSMutableArray *cellTexts = [NSMutableArray arrayWithCapacity:_colCount]; + for (NSUInteger columnIndex = 0; columnIndex < _colCount; columnIndex++) { + NSAttributedString *text = (columnIndex < rowCells.count) ? rowCells[columnIndex].attributedText : nil; + [cellTexts addObject:text ?: [[NSAttributedString alloc] init]]; + } + return [cellTexts copy]; +} + #if TARGET_OS_OSX - (void)renderGridMacOS { @@ -350,16 +357,6 @@ - (void)renderGridMacOS cornerRadius:self.config.tableBorderRadius]; } -- (NSArray *)attributedTextsForRow:(NSArray *)rowCells -{ - NSMutableArray *cellTexts = [NSMutableArray arrayWithCapacity:_colCount]; - for (NSUInteger columnIndex = 0; columnIndex < _colCount; columnIndex++) { - NSAttributedString *text = (columnIndex < rowCells.count) ? rowCells[columnIndex].attributedText : nil; - [cellTexts addObject:text ?: [[NSAttributedString alloc] init]]; - } - return [cellTexts copy]; -} - #else - (void)renderGridIOS @@ -368,108 +365,35 @@ - (void)renderGridIOS _gridContainer.layer.cornerRadius = self.config.tableBorderRadius; _gridContainer.layer.masksToBounds = YES; - CGFloat yOffset = 0; NSUInteger bodyRowIndex = 0; + NSMutableArray *rowDataArray = [NSMutableArray arrayWithCapacity:_rows.count]; - for (NSUInteger r = 0; r < _rows.count; r++) { - NSArray *row = _rows[r]; - CGFloat rowHeight = [_rowHeights[r] doubleValue]; - BOOL isHeaderRow = (row.count > 0 && row.firstObject.isHeader); + for (NSArray *rowCells in _rows) { + BOOL isHeaderRow = (rowCells.count > 0 && rowCells.firstObject.isHeader); - [self renderRow:row atY:yOffset height:rowHeight isHeader:isHeaderRow bodyIndex:bodyRowIndex]; + ENRMTableIOSRowData *rowData = [[ENRMTableIOSRowData alloc] init]; + rowData.backgroundColor = [self backgroundColorForRowIsHeader:isHeaderRow bodyRowIndex:bodyRowIndex]; + rowData.cellTexts = [self attributedTextsForRow:rowCells]; + [rowDataArray addObject:rowData]; - if (!isHeaderRow) + if (!isHeaderRow) { bodyRowIndex++; - yOffset += rowHeight; - } -} -#endif - -#if !TARGET_OS_OSX -- (void)renderRow:(NSArray *)row - atY:(CGFloat)yOffset - height:(CGFloat)height - isHeader:(BOOL)isHeader - bodyIndex:(NSUInteger)bodyIndex -{ - CGFloat xOffset = 0; - RCTUIColor *rowBackground = [self backgroundColorForRowIsHeader:isHeader bodyRowIndex:bodyIndex]; - - for (NSUInteger column = 0; column < _colCount; column++) { - CGFloat columnWidth = [_colWidths[column] doubleValue]; - CGRect cellFrame = CGRectMake(xOffset, yOffset, columnWidth + _borderWidth, height + _borderWidth); - - RCTUIView *cellBackground = [[RCTUIView alloc] initWithFrame:cellFrame]; - cellBackground.backgroundColor = rowBackground; - cellBackground.layer.borderColor = self.config.tableBorderColor.CGColor; - cellBackground.layer.borderWidth = _borderWidth; - [_gridContainer addSubview:cellBackground]; - - if (column < row.count) { - [self addTextToCell:cellBackground data:row[column] width:columnWidth height:height]; } - xOffset += columnWidth; } -} - -- (void)addTextToCell:(RCTUIView *)container data:(TableCellData *)data width:(CGFloat)width height:(CGFloat)height -{ - const CGFloat horizontalPadding = self.config.tableCellPaddingHorizontal; - const CGFloat verticalPadding = self.config.tableCellPaddingVertical; - CGRect contentFrame = - CGRectMake(horizontalPadding, verticalPadding, width - (horizontalPadding * 2), height - (verticalPadding * 2)); - UITextView *cellTextView = [self createCellTextView]; - cellTextView.frame = contentFrame; - cellTextView.attributedText = data.attributedText; - [container addSubview:cellTextView]; -} -- (UITextView *)createCellTextView -{ - UITextView *textView = [[UITextView alloc] init]; - textView.editable = NO; - textView.scrollEnabled = NO; - textView.selectable = NO; - textView.backgroundColor = [RCTUIColor clearColor]; - textView.textContainerInset = UIEdgeInsetsZero; - textView.accessibilityElementsHidden = YES; - textView.linkTextAttributes = @{}; - textView.delegate = self; - - UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(cellTextTapped:)]; - [textView addGestureRecognizer:tapRecognizer]; - textView.textContainer.lineFragmentPadding = 0; - return textView; + ENRMTableIOSGridView *gridView = (ENRMTableIOSGridView *)_gridContainer; + [gridView updateWithRows:[rowDataArray copy] + columnWidths:_colWidths + rowHeights:_rowHeights + borderColor:self.config.tableBorderColor + borderWidth:_borderWidth + horizontalCellPadding:self.config.tableCellPaddingHorizontal + verticalCellPadding:self.config.tableCellPaddingVertical + cornerRadius:self.config.tableBorderRadius]; } #endif #if !TARGET_OS_OSX -- (void)cellTextTapped:(UITapGestureRecognizer *)recognizer -{ - UITextView *textView = (UITextView *)recognizer.view; - NSString *url = linkURLAtTapLocation(textView, recognizer); - if (url && self.onLinkPress) - self.onLinkPress(url); -} - -- (BOOL)textView:(UITextView *)textView - shouldInteractWithURL:(NSURL *)URL - inRange:(NSRange)range - interaction:(UITextItemInteraction)interaction -{ - if (interaction != UITextItemInteractionPresentActions) - return YES; - - NSString *urlString = linkURLAtRange(textView, range); - if (!urlString || self.enableLinkPreview) - return YES; - - if (self.onLinkLongPress) - self.onLinkLongPress(urlString); - return NO; -} - - (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction configurationForMenuAtLocation:(CGPoint)location {