// // MPDocument.m // MacDown // // Created by Tzu-ping Chung on 6/06/2014. // Copyright (c) 2014 Tzu-ping Chung . All rights reserved. // #import "MPDocument.h" #import #import #import #import "hoedown_html_patch.h" #import "HGMarkdownHighlighter.h" #import "MPUtilities.h" #import "MPAutosaving.h" #import "NSColor+HTML.h" #import "NSDocumentController+Document.h" #import "NSPasteboard+Types.h" #import "NSString+Lookup.h" #import "NSTextView+Autocomplete.h" #import "DOMNode+Text.h" #import "MPPreferences.h" #import "MPDocumentSplitView.h" #import "MPEditorView.h" #import "MPRenderer.h" #import "MPPreferencesViewController.h" #import "MPEditorPreferencesViewController.h" #import "MPExportPanelAccessoryViewController.h" #import "MPMathJaxListener.h" #import "WebView+WebViewPrivateHeaders.h" #import "MPToolbarController.h" #import static NSString * const kMPDefaultAutosaveName = @"Untitled"; NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) { if (!key.length) return @"editor"; NSString *first = [[key substringToIndex:1] uppercaseString]; NSString *rest = [key substringFromIndex:1]; return [NSString stringWithFormat:@"editor%@%@", first, rest]; } NS_INLINE NSDictionary *MPEditorKeysToObserve() { static NSDictionary *keys = nil; static dispatch_once_t token; dispatch_once(&token, ^{ keys = @{@"automaticDashSubstitutionEnabled": @NO, @"automaticDataDetectionEnabled": @NO, @"automaticQuoteSubstitutionEnabled": @NO, @"automaticSpellingCorrectionEnabled": @NO, @"automaticTextReplacementEnabled": @NO, @"continuousSpellCheckingEnabled": @NO, @"enabledTextCheckingTypes": @(NSTextCheckingAllTypes), @"grammarCheckingEnabled": @NO}; }); return keys; } NS_INLINE NSSet *MPEditorPreferencesToObserve() { static NSSet *keys = nil; static dispatch_once_t token; dispatch_once(&token, ^{ keys = [NSSet setWithObjects: @"editorBaseFontInfo", @"extensionFootnotes", @"editorHorizontalInset", @"editorVerticalInset", @"editorWidthLimited", @"editorMaximumWidth", @"editorLineSpacing", @"editorOnRight", @"editorStyleName", @"editorShowWordCount", @"editorScrollsPastEnd", nil ]; }); return keys; } NS_INLINE NSString *MPRectStringForAutosaveName(NSString *name) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; NSString *key = [NSString stringWithFormat:@"NSWindow Frame %@", name]; NSString *rectString = [defaults objectForKey:key]; return rectString; } NS_INLINE NSColor *MPGetWebViewBackgroundColor(WebView *webview) { DOMDocument *doc = webview.mainFrameDocument; DOMNodeList *nodes = [doc getElementsByTagName:@"body"]; if (!nodes.length) return nil; id bodyNode = [nodes item:0]; DOMCSSStyleDeclaration *style = [doc getComputedStyle:bodyNode pseudoElement:nil]; return [NSColor colorWithHTMLName:[style backgroundColor]]; } @implementation NSURL (Convert) - (NSString *)absoluteBaseURLString { // Remove fragment (#anchor) and query string. NSString *base = self.absoluteString; base = [base componentsSeparatedByString:@"?"].firstObject; base = [base componentsSeparatedByString:@"#"].firstObject; return base; } @end @implementation WebView (Shortcut) - (NSScrollView *)enclosingScrollView { return self.mainFrame.frameView.documentView.enclosingScrollView; } @end @implementation MPPreferences (Hoedown) - (int)extensionFlags { int flags = 0; if (self.extensionAutolink) flags |= HOEDOWN_EXT_AUTOLINK; if (self.extensionFencedCode) flags |= HOEDOWN_EXT_FENCED_CODE; if (self.extensionFootnotes) flags |= HOEDOWN_EXT_FOOTNOTES; if (self.extensionHighlight) flags |= HOEDOWN_EXT_HIGHLIGHT; if (!self.extensionIntraEmphasis) flags |= HOEDOWN_EXT_NO_INTRA_EMPHASIS; if (self.extensionQuote) flags |= HOEDOWN_EXT_QUOTE; if (self.extensionStrikethough) flags |= HOEDOWN_EXT_STRIKETHROUGH; if (self.extensionSuperscript) flags |= HOEDOWN_EXT_SUPERSCRIPT; if (self.extensionTables) flags |= HOEDOWN_EXT_TABLES; if (self.extensionUnderline) flags |= HOEDOWN_EXT_UNDERLINE; if (self.htmlMathJax) flags |= HOEDOWN_EXT_MATH; if (self.htmlMathJaxInlineDollar) flags |= HOEDOWN_EXT_MATH_EXPLICIT; return flags; } - (int)rendererFlags { int flags = 0; if (self.htmlTaskList) flags |= HOEDOWN_HTML_USE_TASK_LIST; if (self.htmlLineNumbers) flags |= HOEDOWN_HTML_BLOCKCODE_LINE_NUMBERS; if (self.htmlHardWrap) flags |= HOEDOWN_HTML_HARD_WRAP; if (self.htmlCodeBlockAccessory == MPCodeBlockAccessoryCustom) flags |= HOEDOWN_HTML_BLOCKCODE_INFORMATION; return flags; } @end @interface MPDocument () = 101100 WebEditingDelegate, WebFrameLoadDelegate, WebPolicyDelegate, WebResourceLoadDelegate, #endif MPAutosaving, MPRendererDataSource, MPRendererDelegate> typedef NS_ENUM(NSUInteger, MPWordCountType) { MPWordCountTypeWord, MPWordCountTypeCharacter, MPWordCountTypeCharacterNoSpaces, }; @property (weak) IBOutlet NSToolbar *toolbar; @property (weak) IBOutlet MPDocumentSplitView *splitView; @property (weak) IBOutlet NSView *editorContainer; @property (unsafe_unretained) IBOutlet MPEditorView *editor; @property (weak) IBOutlet NSLayoutConstraint *editorPaddingBottom; @property (weak) IBOutlet WebView *preview; @property (weak) IBOutlet NSPopUpButton *wordCountWidget; @property (strong) IBOutlet MPToolbarController *toolbarController; @property (copy, nonatomic) NSString *autosaveName; @property (strong) HGMarkdownHighlighter *highlighter; @property (strong) MPRenderer *renderer; @property CGFloat previousSplitRatio; @property BOOL manualRender; @property BOOL copying; @property BOOL printing; @property BOOL shouldHandleBoundsChange; @property BOOL isPreviewReady; @property (strong) NSURL *currentBaseUrl; @property CGFloat lastPreviewScrollTop; @property (nonatomic, readonly) BOOL needsHtml; @property (nonatomic) NSUInteger totalWords; @property (nonatomic) NSUInteger totalCharacters; @property (nonatomic) NSUInteger totalCharactersNoSpaces; @property (strong) NSMenuItem *wordsMenuItem; @property (strong) NSMenuItem *charMenuItem; @property (strong) NSMenuItem *charNoSpacesMenuItem; @property (nonatomic) BOOL needsToUnregister; @property (nonatomic) BOOL alreadyRenderingInWeb; @property (nonatomic) BOOL renderToWebPending; @property (strong) NSArray *webViewHeaderLocations; @property (strong) NSArray *editorHeaderLocations; @property (nonatomic) BOOL inLiveScroll; // Store file content in initializer until nib is loaded. @property (copy) NSString *loadedString; - (void)scaleWebview; - (void)syncScrollers; -(void) updateHeaderLocations; @end static void (^MPGetPreviewLoadingCompletionHandler(MPDocument *doc))() { __weak MPDocument *weakObj = doc; return ^{ WebView *webView = weakObj.preview; NSWindow *window = webView.window; @synchronized(window) { if (window.isFlushWindowDisabled) [window enableFlushWindow]; } [weakObj scaleWebview]; if (weakObj.preferences.editorSyncScrolling) { [weakObj updateHeaderLocations]; [weakObj syncScrollers]; } else { NSClipView *contentView = webView.enclosingScrollView.contentView; NSRect bounds = contentView.bounds; bounds.origin.y = weakObj.lastPreviewScrollTop; contentView.bounds = bounds; } }; } @implementation MPDocument #pragma mark - Accessor - (MPPreferences *)preferences { return [MPPreferences sharedInstance]; } - (NSString *)markdown { return self.editor.string; } - (void)setMarkdown:(NSString *)markdown { self.editor.string = markdown; } - (NSString *)html { return self.renderer.currentHtml; } - (BOOL)toolbarVisible { return self.windowForSheet.toolbar.visible; } - (BOOL)previewVisible { return (self.preview.frame.size.width != 0.0); } - (BOOL)editorVisible { return (self.editorContainer.frame.size.width != 0.0); } - (BOOL)needsHtml { if (self.preferences.markdownManualRender) return NO; return (self.previewVisible || self.preferences.editorShowWordCount); } - (void)setTotalWords:(NSUInteger)value { _totalWords = value; NSString *key = NSLocalizedString(@"WORDS_PLURAL_STRING", @""); NSInteger rule = kJJPluralFormRule.integerValue; self.wordsMenuItem.title = [JJPluralForm pluralStringForNumber:value withPluralForms:key usingPluralRule:rule localizeNumeral:NO]; } - (void)setTotalCharacters:(NSUInteger)value { _totalCharacters = value; NSString *key = NSLocalizedString(@"CHARACTERS_PLURAL_STRING", @""); NSInteger rule = kJJPluralFormRule.integerValue; self.charMenuItem.title = [JJPluralForm pluralStringForNumber:value withPluralForms:key usingPluralRule:rule localizeNumeral:NO]; } - (void)setTotalCharactersNoSpaces:(NSUInteger)value { _totalCharactersNoSpaces = value; NSString *key = NSLocalizedString(@"CHARACTERS_NO_SPACES_PLURAL_STRING", @""); NSInteger rule = kJJPluralFormRule.integerValue; self.charNoSpacesMenuItem.title = [JJPluralForm pluralStringForNumber:value withPluralForms:key usingPluralRule:rule localizeNumeral:NO]; } - (void)setAutosaveName:(NSString *)autosaveName { _autosaveName = autosaveName; self.splitView.autosaveName = autosaveName; } #pragma mark - Override - (instancetype)init { self = [super init]; if (!self) return nil; self.isPreviewReady = NO; self.shouldHandleBoundsChange = YES; self.previousSplitRatio = -1.0; return self; } - (NSString *)windowNibName { return @"MPDocument"; } - (void)windowControllerDidLoadNib:(NSWindowController *)controller { [super windowControllerDidLoadNib:controller]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; // All files use their absolute path to keep their window states. NSString *autosaveName = kMPDefaultAutosaveName; if (self.fileURL) autosaveName = self.fileURL.absoluteString; controller.window.frameAutosaveName = autosaveName; self.autosaveName = autosaveName; // Perform initial resizing manually because for some reason untitled // documents do not pick up the autosaved frame automatically in 10.10. NSString *rectString = MPRectStringForAutosaveName(autosaveName); if (!rectString) rectString = MPRectStringForAutosaveName(kMPDefaultAutosaveName); if (rectString) [controller.window setFrameFromString:rectString]; self.highlighter = [[HGMarkdownHighlighter alloc] initWithTextView:self.editor waitInterval:0.0]; self.renderer = [[MPRenderer alloc] init]; self.renderer.dataSource = self; self.renderer.delegate = self; for (NSString *key in MPEditorPreferencesToObserve()) { [defaults addObserver:self forKeyPath:key options:NSKeyValueObservingOptionNew context:NULL]; } for (NSString *key in MPEditorKeysToObserve()) { [self.editor addObserver:self forKeyPath:key options:NSKeyValueObservingOptionNew context:NULL]; } self.editor.postsFrameChangedNotifications = YES; self.preview.frameLoadDelegate = self; self.preview.policyDelegate = self; self.preview.editingDelegate = self; self.preview.resourceLoadDelegate = self; NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; [center addObserver:self selector:@selector(editorTextDidChange:) name:NSTextDidChangeNotification object:self.editor]; [center addObserver:self selector:@selector(userDefaultsDidChange:) name:NSUserDefaultsDidChangeNotification object:[NSUserDefaults standardUserDefaults]]; [center addObserver:self selector:@selector(editorBoundsDidChange:) name:NSViewBoundsDidChangeNotification object:self.editor.enclosingScrollView.contentView]; [center addObserver:self selector:@selector(editorFrameDidChange:) name:NSViewFrameDidChangeNotification object:self.editor]; [center addObserver:self selector:@selector(didRequestEditorReload:) name:MPDidRequestEditorSetupNotification object:nil]; [center addObserver:self selector:@selector(didRequestPreviewReload:) name:MPDidRequestPreviewRenderNotification object:nil]; [center addObserver:self selector:@selector(willStartLiveScroll:) name:NSScrollViewWillStartLiveScrollNotification object:self.editor.enclosingScrollView]; [center addObserver:self selector:@selector(didEndLiveScroll:) name:NSScrollViewDidEndLiveScrollNotification object:self.editor.enclosingScrollView]; if (kCFCoreFoundationVersionNumber >= kCFCoreFoundationVersionNumber10_9) { [center addObserver:self selector:@selector(previewDidLiveScroll:) name:NSScrollViewDidEndLiveScrollNotification object:self.preview.enclosingScrollView]; } self.needsToUnregister = YES; self.wordsMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:NULL keyEquivalent:@""]; self.charMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:NULL keyEquivalent:@""]; self.charNoSpacesMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:NULL keyEquivalent:@""]; NSPopUpButton *wordCountWidget = self.wordCountWidget; [wordCountWidget removeAllItems]; [wordCountWidget.menu addItem:self.wordsMenuItem]; [wordCountWidget.menu addItem:self.charMenuItem]; [wordCountWidget.menu addItem:self.charNoSpacesMenuItem]; [wordCountWidget selectItemAtIndex:self.preferences.editorWordCountType]; wordCountWidget.alphaValue = 0.9; wordCountWidget.hidden = !self.preferences.editorShowWordCount; wordCountWidget.enabled = NO; // These needs to be queued until after the window is shown, so that editor // can have the correct dimention for size-limiting and stuff. See // https://github.com/uranusjr/macdown/issues/236 [[NSOperationQueue mainQueue] addOperationWithBlock:^{ [self setupEditor:nil]; [self redrawDivider]; [self reloadFromLoadedString]; }]; } - (void)reloadFromLoadedString { if (self.loadedString && self.editor && self.renderer && self.highlighter) { self.editor.string = self.loadedString; self.loadedString = nil; [self.renderer parseAndRenderNow]; [self.highlighter parseAndHighlightNow]; } } - (void)close { if (self.needsToUnregister) { // Close can be called multiple times, but this can only be done once. // http://www.cocoabuilder.com/archive/cocoa/240166-nsdocument-close-method-calls-itself.html self.needsToUnregister = NO; // Need to cleanup these so that callbacks won't crash the app. [self.highlighter deactivate]; self.highlighter.targetTextView = nil; self.highlighter = nil; self.renderer = nil; self.preview.frameLoadDelegate = nil; self.preview.policyDelegate = nil; [[NSNotificationCenter defaultCenter] removeObserver:self]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; for (NSString *key in MPEditorPreferencesToObserve()) [defaults removeObserver:self forKeyPath:key]; for (NSString *key in MPEditorKeysToObserve()) [self.editor removeObserver:self forKeyPath:key]; } [super close]; } + (BOOL)autosavesInPlace { return YES; } + (NSArray *)writableTypes { return @[@"net.daringfireball.markdown"]; } - (BOOL)isDocumentEdited { // Prevent save dialog on an unnamed, empty document. The file will still // show as modified (because it is), but no save dialog will be presented // when the user closes it. if (!self.presentedItemURL && !self.editor.string.length) return NO; return [super isDocumentEdited]; } - (BOOL)writeToURL:(NSURL *)url ofType:(NSString *)typeName error:(NSError *__autoreleasing *)outError { if (self.preferences.editorEnsuresNewlineAtEndOfFile) { NSCharacterSet *newline = [NSCharacterSet newlineCharacterSet]; NSString *text = self.editor.string; NSUInteger end = text.length; if (end && ![newline characterIsMember:[text characterAtIndex:end - 1]]) { NSRange selection = self.editor.selectedRange; [self.editor insertText:@"\n" replacementRange:NSMakeRange(end, 0)]; self.editor.selectedRange = selection; } } return [super writeToURL:url ofType:typeName error:outError]; } - (NSData *)dataOfType:(NSString *)typeName error:(NSError **)outError { return [self.editor.string dataUsingEncoding:NSUTF8StringEncoding]; } - (BOOL)readFromData:(NSData *)data ofType:(NSString *)typeName error:(NSError **)outError { NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (!content) return NO; self.loadedString = content; [self reloadFromLoadedString]; return YES; } - (BOOL)prepareSavePanel:(NSSavePanel *)savePanel { savePanel.extensionHidden = NO; if (self.fileURL && self.fileURL.isFileURL) { NSString *path = self.fileURL.path; // Use path of parent directory if this is a file. Otherwise this is it. BOOL isDir = NO; BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir]; if (!exists || !isDir) path = [path stringByDeletingLastPathComponent]; savePanel.directoryURL = [NSURL fileURLWithPath:path]; } else { // Suggest a file name for new documents. NSString *fileName = self.presumedFileName; if (fileName && ![fileName hasExtension:@"md"]) { fileName = [fileName stringByAppendingPathExtension:@"md"]; savePanel.nameFieldStringValue = fileName; } } // Get supported extensions from plist static NSMutableArray *supportedExtensions = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ supportedExtensions = [NSMutableArray array]; NSDictionary *infoDict = [NSBundle mainBundle].infoDictionary; for (NSDictionary *docType in infoDict[@"CFBundleDocumentTypes"]) { NSArray *exts = docType[@"CFBundleTypeExtensions"]; if (exts.count) { [supportedExtensions addObjectsFromArray:exts]; } } }); savePanel.allowedFileTypes = supportedExtensions; savePanel.allowsOtherFileTypes = YES; // Allow all extensions. return [super prepareSavePanel:savePanel]; } - (NSPrintInfo *)printInfo { NSPrintInfo *info = [super printInfo]; if (!info) info = [[NSPrintInfo sharedPrintInfo] copy]; info.horizontalPagination = NSAutoPagination; info.verticalPagination = NSAutoPagination; info.verticallyCentered = NO; info.topMargin = 50.0; info.leftMargin = 0.0; info.rightMargin = 0.0; info.bottomMargin = 50.0; return info; } - (NSPrintOperation *)printOperationWithSettings:(NSDictionary *)printSettings error:(NSError *__autoreleasing *)e { NSPrintInfo *info = [self.printInfo copy]; [info.dictionary addEntriesFromDictionary:printSettings]; WebFrameView *view = self.preview.mainFrame.frameView; NSPrintOperation *op = [view printOperationWithPrintInfo:info]; return op; } - (void)printDocumentWithSettings:(NSDictionary *)printSettings showPrintPanel:(BOOL)showPrintPanel delegate:(id)delegate didPrintSelector:(SEL)selector contextInfo:(void *)contextInfo { self.printing = YES; NSInvocation *invocation = nil; if (delegate && selector) { NSMethodSignature *signature = [NSMethodSignature methodSignatureForSelector:selector]; invocation = [NSInvocation invocationWithMethodSignature:signature]; invocation.target = delegate; if (contextInfo) [invocation setArgument:&contextInfo atIndex:2]; } [super printDocumentWithSettings:printSettings showPrintPanel:showPrintPanel delegate:self didPrintSelector:@selector(document:didPrint:context:) contextInfo:(void *)invocation]; } - (BOOL)validateUserInterfaceItem:(id)item { BOOL result = [super validateUserInterfaceItem:item]; SEL action = item.action; if (action == @selector(toggleToolbar:)) { NSMenuItem *it = ((NSMenuItem *)item); it.title = self.toolbarVisible ? NSLocalizedString(@"Hide Toolbar", @"Toggle reveal toolbar") : NSLocalizedString(@"Show Toolbar", @"Toggle reveal toolbar"); } else if (action == @selector(togglePreviewPane:)) { NSMenuItem *it = ((NSMenuItem *)item); it.hidden = (!self.previewVisible && self.previousSplitRatio < 0.0); it.title = self.previewVisible ? NSLocalizedString(@"Hide Preview Pane", @"Toggle preview pane menu item") : NSLocalizedString(@"Restore Preview Pane", @"Toggle preview pane menu item"); } else if (action == @selector(toggleEditorPane:)) { NSMenuItem *it = (NSMenuItem*)item; it.title = self.editorVisible ? NSLocalizedString(@"Hide Editor Pane", @"Toggle editor pane menu item") : NSLocalizedString(@"Restore Editor Pane", @"Toggle editor pane menu item"); } return result; } #pragma mark - NSSplitViewDelegate - (void)splitViewDidResizeSubviews:(NSNotification *)notification { [self redrawDivider]; self.editor.editable = self.editorVisible; } #pragma mark - NSTextViewDelegate - (BOOL)textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector { if (commandSelector == @selector(insertTab:)) return ![self textViewShouldInsertTab:textView]; else if (commandSelector == @selector(insertBacktab:)) return ![self textViewShouldInsertBacktab:textView]; else if (commandSelector == @selector(insertNewline:)) return ![self textViewShouldInsertNewline:textView]; else if (commandSelector == @selector(deleteBackward:)) return ![self textViewShouldDeleteBackward:textView]; else if (commandSelector == @selector(moveToLeftEndOfLine:)) return ![self textViewShouldMoveToLeftEndOfLine:textView]; return NO; } - (BOOL)textView:(NSTextView *)textView shouldChangeTextInRange:(NSRange)range replacementString:(NSString *)str { // Ignore if this originates from an IM marked text commit event. if (NSIntersectionRange(textView.markedRange, range).length) return YES; if (self.preferences.editorCompleteMatchingCharacters) { BOOL strikethrough = self.preferences.extensionStrikethough; if ([textView completeMatchingCharactersForTextInRange:range withString:str strikethroughEnabled:strikethrough]) return NO; } // For every change, set the typing attributes if (range.location > 0) { NSRange prevRange = range; prevRange.location -= 1; prevRange.length = 1; NSDictionary *attr = [[textView attributedString] fontAttributesInRange:prevRange]; [textView setTypingAttributes:attr]; } return YES; } #pragma mark - Fake NSTextViewDelegate - (BOOL)textViewShouldInsertTab:(NSTextView *)textView { if (textView.selectedRange.length != 0) { [self indent:nil]; return NO; } else if (self.preferences.editorConvertTabs) { [textView insertSpacesForTab]; return NO; } return YES; } - (BOOL)textViewShouldInsertBacktab:(NSTextView *)textView { [self unindent:nil]; return NO; } - (BOOL)textViewShouldInsertNewline:(NSTextView *)textView { if ([textView insertMappedContent]) return NO; BOOL inserts = self.preferences.editorInsertPrefixInBlock; if (inserts && [textView completeNextListItem: self.preferences.editorAutoIncrementNumberedLists]) return NO; if (inserts && [textView completeNextBlockquoteLine]) return NO; if ([textView completeNextIndentedLine]) return NO; return YES; } - (BOOL)textViewShouldDeleteBackward:(NSTextView *)textView { NSRange selectedRange = textView.selectedRange; if (self.preferences.editorCompleteMatchingCharacters) { NSUInteger location = selectedRange.location; if ([textView deleteMatchingCharactersAround:location]) return NO; } if (self.preferences.editorConvertTabs && !selectedRange.length) { NSUInteger location = selectedRange.location; if ([textView unindentForSpacesBefore:location]) return NO; } return YES; } - (BOOL)textViewShouldMoveToLeftEndOfLine:(NSTextView *)textView { if (!self.preferences.editorSmartHome) return YES; NSUInteger cur = textView.selectedRange.location; NSUInteger location = [textView.string locationOfFirstNonWhitespaceCharacterInLineBefore:cur]; if (location == cur || cur == 0) return YES; else if (cur >= textView.string.length) cur = textView.string.length - 1; // We don't want to jump rows when the line is wrapped. (#103) // If the line is wrapped, the target will be higher than the current glyph. NSLayoutManager *manager = textView.layoutManager; NSTextContainer *container = textView.textContainer; NSRect targetRect = [manager boundingRectForGlyphRange:NSMakeRange(location, 1) inTextContainer:container]; NSRect currentRect = [manager boundingRectForGlyphRange:NSMakeRange(cur, 1) inTextContainer:container]; if (targetRect.origin.y != currentRect.origin.y) return YES; textView.selectedRange = NSMakeRange(location, 0); return NO; } #pragma mark - WebResourceLoadDelegate - (NSURLRequest *)webView:(WebView *)sender resource:(id)identifier willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse fromDataSource:(WebDataSource *)dataSource { if ([[request.URL lastPathComponent] isEqualToString:@"MathJax.js"]) { NSURLComponents *origComps = [NSURLComponents componentsWithURL:[request URL] resolvingAgainstBaseURL:YES]; NSURLComponents *updatedComps = [NSURLComponents componentsWithURL:[[NSBundle mainBundle] URLForResource:@"MathJax" withExtension:@"js" subdirectory:@"MathJax"] resolvingAgainstBaseURL:NO]; [updatedComps setQueryItems:[origComps queryItems]]; request = [NSURLRequest requestWithURL:[updatedComps URL]]; } return request; } #pragma mark - WebFrameLoadDelegate - (void)webView:(WebView *)sender didCommitLoadForFrame:(WebFrame *)frame { NSWindow *window = sender.window; @synchronized(window) { if (!window.isFlushWindowDisabled) [window disableFlushWindow]; } // If MathJax is off, the on-completion callback will be invoked directly // when loading is done (in -webView:didFinishLoadForFrame:). if (self.preferences.htmlMathJax) { MPMathJaxListener *listener = [[MPMathJaxListener alloc] init]; [listener addCallback:MPGetPreviewLoadingCompletionHandler(self) forKey:@"End"]; [sender.windowScriptObject setValue:listener forKey:@"MathJaxListener"]; } } - (void)webView:(WebView *)sender didFinishLoadForFrame:(WebFrame *)frame { // If MathJax is on, the on-completion callback will be invoked by the // JavaScript handler injected in -webView:didCommitLoadForFrame:. if (!self.preferences.htmlMathJax) { id callback = MPGetPreviewLoadingCompletionHandler(self); NSOperationQueue *queue = [NSOperationQueue mainQueue]; [queue addOperationWithBlock:callback]; } self.isPreviewReady = YES; // Update word count if (self.preferences.editorShowWordCount) [self updateWordCount]; self.alreadyRenderingInWeb = NO; if (self.renderToWebPending) [self.renderer parseAndRenderNow]; self.renderToWebPending = NO; } - (void)webView:(WebView *)sender didFailLoadWithError:(NSError *)error forFrame:(WebFrame *)frame { [self webView:sender didFinishLoadForFrame:frame]; self.alreadyRenderingInWeb = NO; if (self.renderToWebPending) [self.renderer parseAndRenderNow]; self.renderToWebPending = NO; } #pragma mark - WebPolicyDelegate - (void)webView:(WebView *)webView decidePolicyForNavigationAction:(NSDictionary *)information request:(NSURLRequest *)request frame:(WebFrame *)frame decisionListener:(id)listener { switch ([information[WebActionNavigationTypeKey] integerValue]) { case WebNavigationTypeLinkClicked: // If the target is exactly as the current one, ignore. if ([self.currentBaseUrl isEqual:request.URL]) { [listener ignore]; return; } // If this is a different page, intercept and handle ourselves. else if (![self isCurrentBaseUrl:request.URL]) { [listener ignore]; [self openOrCreateFileForUrl:request.URL]; return; } // Otherwise this is somewhere else on the same page. Jump there. break; default: break; } [listener use]; } #pragma mark - WebEditingDelegate - (BOOL)webView:(WebView *)webView doCommandBySelector:(SEL)selector { if (selector == @selector(copy:)) { NSString *html = webView.selectedDOMRange.markupString; // Inject the HTML content later so that it doesn't get cleared during // the native copy operation. [[NSOperationQueue mainQueue] addOperationWithBlock:^{ NSPasteboard *pb = [NSPasteboard generalPasteboard]; if (![pb stringForType:@"public.html"]) [pb setString:html forType:@"public.html"]; }]; } return NO; } #pragma mark - WebUIDelegate - (NSUInteger)webView:(WebView *)webView dragDestinationActionMaskForDraggingInfo:(id)info { return WebDragDestinationActionNone; } #pragma mark - MPRendererDataSource - (BOOL)rendererLoading { return self.preview.loading; } - (NSString *)rendererMarkdown:(MPRenderer *)renderer { return self.editor.string; } - (NSString *)rendererHTMLTitle:(MPRenderer *)renderer { NSString *n = self.fileURL.lastPathComponent.stringByDeletingPathExtension; return n ? n : @""; } #pragma mark - MPRendererDelegate - (int)rendererExtensions:(MPRenderer *)renderer { return self.preferences.extensionFlags; } - (BOOL)rendererHasSmartyPants:(MPRenderer *)renderer { return self.preferences.extensionSmartyPants; } - (BOOL)rendererRendersTOC:(MPRenderer *)renderer { return self.preferences.htmlRendersTOC; } - (NSString *)rendererStyleName:(MPRenderer *)renderer { return self.preferences.htmlStyleName; } - (BOOL)rendererDetectsFrontMatter:(MPRenderer *)renderer { return self.preferences.htmlDetectFrontMatter; } - (BOOL)rendererHasSyntaxHighlighting:(MPRenderer *)renderer { return self.preferences.htmlSyntaxHighlighting; } - (BOOL)rendererHasMermaid:(MPRenderer *)renderer { return self.preferences.htmlMermaid; } - (BOOL)rendererHasGraphviz:(MPRenderer *)renderer { return self.preferences.htmlGraphviz; } - (MPCodeBlockAccessoryType)rendererCodeBlockAccesory:(MPRenderer *)renderer { return self.preferences.htmlCodeBlockAccessory; } - (BOOL)rendererHasMathJax:(MPRenderer *)renderer { return self.preferences.htmlMathJax; } - (NSString *)rendererHighlightingThemeName:(MPRenderer *)renderer { return self.preferences.htmlHighlightingThemeName; } - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html { if (self.alreadyRenderingInWeb) { self.renderToWebPending = YES; return; } if (self.printing) return; self.alreadyRenderingInWeb = YES; // Delayed copying for -copyHtml. if (self.copying) { self.copying = NO; NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; [pasteboard clearContents]; [pasteboard writeObjects:@[self.renderer.currentHtml]]; } NSURL *baseUrl = self.fileURL; if (!baseUrl) // Unsaved doument; just use the default URL. baseUrl = self.preferences.htmlDefaultDirectoryUrl; self.manualRender = self.preferences.markdownManualRender; #if 0 // Unfortunately this DOM-replacing causes a lot of problems... // 1. MathJax needs to be triggered. // 2. Prism rendering is lost. // 3. Potentially more. // Essentially all JavaScript needs to be run again after we replace // the DOM. I have no idea how many more problems there are, so we'll have // to back off from the path for now... :( // If we're working on the same document, try not to reload. if (self.isPreviewReady && [self.currentBaseUrl isEqualTo:baseUrl]) { // HACK: Ideally we should only inject the parts that changed, and only // get the parts we need. For now we only get a complete HTML codument, // and rely on regex to get the parts we want in the DOM. // Use the existing tree if available, and replace the content. DOMDocument *doc = self.preview.mainFrame.DOMDocument; DOMNodeList *htmlNodes = [doc getElementsByTagName:@"html"]; if (htmlNodes.length >= 1) { static NSString *pattern = @"(.*)"; static int opts = NSRegularExpressionDotMatchesLineSeparators; // Find things inside the tag. NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:pattern options:opts error:NULL]; NSTextCheckingResult *result = [regex firstMatchInString:html options:0 range:NSMakeRange(0, html.length)]; html = [html substringWithRange:[result rangeAtIndex:1]]; // Replace everything in the old tag. DOMElement *htmlNode = (DOMElement *)[htmlNodes item:0]; htmlNode.innerHTML = html; return; } } #endif // Reload the page if there's not valid tree to work with. [self.preview.mainFrame loadHTMLString:html baseURL:baseUrl]; self.currentBaseUrl = baseUrl; } #pragma mark - Notification handler - (void)editorTextDidChange:(NSNotification *)notification { if (self.needsHtml) [self.renderer parseAndRenderLater]; } - (void)userDefaultsDidChange:(NSNotification *)notification { MPRenderer *renderer = self.renderer; // Force update if we're switching from manual to auto, or renderer settings // changed. int rendererFlags = self.preferences.rendererFlags; if ((!self.preferences.markdownManualRender && self.manualRender) || renderer.rendererFlags != rendererFlags) { renderer.rendererFlags = rendererFlags; [renderer parseAndRenderLater]; } else { [renderer parseIfPreferencesChanged]; [renderer renderIfPreferencesChanged]; } } - (void)editorFrameDidChange:(NSNotification *)notification { if (self.preferences.editorWidthLimited) [self adjustEditorInsets]; } - (void)willStartLiveScroll:(NSNotification *)notification { [self updateHeaderLocations]; _inLiveScroll = YES; } -(void)didEndLiveScroll:(NSNotification *)notification { _inLiveScroll = NO; } - (void)editorBoundsDidChange:(NSNotification *)notification { if (!self.shouldHandleBoundsChange) return; if (self.preferences.editorSyncScrolling) { @synchronized(self) { self.shouldHandleBoundsChange = NO; if(!_inLiveScroll){ [self updateHeaderLocations]; } [self syncScrollers]; self.shouldHandleBoundsChange = YES; } } } - (void)didRequestEditorReload:(NSNotification *)notification { NSString *key = notification.userInfo[MPDidRequestEditorSetupNotificationKeyName]; [self setupEditor:key]; } - (void)didRequestPreviewReload:(NSNotification *)notification { [self render:nil]; } - (void)previewDidLiveScroll:(NSNotification *)notification { NSClipView *contentView = self.preview.enclosingScrollView.contentView; self.lastPreviewScrollTop = contentView.bounds.origin.y; } #pragma mark - KVO - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (object == self.editor) { if (!self.highlighter.isActive) return; id value = change[NSKeyValueChangeNewKey]; NSString *preferenceKey = MPEditorPreferenceKeyWithValueKey(keyPath); NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; [defaults setObject:value forKey:preferenceKey]; } else if (object == [NSUserDefaults standardUserDefaults]) { if (self.highlighter.isActive) [self setupEditor:keyPath]; [self redrawDivider]; } } #pragma mark - IBAction - (IBAction)copyHtml:(id)sender { // Dis-select things in WebView so that it's more obvious we're NOT // respecting the selection range. [self.preview setSelectedDOMRange:nil affinity:NSSelectionAffinityUpstream]; // If the preview is hidden, the HTML are not updating on text change. // Perform one extra rendering so that the HTML is up to date, and do the // copy in the rendering callback. if (!self.needsHtml) { self.copying = YES; [self.renderer parseAndRenderNow]; return; } NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; [pasteboard clearContents]; [pasteboard writeObjects:@[self.renderer.currentHtml]]; } - (IBAction)exportHtml:(id)sender { NSSavePanel *panel = [NSSavePanel savePanel]; panel.allowedFileTypes = @[@"html"]; if (self.presumedFileName) panel.nameFieldStringValue = self.presumedFileName; MPExportPanelAccessoryViewController *controller = [[MPExportPanelAccessoryViewController alloc] init]; controller.stylesIncluded = (BOOL)self.preferences.htmlStyleName; controller.highlightingIncluded = self.preferences.htmlSyntaxHighlighting; panel.accessoryView = controller.view; NSWindow *w = self.windowForSheet; [panel beginSheetModalForWindow:w completionHandler:^(NSInteger result) { if (result != NSFileHandlingPanelOKButton) return; BOOL styles = controller.stylesIncluded; BOOL highlighting = controller.highlightingIncluded; NSString *html = [self.renderer HTMLForExportWithStyles:styles highlighting:highlighting]; [html writeToURL:panel.URL atomically:NO encoding:NSUTF8StringEncoding error:NULL]; }]; } - (IBAction)exportPdf:(id)sender { NSSavePanel *panel = [NSSavePanel savePanel]; panel.allowedFileTypes = @[@"pdf"]; if (self.presumedFileName) panel.nameFieldStringValue = self.presumedFileName; NSWindow *w = nil; NSArray *windowControllers = self.windowControllers; if (windowControllers.count > 0) w = [windowControllers[0] window]; [panel beginSheetModalForWindow:w completionHandler:^(NSInteger result) { if (result != NSFileHandlingPanelOKButton) return; NSDictionary *settings = @{ NSPrintJobDisposition: NSPrintSaveJob, NSPrintJobSavingURL: panel.URL, }; [self printDocumentWithSettings:settings showPrintPanel:NO delegate:nil didPrintSelector:NULL contextInfo:NULL]; }]; } - (IBAction)convertToH1:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:1]; } - (IBAction)convertToH2:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:2]; } - (IBAction)convertToH3:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:3]; } - (IBAction)convertToH4:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:4]; } - (IBAction)convertToH5:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:5]; } - (IBAction)convertToH6:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:6]; } - (IBAction)convertToParagraph:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:0]; } - (IBAction)toggleStrong:(id)sender { [self.editor toggleForMarkupPrefix:@"**" suffix:@"**"]; } - (IBAction)toggleEmphasis:(id)sender { [self.editor toggleForMarkupPrefix:@"*" suffix:@"*"]; } - (IBAction)toggleInlineCode:(id)sender { [self.editor toggleForMarkupPrefix:@"`" suffix:@"`"]; } - (IBAction)toggleStrikethrough:(id)sender { [self.editor toggleForMarkupPrefix:@"~~" suffix:@"~~"]; } - (IBAction)toggleUnderline:(id)sender { [self.editor toggleForMarkupPrefix:@"_" suffix:@"_"]; } - (IBAction)toggleHighlight:(id)sender { [self.editor toggleForMarkupPrefix:@"==" suffix:@"=="]; } - (IBAction)toggleComment:(id)sender { [self.editor toggleForMarkupPrefix:@""]; } - (IBAction)toggleLink:(id)sender { BOOL inserted = [self.editor toggleForMarkupPrefix:@"[" suffix:@"]()"]; if (!inserted) return; NSRange selectedRange = self.editor.selectedRange; NSUInteger location = selectedRange.location + selectedRange.length + 2; selectedRange = NSMakeRange(location, 0); NSPasteboard *pb = [NSPasteboard generalPasteboard]; NSString *url = [pb URLForType:NSPasteboardTypeString].absoluteString; if (url) { [self.editor insertText:url replacementRange:selectedRange]; selectedRange.length = url.length; } self.editor.selectedRange = selectedRange; } - (IBAction)toggleImage:(id)sender { BOOL inserted = [self.editor toggleForMarkupPrefix:@"![" suffix:@"]()"]; if (!inserted) return; NSRange selectedRange = self.editor.selectedRange; NSUInteger location = selectedRange.location + selectedRange.length + 2; selectedRange = NSMakeRange(location, 0); NSPasteboard *pb = [NSPasteboard generalPasteboard]; NSString *url = [pb URLForType:NSPasteboardTypeString].absoluteString; if (url) { [self.editor insertText:url replacementRange:selectedRange]; selectedRange.length = url.length; } self.editor.selectedRange = selectedRange; } - (IBAction)toggleOrderedList:(id)sender { [self.editor toggleBlockWithPattern:@"^[0-9]+ \\S" prefix:@"1. "]; } - (IBAction)toggleUnorderedList:(id)sender { NSString *marker = self.preferences.editorUnorderedListMarker; [self.editor toggleBlockWithPattern:@"^[\\*\\+-] \\S" prefix:marker]; } - (IBAction)toggleBlockquote:(id)sender { [self.editor toggleBlockWithPattern:@"^> \\S" prefix:@"> "]; } - (IBAction)indent:(id)sender { NSString *padding = @"\t"; if (self.preferences.editorConvertTabs) padding = @" "; [self.editor indentSelectedLinesWithPadding:padding]; } - (IBAction)unindent:(id)sender { [self.editor unindentSelectedLines]; } - (IBAction)insertNewParagraph:(id)sender { NSRange range = self.editor.selectedRange; NSUInteger location = range.location; NSUInteger length = range.length; NSString *content = self.editor.string; NSInteger newlineBefore = [content locationOfFirstNewlineBefore:location]; NSUInteger newlineAfter = [content locationOfFirstNewlineAfter:location + length - 1]; // If we are on an empty line, treat as normal return key; otherwise insert // two newlines. if (location == newlineBefore + 1 && location == newlineAfter) [self.editor insertNewline:self]; else [self.editor insertText:@"\n\n"]; } - (IBAction)setEditorOneQuarter:(id)sender { [self setSplitViewDividerLocation:0.25]; } - (IBAction)setEditorThreeQuarters:(id)sender { [self setSplitViewDividerLocation:0.75]; } - (IBAction)setEqualSplit:(id)sender { [self setSplitViewDividerLocation:0.5]; } - (IBAction)toggleToolbar:(id)sender { [self.windowForSheet toggleToolbarShown:sender]; } - (IBAction)togglePreviewPane:(id)sender { [self toggleSplitterCollapsingEditorPane:NO]; } - (IBAction)toggleEditorPane:(id)sender { [self toggleSplitterCollapsingEditorPane:YES]; } - (IBAction)render:(id)sender { [self.renderer parseAndRenderLater]; } #pragma mark - Private - (void)toggleSplitterCollapsingEditorPane:(BOOL)forEditorPane { BOOL isVisible = forEditorPane ? self.editorVisible : self.previewVisible; BOOL editorOnRight = self.preferences.editorOnRight; float targetRatio = ((forEditorPane == editorOnRight) ? 1.0 : 0.0); if (isVisible) { CGFloat oldRatio = self.splitView.dividerLocation; if (oldRatio != 0.0 && oldRatio != 1.0) { // We don't want to save these values, since they are meaningless. // The user should be able to switch between 100% editor and 100% // preview without losing the old ratio. self.previousSplitRatio = oldRatio; } [self setSplitViewDividerLocation:targetRatio]; } else { // We have an inconsistency here, let's just go back to 0.5, // otherwise nothing will happen if (self.previousSplitRatio < 0.0) self.previousSplitRatio = 0.5; [self setSplitViewDividerLocation:self.previousSplitRatio]; } } - (void)setupEditor:(NSString *)changedKey { [self.highlighter deactivate]; if (!changedKey || [changedKey isEqualToString:@"extensionFootnotes"]) { int extensions = pmh_EXT_NOTES; if (self.preferences.extensionFootnotes) extensions = pmh_EXT_NONE; self.highlighter.extensions = extensions; } if (!changedKey || [changedKey isEqualToString:@"editorHorizontalInset"] || [changedKey isEqualToString:@"editorVerticalInset"] || [changedKey isEqualToString:@"editorWidthLimited"] || [changedKey isEqualToString:@"editorMaximumWidth"]) { [self adjustEditorInsets]; } if (!changedKey || [changedKey isEqualToString:@"editorBaseFontInfo"] || [changedKey isEqualToString:@"editorStyleName"] || [changedKey isEqualToString:@"editorLineSpacing"]) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; style.lineSpacing = self.preferences.editorLineSpacing; self.editor.defaultParagraphStyle = [style copy]; NSFont *font = [self.preferences.editorBaseFont copy]; if (font) self.editor.font = font; self.editor.textColor = nil; self.editor.backgroundColor = [NSColor clearColor]; self.highlighter.styles = nil; [self.highlighter readClearTextStylesFromTextView]; NSString *themeName = [self.preferences.editorStyleName copy]; if (themeName.length) { NSString *path = MPThemePathForName(themeName); NSString *themeString = MPReadFileOfPath(path); [self.highlighter applyStylesFromStylesheet:themeString withErrorHandler: ^(NSArray *errorMessages) { self.preferences.editorStyleName = nil; }]; } CALayer *layer = [CALayer layer]; CGColorRef backgroundCGColor = self.editor.backgroundColor.CGColor; if (backgroundCGColor) layer.backgroundColor = backgroundCGColor; self.editorContainer.layer = layer; } if ([changedKey isEqualToString:@"editorBaseFontInfo"]) { [self scaleWebview]; } if (!changedKey || [changedKey isEqualToString:@"editorShowWordCount"]) { if (self.preferences.editorShowWordCount) { self.wordCountWidget.hidden = NO; self.editorPaddingBottom.constant = 35.0; [self updateWordCount]; } else { self.wordCountWidget.hidden = YES; self.editorPaddingBottom.constant = 0.0; } } if (!changedKey || [changedKey isEqualToString:@"editorScrollsPastEnd"]) { self.editor.scrollsPastEnd = self.preferences.editorScrollsPastEnd; NSRect contentRect = self.editor.contentRect; NSSize minSize = self.editor.enclosingScrollView.contentSize; if (contentRect.size.height < minSize.height) contentRect.size.height = minSize.height; if (contentRect.size.width < minSize.width) contentRect.size.width = minSize.width; self.editor.frame = contentRect; } if (!changedKey) { NSClipView *contentView = self.editor.enclosingScrollView.contentView; contentView.postsBoundsChangedNotifications = YES; NSDictionary *keysAndDefaults = MPEditorKeysToObserve(); NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; for (NSString *key in keysAndDefaults) { NSString *preferenceKey = MPEditorPreferenceKeyWithValueKey(key); id value = [defaults objectForKey:preferenceKey]; value = value ? value : keysAndDefaults[key]; [self.editor setValue:value forKey:key]; } } if (!changedKey || [changedKey isEqualToString:@"editorOnRight"]) { BOOL editorOnRight = self.preferences.editorOnRight; NSArray *subviews = self.splitView.subviews; if ((!editorOnRight && subviews[0] == self.preview) || (editorOnRight && subviews[1] == self.preview)) { [self.splitView swapViews]; if (!self.previewVisible && self.previousSplitRatio >= 0.0) self.previousSplitRatio = 1.0 - self.previousSplitRatio; // Need to queue this or the views won't be initialised correctly. // Don't really know why, but this works. [[NSOperationQueue mainQueue] addOperationWithBlock:^{ self.splitView.needsLayout = YES; }]; } } [self.highlighter activate]; self.editor.automaticLinkDetectionEnabled = NO; } - (void)adjustEditorInsets { CGFloat x = self.preferences.editorHorizontalInset; CGFloat y = self.preferences.editorVerticalInset; if (self.preferences.editorWidthLimited) { CGFloat editorWidth = self.editor.frame.size.width; CGFloat maxWidth = self.preferences.editorMaximumWidth; if (editorWidth > 2 * x + maxWidth) x = (editorWidth - maxWidth) * 0.45; // We tend to expect things in an editor to shift to left a bit. // Hence the 0.45 instead of 0.5 (which whould feel a bit too much). } self.editor.textContainerInset = NSMakeSize(x, y); } - (void)redrawDivider { if (!self.editorVisible) { // If the editor is not visible, detect preview's background color via // DOM query and use it instead. This is more expensive; we should try // to avoid it. // TODO: Is it possible to cache this until the user switches the style? // Will need to take account of the user MODIFIES the style without // switching. Complicated. This will do for now. self.splitView.dividerColor = MPGetWebViewBackgroundColor(self.preview); } else if (!self.previewVisible) { // If the editor is visible, match its background color. self.splitView.dividerColor = self.editor.backgroundColor; } else { // If both sides are visible, draw a default "transparent" divider. // This works around the possibile problem of divider's color being too // similar to both the editor and preview and being obscured. self.splitView.dividerColor = nil; } } - (void)scaleWebview { if (!self.preferences.previewZoomRelativeToBaseFontSize) return; CGFloat fontSize = self.preferences.editorBaseFontSize; if (fontSize <= 0.0) return; static const CGFloat defaultSize = 14.0; CGFloat scale = fontSize / defaultSize; #if 0 // Sadly, this doesn’t work correctly. // It looks fine, but selections are offset relative to the mouse cursor. NSScrollView *previewScrollView = self.preview.mainFrame.frameView.documentView.enclosingScrollView; NSClipView *previewContentView = previewScrollView.contentView; [previewContentView scaleUnitSquareToSize:NSMakeSize(scale, scale)]; [previewContentView setNeedsDisplay:YES]; #else // Warning: this is private webkit API and NOT App Store-safe! [self.preview setPageSizeMultiplier:scale]; #endif } -(void) updateHeaderLocations { CGFloat offset = NSMinY(self.preview.enclosingScrollView.contentView.bounds); NSMutableArray *locations = [NSMutableArray array]; _webViewHeaderLocations = [[self.preview.mainFrame.javaScriptContext evaluateScript:@"var arr = Array.prototype.slice.call(document.querySelectorAll(\"h1, h2, h3, h4, h5, h6, img:only-child\")); arr.map(function(n){ return n.getBoundingClientRect().top })"] toArray]; // add offset to all numbers for (NSNumber *location in _webViewHeaderLocations) { [locations addObject:@([location floatValue] + offset)]; } _webViewHeaderLocations = [locations copy]; // Next, cache the locations of all of the reference nodes in the editor view. NSInteger characterCount = 0; NSLayoutManager *layoutManager = [self.editor layoutManager]; NSArray *documentLines = [self.editor.string componentsSeparatedByString:@"\n"]; [locations removeAllObjects]; // These are the patterns for markdown headers and images respectively. we're only going to // handle images that are not inline with other text/images NSRegularExpression *dashRegex = [NSRegularExpression regularExpressionWithPattern:@"^([-]+)$" options:0 error:nil]; NSRegularExpression *headerRegex = [NSRegularExpression regularExpressionWithPattern:@"^(#+)\\s" options:0 error:nil]; NSRegularExpression *imgRegex = [NSRegularExpression regularExpressionWithPattern:@"^!\\[[^\\]]*\\]\\([^)]*\\)$" options:0 error:nil]; BOOL previousLineHadContent = NO; CGFloat editorContentHeight = ceilf(NSHeight(self.editor.enclosingScrollView.documentView.bounds)); CGFloat editorVisibleHeight = ceilf(NSHeight(self.editor.enclosingScrollView.contentView.bounds)); // We start by splitting our document into lines, and then searching // line by line for headers or images. for (NSInteger lineNumber = 0; lineNumber < [documentLines count]; lineNumber++) { NSString *line = documentLines[lineNumber]; if ((previousLineHadContent && [dashRegex numberOfMatchesInString:line options:0 range:NSMakeRange(0, [line length])]) || [imgRegex numberOfMatchesInString:line options:0 range:NSMakeRange(0, [line length])] || [headerRegex numberOfMatchesInString:line options:0 range:NSMakeRange(0, [line length])]) { // Calculate where this header/image appears vertically in the editor NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:NSMakeRange(characterCount, [line length]) actualCharacterRange:nil]; NSRect topRect = [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:[self.editor textContainer]]; CGFloat headerY = NSMidY(topRect); if(headerY <= editorContentHeight - editorVisibleHeight){ [locations addObject:@(headerY)]; } } previousLineHadContent = [line length] && ![dashRegex numberOfMatchesInString:line options:0 range:NSMakeRange(0, [line length])]; characterCount += [line length] + 1; } _editorHeaderLocations = [locations copy]; } - (void)syncScrollers { CGFloat editorContentHeight = ceilf(NSHeight(self.editor.enclosingScrollView.documentView.bounds)); CGFloat editorVisibleHeight = ceilf(NSHeight(self.editor.enclosingScrollView.contentView.bounds)); CGFloat previewContentHeight = ceilf(NSHeight(self.preview.enclosingScrollView.documentView.bounds)); CGFloat previewVisibleHeight = ceilf(NSHeight(self.preview.enclosingScrollView.contentView.bounds)); NSInteger relativeHeaderIndex = -1; // -1 is start of document, before any other header CGFloat currY = NSMinY(self.editor.enclosingScrollView.contentView.bounds); CGFloat minY = 0; CGFloat maxY = 0; // align the documents at the middle of the screen, except at top/bottom of document CGFloat topTaper = MAX(0, MIN(1.0, currY / editorVisibleHeight)); CGFloat bottomTaper = 1.0 - MAX(0, MIN(1.0, (currY - editorContentHeight + 2 * editorVisibleHeight) / editorVisibleHeight)); CGFloat adjustmentForScroll = topTaper * bottomTaper * editorVisibleHeight / 2; // We start by splitting our document into lines, and then searching // line by line for headers or images. for (NSNumber *headerYNum in _editorHeaderLocations) { CGFloat headerY = [headerYNum floatValue]; headerY -= adjustmentForScroll; if (headerY < currY) { // The header is before our current scroll position. the closest // of these will be our first reference node relativeHeaderIndex += 1; minY = headerY; } else if (maxY == 0 && headerY < editorContentHeight - editorVisibleHeight) { // Skip any headers that are within the last screen of the editor. // we'll interpolate to the end of the document in that case. maxY = headerY; } } // Usually, we'll be scrolling between two reference nodes, but toward the end // of the document we'll ignore nodes and reference the end of the document instead BOOL interpolateToEndOfDocument = NO; if (maxY == 0) { // We only have a reference node before our current position, // but not after, so we'll use the end of the document. maxY = editorContentHeight - editorVisibleHeight + adjustmentForScroll; interpolateToEndOfDocument = YES; } // We are currently at currY offset, between minY and maxY, which represent // headers indexed by relativeHeaderIndex and relativeHeaderIndex+1. currY = MAX(0, currY - minY); maxY -= minY; minY -= minY; CGFloat percentScrolledBetweenHeaders = MAX(0, MIN(1.0, currY / maxY)); // Now that we know where the editor position is relative to two reference nodes, // we need to find the positions of those nodes in the HTML preview CGFloat topHeaderY = 0; CGFloat bottomHeaderY = previewContentHeight - previewVisibleHeight; // Find the Y positions in the preview window that we're scrolling between if ([_webViewHeaderLocations count] > relativeHeaderIndex) { topHeaderY = floorf([_webViewHeaderLocations[relativeHeaderIndex] doubleValue]) - adjustmentForScroll; } if (!interpolateToEndOfDocument && [_webViewHeaderLocations count] > relativeHeaderIndex + 1) { bottomHeaderY = ceilf([_webViewHeaderLocations[relativeHeaderIndex + 1] doubleValue]) - adjustmentForScroll; } // Now we scroll percentScrolledBetweenHeaders percent between those two positions in the webview CGFloat previewY = topHeaderY + (bottomHeaderY - topHeaderY) * percentScrolledBetweenHeaders; NSRect contentBounds = self.preview.enclosingScrollView.contentView.bounds; contentBounds.origin.y = previewY; self.preview.enclosingScrollView.contentView.bounds = contentBounds; } - (void)setSplitViewDividerLocation:(CGFloat)ratio { BOOL wasVisible = self.previewVisible; [self.splitView setDividerLocation:ratio]; if (!wasVisible && self.previewVisible && !self.preferences.markdownManualRender) [self.renderer parseAndRenderNow]; [self setupEditor:NSStringFromSelector(@selector(editorHorizontalInset))]; } - (NSString *)presumedFileName { if (self.fileURL) return self.fileURL.lastPathComponent.stringByDeletingPathExtension; NSString *title = nil; NSString *string = self.editor.string; if (self.preferences.htmlDetectFrontMatter) title = [[[string frontMatter:NULL] objectForKey:@"title"] description]; if (title) return title; title = string.titleString; if (!title) return NSLocalizedString(@"Untitled", @"default filename if no title can be determined"); static NSRegularExpression *regex = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ regex = [NSRegularExpression regularExpressionWithPattern:@"[/|:]" options:0 error:NULL]; }); NSRange range = NSMakeRange(0, title.length); title = [regex stringByReplacingMatchesInString:title options:0 range:range withTemplate:@"-"]; return title; } - (void)updateWordCount { DOMNodeTextCount count = self.preview.mainFrame.DOMDocument.textCount; self.totalWords = count.words; self.totalCharacters = count.characters; self.totalCharactersNoSpaces = count.characterWithoutSpaces; if (self.isPreviewReady) self.wordCountWidget.enabled = YES; } - (BOOL)isCurrentBaseUrl:(NSURL *)another { NSString *mine = self.currentBaseUrl.absoluteBaseURLString; NSString *theirs = another.absoluteBaseURLString; return mine == theirs || [mine isEqualToString:theirs]; } #define OPEN_FAIL_ALERT_INFORMATIVE NSLocalizedString( \ @"Please check the path of your link is correct. Turn on \ “Automatically create link targets” If you want MacDown to \ create nonexistent link targets for you.", \ @"preview navigation error information") #define AUTO_CREATE_FAIL_ALERT_INFORMATIVE NSLocalizedString( \ @"MacDown can’t create a file for the clicked link because \ the current file is not saved anywhere yet. Save the \ current file somewhere to enable this feature.", \ @"preview navigation error information") - (void)openOrCreateFileForUrl:(NSURL *)url { // Simply open the file if it is not local, or exists already. BOOL file = url.isFileURL; BOOL reachable = !file || [url checkResourceIsReachableAndReturnError:NULL]; // If the file is local but doesn't exist, check if a file with // the .md extension exists. if (file && !reachable && [url.pathExtension isEqualToString:@""]) { NSURL *markdownURL = [url URLByAppendingPathExtension:@"md"]; if ([markdownURL checkResourceIsReachableAndReturnError:NULL]) { reachable = YES; url = markdownURL; } } if (reachable) { [[NSWorkspace sharedWorkspace] openURL:url]; return; } // Show an error if the user doesn't want us to create it automatically. if (!self.preferences.createFileForLinkTarget) { NSAlert *alert = [[NSAlert alloc] init]; NSString *template = NSLocalizedString( @"File not found at path:\n%@", @"preview navigation error message"); alert.messageText = [NSString stringWithFormat:template, url.path]; alert.informativeText = OPEN_FAIL_ALERT_INFORMATIVE; [alert runModal]; return; } // We can only create a file if the current file is saved. (Why?) if (!self.fileURL) { NSAlert *alert = [[NSAlert alloc] init]; NSString *template = NSLocalizedString( @"Can’t create file:\n%@", @"preview navigation error message"); alert.messageText = [NSString stringWithFormat:template, url.lastPathComponent]; alert.informativeText = AUTO_CREATE_FAIL_ALERT_INFORMATIVE; [alert runModal]; } // Try to created the file. NSDocumentController *controller = [NSDocumentController sharedDocumentController]; NSError *error = nil; id doc = [controller createNewEmptyDocumentForURL:url display:YES error:&error]; if (!doc) { NSAlert *alert = [[NSAlert alloc] init]; NSString *template = NSLocalizedString( @"Can’t create file:\n%@", @"preview navigation error message"); alert.messageText = [NSString stringWithFormat:template, url.lastPathComponent]; template = NSLocalizedString( @"An error occurred while creating the file:\n%@", @"preview navigation error information"); alert.informativeText = [NSString stringWithFormat:template, error.localizedDescription]; [alert runModal]; } } - (void)document:(NSDocument *)doc didPrint:(BOOL)ok context:(void *)context { if ([doc respondsToSelector:@selector(setPrinting:)]) ((MPDocument *)doc).printing = NO; if (context) { NSInvocation *invocation = (__bridge NSInvocation *)context; if ([invocation isKindOfClass:[NSInvocation class]]) { [invocation setArgument:&doc atIndex:0]; [invocation setArgument:&ok atIndex:1]; [invocation invoke]; } } } @end