diff --git a/MacDown/Code/Application/MPToolbarController.m b/MacDown/Code/Application/MPToolbarController.m index de229773..525ecc6a 100644 --- a/MacDown/Code/Application/MPToolbarController.m +++ b/MacDown/Code/Application/MPToolbarController.m @@ -70,6 +70,7 @@ - (void)setupToolbarItems [self toolbarItemWithIdentifier:@"heading3" label:NSLocalizedString(@"Heading 3", @"Heading 3 toolbar button") icon:@"ToolbarIconHeading3" action:@selector(convertToH3:)] ] ], + [self toolbarItemWithIdentifier:@"headings-navigator" label:NSLocalizedString(@"Navigate Headings", @"Headings navigator toolbar button") icon:NSImageNameListViewTemplate action:@selector(showHeadingsNavigator:)], [self toolbarItemGroupWithIdentifier:@"list-group" separated:YES label:NSLocalizedString(@"Ordered/Unordered List", @"") items:@[ [self toolbarItemWithIdentifier:@"unordered-list" label:NSLocalizedString(@"Unordered List", @"Unordered list toolbar button") icon:@"ToolbarIconUnorderedList" action:@selector(toggleUnorderedList:)], [self toolbarItemWithIdentifier:@"ordered-list" label:NSLocalizedString(@"Ordered List", @"Ordered list toolbar button") icon:@"ToolbarIconOrderedList" action:@selector(toggleOrderedList:)] @@ -157,7 +158,7 @@ - (void)selectedToolbarItemGroupItem:(NSSegmentedControl *)sender // Add space after the specified toolbar item indices int spaceAfterIndices[] = {}; // No space in the default set - int flexibleSpaceAfterIndices[] = {2, 3, 5, 7, 11}; + int flexibleSpaceAfterIndices[] = {3, 4, 6, 8, 12}; // Bounds checking to prevent buffer overflow when accessing C arrays // Empty spaceAfterIndices array must not be accessed (count = 0) diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 312f5c09..f7761ebf 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -19,6 +19,8 @@ @property (nonatomic, readwrite) NSString *markdown; @property (nonatomic, readonly) NSString *html; ++ (NSArray *)headingsInMarkdown:(NSString *)markdown; + /** * Toggle the checkbox at the specified index in the markdown source. * Unchecked checkboxes ([ ]) become checked ([x]), and vice versa. diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 75cc0fea..e08b7d63 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -899,6 +899,10 @@ - (BOOL)validateUserInterfaceItem:(id)item return NO; } } + else if (action == @selector(showHeadingsNavigator:)) + { + return self.editor != nil; + } return result; } @@ -1995,6 +1999,49 @@ - (IBAction)toggleEditorPane:(id)sender [self toggleSplitterCollapsingEditorPane:YES]; } +- (IBAction)showHeadingsNavigator:(id)sender +{ + NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString( + @"Headings", @"Headings navigator menu title")]; + NSArray *headings = [MPDocument headingsInMarkdown:self.editor.string]; + + if (!headings.count) + { + NSMenuItem *item = [[NSMenuItem alloc] + initWithTitle:NSLocalizedString(@"No Headings", @"empty headings navigator") + action:nil keyEquivalent:@""]; + item.enabled = NO; + [menu addItem:item]; + } + + for (NSDictionary *heading in headings) + { + NSString *title = heading[@"title"] ?: @""; + NSInteger level = [heading[@"level"] integerValue]; + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title + action:@selector(jumpToHeading:) + keyEquivalent:@""]; + item.target = self; + item.representedObject = heading[@"location"]; + item.indentationLevel = MAX(0, MIN(5, level - 1)); + [menu addItem:item]; + } + + NSView *view = [sender isKindOfClass:[NSView class]] ? sender : self.editor; + NSPoint point = NSMakePoint(0.0, NSHeight(view.bounds)); + [menu popUpMenuPositioningItem:nil atLocation:point inView:view]; +} + +- (void)jumpToHeading:(NSMenuItem *)sender +{ + NSUInteger location = [sender.representedObject unsignedIntegerValue]; + location = MIN(location, self.editor.string.length); + NSRange range = NSMakeRange(location, 0); + self.editor.selectedRange = range; + [self.editor scrollRangeToVisible:range]; + [self.windowForSheet makeFirstResponder:self.editor]; +} + - (IBAction)render:(id)sender { [self.renderer parseAndRenderLater]; @@ -3018,6 +3065,110 @@ - (void)document:(NSDocument *)doc didPrint:(BOOL)ok context:(void *)context #pragma mark - Interactive Checkbox Support (Issue #269) ++ (NSArray *)headingsInMarkdown:(NSString *)markdown +{ + if (!markdown.length) + return @[]; + + static NSRegularExpression *atxRegex = nil; + static NSRegularExpression *setextRegex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + atxRegex = [NSRegularExpression + regularExpressionWithPattern:@"^[ ]{0,3}(#{1,6})[ \\t]+(.+?)[ \\t#]*$" + options:0 error:NULL]; + setextRegex = [NSRegularExpression + regularExpressionWithPattern:@"^[ ]{0,3}(=+|-+)[ \\t]*$" + options:0 error:NULL]; + }); + + NSMutableArray *headings = [NSMutableArray array]; + NSArray *lines = [markdown componentsSeparatedByString:@"\n"]; + NSCharacterSet *trimSet = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + BOOL inFence = NO; + unichar fenceCharacter = 0; + NSUInteger characterLocation = 0; + + for (NSUInteger index = 0; index < lines.count; index++) + { + NSString *line = lines[index]; + NSString *trimmedLine = [line stringByTrimmingCharactersInSet:trimSet]; + NSUInteger leadingSpaces = 0; + while (leadingSpaces < line.length && + [line characterAtIndex:leadingSpaces] == ' ') + { + leadingSpaces++; + } + BOOL canBeMarkdownBlock = leadingSpaces < 4; + + if (canBeMarkdownBlock && trimmedLine.length >= 3) + { + unichar first = [trimmedLine characterAtIndex:0]; + if (first == '`' || first == '~') + { + NSUInteger fenceLength = 0; + while (fenceLength < trimmedLine.length && + [trimmedLine characterAtIndex:fenceLength] == first) + { + fenceLength++; + } + if (fenceLength >= 3 && (!inFence || first == fenceCharacter)) + { + inFence = !inFence; + fenceCharacter = inFence ? first : 0; + characterLocation += line.length + 1; + continue; + } + } + } + + if (inFence) + { + characterLocation += line.length + 1; + continue; + } + + NSTextCheckingResult *atxMatch = + [atxRegex firstMatchInString:line options:0 + range:NSMakeRange(0, line.length)]; + if (atxMatch) + { + NSString *levelText = [line substringWithRange:[atxMatch rangeAtIndex:1]]; + NSString *title = [line substringWithRange:[atxMatch rangeAtIndex:2]]; + title = [title stringByTrimmingCharactersInSet:trimSet]; + if (title.length) + { + [headings addObject:@{ + @"level": @(levelText.length), + @"title": title, + @"location": @(characterLocation), + }]; + } + } + else if (canBeMarkdownBlock && trimmedLine.length && index + 1 < lines.count) + { + NSString *nextLine = lines[index + 1]; + NSTextCheckingResult *setextMatch = + [setextRegex firstMatchInString:nextLine options:0 + range:NSMakeRange(0, nextLine.length)]; + if (setextMatch) + { + NSString *marker = [nextLine substringWithRange: + [setextMatch rangeAtIndex:1]]; + [headings addObject:@{ + @"level": @([marker hasPrefix:@"="] ? 1 : 2), + @"title": trimmedLine, + @"location": @(characterLocation), + }]; + } + } + + characterLocation += line.length + 1; + } + + return [headings copy]; +} + /** * Handle the checkbox toggle URL from the preview. * URL format: x-macdown-checkbox://toggle/ diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index b5ee90f1..f5b5e815 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -367,6 +367,12 @@ + + + + + + diff --git a/MacDownTests/MPDocumentLifecycleTests.m b/MacDownTests/MPDocumentLifecycleTests.m index 7691dc0d..775bacb7 100644 --- a/MacDownTests/MPDocumentLifecycleTests.m +++ b/MacDownTests/MPDocumentLifecycleTests.m @@ -864,4 +864,29 @@ - (void)testRendersNotBlockedWhenAlreadyRenderingInWebIsNO } } +#pragma mark - Headings Navigator Tests + +- (void)testHeadingsInMarkdownParsesATXAndSetextHeadings +{ + NSString *markdown = @"# One\n\nTwo\n---\n\n### Three\n"; + NSArray *headings = [MPDocument headingsInMarkdown:markdown]; + + XCTAssertEqual(headings.count, 3); + XCTAssertEqualObjects(headings[0][@"title"], @"One"); + XCTAssertEqualObjects(headings[0][@"level"], @1); + XCTAssertEqualObjects(headings[1][@"title"], @"Two"); + XCTAssertEqualObjects(headings[1][@"level"], @2); + XCTAssertEqualObjects(headings[2][@"title"], @"Three"); + XCTAssertEqualObjects(headings[2][@"level"], @3); +} + +- (void)testHeadingsInMarkdownSkipsFencedCodeBlocks +{ + NSString *markdown = @"```markdown\n# Not a heading\n```\n\n# Real\n"; + NSArray *headings = [MPDocument headingsInMarkdown:markdown]; + + XCTAssertEqual(headings.count, 1); + XCTAssertEqualObjects(headings.firstObject[@"title"], @"Real"); +} + @end diff --git a/MacDownTests/MPToolbarControllerTests.m b/MacDownTests/MPToolbarControllerTests.m index d87b84af..b0eeaf40 100644 --- a/MacDownTests/MPToolbarControllerTests.m +++ b/MacDownTests/MPToolbarControllerTests.m @@ -86,6 +86,7 @@ - (void)testAllowedIdentifiersContainAllCustomItems @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", @"list-group", @"blockquote", @"code", @@ -107,10 +108,10 @@ - (void)testAllowedIdentifiersContainAllCustomItems - (void)testAllowedIdentifiersTotalCount { NSArray *allowed = [self.controller toolbarAllowedItemIdentifiers:nil]; - // 13 custom + 3 system (flexible space, space, separator) - XCTAssertEqual(allowed.count, 16, - @"Allowed identifiers should have 16 items: " - @"13 custom + flexible space + space + separator"); + // 14 custom + 3 system (flexible space, space, separator) + XCTAssertEqual(allowed.count, 17, + @"Allowed identifiers should have 17 items: " + @"14 custom + flexible space + space + separator"); } - (void)testAllowedIdentifiersOrderCustomItemsFirst @@ -121,6 +122,7 @@ - (void)testAllowedIdentifiersOrderCustomItemsFirst @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", @"list-group", @"blockquote", @"code", @@ -208,6 +210,7 @@ - (void)testSelectableIdentifiersContainAllCustomItems @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", @"list-group", @"blockquote", @"code", @@ -229,8 +232,8 @@ - (void)testSelectableIdentifiersContainAllCustomItems - (void)testSelectableIdentifiersCount { NSArray *selectable = [self.controller toolbarSelectableItemIdentifiers:nil]; - XCTAssertEqual(selectable.count, 13, - @"Selectable identifiers should have exactly 13 items (custom only, no system items)"); + XCTAssertEqual(selectable.count, 14, + @"Selectable identifiers should have exactly 14 items (custom only, no system items)"); } - (void)testSelectableIdentifiersAcceptsNilToolbar @@ -270,6 +273,7 @@ - (void)testDefaultIdentifiersIncludeExpectedCustomItems @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", @"list-group", @"blockquote", @"code", @@ -303,8 +307,8 @@ - (void)testDefaultIdentifiersContainFlexibleSpaces - (void)testDefaultIdentifiersTotalCount { NSArray *defaults = [self.controller toolbarDefaultItemIdentifiers:nil]; - XCTAssertEqual(defaults.count, 15, - @"Default toolbar should have 15 items: 10 custom + 5 flexible spaces"); + XCTAssertEqual(defaults.count, 16, + @"Default toolbar should have 16 items: 11 custom + 5 flexible spaces"); } - (void)testDefaultIdentifiersDoNotContainFixedSpaces @@ -322,6 +326,7 @@ - (void)testDefaultIdentifiersExactOrder @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", NSToolbarFlexibleSpaceItemIdentifier, @"list-group", NSToolbarFlexibleSpaceItemIdentifier, @@ -361,6 +366,7 @@ - (void)testItemForValidIdentifierReturnsNonNil @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", @"list-group", @"blockquote", @"code", @@ -389,6 +395,7 @@ - (void)testItemForIdentifierHasCorrectIdentifier @"indent-group", @"text-formatting-group", @"heading-group", + @"headings-navigator", @"list-group", @"blockquote", @"code", @@ -631,6 +638,7 @@ - (void)testStandaloneItemsHaveViews @"code", @"link", @"image", + @"headings-navigator", @"copy-html", @"comment", @"highlight",