Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion MacDown/Code/Application/MPToolbarController.m
Original file line number Diff line number Diff line change
Expand Up @@ -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:)]
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions MacDown/Code/Document/MPDocument.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
@property (nonatomic, readwrite) NSString *markdown;
@property (nonatomic, readonly) NSString *html;

+ (NSArray<NSDictionary *> *)headingsInMarkdown:(NSString *)markdown;

/**
* Toggle the checkbox at the specified index in the markdown source.
* Unchecked checkboxes ([ ]) become checked ([x]), and vice versa.
Expand Down
151 changes: 151 additions & 0 deletions MacDown/Code/Document/MPDocument.m
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,10 @@ - (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item
return NO;
}
}
else if (action == @selector(showHeadingsNavigator:))
{
return self.editor != nil;
}
return result;
}

Expand Down Expand Up @@ -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<NSDictionary *> *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];
Expand Down Expand Up @@ -3018,6 +3065,110 @@ - (void)document:(NSDocument *)doc didPrint:(BOOL)ok context:(void *)context

#pragma mark - Interactive Checkbox Support (Issue #269)

+ (NSArray<NSDictionary *> *)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<NSDictionary *> *headings = [NSMutableArray array];
NSArray<NSString *> *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/<index>
Expand Down
6 changes: 6 additions & 0 deletions MacDown/Localization/Base.lproj/MainMenu.xib
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@
<binding destination="LEY-zf-22t" name="enabled" keyPath="self.currentDocument.previewVisible" id="5e7-rJ-oX2"/>
</connections>
</menuItem>
<menuItem title="Headings Navigator" id="jy5-Wc-2Hw">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="showHeadingsNavigator:" target="-1" id="2z4-dp-jT9"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="onf-Mq-Pah"/>
<menuItem title="Left 1:3 Right" id="VW7-VH-9yl">
<modifierMask key="keyEquivalentModifierMask"/>
Expand Down
25 changes: 25 additions & 0 deletions MacDownTests/MPDocumentLifecycleTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -864,4 +864,29 @@ - (void)testRendersNotBlockedWhenAlreadyRenderingInWebIsNO
}
}

#pragma mark - Headings Navigator Tests

- (void)testHeadingsInMarkdownParsesATXAndSetextHeadings
{
NSString *markdown = @"# One\n\nTwo\n---\n\n### Three\n";
NSArray<NSDictionary *> *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<NSDictionary *> *headings = [MPDocument headingsInMarkdown:markdown];

XCTAssertEqual(headings.count, 1);
XCTAssertEqualObjects(headings.firstObject[@"title"], @"Real");
}

@end
24 changes: 16 additions & 8 deletions MacDownTests/MPToolbarControllerTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ - (void)testAllowedIdentifiersContainAllCustomItems
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
@"list-group",
@"blockquote",
@"code",
Expand All @@ -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
Expand All @@ -121,6 +122,7 @@ - (void)testAllowedIdentifiersOrderCustomItemsFirst
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
@"list-group",
@"blockquote",
@"code",
Expand Down Expand Up @@ -208,6 +210,7 @@ - (void)testSelectableIdentifiersContainAllCustomItems
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
@"list-group",
@"blockquote",
@"code",
Expand All @@ -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
Expand Down Expand Up @@ -270,6 +273,7 @@ - (void)testDefaultIdentifiersIncludeExpectedCustomItems
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
@"list-group",
@"blockquote",
@"code",
Expand Down Expand Up @@ -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
Expand All @@ -322,6 +326,7 @@ - (void)testDefaultIdentifiersExactOrder
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
NSToolbarFlexibleSpaceItemIdentifier,
@"list-group",
NSToolbarFlexibleSpaceItemIdentifier,
Expand Down Expand Up @@ -361,6 +366,7 @@ - (void)testItemForValidIdentifierReturnsNonNil
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
@"list-group",
@"blockquote",
@"code",
Expand Down Expand Up @@ -389,6 +395,7 @@ - (void)testItemForIdentifierHasCorrectIdentifier
@"indent-group",
@"text-formatting-group",
@"heading-group",
@"headings-navigator",
@"list-group",
@"blockquote",
@"code",
Expand Down Expand Up @@ -631,6 +638,7 @@ - (void)testStandaloneItemsHaveViews
@"code",
@"link",
@"image",
@"headings-navigator",
@"copy-html",
@"comment",
@"highlight",
Expand Down
Loading