Files
2019-08-20 15:23:09 +02:00

2018 lines
66 KiB
Objective-C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 <WebKit/WebKit.h>
#import <JJPluralForm/JJPluralForm.h>
#import <hoedown/html.h>
#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 <JavaScriptCore/JavaScriptCore.h>
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 ()
<NSSplitViewDelegate, NSTextViewDelegate,
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 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<NSNumber *> *webViewHeaderLocations;
@property (strong) NSArray<NSNumber *> *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<NSValidatedUserInterfaceItem>)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<WebPolicyDecisionListener>)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<NSDraggingInfo>)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 = @"<html>(.*)</html>";
static int opts = NSRegularExpressionDotMatchesLineSeparators;
// Find things inside the <html> 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 <html> 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:@"<!--" suffix:@"-->"];
}
- (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 doesnt 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<NSNumber *> *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<NSString *> *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 cant 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(
@"Cant 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(
@"Cant 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