Create a Mac Markdown Editor with Live Preview

I’m working on a Mac app that previews HTML in a web view, and it gave me a good idea for a tutorial. Create a simple Markdown editor with live preview.

I have the project on GitHub for you to download.

Prerequisites

To take full advantage of this tutorial, you must be running Xcode 11 or later. I use the Swift Package Manager support added in Xcode 11 to add the Ink parser to the project. It might be possible to build a framework for Ink by cloning the GitHub project, but I have not tried it. You could also try searching GitHub for another Markdown parser that supports earlier Xcode versions.

I recommend reading the Create a Document-Based Mac App in Swift article. It provides an introduction to creating document-based Mac apps and covers some topics that I gloss over in this tutorial. It also has links to other introductory Mac development articles. This tutorial is long enough. If I were to add explanations on basic topics like making connections in storyboards, the tutorial would be as long as a short book.

Create the Project

Create a Cocoa App project in Xcode. Choose Storyboards from the User Interface menu because this project does not use SwiftUI. Select the Create Document-Based Application checkbox. Enter md for the document extension, which is the file extension for Markdown documents.

Deal with the App Sandbox

When I created this project I could not get the live preview to display any HTML. The issue involved the App Sandbox. New Xcode projects use the App Sandbox, which disables all network connections by default.

There are two ways to work around the App Sandbox. You can turn off the App Sandbox or turn on network connections by selecting the two Network checkboxes.

AppSandboxNetworkConnections

Import the Ink Parser

This article uses John Sundell’s Ink Markdown parser to convert Markdown to HTML. Add the Ink parser to the list of Swift packages.

Select the project from the project navigator to open the project editor. Select the project file in the project editor. Click the Swift Packages button at the top of the project editor to see a list of installed Swift packages. Click the Add button to add a Swift package.

Enter Ink in the search field and press the Return key to see a list of Swift package repositories with Ink in the name.

ChooseInkParser

Select the Ink item whose owner is JohnSundell. Click the Next button.

Now you must determine which version of the Ink parser to use. Click the Branch radio button and enter master. This tells Xcode to use the most recent stable version of the Ink parser.

SwiftPackageManagerRules

Click the Next button to finish adding the Ink parser Swift package to the project.

Build the User Interface

The user interface for the Markdown editor consists of a vertical split view with two items. The left view is a plain text text view where you enter Markdown text. The right view is a web view that shows the HTML version of the Markdown text.

Open the storyboard. You will see a window with a view controller. Delete that view controller. Add a vertical split view controller to the storyboard. Make the split view controller the window’s content controller by making a connection from the Window Controller item in the window controller scene to the split view controller and selecting the window content relationship segue.

Adding the split view controller creates view controller scenes for the two items in the split view. Remove the views in the two view controllers. Add a Plain Document Content Text View item to the first view controller. Add a WebKit View item to the second view controller.

Select the text view’s scroll view, open the size inspector, and click the two arrows in the inner autoresizing square. Select the web view and do the same thing. The text view and web view will now resize properly when the window resizes.

Set the Text View’s Delegate

Set the text view’s delegate to the text view controller so the text view controller will get notified when the text view’s contents change.

Make a connection from the text view to the text view controller in the storyboard to set the delegate.

Create View Controller Subclasses

The next step is to create Swift files for the three view controllers you created in the storyboard: split view controller, text view controller, and web view controller. I use the name preview view controller for the web view controller.

Choose File > New > File to add a new file to the project. Select Cocoa Class from the list of Mac file templates. Enter the name of the class. For the subclass enter NSSplitViewController for the split view controller and NSViewController for the other two view controllers. Do not create xib files for the view controllers. You created the view controllers in the storyboard.

The text view controller must conform to the NSTextDelegate protocol to receive any text view notifications, such as the text view contents changing.

class TextViewController: NSViewController, NSTextDelegate {

}

Open the storyboard and the identity inspector. Set the custom class for each view controller to the subclass you created.

Create Outlets

The text view controller needs an outlet to access the text view. The preview view controller needs an outlet to access the web view.

Open the storyboard and the view controller source file in separate editors. Make a connection from the view in the storyboard to the class in the source code file to create and connect an outlet.

Accessing the Document

The view controllers need access to the document to access and change its contents. The split view controller has access to the document so you can get the document from the split view controller. Add the following function to the split view controller:

func getDocument() -> Document? {
    if let window = view.window,
        let windowController = window.windowController {

        return windowController.document as? Document
    }
    return nil
}

