Starting to build a notes app

I was recently using a note-taking app. I liked it, but I got pretty frustrated by its syncing feature—it kept on deleting and overwriting my notes as I was switching between my computer and phone! So I started wondering to myself, what’ll it take to make a something that syncs a little better?

I decided to investigate Automerge, a CRDT library. I briefly considered Yjs, but disqualified it as an option since their Rust port, Yrs, used unsafe pretty liberally. This was a little disappointing since Yjs generally performed better in benchmarks. Rust support is important to me since the Rust port is likely to be the source of most of their bindings to other languages.

I wanted to build towards building cross-platform native apps, but I decided to use a web-based text editor. I decided to look into ProseMirror. It seems to be a very mature and popular one.

So to conclude my introduction, I wanted:

  • Fully offline editing
  • Conflict free syncing
  • Native and cross-platform apps, focusing on macOS and iOS
  • Eventually:
    • E2E encryption
    • Append-only CLI interface

I asked for some tips with building native apps in Swift, and a fellow Recurser said that he often builds separate spikes when building apps in Xcode, so I took that advice here.

My first question was around embedding ProseMirror inside a native SwiftUI app. How hard would it be? Would it be fast enough?

I was pleasantly surprised about how simple it was. I won’t go into details, but right now, I’m:

  • Using a WKWebView to display the editor
  • Wrapping the web view inside a SwiftUI view
  • Wrapping that web view inside a NavigationSplitView for a nice navigation UI
  • Using esbuild to build the front end files
  • Packaging the files into the app so it runs offline

Screenshot of a text editor with minimal styling, running in an iOS simulator

With that I got an ugly editor to show up! I already have some experience with building native apps that need communication between JavaScript and native code, so I’m not too worried about that part right now. Things seemed fast enough and natural enough—I’m pretty sure I can style the UI to look like the system UI when it’s time to make things look nice. This answered most of my questions around the text editor in a native app.

So that’s it for this spike. My next big question was around using Automerge. But for now, here are some code snippets. I’m zooming through here, but there’s very little that’s particularly innovative. I found it pretty difficult sometimes to work only with code snippets, so I hope that the full source code of these files are helpful!

Wrappers for iOS/macOS compatibility

//
//  Representable.swift
//  ProsemirrorWrapper
//
//  Created by Zach Ahn on 2/1/24.
//

import SwiftUI

#if os(iOS)
    typealias UnitedRepresentableView = UIViewRepresentable
#elseif os(macOS)
    typealias UnitedRepresentableView = NSViewRepresentable
#endif

The sample below includes some code to inject some JavaScript into the web view. It isn’t super important, but I left it there in case a full example was helpful.

I have a folder called EditorSource where I put in my JS, and a build folder called Editor. Here’s a visual representation of what it looks like on Xcode

Below, I’m referencing this exact Editor directory and telling the WebView to load it.

//
//  EditorWebView.swift
//  ProsemirrorWrapper
//
//  Created by Zach Ahn on 1/31/24.
//

import SwiftUI
import WebKit

class EditorScriptMessageHandler: NSObject, WKScriptMessageHandler {
    override init() {
        super.init()
    }

    func userContentController(_: WKUserContentController, didReceive _: WKScriptMessage) {
        // TODO:
    }
}

struct EditorWebView: UnitedRepresentableView {
    func makeView() -> WKWebView {
        let configuration = WKWebViewConfiguration()
        let scriptMessageHandler = EditorScriptMessageHandler()
        let controller = WKUserContentController()

        let scriptBody = """
        console.log("Hello From The App");
        """
        let script = WKUserScript(source: scriptBody, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
        controller.addUserScript(script)
        controller.add(scriptMessageHandler, name: "sizeNotification")

        configuration.userContentController = controller

        let wkwebView = WKWebView(frame: .zero, configuration: configuration)
        #if DEBUG
            wkwebView.isInspectable = true
        #endif
        let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "Editor")!
        wkwebView.loadFileURL(url, allowingReadAccessTo: url)

        return wkwebView
    }

    #if os(iOS)
        func makeUIView(context _: Context) -> WKWebView {
            makeView()
        }

        func updateUIView(_: WKWebView, context _: Context) {}
    #elseif os(macOS)
        public func makeNSView(context _: Context) -> WKWebView {
            makeView()
        }

        public func updateNSView(_: WKWebView, context _: Context) {}
    #endif
}

#Preview {
    EditorWebView()
}
Posted on 2024-02-11 07:45 PM -0500
Contact
  • hello(at)zachahn(dot)com
© Copyright 2008–2024 Zach Ahn