diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 1ba1279b..df657c21 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -422,6 +422,13 @@ class EnrichedTextInputViewManager : view?.requestHTML(requestId) } + override fun setTextAlignment( + view: EnrichedTextInputView?, + alignment: String, + ) { + TODO("Not yet implemented") + } + override fun measure( context: Context, localData: ReadableMap?, diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index f4801c0a..ce6e15e4 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -85,6 +85,18 @@ const STYLE_ITEMS = [ name: 'checkbox-list', icon: 'check-square-o', }, + { + name: 'align-left', + icon: 'align-left', + }, + { + name: 'align-center', + icon: 'align-center', + }, + { + name: 'align-right', + icon: 'align-right', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; @@ -168,6 +180,15 @@ export const Toolbar: FC = ({ case 'mention': editorRef.current?.startMention('@'); break; + case 'align-left': + editorRef.current?.setTextAlignment('left'); + break; + case 'align-center': + editorRef.current?.setTextAlignment('center'); + break; + case 'align-right': + editorRef.current?.setTextAlignment('right'); + break; } }; @@ -256,6 +277,12 @@ export const Toolbar: FC = ({ return stylesState.mention.isActive; case 'checkbox-list': return stylesState.checkboxList.isActive; + case 'align-left': + return stylesState.alignment === 'left'; + case 'align-center': + return stylesState.alignment === 'center'; + case 'align-right': + return stylesState.alignment === 'right'; default: return false; } diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index f5170a57..feca136c 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -28,6 +28,7 @@ export const DEFAULT_STYLES: StylesState = { image: DEFAULT_STYLE_STATE, mention: DEFAULT_STYLE_STATE, checkboxList: DEFAULT_STYLE_STATE, + alignment: 'left', }; export const DEFAULT_LINK_STATE = { diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index 7c9b9a5c..982c1f35 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -184,4 +184,14 @@ const styles = StyleSheet.create({ height: 1000, backgroundColor: 'rgb(0, 26, 114)', }, + alignmentLabel: { + marginTop: 20, + fontSize: 18, + fontWeight: 'bold', + textAlign: 'center', + color: 'rgb(0, 26, 114)', + }, + alignmentButton: { + width: '25%', + }, }); diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 410b456c..37ee8295 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -292,12 +292,14 @@ interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; } ``` - `isActive` indicates if the style is active within current selection. - `isBlocking` indicates if the style is blocked by other currently active, meaning it can't be toggled. - `isConflicting` indicates if the style is in conflict with other currently active styles, meaning toggling it will remove conflicting style. +- `alignment` indicates the current text alignment of the paragraph at the cursor position. Possible values: `'left'`, `'center'`, `'right'`, `'justify'`. | Type | Platform | |-------------------------------------------------------------|----------| @@ -614,6 +616,16 @@ Sets the selection at the given indexes. - `start: number` - starting index of the selection. - `end: number` - first index after the selection's ending index. For just a cursor in place (no selection), `start` equals `end`. +### `.setTextAlignment()` + +```ts +setTextAlignment: (alignment: 'left' | 'center' | 'right' | 'justify' | 'default') => void; +``` + +Sets text alignment for the paragraph(s) at the current selection. When inside a list, the alignment is applied to all contiguous list items. + +- `alignment` - the desired text alignment. Use `'default'` to reset to the system natural alignment. + ### `.startMention()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 5e81c9f2..888922fc 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,8 +1,10 @@ #import "EnrichedTextInputView.h" +#import "AlignmentUtils.h" #import "CoreText/CoreText.h" #import "ImageAttachment.h" #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" +#import "ParagraphsUtils.h" #import "RCTFabricComponentsPlugins.h" #import "StringExtension.h" #import "StyleHeaders.h" @@ -51,6 +53,7 @@ @implementation EnrichedTextInputView { BOOL _emitTextChange; NSMutableDictionary *_attachmentViews; NSArray *_contextMenuItems; + NSString *_recentlyEmittedAlignment; } // MARK: - Component utils @@ -89,6 +92,7 @@ - (void)setDefaults { _blockedStyles = [[NSMutableSet alloc] init]; _recentlyActiveLinkRange = NSMakeRange(0, 0); _recentlyActiveMentionRange = NSMakeRange(0, 0); + _recentlyEmittedAlignment = @"left"; recentlyChangedRange = NSMakeRange(0, 0); _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; @@ -1121,12 +1125,20 @@ - (void)tryUpdatingActiveStyles { } } + // detect alignment change + NSString *currentAlignment = + [AlignmentUtils currentAlignmentStringForInput:self]; + if (![currentAlignment isEqualToString:_recentlyEmittedAlignment]) { + updateNeeded = YES; + } + if (updateNeeded) { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { // update activeStyles and blockedStyles only if emitter is available _activeStyles = newActiveStyles; _blockedStyles = newBlockedStyles; + _recentlyEmittedAlignment = currentAlignment; emitter->onChangeState( {.bold = GET_STYLE_STATE([BoldStyle getStyleType]), @@ -1147,7 +1159,8 @@ - (void)tryUpdatingActiveStyles { .blockQuote = GET_STYLE_STATE([BlockQuoteStyle getStyleType]), .codeBlock = GET_STYLE_STATE([CodeBlockStyle getStyleType]), .image = GET_STYLE_STATE([ImageStyle getStyleType]), - .checkboxList = GET_STYLE_STATE([CheckboxListStyle getStyleType])}); + .checkboxList = GET_STYLE_STATE([CheckboxListStyle getStyleType]), + .alignment = [currentAlignment UTF8String]}); } } @@ -1282,6 +1295,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"requestHTML"]) { NSInteger requestId = [((NSNumber *)args[0]) integerValue]; [self requestHTML:requestId]; + } else if ([commandName isEqualToString:@"setTextAlignment"]) { + NSString *alignmentString = (NSString *)args[0]; + [AlignmentUtils applyAlignmentFromString:alignmentString toInput:self]; } } diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm index acce99f8..89324fac 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -267,6 +267,7 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput NSForegroundColorAttributeName : [typedInput->config orderedListMarkerColor] }; + CGFloat indent = pStyle.firstLineHeadIndent; NSArray *paragraphs = [ParagraphsUtils getSeparateParagraphsRangesIn:typedInput->textView @@ -308,18 +309,21 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput marker:marker markerAttributes:markerAttributes origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } else if (markerFormat == NSTextListMarkerDisc) { [self drawBullet:typedInput origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } else if ([markerFormat hasPrefix:@"{checkbox"]) { [self drawCheckbox:typedInput markerFormat:markerFormat origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } // only first line of a list gets its // marker drawn @@ -387,7 +391,8 @@ - (CGRect)getTextAlignedUsedRect:(CGRect)usedRect font:(UIFont *)font { - (void)drawCheckbox:(EnrichedTextInputView *)typedInput markerFormat:(NSString *)markerFormat origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { BOOL isChecked = [markerFormat isEqualToString:@"{checkbox:1}"]; UIImage *image = isChecked ? typedInput->config.checkboxCheckedImage @@ -396,7 +401,7 @@ - (void)drawCheckbox:(EnrichedTextInputView *)typedInput CGFloat boxSize = [typedInput->config checkboxListBoxSize]; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; - CGFloat boxX = origin.x + usedRect.origin.x - gapWidth - boxSize; + CGFloat boxX = origin.x + indent - gapWidth - boxSize; CGFloat boxY = centerY - boxSize / 2.0; [image drawAtPoint:CGPointMake(boxX, boxY)]; @@ -404,10 +409,11 @@ - (void)drawCheckbox:(EnrichedTextInputView *)typedInput - (void)drawBullet:(EnrichedTextInputView *)typedInput origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { CGFloat gapWidth = [typedInput->config unorderedListGapWidth]; CGFloat bulletSize = [typedInput->config unorderedListBulletSize]; - CGFloat bulletX = origin.x + usedRect.origin.x - gapWidth - bulletSize / 2; + CGFloat bulletX = origin.x + indent - gapWidth - bulletSize / 2; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; CGContextRef context = UIGraphicsGetCurrentContext(); @@ -425,10 +431,11 @@ - (void)drawDecimal:(EnrichedTextInputView *)typedInput marker:(NSString *)marker markerAttributes:(NSDictionary *)markerAttributes origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { CGFloat gapWidth = [typedInput->config orderedListGapWidth]; CGSize markerSize = [marker sizeWithAttributes:markerAttributes]; - CGFloat markerX = usedRect.origin.x - gapWidth - markerSize.width / 2; + CGFloat markerX = origin.x + indent - gapWidth - markerSize.width / 2; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; CGFloat markerY = centerY - markerSize.height / 2.0; diff --git a/ios/inputParser/InputParser.mm b/ios/inputParser/InputParser.mm index 1348728d..5b470d2d 100644 --- a/ios/inputParser/InputParser.mm +++ b/ios/inputParser/InputParser.mm @@ -1,4 +1,6 @@ #import "InputParser.h" +#import "AlignmentEntry.h" +#import "AlignmentUtils.h" #import "EnrichedTextInputView.h" #import "StringExtension.h" #import "StyleHeaders.h" @@ -219,19 +221,24 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [result appendString:@"\n"]; } + NSString *styleAttr = [self prepareStyleAttrStr:currentRange.location + isOpeningTag:YES]; + // handle starting unordered list if (!inUnorderedList && [currentActiveStyles containsObject:@([UnorderedListStyle getStyleType])]) { inUnorderedList = YES; - [result appendString:@"\n
    "]; + [result + appendString:[NSString stringWithFormat:@"\n", styleAttr]]; } // handle starting ordered list if (!inOrderedList && [currentActiveStyles containsObject:@([OrderedListStyle getStyleType])]) { inOrderedList = YES; - [result appendString:@"\n
      "]; + [result + appendString:[NSString stringWithFormat:@"\n", styleAttr]]; } // handle starting blockquotes if (!inBlockQuote && @@ -252,7 +259,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [currentActiveStyles containsObject:@([CheckboxListStyle getStyleType])]) { inCheckboxList = YES; - [result appendString:@"\n
        "]; + [result appendString:[NSString stringWithFormat: + @"\n
          ", + styleAttr]]; } // don't add the

          tag if some paragraph styles are present @@ -274,7 +283,7 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { containsObject:@([CheckboxListStyle getStyleType])]) { [result appendString:@"\n"]; } else { - [result appendString:@"\n

          "]; + [result appendString:[NSString stringWithFormat:@"", styleAttr]]; } } @@ -486,6 +495,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { - (NSString *)tagContentForStyle:(NSNumber *)style openingTag:(BOOL)openingTag location:(NSInteger)location { + NSString *styleAttr = [self prepareStyleAttrStr:location + isOpeningTag:openingTag]; + if ([style isEqualToNumber:@([BoldStyle getStyleType])]) { return @"b"; } else if ([style isEqualToNumber:@([ItalicStyle getStyleType])]) { @@ -565,17 +577,17 @@ - (NSString *)tagContentForStyle:(NSNumber *)style return @"mention"; } } else if ([style isEqualToNumber:@([H1Style getStyleType])]) { - return @"h1"; + return [NSString stringWithFormat:@"h1%@", styleAttr]; } else if ([style isEqualToNumber:@([H2Style getStyleType])]) { - return @"h2"; + return [NSString stringWithFormat:@"h2%@", styleAttr]; } else if ([style isEqualToNumber:@([H3Style getStyleType])]) { - return @"h3"; + return [NSString stringWithFormat:@"h3%@", styleAttr]; } else if ([style isEqualToNumber:@([H4Style getStyleType])]) { - return @"h4"; + return [NSString stringWithFormat:@"h4%@", styleAttr]; } else if ([style isEqualToNumber:@([H5Style getStyleType])]) { - return @"h5"; + return [NSString stringWithFormat:@"h5%@", styleAttr]; } else if ([style isEqualToNumber:@([H6Style getStyleType])]) { - return @"h6"; + return [NSString stringWithFormat:@"h6%@", styleAttr]; } else if ([style isEqualToNumber:@([UnorderedListStyle getStyleType])] || [style isEqualToNumber:@([OrderedListStyle getStyleType])]) { return @"li"; @@ -595,8 +607,7 @@ - (NSString *)tagContentForStyle:(NSNumber *)style } } else if ([style isEqualToNumber:@([BlockQuoteStyle getStyleType])] || [style isEqualToNumber:@([CodeBlockStyle getStyleType])]) { - // blockquotes and codeblock use

          tags the same way lists use

        • - return @"p"; + return [NSString stringWithFormat:@"p%@", styleAttr]; } return @""; } @@ -605,6 +616,7 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // reset the text first and reset typing attributes _input->textView.text = @""; @@ -617,12 +629,14 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { [self applyProcessedStyles:stylesInfo offsetFromBeginning:0 plainTextLength:plainText.length]; + [self applyAlignments:alignments offset:0]; } - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // we can use ready replace util [TextInsertionUtils replaceText:plainText @@ -634,12 +648,14 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { [self applyProcessedStyles:stylesInfo offsetFromBeginning:range.location plainTextLength:plainText.length]; + [self applyAlignments:alignments offset:range.location]; } - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // same here, insertion utils got our back [TextInsertionUtils insertText:plainText @@ -651,6 +667,7 @@ - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { [self applyProcessedStyles:stylesInfo offsetFromBeginning:location plainTextLength:plainText.length]; + [self applyAlignments:alignments offset:location]; } - (void)applyProcessedStyles:(NSArray *)processedStyles @@ -1092,6 +1109,8 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init]; NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init]; NSMutableDictionary *checkboxStates = [[NSMutableDictionary alloc] init]; + NSMutableArray *foundAlignments = + [[NSMutableArray alloc] init]; BOOL insideCheckboxList = NO; _precedingImageCount = 0; BOOL insideTag = NO; @@ -1132,8 +1151,7 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { isSelfClosing = YES; } - if ([currentTagName isEqualToString:@"p"] || - [currentTagName isEqualToString:@"br"]) { + if ([currentTagName isEqualToString:@"br"]) { // do nothing, we don't include these tags in styles } else if ([currentTagName isEqualToString:@"li"]) { // Only track checkbox state if we're inside a checkbox list @@ -1142,6 +1160,13 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { checkboxStates[@(plainText.length)] = @(isChecked); } } else if (!closingTag) { + BOOL isPlainParagraph = + [currentTagName isEqualToString:@"p"] && + (!currentTagParams || [currentTagParams length] == 0); + + if (isPlainParagraph) { + continue; + } // we finish opening tag - get its location and optionally params and // put them under tag name key in ongoingTags NSMutableArray *tagArr = [[NSMutableArray alloc] init]; @@ -1193,6 +1218,9 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { mutableCopy]; } + [self checkForAlignments:ongoingTags[currentTagName] + plainText:plainText + foundAlignments:foundAlignments]; [self finalizeTagEntry:currentTagName ongoingTags:ongoingTags initiallyProcessedTags:initiallyProcessedTags @@ -1419,7 +1447,7 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { [processedStyles addObject:styleArr]; } - return @[ plainText, processedStyles ]; + return @[ plainText, processedStyles, foundAlignments ]; } - (BOOL)isUlCheckboxList:(NSString *)params { @@ -1442,4 +1470,110 @@ - (NSDictionary *)prepareCheckboxListStyleValue:(NSValue *)rangeValue return statesInRange; } +- (NSString *)cssValueForAlignment:(NSTextAlignment)alignment { + switch (alignment) { + case NSTextAlignmentCenter: + return @"center"; + case NSTextAlignmentRight: + return @"right"; + case NSTextAlignmentJustified: + return @"justify"; + default: + return nil; + } +} + +- (NSString *)prepareStyleAttrStr:(NSInteger)location + isOpeningTag:(BOOL)isOpeningTag { + if (!isOpeningTag) { + return @""; + } + + NSParagraphStyle *pStyle = + [_input->textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:location + effectiveRange:nil]; + NSString *alignStr = [self cssValueForAlignment:pStyle.alignment]; + + if (alignStr) { + return [NSString stringWithFormat:@" style=\"text-align: %@\"", alignStr]; + } + + return @""; +} + +- (NSTextAlignment)alignmentFromStyleParams:(NSString *)params { + if (!params) + return NSTextAlignmentNatural; + + NSString *pattern = @"text-align\\s*:\\s*(left|center|right|justify)"; + + NSRegularExpression *regex = [NSRegularExpression + regularExpressionWithPattern:pattern + options:NSRegularExpressionCaseInsensitive + error:nil]; + + NSTextCheckingResult *match = + [regex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + + if (match) { + // rangeAtIndex:1 corresponds to the capture group + // (left|center|right|justify) + NSString *value = + [[params substringWithRange:[match rangeAtIndex:1]] lowercaseString]; + + if ([value isEqualToString:@"center"]) + return NSTextAlignmentCenter; + if ([value isEqualToString:@"right"]) + return NSTextAlignmentRight; + if ([value isEqualToString:@"justify"]) + return NSTextAlignmentJustified; + if ([value isEqualToString:@"left"]) + return NSTextAlignmentLeft; + } + + return NSTextAlignmentNatural; +} + +- (void)applyAlignments:(NSArray *)alignments + offset:(NSInteger)offset { + for (AlignmentEntry *entry in alignments) { + // Offset the range (e.g. if inserting into the middle of text) + NSRange finalRange = + NSMakeRange(offset + entry.range.location, entry.range.length); + + [AlignmentUtils setAlignment:entry.alignment + forRange:finalRange + inInput:_input]; + } +} + +- (void)checkForAlignments:(NSArray *)tagData + plainText:(NSString *)plainText + foundAlignments:(NSMutableArray *)foundAlignments { + if (tagData == nil) { + return; + } + + // We look at the params stored in ongoingTags + NSString *storedParams = (tagData.count > 1) ? tagData[1] : nil; + NSTextAlignment align = [self alignmentFromStyleParams:storedParams]; + + if (align != NSTextAlignmentNatural) { + NSInteger startLoc = [tagData[0] integerValue]; + // Calculate range relative to plainText + NSInteger actualStart = startLoc + _precedingImageCount; + NSInteger length = plainText.length - startLoc; + + if (length > 0) { + AlignmentEntry *entry = [[AlignmentEntry alloc] init]; + entry.alignment = align; + entry.range = NSMakeRange(actualStart, length); + [foundAlignments addObject:entry]; + } + } +} + @end diff --git a/ios/interfaces/AlignmentEntry.h b/ios/interfaces/AlignmentEntry.h new file mode 100644 index 00000000..12910946 --- /dev/null +++ b/ios/interfaces/AlignmentEntry.h @@ -0,0 +1,9 @@ +#pragma once +#import + +@interface AlignmentEntry : NSObject + +@property(nonatomic, assign) NSRange range; +@property(nonatomic, assign) NSTextAlignment alignment; + +@end diff --git a/ios/interfaces/AlignmentEntry.mm b/ios/interfaces/AlignmentEntry.mm new file mode 100644 index 00000000..160009bc --- /dev/null +++ b/ios/interfaces/AlignmentEntry.mm @@ -0,0 +1,4 @@ +#import "AlignmentEntry.h" + +@implementation AlignmentEntry +@end diff --git a/ios/utils/AlignmentUtils.h b/ios/utils/AlignmentUtils.h new file mode 100644 index 00000000..8e364f51 --- /dev/null +++ b/ios/utils/AlignmentUtils.h @@ -0,0 +1,17 @@ +#import "EnrichedTextInputView.h" +#import + +@interface AlignmentUtils : NSObject + ++ (void)applyAlignmentFromString:(NSString *)alignStr + toInput:(EnrichedTextInputView *)input; + ++ (void)setAlignment:(NSTextAlignment)alignment + forRange:(NSRange)range + inInput:(EnrichedTextInputView *)input; + ++ (NSString *)alignmentToString:(NSTextAlignment)alignment; + ++ (NSString *)currentAlignmentStringForInput:(EnrichedTextInputView *)input; + +@end diff --git a/ios/utils/AlignmentUtils.mm b/ios/utils/AlignmentUtils.mm new file mode 100644 index 00000000..90c0a447 --- /dev/null +++ b/ios/utils/AlignmentUtils.mm @@ -0,0 +1,175 @@ +#import "AlignmentUtils.h" +#import "ParagraphsUtils.h" +#import "StyleHeaders.h" + +@implementation AlignmentUtils + ++ (void)applyAlignmentFromString:(NSString *)alignStr + toInput:(EnrichedTextInputView *)input { + NSTextAlignment alignment = NSTextAlignmentNatural; + + if ([alignStr isEqualToString:@"left"]) { + alignment = NSTextAlignmentLeft; + } else if ([alignStr isEqualToString:@"center"]) { + alignment = NSTextAlignmentCenter; + } else if ([alignStr isEqualToString:@"right"]) { + alignment = NSTextAlignmentRight; + } else if ([alignStr isEqualToString:@"justify"]) { + alignment = NSTextAlignmentJustified; + } + + [AlignmentUtils setAlignment:alignment + forRange:input->textView.selectedRange + inInput:input]; +} + ++ (void)setAlignment:(NSTextAlignment)alignment + forRange:(NSRange)forRange + inInput:(EnrichedTextInputView *)input { + UITextView *textView = input->textView; + // Expand the range if we are inside a List + NSRange targetRange = [AlignmentUtils expandRangeToContiguousList:forRange + inInput:input]; + NSArray *paragraphs = + [ParagraphsUtils getSeparateParagraphsRangesIn:textView + range:targetRange]; + + [textView.textStorage beginEditing]; + for (NSValue *val in paragraphs) { + NSRange pRange = [val rangeValue]; + [textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:pRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + NSMutableParagraphStyle *style = + value ? [value mutableCopy] + : [[NSParagraphStyle defaultParagraphStyle] + mutableCopy]; + style.alignment = alignment; + + [textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:style + range:range]; + }]; + } + [textView.textStorage endEditing]; + + // Update Typing Attributes + NSMutableDictionary *typingAttrs = [textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *typingStyle = + [typingAttrs[NSParagraphStyleAttributeName] mutableCopy]; + typingStyle.alignment = alignment; + typingAttrs[NSParagraphStyleAttributeName] = typingStyle; + textView.typingAttributes = typingAttrs; + + [input anyTextMayHaveBeenModified]; +} + ++ (NSString *)alignmentToString:(NSTextAlignment)alignment { + switch (alignment) { + case NSTextAlignmentLeft: + return @"left"; + case NSTextAlignmentCenter: + return @"center"; + case NSTextAlignmentRight: + return @"right"; + case NSTextAlignmentJustified: + return @"justify"; + case NSTextAlignmentNatural: + default: + return @"left"; + } +} + ++ (NSString *)currentAlignmentStringForInput:(EnrichedTextInputView *)input { + NSParagraphStyle *paraStyle = + input->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment alignment = + paraStyle ? paraStyle.alignment : NSTextAlignmentNatural; + return [AlignmentUtils alignmentToString:alignment]; +} + ++ (NSRange)expandRangeToContiguousList:(NSRange)range + inInput:(EnrichedTextInputView *)input { + NSString *text = input->textView.textStorage.string; + if (text.length == 0) + return range; + + NSArray *listStyles = @[ + input->stylesDict[@([UnorderedListStyle getStyleType])], + input->stylesDict[@([OrderedListStyle getStyleType])], + input->stylesDict[@([CheckboxListStyle getStyleType])] + ]; + + NSRange expandedRange = range; + + // Expand Backward + NSRange startParagraph = + [text paragraphRangeForRange:NSMakeRange(range.location, 0)]; + + // Find which list style is active at the start + id activeStartStyle = nil; + for (id style in listStyles) { + if ([style detectStyle:startParagraph]) { + activeStartStyle = style; + break; + } + } + + // If we found a list style, walk backwards until it stops + if (activeStartStyle) { + NSRange currentPara = startParagraph; + while (currentPara.location > 0) { + // Check the paragraph before the current one + NSRange prevPara = [text + paragraphRangeForRange:NSMakeRange(currentPara.location - 1, 0)]; + + if ([activeStartStyle detectStyle:prevPara]) { + // It's still the same list -> Expand our range. + expandedRange = NSUnionRange(expandedRange, prevPara); + currentPara = prevPara; + } else { + // The list ended here. + break; + } + } + } + + // Expand forward, we check the paragraph at the end of the current selection + NSUInteger endLoc = + (range.length > 0) ? (NSMaxRange(range) - 1) : range.location; + NSRange endParagraph = [text paragraphRangeForRange:NSMakeRange(endLoc, 0)]; + + // Find which list style is active at the end + id activeEndStyle = nil; + for (id style in listStyles) { + if ([style detectStyle:endParagraph]) { + activeEndStyle = style; + break; + } + } + + // If we found a list style, walk forwards until it stops + if (activeEndStyle) { + NSRange currentPara = endParagraph; + while (NSMaxRange(currentPara) < text.length) { + // Check the paragraph after the current one + NSRange nextPara = + [text paragraphRangeForRange:NSMakeRange(NSMaxRange(currentPara), 0)]; + + if ([activeEndStyle detectStyle:nextPara]) { + // It's still the same list -> expand our range. + expandedRange = NSUnionRange(expandedRange, nextPara); + currentPara = nextPara; + } else { + break; + } + } + } + + return expandedRange; +} + +@end diff --git a/ios/utils/ParagraphAttributesUtils.mm b/ios/utils/ParagraphAttributesUtils.mm index 9095bf4a..d273789d 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -53,6 +53,12 @@ + (BOOL)handleBackspaceInRange:(NSRange)range if (range.location == nonNewlineRange.location && range.length >= nonNewlineRange.length) { + // Preserve the paragraph alignment across typing attribute resets. + NSParagraphStyle *currentParaStyle = + typedInput->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = + currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; + // for lists, quotes and codeblocks present we do the following: // - manually do the removing // - reset typing attribtues so that the previous line styles don't get @@ -71,8 +77,8 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = - typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; [cbLStyle addAttributesWithCheckedValue:isCurrentlyChecked inRange:NSMakeRange(range.location, 0) withTypingAttr:YES]; @@ -82,8 +88,8 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = - typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; [style addAttributes:NSMakeRange(range.location, 0) withTypingAttr:YES]; } @@ -99,7 +105,7 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput preserveAlignment:savedAlignment]; return YES; } @@ -249,19 +255,40 @@ + (BOOL)handleResetTypingAttributesOnBackspace:(NSRange)range } if (isLeftLineEmpty && isRightLineEmpty) { + NSParagraphStyle *currentParaStyle = + typedInput->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = + currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; + [TextInsertionUtils replaceText:text at:range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput preserveAlignment:savedAlignment]; return YES; } return NO; } ++ (void)resetTypingAttributes:(EnrichedTextInputView *)input + preserveAlignment:(NSTextAlignment)alignment { + NSMutableDictionary *resetAttrs = + [input->defaultTypingAttributes mutableCopy]; + + if (alignment != NSTextAlignmentNatural) { + NSMutableParagraphStyle *paraStyle = + [resetAttrs[NSParagraphStyleAttributeName] mutableCopy] + ?: [[NSMutableParagraphStyle alloc] init]; + paraStyle.alignment = alignment; + resetAttrs[NSParagraphStyleAttributeName] = paraStyle; + } + + input->textView.typingAttributes = resetAttrs; +} + + (BOOL)isParagraphEmpty:(NSRange)range inString:(NSString *)string { if (range.length == 0) return YES; diff --git a/ios/utils/TextInsertionUtils.mm b/ios/utils/TextInsertionUtils.mm index 6afa0b87..f6b234ce 100644 --- a/ios/utils/TextInsertionUtils.mm +++ b/ios/utils/TextInsertionUtils.mm @@ -22,6 +22,12 @@ + (void)insertText:(NSString *)text [copiedAttrs addEntriesFromDictionary:additionalAttrs]; } + // Give \u200B a tiny kern so the layout engine recognizes ZWS-only lines + // under right/center alignment (zero advance width causes height collapse). + if ([text rangeOfString:@"\u200B"].location != NSNotFound) { + copiedAttrs[NSKernAttributeName] = @(__FLT_EPSILON__); + } + NSAttributedString *newAttrStr = [[NSAttributedString alloc] initWithString:text attributes:copiedAttrs]; [textView.textStorage insertAttributedString:newAttrStr atIndex:index]; @@ -55,6 +61,16 @@ + (void)replaceText:(NSString *)text range:NSMakeRange(range.location, [text length])]; } + // Give \u200B a tiny kern so the layout engine recognizes ZWS-only lines + // under right/center alignment (zero advance width causes height collapse). + if ([text length] > 0 && + [text rangeOfString:@"\u200B"].location != NSNotFound) { + [textView.textStorage + addAttribute:NSKernAttributeName + value:@(__FLT_EPSILON__) + range:NSMakeRange(range.location, [text length])]; + } + if (withSelection) { if (![textView isFirstResponder]) { [textView reactFocus]; diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index ed6063ea..564b560c 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -78,6 +78,9 @@ export interface EnrichedTextInputInstance extends NativeMethods { text: string, attributes?: Record ) => void; + setTextAlignment: ( + alignment: 'left' | 'center' | 'right' | 'justify' | 'default' + ) => void; } export interface ContextMenuItem { @@ -379,6 +382,11 @@ export const EnrichedTextInput = ({ setSelection: (start: number, end: number) => { Commands.setSelection(nullthrows(nativeRef.current), start, end); }, + setTextAlignment: ( + alignment: 'left' | 'center' | 'right' | 'justify' | 'default' + ) => { + Commands.setTextAlignment(nullthrows(nativeRef.current), alignment); + }, })); const handleMentionEvent = (e: NativeSyntheticEvent) => { diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 8bf5186a..082f78f8 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -122,6 +122,7 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; } export interface OnLinkDetected { @@ -268,6 +269,7 @@ export interface OnContextMenuItemPressEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; }; } @@ -460,6 +462,10 @@ interface NativeCommands { viewRef: React.ElementRef, requestId: Int32 ) => void; + setTextAlignment: ( + viewRef: React.ElementRef, + alignment: string + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -493,6 +499,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'setTextAlignment', ], });