Reaching the document requires some deep navigation. You have to get the view, get the view’s window, and get the window’s window controller to access the document.

Accessing the Other View Controllers

An annoying aspect of Mac split view controllers is they have no easy way for the child view controllers to communicate with each other, even though they’re grouped together in the split view. Each view controller scene is self-contained. For the Markdown editor the text view controller knows nothing about the web view. The web view controller knows nothing about the text view. The view controllers have to go through the split view controller to access the other view controllers.

The split view controller has a children property that contains a list of child view controllers. The text view controller is the first child.

func getTextViewController() -> TextViewController? {
    if let viewController = children.first as? TextViewController {
        return viewController
    }
    return nil
}

The preview view controller is the second child.

func getPreviewViewController() -> PreviewViewController? {
    if let viewController = children[1] as? PreviewViewController {
        return viewController
    }
    return nil
}

Add those two functions to the split view controller.

Coding the Live Preview

When the text changes, you must convert the Markdown to HTML and display it in the web view. Start by importing the Ink framework in the preview view controller.

import Ink

To convert the Markdown to HTML in Ink, create a MarkdownParser object and call its html function.

func parse(text: String) -> String {
    let parser = MarkdownParser()
    return parser.html(from: text)
}

To display the HTML in the web view, call the web view’s loadHTMLString function. Supply the HTML string as an argument. You can also supply a base URL for relative paths, which can help if you’re going to display images.

func updatePreview(text: String) {
    let html = parse(text: text)
    previewView.loadHTMLString(html, baseURL: nil)
}

Add the parse and updatePreview functions to the preview view controller.

Telling the Web View to Update

Call the preview view controller’s updatePreview function to update the web view. Add the following function to the text view controller:

func updateLivePreview(text: String) {
    if let splitViewController = parent as? SplitViewController,
        let previewController = 
            splitViewController.getPreviewViewController() {

        previewController.updatePreview(text: text)
    }
}

Notice how the text view controller has to go through the split view controller to access the preview view controller to update the preview.

Call updateLivePreview when the text view’s contents change. Add the following function to the text view controller:

func textDidChange(_ notification: Notification) {
    updateLivePreview(text: textView.string)
}

Run the project now. You should be able to type in the text view and see the HTML in the web view.

Updating the Document Contents

When the text view’s contents change, the document’s contents should also change. Start by adding the following property to the Document class:

var markdown = ""

The markdown property stores the document’s contents.

Add the following functions to the text view controller:

func saveTextViewContents() {
    if let document = getDocument() {
        document.markdown = textView.string
    }
}

func getDocument() -> Document? {
    if let splitViewController = parent as? SplitViewController {
        return splitViewController.getDocument()
    }
    return nil
}

The saveTextViewContents function sets the document’s contents to the text view’s contents. The getDocument function is a helper function to give the text view controller access to the document.

Add a call to saveTextViewContents to the textDidChange function. Now the document’s contents update when the text view’s contents change.

Marking the Document as Changed

When someone types in the text view, you must mark the document as being edited so it will autosave. Add the following code to the textDidChange function:

if let document = getDocument() {
    document.updateChangeCount(NSDocument.ChangeType.changeDone)
}

Saving the Document

Open the Document.swift file. Change the data function to the following:

override func data(ofType typeName: String) throws -> Data {
    return markdown.data(using: .utf8) ?? Data()
}

The code takes the Markdown string and converts it to a Data object that can be saved to a file. If the conversion fails, save an empty file.

Loading the Document

Open the Document.swift file. Change the read function to the following:

override func read(from data: Data, ofType typeName: String) throws {
    if let fileContents = String(data: data,encoding: .utf8) {
        markdown = fileContents
    }
}

The code takes the Data object from the saved document and converts it to a Swift string. If there is saved text, set the document’s contents to the saved text.

Fill the Text View When Opening a Document

The last step is to fill the text view with the document’s contents when you open a document. Override the viewDidAppear function in the text view controller and set the text view’s contents to the document’s contents.

override func viewDidAppear() {
    if let document = getDocument() {
        textView.string = document.markdown
        updateLivePreview(text: textView.string)
    }
}

While you’re setting the text view contents you should also update the web view so it shows the HTML preview. That’s why there’s a call to updateLivePreview. If you didn’t have the call, the preview would be blank until you typed in the text view.

Now when you run the project you should have a basic Markdown editor. You can create documents, preview the HTML contents, save documents, and open documents.