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:
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:
WKWebView
to display the editorNavigationSplitView
for a nice navigation UIWith 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!
//
// 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()
}