From 91e4f9f49030d965a0306d7785294692b0ed7ac9 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 29 Sep 2026 22:07:16 +0200 Subject: [PATCH 01/17] feat(ios): nested lists --- ios/EnrichedTextInputView.mm | 123 +++++- ios/extensions/LayoutManagerExtension.mm | 151 ++++--- ios/htmlParser/HtmlParser.mm | 399 ++++++++++++------- ios/inputHtmlParser/InputHtmlParser.mm | 6 + ios/interfaces/StyleBase.h | 2 + ios/interfaces/StyleBase.mm | 28 ++ ios/interfaces/StyleHeaders.h | 17 +- ios/styles/CheckboxListStyle.mm | 47 +-- ios/styles/ListStyleBase.mm | 249 ++++++++++++ ios/styles/OrderedListStyle.mm | 17 +- ios/styles/UnorderedListStyle.mm | 17 +- ios/textHtmlParser/TextHtmlParser.mm | 6 + ios/utils/CheckboxHitTestUtils.mm | 38 +- ios/utils/TextListsUtils.h | 31 ++ ios/utils/TextListsUtils.mm | 54 +++ src/native/EnrichedTextInput.tsx | 6 + src/spec/EnrichedTextInputNativeComponent.ts | 10 + src/types.ts | 23 ++ 18 files changed, 967 insertions(+), 257 deletions(-) create mode 100644 ios/styles/ListStyleBase.mm diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c44d4d9dd..ac3e1ab7d 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -18,6 +18,7 @@ #import "utils/StyleUtils.h" #import "utils/TextBlockTapGestureRecognizer.h" #import "utils/TextInsertionUtils.h" +#import "utils/TextListsUtils.h" #import "utils/WordsUtils.h" #import "utils/ZeroWidthSpaceUtils.h" #import @@ -33,6 +34,14 @@ .isConflicting = [self isStyle:TYPE_ENUM activeInMap:conflictingStyles] \ } +#define GET_LIST_STYLE_STATE(TYPE_ENUM) \ + { \ + .isActive = [self isStyleActive:TYPE_ENUM], \ + .isBlocking = [self isStyle:TYPE_ENUM activeInMap:blockingStyles], \ + .isConflicting = [self isStyle:TYPE_ENUM activeInMap:conflictingStyles], \ + .depth = static_cast([self listDepthAtSelectionForType:TYPE_ENUM]) \ + } + using namespace facebook::react; @interface EnrichedTextInputView () < @@ -62,6 +71,7 @@ @implementation EnrichedTextInputView { NSString *_submitBehavior; NSDictionary *_capturedAttributesBeforeChange; NSString *_recentlyEmittedAlignment; + NSMutableDictionary *_recentlyEmittedListDepths; } @synthesize blockEmitting = blockEmitting; @@ -133,6 +143,7 @@ - (void)setDefaults { _recentlyActiveLinkRange = NSMakeRange(0, 0); _recentlyActiveMentionRange = NSMakeRange(0, 0); _recentlyEmittedAlignment = @"left"; + _recentlyEmittedListDepths = [[NSMutableDictionary alloc] init]; _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; _emitHtml = NO; @@ -1021,6 +1032,9 @@ - (void)tryUpdatingActiveStyles { // separately NSMutableSet *newBlockedStyles = [_blockedStyles mutableCopy]; + NSMutableDictionary *newListDepths = + [_recentlyEmittedListDepths mutableCopy]; + // data for onLinkDetected event LinkData *detectedLinkData; NSRange detectedLinkRange = NSMakeRange(0, 0); @@ -1060,6 +1074,16 @@ - (void)tryUpdatingActiveStyles { } } + if ([style isList]) { + NSInteger currentDepth = + [self listDepthAtSelectionForType:(StyleType)[type integerValue]]; + NSNumber *previousDepth = newListDepths[type]; + if (previousDepth == nil || previousDepth.integerValue != currentDepth) { + updateNeeded = YES; + newListDepths[type] = @(currentDepth); + } + } + // onLinkDetected event if ([type intValue] == [LinkStyle getType]) { if (isActive) { @@ -1142,6 +1166,7 @@ - (void)tryUpdatingActiveStyles { _activeStyles = newActiveStyles; _blockedStyles = newBlockedStyles; _recentlyEmittedAlignment = currentAlignment; + _recentlyEmittedListDepths = newListDepths; emitter->onChangeState( {.bold = GET_STYLE_STATE([BoldStyle getType]), @@ -1157,12 +1182,12 @@ - (void)tryUpdatingActiveStyles { .h4 = GET_STYLE_STATE([H4Style getType]), .h5 = GET_STYLE_STATE([H5Style getType]), .h6 = GET_STYLE_STATE([H6Style getType]), - .unorderedList = GET_STYLE_STATE([UnorderedListStyle getType]), - .orderedList = GET_STYLE_STATE([OrderedListStyle getType]), + .unorderedList = GET_LIST_STYLE_STATE([UnorderedListStyle getType]), + .orderedList = GET_LIST_STYLE_STATE([OrderedListStyle getType]), .blockQuote = GET_STYLE_STATE([BlockQuoteStyle getType]), .codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]), .image = GET_STYLE_STATE([ImageStyle getType]), - .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]), + .checkboxList = GET_LIST_STYLE_STATE([CheckboxListStyle getType]), .alignment = [currentAlignment UTF8String]}); } } @@ -1214,6 +1239,27 @@ - (bool)isStyle:(StyleType)type activeInMap:(NSDictionary *)styleMap { return false; } +// returns the list nesting depth at the current selection, +// where 0 means no present nesting +- (NSInteger)listDepthAtSelectionForType:(StyleType)type { + if (![self isStyleActive:type] || ![self.stylesDict[@(type)] isList]) { + return 0; + } + + NSRange selection = textView.selectedRange; + NSParagraphStyle *pStyle; + if (selection.length == 0 && textView.isEditable) { + pStyle = textView.typingAttributes[NSParagraphStyleAttributeName]; + } else if (textView.textStorage.length > 0) { + NSUInteger index = MIN(selection.location, textView.textStorage.length - 1); + pStyle = [textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:index + effectiveRange:NULL]; + } + + return MAX(0, [TextListsUtils listDepthInArray:pStyle.textLists] - 1); +} + - (bool)textInputShouldReturn { return [_submitBehavior isEqualToString:@"blurAndSubmit"]; } @@ -1280,6 +1326,10 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"toggleCheckboxList"]) { BOOL checked = [args[0] boolValue]; [self toggleCheckboxList:checked]; + } else if ([commandName isEqualToString:@"indentList"]) { + [self indentList]; + } else if ([commandName isEqualToString:@"outdentList"]) { + [self outdentList]; } else if ([commandName isEqualToString:@"toggleBlockQuote"]) { [self toggleRegularStyle:[BlockQuoteStyle getType]]; } else if ([commandName isEqualToString:@"toggleCodeBlock"]) { @@ -1534,6 +1584,49 @@ - (void)toggleCheckboxList:(BOOL)checked { } } +- (StyleBase *)activeListStyleForRange:(NSRange)range { + UnorderedListStyle *ulStyle = stylesDict[@([UnorderedListStyle getType])]; + OrderedListStyle *olStyle = stylesDict[@([OrderedListStyle getType])]; + CheckboxListStyle *cbStyle = stylesDict[@([CheckboxListStyle getType])]; + + if ([ulStyle detect:range]) { + return ulStyle; + } + if ([olStyle detect:range]) { + return olStyle; + } + if ([cbStyle detect:range]) { + return cbStyle; + } + return nullptr; +} + +- (void)indentList { + NSRange range = [textView.textStorage.string + paragraphRangeForRange:textView.selectedRange]; + StyleBase *activeListStyle = [self activeListStyleForRange:range]; + if (activeListStyle == nullptr) { + return; + } + + [(ListStyleBase *)activeListStyle modifyListNestingInRange:range + increasing:YES]; + [self anyTextMayHaveBeenModified]; +} + +- (void)outdentList { + NSRange range = [textView.textStorage.string + paragraphRangeForRange:textView.selectedRange]; + StyleBase *activeListStyle = [self activeListStyleForRange:range]; + if (activeListStyle == nullptr) { + return; + } + + [(ListStyleBase *)activeListStyle modifyListNestingInRange:range + increasing:NO]; + [self anyTextMayHaveBeenModified]; +} + - (void)addLinkAt:(NSInteger)start end:(NSInteger)end text:(NSString *)text @@ -1906,12 +1999,13 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText { .h6 = GET_STYLE_STATE([H6Style getType]), .codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]), .blockQuote = GET_STYLE_STATE([BlockQuoteStyle getType]), - .orderedList = GET_STYLE_STATE([OrderedListStyle getType]), - .unorderedList = GET_STYLE_STATE([UnorderedListStyle getType]), + .orderedList = GET_LIST_STYLE_STATE([OrderedListStyle getType]), + .unorderedList = + GET_LIST_STYLE_STATE([UnorderedListStyle getType]), .link = GET_STYLE_STATE([LinkStyle getType]), .image = GET_STYLE_STATE([ImageStyle getType]), .mention = GET_STYLE_STATE([MentionStyle getType]), - .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]), + .checkboxList = GET_LIST_STYLE_STATE([CheckboxListStyle getType]), .alignment = [currentAlignment UTF8String]}}); } } @@ -2050,6 +2144,23 @@ - (void)textViewDidChangeSelection:(UITextView *)textView { // manage selection changes [self manageSelectionBasedChanges]; + + // DEBUG: log the textLists (list nesting chain + any other markers) at the + // current selection. + if (textView.textStorage.length > 0) { + NSUInteger debugIndex = + MIN(textView.selectedRange.location, textView.textStorage.length - 1); + NSParagraphStyle *debugPStyle = + [textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:debugIndex + effectiveRange:NULL]; + NSMutableArray *debugMarkers = [NSMutableArray array]; + for (NSTextList *debugList in debugPStyle.textLists) { + [debugMarkers addObject:debugList.markerFormat]; + } + NSLog(@"[EnrichedTextInput] textLists at selection %@: %@", + NSStringFromRange(textView.selectedRange), debugMarkers); + } } // this function isn't called always when some text changes (for example setting diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm index 19b19a120..318c696cb 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -3,6 +3,7 @@ #import "interfaces/EnrichedViewHost.h" #import "interfaces/StyleHeaders.h" #import "utils/RangeUtils.h" +#import "utils/TextListsUtils.h" #import "utils/WeakBox.h" #import @@ -288,55 +289,47 @@ - (void)drawLists:(id)host getTextAlignedUsedRect:usedRect font:referenceFont]; - for (NSTextList *list in pStyle - .textLists) { - NSString *markerFormat = - list.markerFormat; - - if ([markerFormat - hasPrefix: - @"EnrichedAlignment"]) { - continue; - } - - if ([markerFormat - isEqualToString: - @"EnrichedOrderedList"]) { - CGPoint glyphLocation = - [self locationForGlyphAtIndex: - lineGlyphRange.location]; - CGFloat absoluteBaselineY = - origin.y + rect.origin.y + - glyphLocation.y; - NSString *marker = [self - getDecimalMarkerForList:host - charIndex:charIdx]; - - [self drawDecimal:host - marker:marker - markerAttributes:markerAttributes - origin:origin - baselineY:absoluteBaselineY - indent:indent]; - } else if ([markerFormat - isEqualToString: - @"EnrichedUnordered" - @"Lis" - @"t"]) { - [self drawBullet:host + NSString *markerFormat = [TextListsUtils + deepestListMarkerFormatInArray: + pStyle.textLists]; + + if ([markerFormat + isEqualToString: + @"EnrichedOrderedList"]) { + CGPoint glyphLocation = + [self locationForGlyphAtIndex: + lineGlyphRange.location]; + CGFloat absoluteBaselineY = + origin.y + rect.origin.y + + glyphLocation.y; + NSString *marker = [self + getDecimalMarkerForList:host + charIndex:charIdx]; + + [self drawDecimal:host + marker:marker + markerAttributes:markerAttributes + origin:origin + baselineY:absoluteBaselineY + indent:indent]; + } else if ([markerFormat + isEqualToString: + @"EnrichedUnordered" + @"Lis" + @"t"]) { + [self drawBullet:host + origin:origin + usedRect:textUsedRect + indent:indent]; + + } else if ([markerFormat + hasPrefix:@"EnrichedChe" + @"ckbox"]) { + [self drawCheckbox:host + markerFormat:markerFormat origin:origin usedRect:textUsedRect indent:indent]; - - } else if ([markerFormat - hasPrefix:@"EnrichedChe" - @"ckbox"]) { - [self drawCheckbox:host - markerFormat:markerFormat - origin:origin - usedRect:textUsedRect - indent:indent]; - } } // only first line of a list gets its // marker drawn @@ -346,6 +339,18 @@ - (void)drawLists:(id)host } } +// Returns the markerFormat strings of chain, excluding the last (own) entry - +// i.e. the lineage of ancestor list types this paragraph is nested under. +- (NSArray *)ancestorFormatsForChain: + (NSArray *)chain { + NSMutableArray *formats = + [NSMutableArray arrayWithCapacity:MAX(0, (NSInteger)chain.count - 1)]; + for (NSUInteger i = 0; i + 1 < chain.count; i++) { + [formats addObject:chain[i].markerFormat]; + } + return formats; +} + - (NSString *)getDecimalMarkerForList:(id)host charIndex:(NSUInteger)index { NSString *fullText = host.textView.textStorage.string; @@ -353,6 +358,17 @@ - (NSString *)getDecimalMarkerForList:(id)host NSRange currentParagraph = [fullText paragraphRangeForRange:NSMakeRange(index, 0)]; + + NSParagraphStyle *currentPStyle = + [host.textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:currentParagraph.location + effectiveRange:NULL]; + NSArray *currentChain = + [TextListsUtils listChainInArray:currentPStyle.textLists]; + NSInteger currentDepth = currentChain.count; + NSArray *currentAncestorFormats = + [self ancestorFormatsForChain:currentChain]; + if (currentParagraph.location > 0) { OrderedListStyle *olStyle = host.stylesDict[@([OrderedListStyle getType])]; @@ -362,20 +378,43 @@ - (NSString *)getDecimalMarkerForList:(id)host currentParagraph.location - 1, 0)] .location; - // seek for previous lists + // seek for previous items nested under the same ancestor lineage; a + // shallower paragraph (or same-depth one from a different lineage) ends + // this run while (true) { - if ([olStyle detect:NSMakeRange(recentParagraphLocation, 0)]) { - prevParagraphsCount += 1; - - if (recentParagraphLocation > 0) { - recentParagraphLocation = - [fullText - paragraphRangeForRange:NSMakeRange( - recentParagraphLocation - 1, 0)] - .location; + NSParagraphStyle *pStyle = + [host.textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:recentParagraphLocation + effectiveRange:NULL]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + NSInteger depth = chain.count; + + if (depth < currentDepth) { + break; + } + + if (depth == currentDepth) { + NSArray *ancestorFormats = + [self ancestorFormatsForChain:chain]; + BOOL sameLineage = + [ancestorFormats isEqualToArray:currentAncestorFormats]; + if (sameLineage && + [olStyle detect:NSMakeRange(recentParagraphLocation, 0)]) { + prevParagraphsCount += 1; } else { break; } + } + // depth > currentDepth: a nested descendant of an earlier sibling, + // we continue the search + + if (recentParagraphLocation > 0) { + recentParagraphLocation = + [fullText + paragraphRangeForRange:NSMakeRange(recentParagraphLocation - 1, + 0)] + .location; } else { break; } diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index f710a7411..81301bb38 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -8,6 +8,7 @@ #import "interfaces/StyleHeaders.h" #import "interfaces/StylePair.h" #import "utils/AlignmentUtils.h" +#import "utils/TextListsUtils.h" #include "GumboParser.hpp" @@ -424,7 +425,10 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml NSMutableDictionary *checkboxStates = [[NSMutableDictionary alloc] init]; NSMutableArray *foundAlignments = [[NSMutableArray alloc] init]; - BOOL insideCheckboxList = NO; + // stacks to remember parent lists' ongoingTags + NSMutableArray *ulStash = [NSMutableArray array]; + NSMutableArray *olStash = [NSMutableArray array]; + NSInteger precedingImageCount = 0; BOOL insideTag = NO; BOOL gettingTagName = NO; @@ -472,6 +476,9 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml if (!closingTag) { // Opening tag
  • // Track checkbox state if we're inside a checkbox list + NSArray *currentUlFrame = ongoingTags[@"ul"]; + BOOL insideCheckboxList = currentUlFrame.count > 2 && + [self isUlCheckboxList:currentUlFrame[2]]; if (insideCheckboxList) { BOOL isChecked = [currentTagParams containsString:@"checked"]; checkboxStates[@(plainText.length)] = @(isChecked); @@ -513,14 +520,20 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml if (currentTagParams.length > 0) { [tagArr addObject:[currentTagParams copy]]; } - ongoingTags[currentTagName] = tagArr; - // Check if this is a checkbox list - if ([currentTagName isEqualToString:@"ul"] && - [self isUlCheckboxList:currentTagParams]) { - insideCheckboxList = YES; + // we open another list, we must stash the parent one + if ([currentTagName isEqualToString:@"ul"]) { + if (ongoingTags[@"ul"] != nil) { + [ulStash addObject:ongoingTags[@"ul"]]; + } + } else if ([currentTagName isEqualToString:@"ol"]) { + if (ongoingTags[@"ol"] != nil) { + [olStash addObject:ongoingTags[@"ol"]]; + } } + ongoingTags[currentTagName] = tagArr; + // skip one newline if it was added after opening tags that are in // separate lines if ([self isBlockTag:currentTagName] && i + 1 < fixedHtml.length && @@ -546,13 +559,6 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml } else { // we finish closing tags - pack tag name, tag range and optionally tag // params into an entry that goes inside initiallyProcessedTags - - // Check if we're closing a checkbox list by looking at the params - if ([currentTagName isEqualToString:@"ul"] && - [self isUlCheckboxList:currentTagParams]) { - insideCheckboxList = NO; - } - BOOL isBlockTag = [self isBlockTag:currentTagName]; // ZWS logic for blockquote and codeblock @@ -599,6 +605,16 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml initiallyProcessedTags:initiallyProcessedTags plainText:plainText precedingImageCount:&precedingImageCount]; + + // we restore the parent list into ongoingTags if applicable + if ([currentTagName isEqualToString:@"ul"] && ulStash.count > 0) { + ongoingTags[@"ul"] = ulStash.lastObject; + [ulStash removeLastObject]; + } else if ([currentTagName isEqualToString:@"ol"] && + olStash.count > 0) { + ongoingTags[@"ol"] = olStash.lastObject; + [olStash removeLastObject]; + } } // post-tag cleanup closingTag = NO; @@ -845,7 +861,51 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml [processedStyles addObject:styleArr]; } - return @[ plainText, processedStyles, foundAlignments ]; + // we need to sort the processed list styles, so the leafs of the + // nested lists will get applied last + NSMutableArray *otherStyles = [[NSMutableArray alloc] init]; + NSMutableArray *listStyles = [[NSMutableArray alloc] init]; + + NSNumber *ulType = @([UnorderedListStyle getType]); + NSNumber *olType = @([OrderedListStyle getType]); + NSNumber *cbType = @([CheckboxListStyle getType]); + + for (NSArray *styleArr in processedStyles) { + NSNumber *type = styleArr.firstObject; + if ([type isEqualToNumber:ulType] || [type isEqualToNumber:olType] || + [type isEqualToNumber:cbType]) { + [listStyles addObject:styleArr]; + } else { + [otherStyles addObject:styleArr]; + } + } + + [listStyles + sortUsingComparator:^NSComparisonResult(NSArray *arr1, NSArray *arr2) { + StylePair *pair1 = arr1.lastObject; + StylePair *pair2 = arr2.lastObject; + NSRange r1 = pair1.rangeValue.rangeValue; + NSRange r2 = pair2.rangeValue.rangeValue; + + if (r1.location < r2.location) { + return NSOrderedAscending; + } else if (r1.location > r2.location) { + return NSOrderedDescending; + } else { + if (r1.length > r2.length) { + return NSOrderedAscending; + } else if (r1.length < r2.length) { + return NSOrderedDescending; + } + return NSOrderedSame; + } + }]; + + NSMutableArray *finalProcessedStyles = + [NSMutableArray arrayWithArray:otherStyles]; + [finalProcessedStyles addObjectsFromArray:listStyles]; + + return @[ plainText, finalProcessedStyles, foundAlignments ]; } + (NSString *)parseToHtmlFromRange:(NSRange)range @@ -864,7 +924,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range BOOL inOrderedList = NO; BOOL inBlockQuote = NO; BOOL inCodeBlock = NO; - BOOL inCheckboxList = NO; + NSMutableArray *openListStack = [[NSMutableArray alloc] init]; unichar lastCharacter = 0; for (int i = 0; i < text.length; i++) { @@ -903,69 +963,78 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range // we can either have an empty list item OR need to close the list and // put a BR in such a situation the existence of the list must be // checked on 0 length range, not on the newline character - if (inOrderedList) { - OrderedListStyle *oStyle = host.stylesDict[@(OrderedList)]; - BOOL detected = [oStyle detect:NSMakeRange(currentRange.location, 0)]; - if (detected) { - [result appendString:@"\n
  • "]; - } else { - [result appendString:@"\n\n
    "]; - inOrderedList = NO; - } - } else if (inUnorderedList) { - UnorderedListStyle *uStyle = host.stylesDict[@(UnorderedList)]; - BOOL detected = [uStyle detect:NSMakeRange(currentRange.location, 0)]; - if (detected) { - [result appendString:@"\n
  • "]; - } else { - [result appendString:@"\n\n
    "]; - inUnorderedList = NO; - } - } else if (inBlockQuote) { - BlockQuoteStyle *bqStyle = host.stylesDict[@(BlockQuote)]; - BOOL detected = - [bqStyle detect:NSMakeRange(currentRange.location, 0)]; - if (detected) { - [result appendString:@"\n
    "]; - } else { - [result appendString:@"\n\n
    "]; - inBlockQuote = NO; - } - } else if (inCodeBlock) { - CodeBlockStyle *cbStyle = host.stylesDict[@(CodeBlock)]; - BOOL detected = - [cbStyle detect:NSMakeRange(currentRange.location, 0)]; - if (detected) { - [result appendString:@"\n
    "]; - } else { - [result appendString:@"\n\n
    "]; - inCodeBlock = NO; - } - } else if (inCheckboxList) { - CheckboxListStyle *cbLStyle = host.stylesDict[@(CheckboxList)]; - BOOL detected = - [cbLStyle detect:NSMakeRange(currentRange.location, 0)]; - if (detected) { - BOOL checked = [cbLStyle getCheckboxStateAt:currentRange.location]; - if (checked) { - [result appendString:@"\n
  • "]; + + NSArray *targetChain = + [self listChainStyleTypesAtLocation:currentRange.location + host:host]; + if (targetChain.count > 0) { + // we have some lists, we might have traversed nested list structure + // in some way, we need to sync the stack, by closing and opening + // relevant tags + [self syncListStackTo:targetChain + result:result + stack:openListStack + host:host + location:currentRange.location]; + + if (openListStack.count > 0) { + NSNumber *leafType = openListStack.lastObject; + if ([leafType isEqualToNumber:@([CheckboxListStyle getType])]) { + CheckboxListStyle *cbLStyle = + host.stylesDict[@([CheckboxListStyle getType])]; + BOOL checked = + [cbLStyle getCheckboxStateAt:currentRange.location]; + if (checked) { + [result appendString:@"\n
  • "]; + } else { + [result appendString:@"\n
  • "]; + } } else { [result appendString:@"\n
  • "]; } - } else { - [result appendString:@"\n\n
    "]; - inCheckboxList = NO; } } else { - NSString *cssStyleString = - [self prepareCssStyleString:currentRange.location - isOpeningTag:YES - host:host]; - if (cssStyleString.length > 0) { - [result appendString:[NSString stringWithFormat:@"\n

    ", - cssStyleString]]; + if (openListStack.count > 0) { + // there are no lists present in the current location, we flush + // the stack and produce closing tags + [self syncListStackTo:@[] + result:result + stack:openListStack + host:host + location:currentRange.location]; + } + + if (inBlockQuote) { + BlockQuoteStyle *bqStyle = host.stylesDict[@(BlockQuote)]; + BOOL detected = + [bqStyle detect:NSMakeRange(currentRange.location, 0)]; + if (detected) { + [result appendString:@"\n
    "]; + } else { + [result appendString:@"\n\n
    "]; + inBlockQuote = NO; + } + } else if (inCodeBlock) { + CodeBlockStyle *cbStyle = host.stylesDict[@(CodeBlock)]; + BOOL detected = + [cbStyle detect:NSMakeRange(currentRange.location, 0)]; + if (detected) { + [result appendString:@"\n
    "]; + } else { + [result appendString:@"\n\n
    "]; + inCodeBlock = NO; + } } else { - [result appendString:@"\n
    "]; + NSString *cssStyleString = + [self prepareCssStyleString:currentRange.location + isOpeningTag:YES + host:host]; + if (cssStyleString.length > 0) { + [result appendString:[NSString stringWithFormat:@"\n

    ", + cssStyleString]]; + } else { + [result appendString:@"\n
    "]; + } } } } else { @@ -1022,20 +1091,16 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range if (newLine) { newLine = NO; - // handle ending unordered list - if (inUnorderedList && - ![currentActiveStyles - containsObject:@([UnorderedListStyle getType])]) { - inUnorderedList = NO; - [result appendString:@"\n"]; - } - // handle ending ordered list - if (inOrderedList && - ![currentActiveStyles - containsObject:@([OrderedListStyle getType])]) { - inOrderedList = NO; - [result appendString:@"\n"]; - } + // handle ending and starting lists + NSArray *targetChain = + [self listChainStyleTypesAtLocation:currentRange.location + host:host]; + [self syncListStackTo:targetChain + result:result + stack:openListStack + host:host + location:currentRange.location]; + // handle ending blockquotes if (inBlockQuote && ![currentActiveStyles containsObject:@([BlockQuoteStyle getType])]) { @@ -1048,35 +1113,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range inCodeBlock = NO; [result appendString:@"\n"]; } - // handle ending checkbox list - if (inCheckboxList && - ![currentActiveStyles - containsObject:@([CheckboxListStyle getType])]) { - inCheckboxList = NO; - [result appendString:@"\n"]; - } - NSString *cssStyleString = - [self prepareCssStyleString:currentRange.location - isOpeningTag:YES - host:host]; - - // handle starting unordered list - if (!inUnorderedList && - [currentActiveStyles - containsObject:@([UnorderedListStyle getType])]) { - inUnorderedList = YES; - [result appendString:[NSString stringWithFormat:@"\n", - cssStyleString]]; - } - // handle starting ordered list - if (!inOrderedList && - [currentActiveStyles - containsObject:@([OrderedListStyle getType])]) { - inOrderedList = YES; - [result appendString:[NSString stringWithFormat:@"\n", - cssStyleString]]; - } // handle starting blockquotes if (!inBlockQuote && [currentActiveStyles containsObject:@([BlockQuoteStyle getType])]) { @@ -1089,15 +1126,6 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range inCodeBlock = YES; [result appendString:@"\n"]; } - // handle starting checkbox list - if (!inCheckboxList && - [currentActiveStyles - containsObject:@([CheckboxListStyle getType])]) { - inCheckboxList = YES; - [result appendString:[NSString stringWithFormat: - @"\n
      ", - cssStyleString]]; - } // don't add the

      tag if some paragraph styles are present if ([currentActiveStyles @@ -1116,6 +1144,10 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range containsObject:@([CheckboxListStyle getType])]) { [result appendString:@"\n"]; } else { + NSString *cssStyleString = + [self prepareCssStyleString:currentRange.location + isOpeningTag:YES + host:host]; [result appendString:[NSString stringWithFormat:@"\n", cssStyleString]]; } @@ -1249,22 +1281,19 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range [result appendString:[NSString stringWithFormat:@"", tagContent]]; } - // finish the paragraph - // handle ending of some paragraph styles - if ([previousActiveStyles containsObject:@([UnorderedListStyle getType])]) { - [result appendString:@"\n

    "]; - } else if ([previousActiveStyles - containsObject:@([OrderedListStyle getType])]) { - [result appendString:@"\n"]; + if (openListStack.count > 0) { + // flush the nested list stack + [self syncListStackTo:@[] + result:result + stack:openListStack + host:host + location:host.textView.textStorage.string.length - 1]; } else if ([previousActiveStyles containsObject:@([BlockQuoteStyle getType])]) { [result appendString:@"\n"]; } else if ([previousActiveStyles containsObject:@([CodeBlockStyle getType])]) { [result appendString:@"\n
    "]; - } else if ([previousActiveStyles - containsObject:@([CheckboxListStyle getType])]) { - [result appendString:@"\n"]; } else if ([previousActiveStyles containsObject:@([H1Style getType])] || [previousActiveStyles containsObject:@([H2Style getType])] || [previousActiveStyles containsObject:@([H3Style getType])] || @@ -1277,13 +1306,13 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range } } else { // newline character was last - some paragraph styles need to be closed - if (inUnorderedList) { - inUnorderedList = NO; - [result appendString:@"\n"]; - } - if (inOrderedList) { - inOrderedList = NO; - [result appendString:@"\n"]; + if (openListStack.count > 0) { + // flush the nested list stack + [self syncListStackTo:@[] + result:result + stack:openListStack + host:host + location:host.textView.textStorage.string.length - 1]; } if (inBlockQuote) { inBlockQuote = NO; @@ -1293,10 +1322,6 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range inCodeBlock = NO; [result appendString:@"\n"]; } - if (inCheckboxList) { - inCheckboxList = NO; - [result appendString:@"\n"]; - } } [result appendString:@"\n"]; @@ -1498,4 +1523,90 @@ + (void)checkForAlignments:(NSArray *)tagData } } ++ (NSArray *)listChainStyleTypesAtLocation:(NSInteger)location + host:(id) + host { + if (location < 0 || location >= host.textView.textStorage.string.length) { + return @[]; + } + + NSParagraphStyle *pStyle = + [host.textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:location + effectiveRange:NULL]; + if (pStyle == nil) { + return @[]; + } + + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + NSMutableArray *result = + [NSMutableArray arrayWithCapacity:chain.count]; + + for (NSTextList *entry in chain) { + NSString *format = entry.markerFormat; + if ([format isEqualToString:@"EnrichedUnorderedList"]) { + [result addObject:@([UnorderedListStyle getType])]; + } else if ([format isEqualToString:@"EnrichedOrderedList"]) { + [result addObject:@([OrderedListStyle getType])]; + } else if ([format hasPrefix:@"EnrichedCheckbox"]) { + [result addObject:@([CheckboxListStyle getType])]; + } + } + + return result; +} + +// syncing the current nested list data stack to the new one. If the +// new stack removes a list, the relevant closing tag is appended. If the +// new stack adds a new list, the relevant opening tag is appended instead ++ (void)syncListStackTo:(NSArray *)targetChain + result:(NSMutableString *)result + stack:(NSMutableArray *)openListStack + host:(id)host + location:(NSInteger)location { + NSUInteger commonPrefix = 0; + while ( + commonPrefix < openListStack.count && commonPrefix < targetChain.count && + [openListStack[commonPrefix] isEqualToNumber:targetChain[commonPrefix]]) { + commonPrefix++; + } + + while (openListStack.count > commonPrefix) { + NSNumber *type = openListStack.lastObject; + NSString *closeTag = @""; + + if ([type isEqualToNumber:@([UnorderedListStyle getType])]) { + closeTag = @"\n"; + } else if ([type isEqualToNumber:@([OrderedListStyle getType])]) { + closeTag = @"\n"; + } else if ([type isEqualToNumber:@([CheckboxListStyle getType])]) { + closeTag = @"\n"; + } + + [result appendString:closeTag]; + [openListStack removeLastObject]; + } + + for (NSUInteger k = commonPrefix; k < targetChain.count; k++) { + NSNumber *type = targetChain[k]; + NSString *cssStyleString = [self prepareCssStyleString:location + isOpeningTag:YES + host:host]; + NSString *openTag = @""; + + if ([type isEqualToNumber:@([UnorderedListStyle getType])]) { + openTag = [NSString stringWithFormat:@"\n", cssStyleString]; + } else if ([type isEqualToNumber:@([OrderedListStyle getType])]) { + openTag = [NSString stringWithFormat:@"\n", cssStyleString]; + } else if ([type isEqualToNumber:@([CheckboxListStyle getType])]) { + openTag = [NSString + stringWithFormat:@"\n
      ", cssStyleString]; + } + + [result appendString:openTag]; + [openListStack addObject:type]; + } +} + @end diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index 8dec7819e..e76bc271f 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -182,6 +182,12 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles } } } + } else if ([baseStyle isList]) { + [(ListStyleBase *)baseStyle add:styleRange + withValue:[baseStyle getValue] + forcingNewLevel:YES + withTyping:NO + withDirtyRange:NO]; } else { [baseStyle add:styleRange withTyping:shouldAddTypingAttr diff --git a/ios/interfaces/StyleBase.h b/ios/interfaces/StyleBase.h index 343f88de3..edcb29a28 100644 --- a/ios/interfaces/StyleBase.h +++ b/ios/interfaces/StyleBase.h @@ -12,6 +12,7 @@ - (NSString *)getValue; - (NSString *)getMarkerPrefix; - (BOOL)isParagraph; +- (BOOL)isList; - (BOOL)needsZWS; - (BOOL)appliesStylingToTyping; - (NSInteger)stylePriority; @@ -28,6 +29,7 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange; - (void)addTypingWithValue:(NSString *)value; - (void)removeTyping; +- (void)restoreListChainFromStylePair:(StylePair *)pair range:(NSRange)range; - (BOOL)styleCondition:(id)value range:(NSRange)range; - (BOOL)detect:(NSRange)range; - (BOOL)any:(NSRange)range; diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm index 35b501556..5e585db10 100644 --- a/ios/interfaces/StyleBase.mm +++ b/ios/interfaces/StyleBase.mm @@ -37,6 +37,10 @@ - (BOOL)isParagraph { return false; } +- (BOOL)isList { + return false; +} + - (BOOL)needsZWS { return NO; } @@ -268,6 +272,30 @@ - (void)reapplyFromStylePair:(StylePair *)pair { [self add:range withTyping:NO withDirtyRange:NO]; } +// restoring the list-scoped textLists entries from the given +// style pair. Used in the InputAttributesManager, where we reset +// all typing attributes, so we need to restore that nested list meta data +- (void)restoreListChainFromStylePair:(StylePair *)pair range:(NSRange)range { + NSParagraphStyle *savedPStyle = (NSParagraphStyle *)pair.styleValue; + + [self.host.textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:range + options:0 + usingBlock:^(id _Nullable existingValue, NSRange subRange, + BOOL *_Nonnull stop) { + NSMutableParagraphStyle *pStyle = + [(NSParagraphStyle *)existingValue mutableCopy]; + if (pStyle == nullptr) + return; + pStyle.textLists = savedPStyle.textLists; + [self.host.textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:subRange]; + }]; +} + // Gets a custom attribtue entry for the typingAttributes. // Only used with inline styles. - (AttributeEntry *)getEntryIfPresent:(NSRange)range { diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index aca5d4a3c..4c9ee5ced 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -72,13 +72,24 @@ @interface H6Style : HeadingStyleBase @end -@interface UnorderedListStyle : StyleBase +@interface ListStyleBase : StyleBase +- (CGFloat)cumulativeHeadIndentForChain:(NSArray *)chain; +- (void)modifyListNestingInRange:(NSRange)range + increasing:(BOOL)increasingNesting; +- (void)add:(NSRange)range + withValue:(NSString *)value + forcingNewLevel:(BOOL)forcingNewLevel + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange; @end -@interface OrderedListStyle : StyleBase +@interface UnorderedListStyle : ListStyleBase @end -@interface CheckboxListStyle : StyleBase +@interface OrderedListStyle : ListStyleBase +@end + +@interface CheckboxListStyle : ListStyleBase - (void)toggleWithChecked:(BOOL)checked range:(NSRange)range; - (void)addWithChecked:(BOOL)checked range:(NSRange)range diff --git a/ios/styles/CheckboxListStyle.mm b/ios/styles/CheckboxListStyle.mm index 9c2a680d0..3f584c736 100644 --- a/ios/styles/CheckboxListStyle.mm +++ b/ios/styles/CheckboxListStyle.mm @@ -26,20 +26,12 @@ - (BOOL)needsZWS { return YES; } -- (CGFloat)headIndent { - return [self.host.config checkboxListMarginLeft] + - [self.host.config checkboxListGapWidth] + - [self.host.config checkboxListBoxSize]; -} - - (CGFloat)calculateMinimumLineHeight:(CGFloat)currentLineHeight { CGFloat boxSize = [self.host.config checkboxListBoxSize]; return MAX(currentLineHeight, boxSize); } - (void)applyStyling:(NSRange)range { - CGFloat listHeadIndent = [self headIndent]; - [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range @@ -48,6 +40,10 @@ - (void)applyStyling:(NSRange)range { BOOL *_Nonnull stop) { NSMutableParagraphStyle *pStyle = [(NSParagraphStyle *)value mutableCopy]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + CGFloat listHeadIndent = + [self cumulativeHeadIndentForChain:chain]; pStyle.headIndent = listHeadIndent; pStyle.firstLineHeadIndent = listHeadIndent; pStyle.minimumLineHeight = @@ -68,7 +64,9 @@ - (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { [attributes[NSParagraphStyleAttributeName] mutableCopy]; if (pStyle == nil) return; - CGFloat indent = [self headIndent]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + CGFloat indent = [self cumulativeHeadIndentForChain:chain]; pStyle.headIndent = indent; pStyle.firstLineHeadIndent = indent; pStyle.minimumLineHeight = @@ -76,14 +74,6 @@ - (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { attributes[NSParagraphStyleAttributeName] = pStyle; } -- (BOOL)styleCondition:(id)value range:(NSRange)range { - NSParagraphStyle *pStyle = (NSParagraphStyle *)value; - if (pStyle == nil) - return NO; - return [TextListsUtils textLists:pStyle.textLists - containsPrefix:[self getMarkerPrefix]]; -} - - (void)toggleWithChecked:(BOOL)checked range:(NSRange)range { NSRange actualRange = [self actualUsedRange:range]; BOOL isPresent = [self detect:actualRange]; @@ -109,15 +99,15 @@ - (void)addWithChecked:(BOOL)checked withDirtyRange:withDirtyRange]; } -// During dirty range re-application the default add: would use getValue -// (EnrichedCheckbox0) and lose the checked state. Instead, read the original -// marker format from the saved StylePair - (void)reapplyFromStylePair:(StylePair *)pair { NSRange range = [pair.rangeValue rangeValue]; NSParagraphStyle *savedPStyle = (NSParagraphStyle *)pair.styleValue; - BOOL checked = [TextListsUtils textLists:savedPStyle.textLists - containsValue:@"EnrichedCheckbox1"]; + NSString *ownFormat = + [TextListsUtils deepestListMarkerFormatInArray:savedPStyle.textLists]; + BOOL checked = [ownFormat isEqualToString:@"EnrichedCheckbox1"]; [self addWithChecked:checked range:range withTyping:NO withDirtyRange:NO]; + // restore nested list metadata + [self restoreListChainFromStylePair:pair range:range]; } - (void)toggleCheckedAt:(NSUInteger)location @@ -130,11 +120,9 @@ - (void)toggleCheckedAt:(NSUInteger)location [self.host.textView.textStorage attribute:NSParagraphStyleAttributeName atIndex:location effectiveRange:NULL]; - NSTextList *list = - [TextListsUtils firstTextListWithPrefix:[self getMarkerPrefix] - inArray:pStyle.textLists]; - BOOL isCurrentlyChecked = - [list.markerFormat isEqualToString:@"EnrichedCheckbox1"]; + NSString *ownFormat = + [TextListsUtils deepestListMarkerFormatInArray:pStyle.textLists]; + BOOL isCurrentlyChecked = [ownFormat isEqualToString:@"EnrichedCheckbox1"]; NSRange paragraphRange = [self.host.textView.textStorage.string paragraphRangeForRange:NSMakeRange(location, 0)]; @@ -155,8 +143,9 @@ - (BOOL)getCheckboxStateAt:(NSUInteger)location { atIndex:location effectiveRange:NULL]; - return [TextListsUtils textLists:style.textLists - containsValue:@"EnrichedCheckbox1"]; + NSString *ownFormat = + [TextListsUtils deepestListMarkerFormatInArray:style.textLists]; + return [ownFormat isEqualToString:@"EnrichedCheckbox1"]; } - (BOOL)handleNewlinesInRange:(NSRange)range replacementText:(NSString *)text { diff --git a/ios/styles/ListStyleBase.mm b/ios/styles/ListStyleBase.mm new file mode 100644 index 000000000..e8ec40114 --- /dev/null +++ b/ios/styles/ListStyleBase.mm @@ -0,0 +1,249 @@ +#import "EnrichedTextInputView.h" +#import "interfaces/StyleHeaders.h" +#import "utils/RangeUtils.h" +#import "utils/TextListsUtils.h" + +// adds `value` to the existing textLists array. If `forcingNewLevel` is set +// to `true`, we append the marker, thus increasing the nesting. If set to +// `false`, we update the format marker if relevant - e.g. from +// `EnrichedCheckbox0` to `EnrichedCheckbox1`. If not applicable, this operation +// is effectively idepotent +static NSArray * +ListTextListsAfterAdding(NSArray *existing, NSString *value, + NSString *markerPrefix, BOOL forcingNewLevel) { + NSString *deepestFormat = + [TextListsUtils deepestListMarkerFormatInArray:existing]; + if (!forcingNewLevel && [deepestFormat isEqualToString:value]) { + return existing; + } + if (!forcingNewLevel && markerPrefix != nil && deepestFormat != nil && + [deepestFormat hasPrefix:markerPrefix]) { + return [TextListsUtils textListsByReplacingDeepestChainEntry:value + toArray:existing]; + } + return [TextListsUtils textListsByAppendingToChain:value toArray:existing]; +} + +@implementation ListStyleBase + +- (BOOL)styleCondition:(id)value range:(NSRange)range { + NSParagraphStyle *pStyle = (NSParagraphStyle *)value; + if (pStyle == nil) { + return NO; + } + + // we check the deepest textLists marker - the nested list leaf + NSString *ownFormat = + [TextListsUtils deepestListMarkerFormatInArray:pStyle.textLists]; + if (ownFormat == nil) { + return false; + } + + NSString *markerPrefix = [self getMarkerPrefix]; + + if (markerPrefix != nil) { + return [ownFormat hasPrefix:[self getMarkerPrefix]]; + } else { + return [ownFormat isEqualToString:[self getValue]]; + } +} + +- (BOOL)isList { + return YES; +} + +- (void)add:(NSRange)range + withValue:(NSString *)value + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange { + [self add:range + withValue:value + forcingNewLevel:NO + withTyping:withTyping + withDirtyRange:withDirtyRange]; +} + +- (void)add:(NSRange)range + withValue:(NSString *)value + forcingNewLevel:(BOOL)forcingNewLevel + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange { + NSRange actualRange = [self actualUsedRange:range]; + NSString *markerPrefix = [self getMarkerPrefix]; + [self.host.textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:actualRange + options:0 + usingBlock:^(id _Nullable existingValue, NSRange subRange, + BOOL *_Nonnull stop) { + NSParagraphStyle *currentStyle = + (NSParagraphStyle *)existingValue; + if (currentStyle == nil) + return; + + NSArray *updatedTextLists = + ListTextListsAfterAdding(currentStyle.textLists, value, + markerPrefix, forcingNewLevel); + if (updatedTextLists == currentStyle.textLists) { + return; + } + + NSMutableParagraphStyle *pStyle = [currentStyle mutableCopy]; + pStyle.textLists = updatedTextLists; + [self.host.textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:subRange]; + }]; + + if (withTyping) { + NSMutableDictionary *newTypingAttrs = + [self.host.textView.typingAttributes mutableCopy]; + NSParagraphStyle *currentStyle = + newTypingAttrs[NSParagraphStyleAttributeName]; + if (currentStyle != nil) { + NSMutableParagraphStyle *pStyle = [currentStyle mutableCopy]; + pStyle.textLists = ListTextListsAfterAdding( + currentStyle.textLists, value, markerPrefix, forcingNewLevel); + newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; + self.host.textView.typingAttributes = newTypingAttrs; + } + } + + // Notify attributes manager of styling to be re-done if needed. + if (withDirtyRange) { + [self.host.attributesManager addDirtyRange:actualRange]; + } +} + +- (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { + NSRange actualRange = [self actualUsedRange:range]; + + [self.host.textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:actualRange + options:0 + usingBlock:^(id _Nullable existingValue, NSRange subRange, + BOOL *_Nonnull stop) { + NSMutableParagraphStyle *pStyle = + [(NSParagraphStyle *)existingValue mutableCopy]; + if (pStyle == nullptr) + return; + pStyle.textLists = [TextListsUtils + textListsByRemovingDeepestChainEntry:pStyle.textLists]; + [self.host.textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:subRange]; + }]; + [self removeTyping]; + + // Notify attributes manager of styling to be re-done if needed. + if (withDirtyRange) { + [self.host.attributesManager addDirtyRange:actualRange]; + } +} + +- (void)addTypingWithValue:(NSString *)value { + NSMutableDictionary *newTypingAttrs = + [self.host.textView.typingAttributes mutableCopy]; + + NSParagraphStyle *currentStyle = + newTypingAttrs[NSParagraphStyleAttributeName]; + if (currentStyle == nil) + return; + + NSArray *updatedTextLists = ListTextListsAfterAdding( + currentStyle.textLists, value, [self getMarkerPrefix], NO); + if (updatedTextLists == currentStyle.textLists) { + return; + } + + NSMutableParagraphStyle *pStyle = [currentStyle mutableCopy]; + pStyle.textLists = updatedTextLists; + newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; + self.host.textView.typingAttributes = newTypingAttrs; +} + +- (void)removeTyping { + NSMutableDictionary *newTypingAttrs = + [self.host.textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *pStyle = + [newTypingAttrs[NSParagraphStyleAttributeName] mutableCopy]; + if (pStyle == nil) + return; + pStyle.textLists = + [TextListsUtils textListsByRemovingDeepestChainEntry:pStyle.textLists]; + newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; + self.host.textView.typingAttributes = newTypingAttrs; +} + +- (void)reapplyFromStylePair:(StylePair *)pair { + NSRange range = [pair.rangeValue rangeValue]; + [self add:range withTyping:NO withDirtyRange:NO]; + // restore nested list metadata + [self restoreListChainFromStylePair:pair range:range]; +} + +- (CGFloat)cumulativeHeadIndentForChain:(NSArray *)chain { + CGFloat total = 0; + for (NSTextList *entry in chain) { + NSString *format = entry.markerFormat; + if ([format isEqualToString:@"EnrichedUnorderedList"]) { + total += [self.host.config unorderedListMarginLeft] + + [self.host.config unorderedListGapWidth]; + } else if ([format isEqualToString:@"EnrichedOrderedList"]) { + total += [self.host.config orderedListMarginLeft] + + [self.host.config orderedListGapWidth]; + } else if ([format hasPrefix:@"EnrichedCheckbox"]) { + total += [self.host.config checkboxListMarginLeft] + + [self.host.config checkboxListGapWidth] + + [self.host.config checkboxListBoxSize]; + } + } + return total; +} + +- (NSArray *)textListsAtParagraphRange:(NSRange)paragraphRange { + NSParagraphStyle *pStyle; + if (paragraphRange.length == 0) { + pStyle = self.host.textView.typingAttributes[NSParagraphStyleAttributeName]; + } else { + pStyle = + [self.host.textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:NULL]; + } + return pStyle.textLists; +} + +- (void)modifyListNestingInRange:(NSRange)range + increasing:(BOOL)increasingNesting { + NSRange actualRange = [self actualUsedRange:range]; + NSArray *paragraphRanges = + [RangeUtils getSeparateParagraphsRangesIn:self.host.textView + range:actualRange]; + + for (NSValue *paragraphRangeValue in paragraphRanges) { + NSRange paragraphRange = [paragraphRangeValue rangeValue]; + NSArray *textLists = + [self textListsAtParagraphRange:paragraphRange]; + + if (increasingNesting) { + NSString *ownFormat = + [TextListsUtils deepestListMarkerFormatInArray:textLists]; + if (ownFormat == nil) { + continue; + } + [self add:paragraphRange + withValue:ownFormat + forcingNewLevel:YES + withTyping:YES + withDirtyRange:YES]; + } else if ([TextListsUtils listDepthInArray:textLists] > 1) { + [self remove:paragraphRange withDirtyRange:YES]; + } + } +} + +@end diff --git a/ios/styles/OrderedListStyle.mm b/ios/styles/OrderedListStyle.mm index 2d4adf364..d4f4743bc 100644 --- a/ios/styles/OrderedListStyle.mm +++ b/ios/styles/OrderedListStyle.mm @@ -3,6 +3,7 @@ #import "utils/RangeUtils.h" #import "utils/StyleUtils.h" #import "utils/TextInsertionUtils.h" +#import "utils/TextListsUtils.h" @implementation OrderedListStyle @@ -22,16 +23,8 @@ - (BOOL)needsZWS { return YES; } -- (CGFloat)headIndent { - return [self.host.config orderedListMarginLeft] + - [self.host.config orderedListGapWidth]; -} - - (void)applyStyling:(NSRange)range { // lists are drawn manually - // margin before marker + gap between marker and paragraph - CGFloat listHeadIndent = [self headIndent]; - [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range @@ -40,6 +33,10 @@ - (void)applyStyling:(NSRange)range { BOOL *_Nonnull stop) { NSMutableParagraphStyle *pStyle = [(NSParagraphStyle *)value mutableCopy]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + CGFloat listHeadIndent = + [self cumulativeHeadIndentForChain:chain]; pStyle.headIndent = listHeadIndent; pStyle.firstLineHeadIndent = listHeadIndent; [self.host.textView.textStorage @@ -58,7 +55,9 @@ - (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { [attributes[NSParagraphStyleAttributeName] mutableCopy]; if (pStyle == nil) return; - CGFloat indent = [self headIndent]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + CGFloat indent = [self cumulativeHeadIndentForChain:chain]; pStyle.headIndent = indent; pStyle.firstLineHeadIndent = indent; attributes[NSParagraphStyleAttributeName] = pStyle; diff --git a/ios/styles/UnorderedListStyle.mm b/ios/styles/UnorderedListStyle.mm index b744c9cfe..7bb9e1a93 100644 --- a/ios/styles/UnorderedListStyle.mm +++ b/ios/styles/UnorderedListStyle.mm @@ -3,6 +3,7 @@ #import "utils/RangeUtils.h" #import "utils/StyleUtils.h" #import "utils/TextInsertionUtils.h" +#import "utils/TextListsUtils.h" @implementation UnorderedListStyle @@ -22,16 +23,8 @@ - (BOOL)needsZWS { return YES; } -- (CGFloat)headIndent { - return [self.host.config unorderedListMarginLeft] + - [self.host.config unorderedListGapWidth]; -} - - (void)applyStyling:(NSRange)range { // lists are drawn manually - // margin before bullet + gap between bullet and paragraph - CGFloat listHeadIndent = [self headIndent]; - [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range @@ -40,6 +33,10 @@ - (void)applyStyling:(NSRange)range { BOOL *_Nonnull stop) { NSMutableParagraphStyle *pStyle = [(NSParagraphStyle *)value mutableCopy]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + CGFloat listHeadIndent = + [self cumulativeHeadIndentForChain:chain]; pStyle.headIndent = listHeadIndent; pStyle.firstLineHeadIndent = listHeadIndent; [self.host.textView.textStorage @@ -58,7 +55,9 @@ - (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { [attributes[NSParagraphStyleAttributeName] mutableCopy]; if (pStyle == nil) return; - CGFloat indent = [self headIndent]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + CGFloat indent = [self cumulativeHeadIndentForChain:chain]; pStyle.headIndent = indent; pStyle.firstLineHeadIndent = indent; attributes[NSParagraphStyleAttributeName] = pStyle; diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm index 679cb3dc7..1231d4e0c 100644 --- a/ios/textHtmlParser/TextHtmlParser.mm +++ b/ios/textHtmlParser/TextHtmlParser.mm @@ -124,6 +124,12 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { } } } + } else if ([style isList]) { + [(ListStyleBase *)style add:styleRange + withValue:[style getValue] + forcingNewLevel:YES + withTyping:NO + withDirtyRange:NO]; } else { [style add:styleRange withTyping:NO withDirtyRange:NO]; } diff --git a/ios/utils/CheckboxHitTestUtils.mm b/ios/utils/CheckboxHitTestUtils.mm index 78fe181a5..d6c94c0bf 100644 --- a/ios/utils/CheckboxHitTestUtils.mm +++ b/ios/utils/CheckboxHitTestUtils.mm @@ -2,6 +2,7 @@ #import "EnrichedTextInputView.h" #import "config/EnrichedConfig.h" #import "interfaces/StyleHeaders.h" +#import "utils/TextListsUtils.h" static const CGFloat kCheckboxHitSlopLeft = 8.0; static const CGFloat kCheckboxHitSlopVertical = 6.0; @@ -52,6 +53,35 @@ + (BOOL)isCheckboxGlyph:(NSUInteger)glyphIndex // MARK: - Checkbox rect +// the checkbox list might be nested, so we need to add the +// ancestor lists' cumulative margins to properly draw the checkbox ++ (CGFloat)ancestorHeadIndentForCharIndex:(NSUInteger)charIndex + inInput:(EnrichedTextInputView *)input { + NSTextStorage *storage = input->textView.textStorage; + if (charIndex >= storage.length) { + return 0; + } + + NSParagraphStyle *pStyle = [storage attribute:NSParagraphStyleAttributeName + atIndex:charIndex + effectiveRange:NULL]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + if (chain.count <= 1) { + return 0; + } + + NSArray *ancestorChain = + [chain subarrayWithRange:NSMakeRange(0, chain.count - 1)]; + + ListStyleBase *checkboxListStyle = + (ListStyleBase *)input->stylesDict[@([CheckboxListStyle getType])]; + + return checkboxListStyle + ? [checkboxListStyle cumulativeHeadIndentForChain:ancestorChain] + : 0; +} + + (CGRect)checkboxRectForGlyphIndex:(NSUInteger)glyphIndex inInput:(EnrichedTextInputView *)input { UITextView *textView = input->textView; @@ -62,10 +92,16 @@ + (CGRect)checkboxRectForGlyphIndex:(NSUInteger)glyphIndex return CGRectNull; } + NSUInteger charIndex = + [layoutManager characterIndexForGlyphAtIndex:glyphIndex]; + CGFloat ancestorIndent = [self ancestorHeadIndentForCharIndex:charIndex + inInput:input]; + CGRect lineRect = [layoutManager lineFragmentRectForGlyphAtIndex:glyphIndex effectiveRange:nil]; - CGFloat originX = lineRect.origin.x + config.checkboxListMarginLeft; + CGFloat originX = + lineRect.origin.x + ancestorIndent + config.checkboxListMarginLeft; CGFloat originY = lineRect.origin.y + (lineRect.size.height - config.checkboxListBoxSize) / 2.0; diff --git a/ios/utils/TextListsUtils.h b/ios/utils/TextListsUtils.h index 1ed648402..119b1a888 100644 --- a/ios/utils/TextListsUtils.h +++ b/ios/utils/TextListsUtils.h @@ -37,4 +37,35 @@ firstTextListWithPrefix:(NSString *_Nullable)prefix inArray:(NSArray *_Nullable)textLists; +// nested-lists-scoped utils + +// Returns the sub-sequence of textLists (preserving relative order) that +// represents the real list nesting chain. The last entry is this paragraph's +// own list type. ++ (NSArray *_Nonnull)listChainInArray: + (NSArray *_Nullable)textLists; + +// Returns the chain length (nesting depth) of markers representing list styles. ++ (NSInteger)listDepthInArray:(NSArray *_Nullable)textLists; + +// Returns the markerFormat of the paragraph's own deepest list entry. ++ (NSString *_Nullable)deepestListMarkerFormatInArray: + (NSArray *_Nullable)textLists; + +// Append a new entry to the existing chain, becoming the new deepest nested +// list level. ++ (NSArray *_Nonnull) + textListsByAppendingToChain:(NSString *_Nonnull)value + toArray:(NSArray *_Nullable)existing; + +// Removes a deepest entry from the existing chain. ++ (NSArray *_Nonnull)textListsByRemovingDeepestChainEntry: + (NSArray *_Nullable)existing; + +// Replaces the deepest existing chain entry. ++ (NSArray *_Nonnull) + textListsByReplacingDeepestChainEntry:(NSString *_Nonnull)value + toArray:(NSArray *_Nullable) + existing; + @end diff --git a/ios/utils/TextListsUtils.mm b/ios/utils/TextListsUtils.mm index c5d39b75c..c01f65baf 100644 --- a/ios/utils/TextListsUtils.mm +++ b/ios/utils/TextListsUtils.mm @@ -90,4 +90,58 @@ + (BOOL)textLists:(NSArray *_Nullable)textLists return nil; } ++ (NSArray *_Nonnull)listChainInArray: + (NSArray *_Nullable)textLists { + NSMutableArray *chain = [NSMutableArray array]; + for (NSTextList *list in textLists) { + if (![list.markerFormat hasPrefix:@"EnrichedAlignment"]) { + [chain addObject:list]; + } + } + return chain; +} + ++ (NSInteger)listDepthInArray:(NSArray *_Nullable)textLists { + return [self listChainInArray:textLists].count; +} + ++ (NSString *_Nullable)deepestListMarkerFormatInArray: + (NSArray *_Nullable)textLists { + return [self listChainInArray:textLists].lastObject.markerFormat; +} + ++ (NSArray *_Nonnull) + textListsByAppendingToChain:(NSString *_Nonnull)value + toArray:(NSArray *_Nullable)existing { + NSMutableArray *updated = + existing ? [existing mutableCopy] : [NSMutableArray array]; + [updated addObject:[[NSTextList alloc] initWithMarkerFormat:value options:0]]; + return updated; +} + ++ (NSArray *_Nonnull)textListsByRemovingDeepestChainEntry: + (NSArray *_Nullable)existing { + NSArray *chain = [self listChainInArray:existing]; + if (chain.count == 0) { + return existing ?: @[]; + } + + NSMutableArray *updated = + existing ? [existing mutableCopy] : [NSMutableArray array]; + NSUInteger index = [updated indexOfObjectIdenticalTo:chain.lastObject]; + if (index != NSNotFound) { + [updated removeObjectAtIndex:index]; + } + return updated; +} + ++ (NSArray *_Nonnull) + textListsByReplacingDeepestChainEntry:(NSString *_Nonnull)value + toArray:(NSArray *_Nullable) + existing { + NSArray *withoutDeepest = + [self textListsByRemovingDeepestChainEntry:existing]; + return [self textListsByAppendingToChain:value toArray:withoutDeepest]; +} + @end diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index cfe12104c..196fb2284 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -240,6 +240,12 @@ export const EnrichedTextInput = ({ toggleCheckboxList: (checked: boolean) => { Commands.toggleCheckboxList(nullthrows(nativeRef.current), checked); }, + indent: () => { + Commands.indentList(nullthrows(nativeRef.current)); + }, + outdent: () => { + Commands.outdentList(nullthrows(nativeRef.current)); + }, setLink: (start: number, end: number, text: string, url: string) => { Commands.addLink(nullthrows(nativeRef.current), start, end, text, url); }, diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index c5d2e61e3..c060ee8f8 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -95,11 +95,13 @@ export interface OnChangeStateEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: CodegenTypes.Int32; }; unorderedList: { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: CodegenTypes.Int32; }; link: { isActive: boolean; @@ -120,6 +122,7 @@ export interface OnChangeStateEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: CodegenTypes.Int32; }; alignment: string; } @@ -251,11 +254,13 @@ export interface OnContextMenuItemPressEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: CodegenTypes.Int32; }; unorderedList: { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: CodegenTypes.Int32; }; link: { isActive: boolean; @@ -276,6 +281,7 @@ export interface OnContextMenuItemPressEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: CodegenTypes.Int32; }; alignment: string; }; @@ -446,6 +452,8 @@ interface NativeCommands { viewRef: React.ElementRef, checked: boolean ) => void; + indentList: (viewRef: React.ElementRef) => void; + outdentList: (viewRef: React.ElementRef) => void; addLink: ( viewRef: React.ElementRef, start: CodegenTypes.Int32, @@ -509,6 +517,8 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'toggleOrderedList', 'toggleUnorderedList', 'toggleCheckboxList', + 'indentList', + 'outdentList', 'addLink', 'removeLink', 'addImage', diff --git a/src/types.ts b/src/types.ts index d478b123c..61206a3f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -362,11 +362,15 @@ export interface OnChangeStateEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + /** Nesting depth of the list item at the current selection (`0` = top level). */ + depth: number; }; unorderedList: { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + /** Nesting depth of the list item at the current selection (`0` = top level). */ + depth: number; }; link: { isActive: boolean; @@ -387,6 +391,8 @@ export interface OnChangeStateEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + /** Nesting depth of the list item at the current selection (`0` = top level). */ + depth: number; }; alignment: string; } @@ -524,6 +530,23 @@ export interface EnrichedTextInputInstance { */ toggleCheckboxList: (checked: boolean) => void; + /** + * Increases the nesting depth of the list item(s) at the current selection + * by one. No-op when the selection isn't inside a list. + * + * @platform ios + */ + indent: () => void; + + /** + * Decreases the nesting depth of the list item(s) at the current selection + * by one, down to a minimum of the top level (depth `0`). No-op when the + * selection isn't inside a list. + * + * @platform ios + */ + outdent: () => void; + /** * Inserts or updates a hyperlink over the character range `[start, end)`. * From e16032740b8f7e18b6895b36dd43cd81ad0d978e Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sat, 3 Oct 2026 22:07:53 +0200 Subject: [PATCH 02/17] feat(ios): handle conflicting list styles dduring parsers nested list building --- ios/EnrichedTextInputView.mm | 12 +++++-- ios/inputHtmlParser/InputHtmlParser.mm | 32 +++++++++++++++--- ios/interfaces/StyleBase.h | 1 + ios/interfaces/StyleBase.mm | 4 +++ ios/interfaces/StyleHeaders.h | 4 ++- ios/styles/CheckboxListStyle.mm | 17 +++++++++- ios/styles/ListStyleBase.mm | 15 ++++++++ ios/textHtmlParser/TextHtmlParser.mm | 25 +++++++++++--- ios/utils/StyleUtils.h | 3 ++ ios/utils/StyleUtils.mm | 9 +++++ ios/utils/TextListsUtils.h | 10 ++++++ ios/utils/TextListsUtils.mm | 47 ++++++++++++++++++++++++++ 12 files changed, 167 insertions(+), 12 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index ac3e1ab7d..728c571c5 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1560,10 +1560,13 @@ - (void)toggleRegularStyle:(StyleType)type { if ([style isParagraph]) { range = [textView.textStorage.string paragraphRangeForRange:range]; } + BOOL conflictPresent = [StyleUtils conflictPresent:type + range:range + forHost:self]; if ([StyleUtils handleStyleBlocksAndConflicts:type range:range forHost:self]) { - [style toggle:range]; + [style toggle:range withConflictReplaced:conflictPresent]; [self anyTextMayHaveBeenModified]; } } @@ -1576,10 +1579,15 @@ - (void)toggleCheckboxList:(BOOL)checked { } NSRange range = [textView.textStorage.string paragraphRangeForRange:textView.selectedRange]; + BOOL conflictPresent = [StyleUtils conflictPresent:[CheckboxListStyle getType] + range:range + forHost:self]; if ([StyleUtils handleStyleBlocksAndConflicts:[CheckboxListStyle getType] range:range forHost:self]) { - [style toggleWithChecked:checked range:range]; + [style toggleWithChecked:checked + range:range + withConflictReplaced:conflictPresent]; [self anyTextMayHaveBeenModified]; } } diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index e76bc271f..6f59aa7d1 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -6,6 +6,7 @@ #import "interfaces/StyleHeaders.h" #import "utils/StyleUtils.h" #import "utils/TextInsertionUtils.h" +#import "utils/TextListsUtils.h" #import @implementation InputHtmlParser { @@ -136,6 +137,16 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles NSMakeRange(offset + zeroWidthSpaceOffset + parsedRange.location, parsedRange.length); + // we save the textLists data to restore it, as removing a conflicting + // style annihilates it. We need to persist it to correctly build the + // nested list data + NSArray *listChainSnapshot = + [baseStyle isList] + ? [TextListsUtils + snapshotTextListsInRange:styleRange + textStorage:_input->textView.textStorage] + : nil; + // of course any changes here need to take blocks and conflicts into // consideration if ([StyleUtils handleStyleBlocksAndConflicts:[[baseStyle class] getType] @@ -164,12 +175,19 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles NSDictionary *checkboxStates = (NSDictionary *)stylePair.styleValue; CheckboxListStyle *cbLStyle = (CheckboxListStyle *)baseStyle; + if (listChainSnapshot != nil) { + [TextListsUtils + restoreTextListsSnapshot:listChainSnapshot + textStorage:_input->textView.textStorage]; + } + // First apply the checkbox list style to the entire range with // unchecked value - [cbLStyle addWithChecked:NO - range:styleRange - withTyping:shouldAddTypingAttr - withDirtyRange:YES]; + [cbLStyle add:styleRange + withValue:[cbLStyle getValue] + forcingNewLevel:YES + withTyping:shouldAddTypingAttr + withDirtyRange:YES]; if (checkboxStates && checkboxStates.count > 0) { // Then toggle checked checkboxes @@ -183,6 +201,12 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles } } } else if ([baseStyle isList]) { + if (listChainSnapshot != nil) { + [TextListsUtils + restoreTextListsSnapshot:listChainSnapshot + textStorage:_input->textView.textStorage]; + } + [(ListStyleBase *)baseStyle add:styleRange withValue:[baseStyle getValue] forcingNewLevel:YES diff --git a/ios/interfaces/StyleBase.h b/ios/interfaces/StyleBase.h index edcb29a28..337665df1 100644 --- a/ios/interfaces/StyleBase.h +++ b/ios/interfaces/StyleBase.h @@ -19,6 +19,7 @@ - (instancetype)initWithHost:(id)host; - (NSRange)actualUsedRange:(NSRange)range; - (void)toggle:(NSRange)range; +- (void)toggle:(NSRange)range withConflictReplaced:(BOOL)conflictReplaced; - (void)add:(NSRange)range withTyping:(BOOL)withTyping withDirtyRange:(BOOL)withDirtyRange; diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm index 5e585db10..816b48ea5 100644 --- a/ios/interfaces/StyleBase.mm +++ b/ios/interfaces/StyleBase.mm @@ -67,6 +67,10 @@ - (NSRange)actualUsedRange:(NSRange)range { return [self.host.textView.textStorage.string paragraphRangeForRange:range]; } +- (void)toggle:(NSRange)range withConflictReplaced:(BOOL)conflictReplaced { + [self toggle:range]; +} + - (void)toggle:(NSRange)range { NSRange actualRange = [self actualUsedRange:range]; diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index 4c9ee5ced..f6773e205 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -90,7 +90,9 @@ @end @interface CheckboxListStyle : ListStyleBase -- (void)toggleWithChecked:(BOOL)checked range:(NSRange)range; +- (void)toggleWithChecked:(BOOL)checked + range:(NSRange)range + withConflictReplaced:(BOOL)conflictReplaced; - (void)addWithChecked:(BOOL)checked range:(NSRange)range withTyping:(BOOL)withTyping diff --git a/ios/styles/CheckboxListStyle.mm b/ios/styles/CheckboxListStyle.mm index 3f584c736..2ac6f20e9 100644 --- a/ios/styles/CheckboxListStyle.mm +++ b/ios/styles/CheckboxListStyle.mm @@ -74,8 +74,23 @@ - (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { attributes[NSParagraphStyleAttributeName] = pStyle; } -- (void)toggleWithChecked:(BOOL)checked range:(NSRange)range { +- (void)toggleWithChecked:(BOOL)checked + range:(NSRange)range + withConflictReplaced:(BOOL)conflictReplaced { NSRange actualRange = [self actualUsedRange:range]; + + // during the toggle, a conflicting style (a different list) + // might have been removed. If so, we just add a new value to + // the textLists. Otherwise we follow the base flow + if (conflictReplaced) { + [self add:actualRange + withValue:checked ? @"EnrichedCheckbox1" : @"EnrichedCheckbox0" + forcingNewLevel:YES + withTyping:YES + withDirtyRange:YES]; + return; + } + BOOL isPresent = [self detect:actualRange]; if (isPresent) { diff --git a/ios/styles/ListStyleBase.mm b/ios/styles/ListStyleBase.mm index e8ec40114..f7d39a9c3 100644 --- a/ios/styles/ListStyleBase.mm +++ b/ios/styles/ListStyleBase.mm @@ -116,6 +116,21 @@ - (void)add:(NSRange)range } } +- (void)toggle:(NSRange)range withConflictReplaced:(BOOL)conflictReplaced { + // during the toggle, a conflicting style (a different list) + // might have been removed. If so, we just add a new value to + // the textLists. Otherwise we follow the base flow + if (!conflictReplaced) { + [self toggle:range]; + return; + } + [self add:[self actualUsedRange:range] + withValue:[self getValue] + forcingNewLevel:YES + withTyping:YES + withDirtyRange:YES]; +} + - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { NSRange actualRange = [self actualUsedRange:range]; diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm index 1231d4e0c..24b3f85bb 100644 --- a/ios/textHtmlParser/TextHtmlParser.mm +++ b/ios/textHtmlParser/TextHtmlParser.mm @@ -7,6 +7,7 @@ #import "interfaces/MentionParams.h" #import "interfaces/StyleHeaders.h" #import "utils/StyleUtils.h" +#import "utils/TextListsUtils.h" #import "utils/ZeroWidthSpaceUtils.h" #import @@ -86,12 +87,27 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { NSRange styleRange = NSMakeRange( zeroWidthSpaceOffset + parsedRange.location, parsedRange.length); + // we save the textLists data to restore it, as removing a conflicting + // style annihilates it. We need to persist it to correctly build the + // nested list data + NSArray *listChainSnapshot = + [style isList] + ? [TextListsUtils + snapshotTextListsInRange:styleRange + textStorage:_view->textView.textStorage] + : nil; + if (![StyleUtils handleStyleBlocksAndConflicts:[[style class] getType] range:styleRange forHost:_view]) { continue; } + if (listChainSnapshot != nil) { + [TextListsUtils restoreTextListsSnapshot:listChainSnapshot + textStorage:_view->textView.textStorage]; + } + if ([styleType isEqualToNumber:@([LinkStyle getType])]) { LinkData *linkData = (LinkData *)stylePair.styleValue; [((LinkStyle *)style) applyLinkMetaWithData:linkData range:styleRange]; @@ -108,10 +124,11 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { NSDictionary *checkboxStates = (NSDictionary *)stylePair.styleValue; CheckboxListStyle *cbStyle = (CheckboxListStyle *)style; - [cbStyle addWithChecked:NO - range:styleRange - withTyping:NO - withDirtyRange:NO]; + [cbStyle add:styleRange + withValue:[cbStyle getValue] + forcingNewLevel:YES + withTyping:NO + withDirtyRange:NO]; if (checkboxStates && checkboxStates.count > 0) { for (NSNumber *key in checkboxStates) { diff --git a/ios/utils/StyleUtils.h b/ios/utils/StyleUtils.h index de57444b6..03082ace4 100644 --- a/ios/utils/StyleUtils.h +++ b/ios/utils/StyleUtils.h @@ -3,6 +3,9 @@ @interface StyleUtils : NSObject + (NSDictionary *> *)conflictMap; ++ (BOOL)conflictPresent:(StyleType)type + range:(NSRange)range + forHost:(id)host; + (NSDictionary *> *)blockingMap; + (NSDictionary *)stylesDictForHost: (id)host diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm index 167a32df5..928099766 100644 --- a/ios/utils/StyleUtils.mm +++ b/ios/utils/StyleUtils.mm @@ -181,6 +181,15 @@ + (NSDictionary *)stylesDictForHost:(id)host return [dict copy]; } ++ (BOOL)conflictPresent:(StyleType)type + range:(NSRange)range + forHost:(id)host { + return [self getPresentStyleTypesFrom:host.conflictingStyles[@(type)] + range:range + forHost:host] + .count != 0; +} + // returns false when style shouldn't be applied and true when it can be + (BOOL)isStyleBlocked:(StyleType)type range:(NSRange)range diff --git a/ios/utils/TextListsUtils.h b/ios/utils/TextListsUtils.h index 119b1a888..6b9dbe03d 100644 --- a/ios/utils/TextListsUtils.h +++ b/ios/utils/TextListsUtils.h @@ -68,4 +68,14 @@ toArray:(NSArray *_Nullable) existing; +// Captures textLists of every paragraph overlapping range, so they can be put +// back with restoreTextListsSnapshot:inTextStorage:. ++ (NSArray *_Nonnull)snapshotTextListsInRange:(NSRange)range + textStorage: + (NSTextStorage *_Nonnull)textStorage; + +// Restores textLists captured by snapshotTextListsInRange:textStorage:. ++ (void)restoreTextListsSnapshot:(NSArray *_Nonnull)snapshot + textStorage:(NSTextStorage *_Nonnull)textStorage; + @end diff --git a/ios/utils/TextListsUtils.mm b/ios/utils/TextListsUtils.mm index c01f65baf..8f74b50f5 100644 --- a/ios/utils/TextListsUtils.mm +++ b/ios/utils/TextListsUtils.mm @@ -144,4 +144,51 @@ + (NSString *_Nullable)deepestListMarkerFormatInArray: return [self textListsByAppendingToChain:value toArray:withoutDeepest]; } ++ (NSArray *_Nonnull)snapshotTextListsInRange:(NSRange)range + textStorage: + (NSTextStorage *_Nonnull)textStorage { + NSMutableArray *snapshot = [NSMutableArray array]; + if (NSMaxRange(range) > textStorage.length) { + return snapshot; + } + NSRange paragraphsRange = [textStorage.string paragraphRangeForRange:range]; + [textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:paragraphsRange + options:0 + usingBlock:^(id _Nullable value, NSRange subRange, + BOOL *_Nonnull stop) { + NSParagraphStyle *pStyle = (NSParagraphStyle *)value; + if (pStyle == nil) { + return; + } + [snapshot addObject:@[ + [NSValue valueWithRange:subRange], pStyle.textLists ?: @[] + ]]; + }]; + return snapshot; +} + ++ (void)restoreTextListsSnapshot:(NSArray *_Nonnull)snapshot + textStorage:(NSTextStorage *_Nonnull)textStorage { + for (NSArray *entry in snapshot) { + NSRange range = [entry[0] rangeValue]; + if (NSMaxRange(range) > textStorage.length) { + continue; + } + NSParagraphStyle *current = + [textStorage attribute:NSParagraphStyleAttributeName + atIndex:range.location + effectiveRange:NULL]; + if (current == nil) { + continue; + } + NSMutableParagraphStyle *pStyle = [current mutableCopy]; + pStyle.textLists = entry[1]; + [textStorage addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:range]; + } +} + @end From 01fc3c326d71f72e03bdd6e48415e9e0513cfec7 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sun, 4 Oct 2026 01:02:38 +0200 Subject: [PATCH 03/17] feat(ios): check if nesting is possible --- ios/interfaces/StyleHeaders.h | 2 + ios/styles/ListStyleBase.mm | 77 ++++++++++++++++++++++------------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index f6773e205..ade2760bc 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -76,6 +76,8 @@ - (CGFloat)cumulativeHeadIndentForChain:(NSArray *)chain; - (void)modifyListNestingInRange:(NSRange)range increasing:(BOOL)increasingNesting; +- (void)addTypingWithValue:(NSString *)value + forcingNewLevel:(BOOL)forcingNewLevel; - (void)add:(NSRange)range withValue:(NSString *)value forcingNewLevel:(BOOL)forcingNewLevel diff --git a/ios/styles/ListStyleBase.mm b/ios/styles/ListStyleBase.mm index f7d39a9c3..4f4a90d4b 100644 --- a/ios/styles/ListStyleBase.mm +++ b/ios/styles/ListStyleBase.mm @@ -3,36 +3,36 @@ #import "utils/RangeUtils.h" #import "utils/TextListsUtils.h" -// adds `value` to the existing textLists array. If `forcingNewLevel` is set -// to `true`, we append the marker, thus increasing the nesting. If set to -// `false`, we update the format marker if relevant - e.g. from -// `EnrichedCheckbox0` to `EnrichedCheckbox1`. If not applicable, this operation -// is effectively idepotent -static NSArray * -ListTextListsAfterAdding(NSArray *existing, NSString *value, - NSString *markerPrefix, BOOL forcingNewLevel) { +@implementation ListStyleBase + +- (NSArray *)textListsByAddingValue:(NSString *)value + toArray: + (NSArray *)existing + forcingNewLevel:(BOOL)forcingNewLevel { NSString *deepestFormat = [TextListsUtils deepestListMarkerFormatInArray:existing]; + if (!forcingNewLevel && [deepestFormat isEqualToString:value]) { return existing; } + + NSString *markerPrefix = [self getMarkerPrefix]; if (!forcingNewLevel && markerPrefix != nil && deepestFormat != nil && [deepestFormat hasPrefix:markerPrefix]) { return [TextListsUtils textListsByReplacingDeepestChainEntry:value toArray:existing]; } + return [TextListsUtils textListsByAppendingToChain:value toArray:existing]; } -@implementation ListStyleBase - - (BOOL)styleCondition:(id)value range:(NSRange)range { NSParagraphStyle *pStyle = (NSParagraphStyle *)value; if (pStyle == nil) { return NO; } - // we check the deepest textLists marker - the nested list leaf + // check the deepest textLists marker - the nested list leaf NSString *ownFormat = [TextListsUtils deepestListMarkerFormatInArray:pStyle.textLists]; if (ownFormat == nil) { @@ -69,7 +69,7 @@ - (void)add:(NSRange)range withTyping:(BOOL)withTyping withDirtyRange:(BOOL)withDirtyRange { NSRange actualRange = [self actualUsedRange:range]; - NSString *markerPrefix = [self getMarkerPrefix]; + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:actualRange @@ -82,8 +82,9 @@ - (void)add:(NSRange)range return; NSArray *updatedTextLists = - ListTextListsAfterAdding(currentStyle.textLists, value, - markerPrefix, forcingNewLevel); + [self textListsByAddingValue:value + toArray:currentStyle.textLists + forcingNewLevel:forcingNewLevel]; if (updatedTextLists == currentStyle.textLists) { return; } @@ -97,17 +98,7 @@ - (void)add:(NSRange)range }]; if (withTyping) { - NSMutableDictionary *newTypingAttrs = - [self.host.textView.typingAttributes mutableCopy]; - NSParagraphStyle *currentStyle = - newTypingAttrs[NSParagraphStyleAttributeName]; - if (currentStyle != nil) { - NSMutableParagraphStyle *pStyle = [currentStyle mutableCopy]; - pStyle.textLists = ListTextListsAfterAdding( - currentStyle.textLists, value, markerPrefix, forcingNewLevel); - newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; - self.host.textView.typingAttributes = newTypingAttrs; - } + [self addTypingWithValue:value forcingNewLevel:forcingNewLevel]; } // Notify attributes manager of styling to be re-done if needed. @@ -160,6 +151,11 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { } - (void)addTypingWithValue:(NSString *)value { + [self addTypingWithValue:value forcingNewLevel:NO]; +} + +- (void)addTypingWithValue:(NSString *)value + forcingNewLevel:(BOOL)forcingNewLevel { NSMutableDictionary *newTypingAttrs = [self.host.textView.typingAttributes mutableCopy]; @@ -168,8 +164,10 @@ - (void)addTypingWithValue:(NSString *)value { if (currentStyle == nil) return; - NSArray *updatedTextLists = ListTextListsAfterAdding( - currentStyle.textLists, value, [self getMarkerPrefix], NO); + NSArray *updatedTextLists = + [self textListsByAddingValue:value + toArray:currentStyle.textLists + forcingNewLevel:forcingNewLevel]; if (updatedTextLists == currentStyle.textLists) { return; } @@ -247,9 +245,32 @@ - (void)modifyListNestingInRange:(NSRange)range if (increasingNesting) { NSString *ownFormat = [TextListsUtils deepestListMarkerFormatInArray:textLists]; - if (ownFormat == nil) { + if (ownFormat == nil || paragraphRange.location == 0) { + continue; + } + + // we look at the line before, to inherit the parent list's textLists + NSRange prevRange = [self.host.textView.textStorage.string + paragraphRangeForRange:NSMakeRange(paragraphRange.location - 1, 0)]; + NSArray *prevChain = [TextListsUtils + listChainInArray:[self textListsAtParagraphRange:prevRange]]; + NSUInteger currentDepth = [TextListsUtils listDepthInArray:textLists]; + if (prevChain.count < currentDepth) { continue; } + + for (NSUInteger i = 0; i < currentDepth; i++) { + [self remove:paragraphRange withDirtyRange:NO]; + } + + for (NSUInteger i = 0; i < currentDepth; i++) { + [self add:paragraphRange + withValue:prevChain[i].markerFormat + forcingNewLevel:YES + withTyping:YES + withDirtyRange:NO]; + } + [self add:paragraphRange withValue:ownFormat forcingNewLevel:YES From 7002356ad6a2c73972788765b94bdfc80c336a27 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 5 Oct 2026 12:28:55 +0200 Subject: [PATCH 04/17] feat(ios): list integrity validation --- ios/EnrichedTextInputView.mm | 3 + ios/utils/ListIntegrityUtils.h | 8 ++ ios/utils/ListIntegrityUtils.mm | 190 ++++++++++++++++++++++++++++++++ ios/utils/TextListsUtils.h | 5 + ios/utils/TextListsUtils.mm | 13 +++ 5 files changed, 219 insertions(+) create mode 100644 ios/utils/ListIntegrityUtils.h create mode 100644 ios/utils/ListIntegrityUtils.mm diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 728c571c5..2d8318860 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -13,6 +13,7 @@ #import "utils/AttachmentLayoutUtils.h" #import "utils/DotReplacementUtils.h" #import "utils/KeyboardUtils.h" +#import "utils/ListIntegrityUtils.h" #import "utils/ParagraphAttributesUtils.h" #import "utils/ShortcutsUtils.h" #import "utils/StyleUtils.h" @@ -1856,6 +1857,8 @@ - (void)anyTextMayHaveBeenModified { } } } + // nested lists validation around the dirty ranges; fixes add their own dirty ranges + [ListIntegrityUtils validateListsInDirtyRanges:self]; // all the visible (not meta) attributes handling in the ranges that could // have changed [attributesManager handleDirtyRangesStyling]; diff --git a/ios/utils/ListIntegrityUtils.h b/ios/utils/ListIntegrityUtils.h new file mode 100644 index 000000000..81d7a69c7 --- /dev/null +++ b/ios/utils/ListIntegrityUtils.h @@ -0,0 +1,8 @@ +#import "EnrichedTextInputView.h" +#import +#pragma once + +@interface ListIntegrityUtils : NSObject +// Updates nested lists structure, so all child/parent list relations are valid. ++ (void)validateListsInDirtyRanges:(EnrichedTextInputView *)input; +@end diff --git a/ios/utils/ListIntegrityUtils.mm b/ios/utils/ListIntegrityUtils.mm new file mode 100644 index 000000000..8e6b6fabe --- /dev/null +++ b/ios/utils/ListIntegrityUtils.mm @@ -0,0 +1,190 @@ +#import "ListIntegrityUtils.h" +#import "EnrichedTextInputView.h" +#import "TextListsUtils.h" + +@implementation ListIntegrityUtils + ++ (void)validateListsInDirtyRanges:(EnrichedTextInputView *)input { + NSString *string = input->textView.textStorage.string; + // ranges of list compounds that were already validated, so that + // other dirty ranges within the same compound are skipped + NSMutableIndexSet *validatedIndexes = [NSMutableIndexSet indexSet]; + + NSArray *dirtyRanges = [[input->attributesManager getDirtyRanges] copy]; + for (NSValue *rangeValue in dirtyRanges) { + NSRange dirtyRange = [rangeValue rangeValue]; + if (NSMaxRange(dirtyRange) > string.length) { + continue; + } + + NSRange paragraphsRange = [string paragraphRangeForRange:dirtyRange]; + // the paragraph right after the edit got a new predecessor, so it has to be + // checked as well + if (NSMaxRange(paragraphsRange) < string.length) { + paragraphsRange = NSUnionRange( + paragraphsRange, + [string paragraphRangeForRange:NSMakeRange( + NSMaxRange(paragraphsRange), 0)]); + } + + NSUInteger position = paragraphsRange.location; + while (position < NSMaxRange(paragraphsRange)) { + NSRange paragraphRange = + [string paragraphRangeForRange:NSMakeRange(position, 0)]; + position = NSMaxRange(paragraphRange); + + if ([validatedIndexes containsIndex:paragraphRange.location] || + ![self isListParagraph:paragraphRange input:input]) { + continue; + } + + NSRange compoundRange = [self listCompoundRangeAround:paragraphRange + input:input]; + [self validateListCompound:compoundRange input:input]; + [validatedIndexes addIndexesInRange:compoundRange]; + } + } +} + +// Expands the paragraph to the contiguous run of list paragraphs around it. ++ (NSRange)listCompoundRangeAround:(NSRange)paragraphRange + input:(EnrichedTextInputView *)input { + NSString *string = input->textView.textStorage.string; + NSRange compoundRange = paragraphRange; + + while (compoundRange.location > 0) { + NSRange prevRange = [string + paragraphRangeForRange:NSMakeRange(compoundRange.location - 1, 0)]; + if (![self isListParagraph:prevRange input:input]) { + break; + } + compoundRange = NSUnionRange(compoundRange, prevRange); + } + + while (NSMaxRange(compoundRange) < string.length) { + NSRange nextRange = [string + paragraphRangeForRange:NSMakeRange(NSMaxRange(compoundRange), 0)]; + if (![self isListParagraph:nextRange input:input]) { + break; + } + compoundRange = NSUnionRange(compoundRange, nextRange); + } + + return compoundRange; +} + ++ (void)validateListCompound:(NSRange)compoundRange + input:(EnrichedTextInputView *)input { + NSTextStorage *textStorage = input->textView.textStorage; + NSString *string = textStorage.string; + NSRange cursorParagraphRange = [string + paragraphRangeForRange:NSMakeRange(input->textView.selectedRange.location, + 0)]; + + // chains of the preceding paragraphs that can still be parents of the + // following ones, along with their original depths + NSMutableArray *depthsStack = [NSMutableArray array]; + NSMutableArray *> *chainsStack = [NSMutableArray array]; + + NSUInteger position = compoundRange.location; + while (position < NSMaxRange(compoundRange)) { + NSRange paragraphRange = + [string paragraphRangeForRange:NSMakeRange(position, 0)]; + position = NSMaxRange(paragraphRange); + + NSParagraphStyle *pStyle = + [textStorage attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:NULL]; + NSArray *chain = + [TextListsUtils listChainInArray:pStyle.textLists]; + NSUInteger depth = chain.count; + + // pop from both stacks if didn't move deeper into nesting + while (depthsStack.count > 0 && + depthsStack.lastObject.unsignedIntegerValue >= depth) { + [depthsStack removeLastObject]; + [chainsStack removeLastObject]; + } + NSArray *parentChain = chainsStack.lastObject ?: @[]; + + if (![self chain:chain hasParentChain:parentChain]) { + // rebuild the list chain using the parent chain + NSMutableArray *newChain = [NSMutableArray array]; + for (NSTextList *ancestor in parentChain) { + [newChain addObject:[[NSTextList alloc] + initWithMarkerFormat:ancestor.markerFormat + options:0]]; + } + [newChain addObject:chain.lastObject]; + + // add the updated paragraph style attribute + NSMutableParagraphStyle *newStyle = [pStyle mutableCopy]; + newStyle.textLists = + [TextListsUtils textListsByReplacingChainWith:newChain + inArray:pStyle.textLists]; + [textStorage addAttribute:NSParagraphStyleAttributeName + value:newStyle + range:paragraphRange]; + [input->attributesManager addDirtyRange:paragraphRange]; + + if (NSEqualRanges(paragraphRange, cursorParagraphRange)) { + [self updateTypingTextLists:newStyle.textLists input:input]; + } + chain = newChain; + } + + [depthsStack addObject:@(depth)]; + [chainsStack addObject:chain]; + } +} + +// checks if nested structure is valid ++ (BOOL)chain:(NSArray *)chain + hasParentChain:(NSArray *)parentChain { + if (chain.count != parentChain.count + 1) { + return NO; + } + for (NSUInteger i = 0; i < parentChain.count; i++) { + if (![self listFormat:chain[i].markerFormat + hasKindOf:parentChain[i].markerFormat]) { + return NO; + } + } + return YES; +} + ++ (BOOL)listFormat:(NSString *)format hasKindOf:(NSString *)otherFormat { + if ([format hasPrefix:@"EnrichedCheckbox"]) { + return [otherFormat hasPrefix:@"EnrichedCheckbox"]; + } + return [format isEqualToString:otherFormat]; +} + ++ (BOOL)isListParagraph:(NSRange)paragraphRange + input:(EnrichedTextInputView *)input { + if (paragraphRange.length == 0) { + return NO; + } + NSParagraphStyle *pStyle = + [input->textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:NULL]; + return [TextListsUtils listDepthInArray:pStyle.textLists] > 0; +} + ++ (void)updateTypingTextLists:(NSArray *)textLists + input:(EnrichedTextInputView *)input { + NSMutableDictionary *typingAttrs = + [input->textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *typingStyle = + [typingAttrs[NSParagraphStyleAttributeName] mutableCopy]; + if (typingStyle == nil) { + return; + } + typingStyle.textLists = textLists; + typingAttrs[NSParagraphStyleAttributeName] = typingStyle; + input->textView.typingAttributes = typingAttrs; +} + +@end diff --git a/ios/utils/TextListsUtils.h b/ios/utils/TextListsUtils.h index 6b9dbe03d..4203afe7b 100644 --- a/ios/utils/TextListsUtils.h +++ b/ios/utils/TextListsUtils.h @@ -45,6 +45,11 @@ + (NSArray *_Nonnull)listChainInArray: (NSArray *_Nullable)textLists; +// Replaces the whole list chain with the given one. ++ (NSArray *_Nonnull) + textListsByReplacingChainWith:(NSArray *_Nonnull)chain + inArray:(NSArray *_Nullable)existing; + // Returns the chain length (nesting depth) of markers representing list styles. + (NSInteger)listDepthInArray:(NSArray *_Nullable)textLists; diff --git a/ios/utils/TextListsUtils.mm b/ios/utils/TextListsUtils.mm index 8f74b50f5..368fd272f 100644 --- a/ios/utils/TextListsUtils.mm +++ b/ios/utils/TextListsUtils.mm @@ -101,6 +101,19 @@ + (BOOL)textLists:(NSArray *_Nullable)textLists return chain; } ++ (NSArray *_Nonnull) + textListsByReplacingChainWith:(NSArray *_Nonnull)chain + inArray:(NSArray *_Nullable)existing { + NSMutableArray *updated = [NSMutableArray array]; + for (NSTextList *list in existing) { + if ([list.markerFormat hasPrefix:@"EnrichedAlignment"]) { + [updated addObject:list]; + } + } + [updated addObjectsFromArray:chain]; + return updated; +} + + (NSInteger)listDepthInArray:(NSArray *_Nullable)textLists { return [self listChainInArray:textLists].count; } From ce7b9b45f3d7d780fc73d09fb6bb621ecabc5930 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 5 Oct 2026 13:29:48 +0200 Subject: [PATCH 05/17] refactor: method rename --- apps/example/src/screens/DevScreen.tsx | 8 ++++++++ src/native/EnrichedTextInput.tsx | 4 ++-- src/types.ts | 7 ++----- src/web/EnrichedTextInput.tsx | 4 ++++ src/web/tiptapWatchers/useOnChangeState.ts | 18 +++++++++++++++--- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index d5b4ba3f1..a87afa94e 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -82,6 +82,14 @@ export function DevScreen({ onSwitch }: DevScreenProps) { onSelectImage={editor.openImageModal} layout="horizontal" /> +
    "; } + [result appendString:closeItemTag]; [result appendString:closeTag]; [openListStack removeLastObject]; + closeItemTag = @"\n"; + } + + // next item is a sibling at this depth; close the previous one + if (commonPrefix > 0 && commonPrefix == targetChain.count) { + [result appendString:closeItemTag]; } for (NSUInteger k = commonPrefix; k < targetChain.count; k++) { @@ -1668,19 +1696,32 @@ + (void)syncListStackTo:(NSArray *)targetChain NSString *cssStyleString = [self prepareCssStyleString:location isOpeningTag:YES host:host]; + // the innermost open item directly follows its text content, so we can't + // append the '\n' for now, unless we are not in any nested context (k=0) + NSString *newline = k > 0 ? @"" : @"\n"; NSString *openTag = @""; if ([type isEqualToNumber:@([UnorderedListStyle getType])]) { - openTag = [NSString stringWithFormat:@"\n", cssStyleString]; + openTag = + [NSString stringWithFormat:@"%@", newline, cssStyleString]; } else if ([type isEqualToNumber:@([OrderedListStyle getType])]) { - openTag = [NSString stringWithFormat:@"\n", cssStyleString]; + openTag = + [NSString stringWithFormat:@"%@", newline, cssStyleString]; } else if ([type isEqualToNumber:@([CheckboxListStyle getType])]) { - openTag = [NSString - stringWithFormat:@"\n
      ", cssStyleString]; + openTag = [NSString stringWithFormat:@"%@
        ", + newline, cssStyleString]; } [result appendString:openTag]; [openListStack addObject:type]; + + // Moving more than one level deeper means we have no text content in those + // list elements and a proper opening
      • tag will not get appended (this + // shouldn't happen as we keep the nested list structure valid in the input + // logic), so append it here, to keep the HTML structure intact + if (k < targetChain.count - 1) { + [result appendString:@"\n
      • "]; + } } } From 750de9988fbdae31b5ec097fae49458a59570a7a Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 9 Oct 2026 14:20:17 +0200 Subject: [PATCH 11/17] fix(ios): append newline between nested lists inside a single li tag during html generation --- ios/htmlParser/HtmlParser.mm | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index 3bb295468..3f48c9fdc 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1664,9 +1664,8 @@ + (void)syncListStackTo:(NSArray *)targetChain commonPrefix++; } - // the innermost open item directly follows its text content, so we can't - // append the '\n' for now NSString *closeItemTag = @"
      • "; + BOOL closedAnyList = openListStack.count > commonPrefix; while (openListStack.count > commonPrefix) { NSNumber *type = openListStack.lastObject; @@ -1698,7 +1697,10 @@ + (void)syncListStackTo:(NSArray *)targetChain host:host]; // the innermost open item directly follows its text content, so we can't // append the '\n' for now, unless we are not in any nested context (k=0) - NSString *newline = k > 0 ? @"" : @"\n"; + // or a list has been just closed and we still have another one to open + // (more than one list inside a
      • tag) + BOOL followsItemContent = k > 0 && (k > commonPrefix || !closedAnyList); + NSString *newline = followsItemContent ? @"" : @"\n"; NSString *openTag = @""; if ([type isEqualToNumber:@([UnorderedListStyle getType])]) { From 36d3732fe8e259eabe9c90a19ff058a02f3de171 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 9 Oct 2026 14:21:38 +0200 Subject: [PATCH 12/17] fix(ios): keep the style after outdenting the list --- ios/styles/ListStyleBase.mm | 41 ++++++++++++++++++++++++++++++++++++- ios/utils/TextListsUtils.h | 4 ++++ ios/utils/TextListsUtils.mm | 11 ++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/ios/styles/ListStyleBase.mm b/ios/styles/ListStyleBase.mm index 4f4a90d4b..d4fcaeacf 100644 --- a/ios/styles/ListStyleBase.mm +++ b/ios/styles/ListStyleBase.mm @@ -277,9 +277,48 @@ - (void)modifyListNestingInRange:(NSRange)range withTyping:YES withDirtyRange:YES]; } else if ([TextListsUtils listDepthInArray:textLists] > 1) { - [self remove:paragraphRange withDirtyRange:YES]; + [self removeParentListInRange:paragraphRange]; } } } +- (void)removeParentListInRange:(NSRange)range { + NSRange actualRange = [self actualUsedRange:range]; + + [self.host.textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:actualRange + options:0 + usingBlock:^(id _Nullable existingValue, NSRange subRange, + BOOL *_Nonnull stop) { + NSMutableParagraphStyle *pStyle = + [(NSParagraphStyle *)existingValue mutableCopy]; + if (pStyle == nullptr) + return; + pStyle.textLists = [TextListsUtils + textListsByRemovingParentChainEntry:pStyle.textLists]; + [self.host.textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:subRange]; + }]; + + // keep the typing attributes in sync + NSUInteger cursor = self.host.textView.selectedRange.location; + if (cursor >= actualRange.location && cursor <= NSMaxRange(actualRange)) { + NSMutableDictionary *newTypingAttrs = + [self.host.textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *typingStyle = + [newTypingAttrs[NSParagraphStyleAttributeName] mutableCopy]; + if (typingStyle != nil) { + typingStyle.textLists = [TextListsUtils + textListsByRemovingParentChainEntry:typingStyle.textLists]; + newTypingAttrs[NSParagraphStyleAttributeName] = typingStyle; + self.host.textView.typingAttributes = newTypingAttrs; + } + } + + [self.host.attributesManager addDirtyRange:actualRange]; +} + @end diff --git a/ios/utils/TextListsUtils.h b/ios/utils/TextListsUtils.h index 4203afe7b..2f68f1812 100644 --- a/ios/utils/TextListsUtils.h +++ b/ios/utils/TextListsUtils.h @@ -67,6 +67,10 @@ + (NSArray *_Nonnull)textListsByRemovingDeepestChainEntry: (NSArray *_Nullable)existing; +// Removes the closest parent (second to last element) in the list chain. ++ (NSArray *_Nonnull)textListsByRemovingParentChainEntry: + (NSArray *_Nullable)existing; + // Replaces the deepest existing chain entry. + (NSArray *_Nonnull) textListsByReplacingDeepestChainEntry:(NSString *_Nonnull)value diff --git a/ios/utils/TextListsUtils.mm b/ios/utils/TextListsUtils.mm index 368fd272f..e4399fe6d 100644 --- a/ios/utils/TextListsUtils.mm +++ b/ios/utils/TextListsUtils.mm @@ -148,6 +148,17 @@ + (NSString *_Nullable)deepestListMarkerFormatInArray: return updated; } ++ (NSArray *_Nonnull)textListsByRemovingParentChainEntry: + (NSArray *_Nullable)existing { + NSMutableArray *chain = + [[self listChainInArray:existing] mutableCopy]; + if (chain.count < 2) { + return existing ?: @[]; + } + [chain removeObjectAtIndex:chain.count - 2]; + return [self textListsByReplacingChainWith:chain inArray:existing]; +} + + (NSArray *_Nonnull) textListsByReplacingDeepestChainEntry:(NSString *_Nonnull)value toArray:(NSArray *_Nullable) From 7d26757608923372897ca5fafab4dea51f5bb92d Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 9 Oct 2026 14:35:27 +0200 Subject: [PATCH 13/17] fix(ios): conflicting styles handling in text component --- ios/utils/StyleUtils.mm | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm index 928099766..c360a989a 100644 --- a/ios/utils/StyleUtils.mm +++ b/ios/utils/StyleUtils.mm @@ -215,17 +215,21 @@ + (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:range forHost:host]; if (conflicting.count != 0) { + // only hosts with an attributes manager (the input) track dirty ranges + BOOL withDirtyRange = + [host respondsToSelector:@selector(attributesManager)]; + for (NSNumber *type in conflicting) { StyleBase *style = host.stylesDict[type]; if ([style isParagraph]) { // for paragraph styles we can just call remove since it will pick up // proper paragraph range - [style remove:range withDirtyRange:YES]; + [style remove:range withDirtyRange:withDirtyRange]; } else { // for inline styles we have to differentiate betweeen normal and typing // attributes removal - range.length >= 1 ? [style remove:range withDirtyRange:YES] + range.length >= 1 ? [style remove:range withDirtyRange:withDirtyRange] : [style removeTyping]; } } From 277d1f90da0756d7e25df5b8d884a63515f66201 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 12 Oct 2026 13:42:34 +0200 Subject: [PATCH 14/17] refactor: new level forcing argument rename --- ios/inputHtmlParser/InputHtmlParser.mm | 10 ++--- ios/interfaces/StyleHeaders.h | 11 +++-- ios/styles/CheckboxListStyle.mm | 8 ++-- ios/styles/ListStyleBase.mm | 57 +++++++++++++------------- ios/textHtmlParser/TextHtmlParser.mm | 10 ++--- 5 files changed, 47 insertions(+), 49 deletions(-) diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index 6f59aa7d1..dbe60b30f 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -184,10 +184,10 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles // First apply the checkbox list style to the entire range with // unchecked value [cbLStyle add:styleRange - withValue:[cbLStyle getValue] - forcingNewLevel:YES - withTyping:shouldAddTypingAttr - withDirtyRange:YES]; + withValue:[cbLStyle getValue] + withNesting:YES + withTyping:shouldAddTypingAttr + withDirtyRange:YES]; if (checkboxStates && checkboxStates.count > 0) { // Then toggle checked checkboxes @@ -209,7 +209,7 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles [(ListStyleBase *)baseStyle add:styleRange withValue:[baseStyle getValue] - forcingNewLevel:YES + withNesting:YES withTyping:NO withDirtyRange:NO]; } else { diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index ade2760bc..8881df5d9 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -76,13 +76,12 @@ - (CGFloat)cumulativeHeadIndentForChain:(NSArray *)chain; - (void)modifyListNestingInRange:(NSRange)range increasing:(BOOL)increasingNesting; -- (void)addTypingWithValue:(NSString *)value - forcingNewLevel:(BOOL)forcingNewLevel; +- (void)addTypingWithValue:(NSString *)value withNesting:(BOOL)withNesting; - (void)add:(NSRange)range - withValue:(NSString *)value - forcingNewLevel:(BOOL)forcingNewLevel - withTyping:(BOOL)withTyping - withDirtyRange:(BOOL)withDirtyRange; + withValue:(NSString *)value + withNesting:(BOOL)withNesting + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange; @end @interface UnorderedListStyle : ListStyleBase diff --git a/ios/styles/CheckboxListStyle.mm b/ios/styles/CheckboxListStyle.mm index 2ac6f20e9..41e1a3061 100644 --- a/ios/styles/CheckboxListStyle.mm +++ b/ios/styles/CheckboxListStyle.mm @@ -84,10 +84,10 @@ - (void)toggleWithChecked:(BOOL)checked // the textLists. Otherwise we follow the base flow if (conflictReplaced) { [self add:actualRange - withValue:checked ? @"EnrichedCheckbox1" : @"EnrichedCheckbox0" - forcingNewLevel:YES - withTyping:YES - withDirtyRange:YES]; + withValue:checked ? @"EnrichedCheckbox1" : @"EnrichedCheckbox0" + withNesting:YES + withTyping:YES + withDirtyRange:YES]; return; } diff --git a/ios/styles/ListStyleBase.mm b/ios/styles/ListStyleBase.mm index d4fcaeacf..d40b55211 100644 --- a/ios/styles/ListStyleBase.mm +++ b/ios/styles/ListStyleBase.mm @@ -8,16 +8,16 @@ @implementation ListStyleBase - (NSArray *)textListsByAddingValue:(NSString *)value toArray: (NSArray *)existing - forcingNewLevel:(BOOL)forcingNewLevel { + withNesting:(BOOL)withNesting { NSString *deepestFormat = [TextListsUtils deepestListMarkerFormatInArray:existing]; - if (!forcingNewLevel && [deepestFormat isEqualToString:value]) { + if (!withNesting && [deepestFormat isEqualToString:value]) { return existing; } NSString *markerPrefix = [self getMarkerPrefix]; - if (!forcingNewLevel && markerPrefix != nil && deepestFormat != nil && + if (!withNesting && markerPrefix != nil && deepestFormat != nil && [deepestFormat hasPrefix:markerPrefix]) { return [TextListsUtils textListsByReplacingDeepestChainEntry:value toArray:existing]; @@ -57,17 +57,17 @@ - (void)add:(NSRange)range withTyping:(BOOL)withTyping withDirtyRange:(BOOL)withDirtyRange { [self add:range - withValue:value - forcingNewLevel:NO - withTyping:withTyping - withDirtyRange:withDirtyRange]; + withValue:value + withNesting:NO + withTyping:withTyping + withDirtyRange:withDirtyRange]; } - (void)add:(NSRange)range - withValue:(NSString *)value - forcingNewLevel:(BOOL)forcingNewLevel - withTyping:(BOOL)withTyping - withDirtyRange:(BOOL)withDirtyRange { + withValue:(NSString *)value + withNesting:(BOOL)withNesting + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange { NSRange actualRange = [self actualUsedRange:range]; [self.host.textView.textStorage @@ -84,7 +84,7 @@ - (void)add:(NSRange)range NSArray *updatedTextLists = [self textListsByAddingValue:value toArray:currentStyle.textLists - forcingNewLevel:forcingNewLevel]; + withNesting:withNesting]; if (updatedTextLists == currentStyle.textLists) { return; } @@ -98,7 +98,7 @@ - (void)add:(NSRange)range }]; if (withTyping) { - [self addTypingWithValue:value forcingNewLevel:forcingNewLevel]; + [self addTypingWithValue:value withNesting:withNesting]; } // Notify attributes manager of styling to be re-done if needed. @@ -116,10 +116,10 @@ - (void)toggle:(NSRange)range withConflictReplaced:(BOOL)conflictReplaced { return; } [self add:[self actualUsedRange:range] - withValue:[self getValue] - forcingNewLevel:YES - withTyping:YES - withDirtyRange:YES]; + withValue:[self getValue] + withNesting:YES + withTyping:YES + withDirtyRange:YES]; } - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { @@ -151,11 +151,10 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { } - (void)addTypingWithValue:(NSString *)value { - [self addTypingWithValue:value forcingNewLevel:NO]; + [self addTypingWithValue:value withNesting:NO]; } -- (void)addTypingWithValue:(NSString *)value - forcingNewLevel:(BOOL)forcingNewLevel { +- (void)addTypingWithValue:(NSString *)value withNesting:(BOOL)withNesting { NSMutableDictionary *newTypingAttrs = [self.host.textView.typingAttributes mutableCopy]; @@ -167,7 +166,7 @@ - (void)addTypingWithValue:(NSString *)value NSArray *updatedTextLists = [self textListsByAddingValue:value toArray:currentStyle.textLists - forcingNewLevel:forcingNewLevel]; + withNesting:withNesting]; if (updatedTextLists == currentStyle.textLists) { return; } @@ -265,17 +264,17 @@ - (void)modifyListNestingInRange:(NSRange)range for (NSUInteger i = 0; i < currentDepth; i++) { [self add:paragraphRange - withValue:prevChain[i].markerFormat - forcingNewLevel:YES - withTyping:YES - withDirtyRange:NO]; + withValue:prevChain[i].markerFormat + withNesting:YES + withTyping:YES + withDirtyRange:NO]; } [self add:paragraphRange - withValue:ownFormat - forcingNewLevel:YES - withTyping:YES - withDirtyRange:YES]; + withValue:ownFormat + withNesting:YES + withTyping:YES + withDirtyRange:YES]; } else if ([TextListsUtils listDepthInArray:textLists] > 1) { [self removeParentListInRange:paragraphRange]; } diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm index 24b3f85bb..daca289f2 100644 --- a/ios/textHtmlParser/TextHtmlParser.mm +++ b/ios/textHtmlParser/TextHtmlParser.mm @@ -125,10 +125,10 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { CheckboxListStyle *cbStyle = (CheckboxListStyle *)style; [cbStyle add:styleRange - withValue:[cbStyle getValue] - forcingNewLevel:YES - withTyping:NO - withDirtyRange:NO]; + withValue:[cbStyle getValue] + withNesting:YES + withTyping:NO + withDirtyRange:NO]; if (checkboxStates && checkboxStates.count > 0) { for (NSNumber *key in checkboxStates) { @@ -144,7 +144,7 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { } else if ([style isList]) { [(ListStyleBase *)style add:styleRange withValue:[style getValue] - forcingNewLevel:YES + withNesting:YES withTyping:NO withDirtyRange:NO]; } else { From 9139612395d8ceca13313da44d1199ee13040148 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 12 Oct 2026 15:35:40 +0200 Subject: [PATCH 15/17] refactor: comments clean up --- ios/EnrichedTextInputView.mm | 17 ----------------- src/types.ts | 5 ++--- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c65ec2b92..09dc4f5ca 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -2156,23 +2156,6 @@ - (void)textViewDidChangeSelection:(UITextView *)textView { // manage selection changes [self manageSelectionBasedChanges]; - - // DEBUG: log the textLists (list nesting chain + any other markers) at the - // current selection. - if (textView.textStorage.length > 0) { - NSUInteger debugIndex = - MIN(textView.selectedRange.location, textView.textStorage.length - 1); - NSParagraphStyle *debugPStyle = - [textView.textStorage attribute:NSParagraphStyleAttributeName - atIndex:debugIndex - effectiveRange:NULL]; - NSMutableArray *debugMarkers = [NSMutableArray array]; - for (NSTextList *debugList in debugPStyle.textLists) { - [debugMarkers addObject:debugList.markerFormat]; - } - NSLog(@"[EnrichedTextInput] textLists at selection %@: %@", - NSStringFromRange(textView.selectedRange), debugMarkers); - } } // this function isn't called always when some text changes (for example setting diff --git a/src/types.ts b/src/types.ts index 606800bf7..bff099b95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -529,7 +529,7 @@ export interface EnrichedTextInputInstance { /** * Increases the nesting depth of the list item(s) at the current selection - * by one. No-op when the selection isn't inside a list. + * by one. * * @platform ios */ @@ -537,8 +537,7 @@ export interface EnrichedTextInputInstance { /** * Decreases the nesting depth of the list item(s) at the current selection - * by one, down to a minimum of the top level (depth `0`). No-op when the - * selection isn't inside a list. + * by one, down to a minimum of the top level (depth `0`). * * @platform ios */ From 4ad8b01f351f18413a2db097ae3ec7ddc8351535 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 12 Oct 2026 16:02:26 +0200 Subject: [PATCH 16/17] fix: usage of a stale active styles state --- ios/EnrichedTextInputView.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 09dc4f5ca..c11e9c973 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1243,7 +1243,8 @@ - (bool)isStyle:(StyleType)type activeInMap:(NSDictionary *)styleMap { // returns the list nesting depth at the current selection, // where 0 means no present nesting - (NSInteger)listDepthAtSelectionForType:(StyleType)type { - if (![self isStyleActive:type] || ![self.stylesDict[@(type)] isList]) { + if (![self.stylesDict[@(type)] isList] || + ![self.stylesDict[@(type)] detect:textView.selectedRange]) { return 0; } From dd32d6b90d82e262d421494e31df766c237b3461 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 12 Oct 2026 16:11:07 +0200 Subject: [PATCH 17/17] fix: web and android interface stubs --- .../enriched/textinput/EnrichedTextInputViewManager.kt | 6 ++++++ .../swmansion/enriched/textinput/utils/EnrichedSpanState.kt | 5 +++++ src/web/EnrichedTextInput.tsx | 4 ++-- src/web/tiptapWatchers/useOnChangeState.ts | 6 +++--- 4 files changed, 16 insertions(+), 5 deletions(-) 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 8b43f3280..5fd70c6fe 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -437,6 +437,12 @@ class EnrichedTextInputViewManager : view?.toggleCheckboxListItem(isChecked) } + // TODO: nested lists to be implemented on Android, no-op placeholder for now + override fun indentList(view: EnrichedTextInputView?) = Unit + + // TODO: nested lists to be implemented on Android, no-op placeholder for now + override fun outdentList(view: EnrichedTextInputView?) = Unit + override fun addLink( view: EnrichedTextInputView?, start: Int, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index ec4e27285..33f44da99 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -309,6 +309,11 @@ class EnrichedSpanState( val isConflicting = conflictingList?.any { activeStyles.contains(it) } ?: false state.putBoolean("isConflicting", isConflicting) + // TODO: nested lists to be implemented on Android, depth is a placeholder (always 0) + if (type == EnrichedSpans.ORDERED_LIST || type == EnrichedSpans.UNORDERED_LIST || type == EnrichedSpans.CHECKBOX_LIST) { + state.putInt("depth", 0) + } + return state } diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 6a365807c..dfc3acf1b 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -401,9 +401,9 @@ export const EnrichedTextInput = ({ runFocused(editor, (c) => c.toggleUnorderedList()), toggleCheckboxList: (checked: boolean) => runFocused(editor, (c) => c.toggleCheckboxList(checked)), - // TEMP: to match types + // TODO: nested lists to be implemented on web, placeholder for now indentList: () => {}, - // TEMP: to match types + // TODO: nested lists to be implemented on web, placeholder for now outdentList: () => {}, setLink: (start: number, end: number, text: string, url: string) => runSafelyInEditor(editor, (e) => setLink(e, start, end, text, url)), diff --git a/src/web/tiptapWatchers/useOnChangeState.ts b/src/web/tiptapWatchers/useOnChangeState.ts index f545baa3b..a479d182c 100644 --- a/src/web/tiptapWatchers/useOnChangeState.ts +++ b/src/web/tiptapWatchers/useOnChangeState.ts @@ -82,17 +82,17 @@ function buildState( ), orderedList: { ...paragraphFormat(editor.isActive('orderedList')), - // TEMP: to match types + // TODO: nested lists to be implemented on web, placeholder for now depth: 0, }, unorderedList: { ...paragraphFormat(editor.isActive('unorderedList')), - // TEMP: to match types + // TODO: nested lists to be implemented on web, placeholder for now depth: 0, }, checkboxList: { ...paragraphFormat(editor.isActive('checkboxList')), - // TEMP: to match types + // TODO: nested lists to be implemented on web, placeholder for now depth: 0, }, link: inlineFormat(