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/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 20109e390..4d9ec5918 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -85,6 +85,14 @@ const STYLE_ITEMS = [ name: 'checkbox-list', icon: 'check-square-o', }, + { + name: 'indent', + icon: 'indent', + }, + { + name: 'outdent', + icon: 'outdent', + }, { name: 'align-left', icon: 'align-left', @@ -171,6 +179,12 @@ export const Toolbar: FC = ({ // Make checkbox checked by default editorRef.current?.toggleCheckboxList(true); break; + case 'indent': + editorRef.current?.indentList(); + break; + case 'outdent': + editorRef.current?.outdentList(); + break; case 'link': onOpenLinkModal(); break; diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index 2b9d44b0b..c8d71f7ac 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -12,6 +12,11 @@ export const DEFAULT_STYLE_STATE = { isBlocking: false, }; +export const DEFAULT_LIST_STYLE_STATE = { + ...DEFAULT_STYLE_STATE, + depth: 0, +}; + export const DEFAULT_STYLES: StylesState = { bold: DEFAULT_STYLE_STATE, italic: DEFAULT_STYLE_STATE, @@ -26,12 +31,12 @@ export const DEFAULT_STYLES: StylesState = { h6: DEFAULT_STYLE_STATE, blockQuote: DEFAULT_STYLE_STATE, codeBlock: DEFAULT_STYLE_STATE, - orderedList: DEFAULT_STYLE_STATE, - unorderedList: DEFAULT_STYLE_STATE, + orderedList: DEFAULT_LIST_STYLE_STATE, + unorderedList: DEFAULT_LIST_STYLE_STATE, link: DEFAULT_STYLE_STATE, image: DEFAULT_STYLE_STATE, mention: DEFAULT_STYLE_STATE, - checkboxList: DEFAULT_STYLE_STATE, + checkboxList: DEFAULT_LIST_STYLE_STATE, alignment: 'auto', }; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c44d4d9dd..c11e9c973 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -13,11 +13,13 @@ #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" #import "utils/TextBlockTapGestureRecognizer.h" #import "utils/TextInsertionUtils.h" +#import "utils/TextListsUtils.h" #import "utils/WordsUtils.h" #import "utils/ZeroWidthSpaceUtils.h" #import @@ -33,6 +35,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 +72,7 @@ @implementation EnrichedTextInputView { NSString *_submitBehavior; NSDictionary *_capturedAttributesBeforeChange; NSString *_recentlyEmittedAlignment; + NSMutableDictionary *_recentlyEmittedListDepths; } @synthesize blockEmitting = blockEmitting; @@ -133,6 +144,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 +1033,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 +1075,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 +1167,7 @@ - (void)tryUpdatingActiveStyles { _activeStyles = newActiveStyles; _blockedStyles = newBlockedStyles; _recentlyEmittedAlignment = currentAlignment; + _recentlyEmittedListDepths = newListDepths; emitter->onChangeState( {.bold = GET_STYLE_STATE([BoldStyle getType]), @@ -1157,12 +1183,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 +1240,28 @@ - (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.stylesDict[@(type)] isList] || + ![self.stylesDict[@(type)] detect:textView.selectedRange]) { + 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 +1328,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"]) { @@ -1510,10 +1562,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]; } } @@ -1526,14 +1581,62 @@ - (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]; } } +- (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 @@ -1755,6 +1858,9 @@ - (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]; @@ -1906,12 +2012,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]}}); } } 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..3f48c9fdc 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" @@ -207,9 +208,86 @@ + (NSDictionary *)prepareCheckboxListStyleValue:(NSValue *)rangeValue return statesInRange; } +// Moves lists nested inside list items (
  • a
      ...
  • ) right after +// them (
  • a
    • ...
    ). This makes the structure more flat and more +// accessible for the parser logic, as the whole parser was not designed to +// process text-content mixed with list tags inside a single
  • . ++ (NSString *)liftNestedListsOutOfListItems:(NSString *)html { + NSMutableString *output = [NSMutableString stringWithCapacity:html.length]; + // per open list: whether its current
  • is still open + NSMutableArray *itemOpenStack = [NSMutableArray array]; + NSCharacterSet *tagNameSet = [NSCharacterSet alphanumericCharacterSet]; + NSCharacterSet *whitespaceAndNewlineSet = + [NSCharacterSet whitespaceAndNewlineCharacterSet]; + + NSUInteger i = 0; + while (i < html.length) { + NSRange tagEnd = NSMakeRange(NSNotFound, 0); + if ([html characterAtIndex:i] == '<') { + tagEnd = [html rangeOfString:@">" + options:0 + range:NSMakeRange(i, html.length - i)]; + } + if (tagEnd.location == NSNotFound) { + NSRange nextTag = + [html rangeOfString:@"<" + options:0 + range:NSMakeRange(i + 1, html.length - i - 1)]; + NSUInteger runEnd = + nextTag.location == NSNotFound ? html.length : nextTag.location; + [output + appendString:[html substringWithRange:NSMakeRange(i, runEnd - i)]]; + i = runEnd; + continue; + } + + NSString *tag = + [html substringWithRange:NSMakeRange(i, tagEnd.location - i + 1)]; + i = tagEnd.location + 1; + + BOOL closing = tag.length > 1 && [tag characterAtIndex:1] == '/'; + NSUInteger nameStart = closing ? 2 : 1; + NSUInteger nameEnd = nameStart; + while (nameEnd < tag.length && + [tagNameSet characterIsMember:[tag characterAtIndex:nameEnd]]) { + nameEnd++; + } + NSString *name = + [[tag substringWithRange:NSMakeRange(nameStart, nameEnd - nameStart)] + lowercaseString]; + BOOL isList = [name isEqualToString:@"ul"] || [name isEqualToString:@"ol"]; + + if (isList && !closing) { + // a list nested inside an item; close the item before it + if (itemOpenStack.lastObject.boolValue) { + [output appendString:@"
  • "]; + itemOpenStack[itemOpenStack.count - 1] = @NO; + } + [itemOpenStack addObject:@NO]; + } else if (isList && closing) { + if (itemOpenStack.count > 0) { + [itemOpenStack removeLastObject]; + } + } else if ([name isEqualToString:@"li"] && itemOpenStack.count > 0) { + if (closing && !itemOpenStack.lastObject.boolValue) { + // the item has already been closed before its nested list, + // skip appending the tag + continue; + } + itemOpenStack[itemOpenStack.count - 1] = @(!closing); + } + + [output appendString:tag]; + } + + return output; +} + + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html useHtmlNormalizer:(BOOL)useHtmlNormalizer { - NSString *htmlWithoutSpaces = [self stripExtraWhiteSpacesAndNewlines:html]; + NSString *htmlWithoutSpaces = [self + stripExtraWhiteSpacesAndNewlines:[self + liftNestedListsOutOfListItems:html]]; NSString *fixedHtml = nullptr; if (htmlWithoutSpaces.length >= 13) { @@ -424,7 +502,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 +553,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 +597,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 +636,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 +682,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 +938,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 +1001,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 +1040,80 @@ + (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]; + // the item is closed by syncListStackTo, as nested lists may + // still follow + if (checked) { + [result appendString:@"\n
  • "]; + } else { + [result appendString:@"\n
  • "]; + } } else { - [result appendString:@"\n
  • "]; + [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 { @@ -976,9 +1124,11 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range sortDescriptorWithKey:@"intValue" ascending:NO] ]]; - // append closing tags + // append closing tags; list items are closed by syncListStackTo, as + // nested lists may still follow for (NSNumber *style in sortedEndedStyles) { - if ([style isEqualToNumber:@([ImageStyle getType])]) { + if ([style isEqualToNumber:@([ImageStyle getType])] || + [self isListStyleType:style]) { continue; } NSString *tagContent = [self tagContentForStyle:style @@ -1022,20 +1172,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 +1194,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 +1207,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 +1225,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]]; } @@ -1236,9 +1349,10 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range sortDescriptorWithKey:@"intValue" ascending:NO] ]]; - // append closing tags + // append closing tags; list items are closed by syncListStackTo for (NSNumber *style in sortedEndedStyles) { - if ([style isEqualToNumber:@([ImageStyle getType])]) { + if ([style isEqualToNumber:@([ImageStyle getType])] || + [self isListStyleType:style]) { continue; } NSString *tagContent = @@ -1249,22 +1363,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 +1388,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 +1404,6 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range inCodeBlock = NO; [result appendString:@"\n"]; } - if (inCheckboxList) { - inCheckboxList = NO; - [result appendString:@"\n"]; - } } [result appendString:@"\n"]; @@ -1498,4 +1605,126 @@ + (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; +} + ++ (BOOL)isListStyleType:(NSNumber *)type { + return [type isEqualToNumber:@([UnorderedListStyle getType])] || + [type isEqualToNumber:@([OrderedListStyle getType])] || + [type isEqualToNumber:@([CheckboxListStyle getType])]; +} + +// 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. +// Every list level on the stack has its
  • left open, so that a nested list +// can be placed inside the item it belongs to (
  • a
    • b
  • ). +// The items are closed only once a paragraph at the same or a shallower depth +// (or a non-list one) follows. ++ (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++; + } + + NSString *closeItemTag = @""; + BOOL closedAnyList = openListStack.count > 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: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++) { + NSNumber *type = targetChain[k]; + 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) + // 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])]) { + openTag = + [NSString stringWithFormat:@"%@", newline, cssStyleString]; + } else if ([type isEqualToNumber:@([OrderedListStyle getType])]) { + openTag = + [NSString stringWithFormat:@"%@", newline, cssStyleString]; + } else if ([type isEqualToNumber:@([CheckboxListStyle getType])]) { + 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
    • "]; + } + } +} + @end diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index 8dec7819e..dbe60b30f 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] + withNesting:YES + withTyping:shouldAddTypingAttr + withDirtyRange:YES]; if (checkboxStates && checkboxStates.count > 0) { // Then toggle checked checkboxes @@ -182,6 +200,18 @@ - (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] + withNesting: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..337665df1 100644 --- a/ios/interfaces/StyleBase.h +++ b/ios/interfaces/StyleBase.h @@ -12,12 +12,14 @@ - (NSString *)getValue; - (NSString *)getMarkerPrefix; - (BOOL)isParagraph; +- (BOOL)isList; - (BOOL)needsZWS; - (BOOL)appliesStylingToTyping; - (NSInteger)stylePriority; - (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; @@ -28,6 +30,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..816b48ea5 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; } @@ -63,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]; @@ -268,6 +276,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..8881df5d9 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -72,14 +72,28 @@ @interface H6Style : HeadingStyleBase @end -@interface UnorderedListStyle : StyleBase +@interface ListStyleBase : StyleBase +- (CGFloat)cumulativeHeadIndentForChain:(NSArray *)chain; +- (void)modifyListNestingInRange:(NSRange)range + increasing:(BOOL)increasingNesting; +- (void)addTypingWithValue:(NSString *)value withNesting:(BOOL)withNesting; +- (void)add:(NSRange)range + withValue:(NSString *)value + withNesting:(BOOL)withNesting + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange; @end -@interface OrderedListStyle : StyleBase +@interface UnorderedListStyle : ListStyleBase @end -@interface CheckboxListStyle : StyleBase -- (void)toggleWithChecked:(BOOL)checked range:(NSRange)range; +@interface OrderedListStyle : ListStyleBase +@end + +@interface CheckboxListStyle : ListStyleBase +- (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 9c2a680d0..41e1a3061 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,16 +74,23 @@ - (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 { +- (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" + withNesting:YES + withTyping:YES + withDirtyRange:YES]; + return; + } + BOOL isPresent = [self detect:actualRange]; if (isPresent) { @@ -109,15 +114,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 +135,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 +158,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..d40b55211 --- /dev/null +++ b/ios/styles/ListStyleBase.mm @@ -0,0 +1,323 @@ +#import "EnrichedTextInputView.h" +#import "interfaces/StyleHeaders.h" +#import "utils/RangeUtils.h" +#import "utils/TextListsUtils.h" + +@implementation ListStyleBase + +- (NSArray *)textListsByAddingValue:(NSString *)value + toArray: + (NSArray *)existing + withNesting:(BOOL)withNesting { + NSString *deepestFormat = + [TextListsUtils deepestListMarkerFormatInArray:existing]; + + if (!withNesting && [deepestFormat isEqualToString:value]) { + return existing; + } + + NSString *markerPrefix = [self getMarkerPrefix]; + if (!withNesting && markerPrefix != nil && deepestFormat != nil && + [deepestFormat hasPrefix:markerPrefix]) { + return [TextListsUtils textListsByReplacingDeepestChainEntry:value + toArray:existing]; + } + + return [TextListsUtils textListsByAppendingToChain:value toArray:existing]; +} + +- (BOOL)styleCondition:(id)value range:(NSRange)range { + NSParagraphStyle *pStyle = (NSParagraphStyle *)value; + if (pStyle == nil) { + return NO; + } + + // 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 + withNesting:NO + withTyping:withTyping + withDirtyRange:withDirtyRange]; +} + +- (void)add:(NSRange)range + withValue:(NSString *)value + withNesting:(BOOL)withNesting + withTyping:(BOOL)withTyping + 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) { + NSParagraphStyle *currentStyle = + (NSParagraphStyle *)existingValue; + if (currentStyle == nil) + return; + + NSArray *updatedTextLists = + [self textListsByAddingValue:value + toArray:currentStyle.textLists + withNesting:withNesting]; + if (updatedTextLists == currentStyle.textLists) { + return; + } + + NSMutableParagraphStyle *pStyle = [currentStyle mutableCopy]; + pStyle.textLists = updatedTextLists; + [self.host.textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:subRange]; + }]; + + if (withTyping) { + [self addTypingWithValue:value withNesting:withNesting]; + } + + // Notify attributes manager of styling to be re-done if needed. + if (withDirtyRange) { + [self.host.attributesManager addDirtyRange:actualRange]; + } +} + +- (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] + withNesting:YES + withTyping:YES + withDirtyRange:YES]; +} + +- (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 { + [self addTypingWithValue:value withNesting:NO]; +} + +- (void)addTypingWithValue:(NSString *)value withNesting:(BOOL)withNesting { + NSMutableDictionary *newTypingAttrs = + [self.host.textView.typingAttributes mutableCopy]; + + NSParagraphStyle *currentStyle = + newTypingAttrs[NSParagraphStyleAttributeName]; + if (currentStyle == nil) + return; + + NSArray *updatedTextLists = + [self textListsByAddingValue:value + toArray:currentStyle.textLists + withNesting:withNesting]; + 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 || 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 + withNesting:YES + withTyping:YES + withDirtyRange:NO]; + } + + [self add:paragraphRange + withValue:ownFormat + withNesting:YES + withTyping:YES + withDirtyRange:YES]; + } else if ([TextListsUtils listDepthInArray:textLists] > 1) { + [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/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..daca289f2 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] + withNesting:YES + withTyping:NO + withDirtyRange:NO]; if (checkboxStates && checkboxStates.count > 0) { for (NSNumber *key in checkboxStates) { @@ -124,6 +141,12 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { } } } + } else if ([style isList]) { + [(ListStyleBase *)style add:styleRange + withValue:[style getValue] + withNesting: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/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/ParagraphAttributesUtils.mm b/ios/utils/ParagraphAttributesUtils.mm index a8b6d3f3e..6fc0034c3 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -86,6 +86,19 @@ + (BOOL)handleBackspaceInRange:(NSRange)range withTyping:YES withDirtyRange:YES]; } + + // restore the full nested list chain in the typing attributes + NSArray *savedTextLists = currentParaStyle.textLists; + if (savedTextLists.count > 0) { + NSMutableDictionary *typingAttrs = + [typedInput->textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *typingPStyle = + [typingAttrs[NSParagraphStyleAttributeName] mutableCopy] + ?: [[NSMutableParagraphStyle alloc] init]; + typingPStyle.textLists = savedTextLists; + typingAttrs[NSParagraphStyleAttributeName] = typingPStyle; + typedInput->textView.typingAttributes = typingAttrs; + } return YES; } } 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..c360a989a 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 @@ -206,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]; } } diff --git a/ios/utils/TextListsUtils.h b/ios/utils/TextListsUtils.h index 1ed648402..2f68f1812 100644 --- a/ios/utils/TextListsUtils.h +++ b/ios/utils/TextListsUtils.h @@ -37,4 +37,54 @@ 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; + +// 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; + +// 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; + +// 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 + 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 c5d39b75c..e4399fe6d 100644 --- a/ios/utils/TextListsUtils.mm +++ b/ios/utils/TextListsUtils.mm @@ -90,4 +90,129 @@ + (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; +} + ++ (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; +} + ++ (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)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) + existing { + NSArray *withoutDeepest = + [self textListsByRemovingDeepestChainEntry:existing]; + 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 diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index cfe12104c..8324c7f26 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); }, + indentList: () => { + Commands.indentList(nullthrows(nativeRef.current)); + }, + outdentList: () => { + 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..bff099b95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -362,11 +362,13 @@ export interface OnChangeStateEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: number; }; unorderedList: { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: number; }; link: { isActive: boolean; @@ -387,6 +389,7 @@ export interface OnChangeStateEvent { isActive: boolean; isConflicting: boolean; isBlocking: boolean; + depth: number; }; alignment: string; } @@ -524,6 +527,22 @@ export interface EnrichedTextInputInstance { */ toggleCheckboxList: (checked: boolean) => void; + /** + * Increases the nesting depth of the list item(s) at the current selection + * by one. + * + * @platform ios + */ + indentList: () => 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`). + * + * @platform ios + */ + outdentList: () => void; + /** * Inserts or updates a hyperlink over the character range `[start, end)`. * diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 1002b647c..dfc3acf1b 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -401,6 +401,10 @@ export const EnrichedTextInput = ({ runFocused(editor, (c) => c.toggleUnorderedList()), toggleCheckboxList: (checked: boolean) => runFocused(editor, (c) => c.toggleCheckboxList(checked)), + // TODO: nested lists to be implemented on web, placeholder for now + indentList: () => {}, + // 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)), removeLink: (start: number, end: number) => diff --git a/src/web/tiptapWatchers/useOnChangeState.ts b/src/web/tiptapWatchers/useOnChangeState.ts index cdee4b57b..a479d182c 100644 --- a/src/web/tiptapWatchers/useOnChangeState.ts +++ b/src/web/tiptapWatchers/useOnChangeState.ts @@ -80,9 +80,21 @@ function buildState( editor.isActive('codeBlock'), editor.isActive('link') ), - orderedList: paragraphFormat(editor.isActive('orderedList')), - unorderedList: paragraphFormat(editor.isActive('unorderedList')), - checkboxList: paragraphFormat(editor.isActive('checkboxList')), + orderedList: { + ...paragraphFormat(editor.isActive('orderedList')), + // TODO: nested lists to be implemented on web, placeholder for now + depth: 0, + }, + unorderedList: { + ...paragraphFormat(editor.isActive('unorderedList')), + // TODO: nested lists to be implemented on web, placeholder for now + depth: 0, + }, + checkboxList: { + ...paragraphFormat(editor.isActive('checkboxList')), + // TODO: nested lists to be implemented on web, placeholder for now + depth: 0, + }, link: inlineFormat( 'link', editor.isActive('code') ||