From b16a0fe8829967975c1c534378a69d4f1a3f9ae9 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 10 Sep 2025 23:05:35 +1200 Subject: [PATCH] Extract WebBridge to be reused in SwiftUI.WebView --- ios/Demo-iOS/Sources/ContentView.swift | 11 +- ios/Demo-iOS/Sources/EditorView.swift | 24 +++- .../Cache/CachedAssetSchemeHandler.swift | 123 +++++++++++------ .../Sources/SwiftUI/GutenbergEditor.swift | 65 +++++++++ .../{ => UIKit}/EditorViewController.swift | 113 ++++----------- .../EditorViewControllerDelegate.swift | 0 .../Sources/{ => UIKit}/GBWebView.swift | 0 .../GutenbergKit/Sources/WebBridge.swift | 130 ++++++++++++++++++ 8 files changed, 323 insertions(+), 143 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/SwiftUI/GutenbergEditor.swift rename ios/Sources/GutenbergKit/Sources/{ => UIKit}/EditorViewController.swift (76%) rename ios/Sources/GutenbergKit/Sources/{ => UIKit}/EditorViewControllerDelegate.swift (100%) rename ios/Sources/GutenbergKit/Sources/{ => UIKit}/GBWebView.swift (100%) create mode 100644 ios/Sources/GutenbergKit/Sources/WebBridge.swift diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index 747107a2..07cf9b45 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -5,13 +5,15 @@ let editorURL: URL? = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL" struct ContentView: View { + @AppStorage("UseSwiftUIView") var useSwiftUI: Bool = false + let remoteEditorConfigurations: [EditorConfiguration] = [.template] var body: some View { List { Section { NavigationLink { - EditorView(configuration: .default) + EditorView(configuration: .default, useSwiftUI: useSwiftUI) } label: { Text("Bundled Editor") } @@ -20,7 +22,7 @@ struct ContentView: View { Section { ForEach(remoteEditorConfigurations, id: \.siteURL) { configuration in NavigationLink { - EditorView(configuration: configuration) + EditorView(configuration: configuration, useSwiftUI: useSwiftUI) } label: { Text(URL(string: configuration.siteURL)?.host ?? configuration.siteURL) } @@ -38,6 +40,10 @@ struct ContentView: View { Text("Note: The editor is backed by the compiled web app created by `make build`.") } } + + Section("Configuration") { + Toggle(isOn: $useSwiftUI) { Text("Use SwiftUI WebView") } + } } .toolbar { ToolbarItem(placement: .primaryAction) { @@ -57,7 +63,6 @@ struct ContentView: View { } label: { Image(systemName: "arrow.clockwise") } - } } } diff --git a/ios/Demo-iOS/Sources/EditorView.swift b/ios/Demo-iOS/Sources/EditorView.swift index c8afea9e..6014e170 100644 --- a/ios/Demo-iOS/Sources/EditorView.swift +++ b/ios/Demo-iOS/Sources/EditorView.swift @@ -2,14 +2,11 @@ import SwiftUI import GutenbergKit struct EditorView: View { - private let configuration: EditorConfiguration - - init(configuration: EditorConfiguration) { - self.configuration = configuration - } + let configuration: EditorConfiguration + let useSwiftUI: Bool var body: some View { - _EditorView(configuration: configuration) + editorView .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button(action: {}, label: { @@ -35,6 +32,19 @@ struct EditorView: View { } } + @ViewBuilder + var editorView: some View { + if #available(iOS 26.0, *) { + if useSwiftUI { + GutenbergEditor(configuration: configuration) + } else { + _EditorView(configuration: configuration) + } + } else { + _EditorView(configuration: configuration) + } + } + private var moreMenu: some View { Menu { Section { @@ -96,6 +106,6 @@ private struct _EditorView: UIViewControllerRepresentable { #Preview { NavigationStack { - EditorView(configuration: .default) + EditorView(configuration: .default, useSwiftUI: false) } } diff --git a/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift index 6b57f9ca..3b42e59e 100644 --- a/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift +++ b/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift @@ -23,7 +23,7 @@ class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { return nil } - let worker: Worker + private let worker: Worker init(library: EditorAssetsLibrary) { self.worker = .init(library: library) @@ -40,69 +40,102 @@ class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { await worker.stop(urlSchemeTask) } } +} - actor Worker { - struct TaskInfo { - var webViewTask: WKURLSchemeTask - var fetchAssetTask: Task +private actor Worker { + struct TaskInfo { + var webViewTask: WKURLSchemeTask + var fetchAssetTask: Task - func cancel() { - fetchAssetTask.cancel() - } - } + func cancel() { + fetchAssetTask.cancel() + } + } + + let library: EditorAssetsLibrary + var tasks: [ObjectIdentifier: TaskInfo] = [:] + + init(library: EditorAssetsLibrary) { + self.library = library + } - let library: EditorAssetsLibrary - var tasks: [ObjectIdentifier: TaskInfo] = [:] + deinit { + for (_, task) in tasks { + task.cancel() + } + } - init(library: EditorAssetsLibrary) { - self.library = library + func start(_ task: WKURLSchemeTask) { + guard let url = task.request.url, let httpURL = CachedAssetSchemeHandler.originalHTTPURL(from: url) else { + task.didFailWithError(URLError(.badURL)) + return } - deinit { - for (_, task) in tasks { - task.cancel() + let taskKey = ObjectIdentifier(task) + + let fetchAssetTask = Task { [library, weak self] in + do { + let (response, content) = try await library.cacheAsset(from: httpURL, webViewURL: url) + + await self?.tasks[taskKey]?.webViewTask.didReceive(response) + await self?.tasks[taskKey]?.webViewTask.didReceive(content) + + await self?.finish(with: nil, taskKey: taskKey) + } catch { + await self?.finish(with: error, taskKey: taskKey) } } + tasks[taskKey] = .init(webViewTask: task, fetchAssetTask: fetchAssetTask) + } - func start(_ task: WKURLSchemeTask) { - guard let url = task.request.url, let httpURL = CachedAssetSchemeHandler.originalHTTPURL(from: url) else { - task.didFailWithError(URLError(.badURL)) - return - } + func stop(_ task: WKURLSchemeTask) { + let taskKey = ObjectIdentifier(task) + tasks[taskKey]?.cancel() + tasks[taskKey] = nil + } + + private func finish(with error: Error?, taskKey: ObjectIdentifier) { + guard let task = tasks[taskKey] else { return } - let taskKey = ObjectIdentifier(task) + if let error { + task.webViewTask.didFailWithError(error) + } else { + task.webViewTask.didFinish() + } + tasks[taskKey] = nil + } +} + +@available(iOS 26.0, *) +extension CachedAssetSchemeHandler: URLSchemeHandler { + func reply(for request: URLRequest) -> AsyncThrowingStream { + AsyncThrowingStream { [library = worker.library] continuation in + let task = Task { + guard let url = request.url, + let httpURL = CachedAssetSchemeHandler.originalHTTPURL(from: url) else { + continuation.yield(with: .failure(URLError(.badURL))) + continuation.finish() + return + } - let fetchAssetTask = Task { [library, weak self] in do { let (response, content) = try await library.cacheAsset(from: httpURL, webViewURL: url) + try Task.checkCancellation() - await self?.tasks[taskKey]?.webViewTask.didReceive(response) - await self?.tasks[taskKey]?.webViewTask.didReceive(content) - - await self?.finish(with: nil, taskKey: taskKey) + continuation.yield(with: .success(.response(response))) + continuation.yield(with: .success(.data(content))) } catch { - await self?.finish(with: error, taskKey: taskKey) + try Task.checkCancellation() + continuation.yield(with: .failure(error)) } + continuation.finish() } - tasks[taskKey] = .init(webViewTask: task, fetchAssetTask: fetchAssetTask) - } - - func stop(_ task: WKURLSchemeTask) { - let taskKey = ObjectIdentifier(task) - tasks[taskKey]?.cancel() - tasks[taskKey] = nil - } - private func finish(with error: Error?, taskKey: ObjectIdentifier) { - guard let task = tasks[taskKey] else { return } - - if let error { - task.webViewTask.didFailWithError(error) - } else { - task.webViewTask.didFinish() + continuation.onTermination = { + if case .cancelled = $0 { + task.cancel() + } } - tasks[taskKey] = nil } } } - diff --git a/ios/Sources/GutenbergKit/Sources/SwiftUI/GutenbergEditor.swift b/ios/Sources/GutenbergKit/Sources/SwiftUI/GutenbergEditor.swift new file mode 100644 index 00000000..72148975 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/SwiftUI/GutenbergEditor.swift @@ -0,0 +1,65 @@ +import Foundation +import SwiftUI +import WebKit +import Combine + +@available(iOS 26.0, *) +public struct GutenbergEditor: View { + @StateObject private var viewModel: GutenbergEditorViewModel + + public init(configuration: EditorConfiguration = .default) { + self._viewModel = StateObject(wrappedValue: GutenbergEditorViewModel(configuration: configuration)) + } + + public var body: some View { + WebView(viewModel.webPage) + .textSelection(.enabled) + .scrollDismissesKeyboard(.interactively) + .task { + await viewModel.loadEditor() + } + } + +} + +@available(iOS 26.0, *) +@MainActor +private final class GutenbergEditorViewModel: ObservableObject { + @Published private(set) var webPage: WebPage + + var configuration: EditorConfiguration + private let webBridge: WebBridge + private let controller: GutenbergEditorController + + init(configuration: EditorConfiguration = .default) { + self.configuration = configuration + self.webBridge = WebBridge(configuration: configuration) + self.controller = GutenbergEditorController(configuration: configuration) + + var config = WebPage.Configuration() + webBridge.configure(with: &config) + + self.webPage = WebPage(configuration: config, navigationDecider: controller) + +#if DEBUG + self.webPage.isInspectable = true +#endif + } + + func loadEditor() async { + if configuration.plugins { + // Handle remote editor loading + if let remoteURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_REMOTE_URL"].flatMap(URL.init) { + webPage.load(URLRequest(url: remoteURL)) + } else { + let remoteURL = Bundle.module.url(forResource: "remote", withExtension: "html", subdirectory: "Gutenberg")! + webPage.load(URLRequest(url: remoteURL)) + } + } else if let editorURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) { + webPage.load(URLRequest(url: editorURL)) + } else { + let indexURL = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg")! + webPage.load(URLRequest(url: indexURL)) + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/UIKit/EditorViewController.swift similarity index 76% rename from ios/Sources/GutenbergKit/Sources/EditorViewController.swift rename to ios/Sources/GutenbergKit/Sources/UIKit/EditorViewController.swift index 99c17876..9cfa8ddf 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/UIKit/EditorViewController.swift @@ -5,9 +5,9 @@ import Combine import CryptoKit @MainActor -public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { +public final class EditorViewController: UIViewController { public let webView: WKWebView - let assetsLibrary: EditorAssetsLibrary + let webBridge: WebBridge public var configuration: EditorConfiguration private var _isEditorRendered = false @@ -28,7 +28,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Initalizes the editor with the initial content (Gutenberg). public init(configuration: EditorConfiguration = .default, isWarmupMode: Bool = false) { self.configuration = configuration - self.assetsLibrary = EditorAssetsLibrary(configuration: configuration) + self.webBridge = WebBridge(configuration: configuration) self.controller = GutenbergEditorController(configuration: configuration) // The `allowFileAccessFromFileURLs` allows the web view to access the @@ -37,16 +37,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") config.setValue(true, forKey: "allowUniversalAccessFromFileURLs") - // Set-up communications with the editor. - config.userContentController.add(controller, name: "editorDelegate") - // This is important so they user can't select anything but text across blocks. config.selectionGranularity = .character - let schemeHandler = CachedAssetSchemeHandler(library: assetsLibrary) - for scheme in CachedAssetSchemeHandler.supportedURLSchemes { - config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) - } + self.webBridge.configure(with: config) self.webView = GBWebView(frame: .zero, configuration: config) self.webView.scrollView.keyboardDismissMode = .interactive @@ -54,6 +48,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.isWarmupMode = isWarmupMode super.init(nibName: nil, bundle: nil) + + self.webBridge.messages + .sink { [weak self] in self?.didReceive(message: $0) } + .store(in: &cancellables) } required init?(coder: NSCoder) { @@ -63,7 +61,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro public override func viewDidLoad() { super.viewDidLoad() - controller.delegate = self webView.navigationDelegate = controller // FIXME: implement with CSS (bottom toolbar) @@ -81,7 +78,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro webView.alpha = 0 if isWarmupMode { - setUpEditor() loadEditor() } @@ -113,21 +109,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - private func setUpEditor() { - let webViewConfiguration = webView.configuration - let userContentController = webViewConfiguration.userContentController - let editorInitialConfig = getEditorConfiguration() - userContentController.addUserScript(editorInitialConfig) - } - private func loadEditor() { if configuration.plugins { - webView.configuration.userContentController.addScriptMessageHandler( - EditorAssetsProvider(library: assetsLibrary), - contentWorld: .page, - name: "loadFetchedEditorAssets" - ) - if let remoteURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_REMOTE_URL"].flatMap(URL.init) { webView.load(URLRequest(url: remoteURL)) } else { @@ -142,50 +125,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - private func getEditorConfiguration() -> WKUserScript { - let escapedTitle = configuration.title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - let escapedContent = configuration.content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - - // Convert editor settings to JSON string if available - var editorSettingsJS = "undefined" - if let settings = configuration.editorSettings { - do { - let jsonData = try JSONSerialization.data(withJSONObject: settings, options: []) - if let jsonString = String(data: jsonData, encoding: .utf8) { - editorSettingsJS = jsonString - } - } catch { - NSLog("Failed to serialize editor settings: \(error)") - } - } - - let jsCode = """ - window.GBKit = { - siteURL: '\(configuration.siteURL)', - siteApiRoot: '\(configuration.siteApiRoot)', - siteApiNamespace: \(Array(configuration.siteApiNamespace)), - namespaceExcludedPaths: \(Array(configuration.namespaceExcludedPaths)), - authHeader: '\(configuration.authHeader)', - themeStyles: \(configuration.themeStyles), - hideTitle: \(configuration.hideTitle), - editorSettings: \(editorSettingsJS), - locale: '\(configuration.locale)', - post: { - id: \(configuration.postID ?? -1), - title: '\(escapedTitle)', - content: '\(escapedContent)' - }, - }; - - localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); - - "done"; - """ - - let editorScript = WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) - return editorScript - } - // MARK: - Public API // TODO: synchronize with the editor user-generated updates @@ -246,7 +185,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro guard !_isEditorSetup else { return } _isEditorSetup = true - setUpEditor() loadEditor() } @@ -298,9 +236,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));") } - // MARK: - GutenbergEditorControllerDelegate - - fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) { + private func didReceive(message: EditorJSMessage) { do { switch message.type { case .onEditorLoaded: @@ -365,14 +301,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } -@MainActor -private protocol GutenbergEditorControllerDelegate: AnyObject { - func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) -} - /// Hiding the conformances, and breaking retain cycles. -private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - weak var delegate: GutenbergEditorControllerDelegate? +final class GutenbergEditorController: NSObject { private let configuration: EditorConfiguration private let editorURL: URL? @@ -381,8 +311,9 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W self.editorURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) super.init() } +} - // MARK: - WKNavigationDelegate +extension GutenbergEditorController: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { NSLog("navigation: \(String(describing: navigation))") @@ -411,15 +342,21 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W decisionHandler(.allow) } +} - // MARK: - WKScriptMessageHandler - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let message = EditorJSMessage(message: message) else { - return NSLog("Unsupported message: \(message.body)") +@available(iOS 26.0, *) +extension GutenbergEditorController: WebPage.NavigationDeciding { + func decidePolicy(for action: WebPage.NavigationAction, preferences: inout WebPage.NavigationPreferences) async -> WKNavigationActionPolicy { + guard let url = action.request.url else { + return .allow } - MainActor.assumeIsolated { - delegate?.controller(self, didReceiveMessage: message) + + if action.navigationType == .linkActivated { + // Open the request in OS browser + await UIApplication.shared.open(url) + return .cancel } + + return .allow } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/UIKit/EditorViewControllerDelegate.swift similarity index 100% rename from ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift rename to ios/Sources/GutenbergKit/Sources/UIKit/EditorViewControllerDelegate.swift diff --git a/ios/Sources/GutenbergKit/Sources/GBWebView.swift b/ios/Sources/GutenbergKit/Sources/UIKit/GBWebView.swift similarity index 100% rename from ios/Sources/GutenbergKit/Sources/GBWebView.swift rename to ios/Sources/GutenbergKit/Sources/UIKit/GBWebView.swift diff --git a/ios/Sources/GutenbergKit/Sources/WebBridge.swift b/ios/Sources/GutenbergKit/Sources/WebBridge.swift new file mode 100644 index 00000000..95ccf39e --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/WebBridge.swift @@ -0,0 +1,130 @@ +import Foundation +import WebKit +import Combine + +@MainActor +final class WebBridge { + private let configuration: EditorConfiguration + private let assetsLibrary: EditorAssetsLibrary + private var editorDelegate: EditorDelegate + + private let messagesSubject: PassthroughSubject = .init() + var messages: AnyPublisher { messagesSubject.eraseToAnyPublisher() } + + init(configuration: EditorConfiguration) { + self.configuration = configuration + self.assetsLibrary = EditorAssetsLibrary(configuration: configuration) + self.editorDelegate = EditorDelegate() + self.editorDelegate.bridge = self + } + + fileprivate func didReceiveMessageFromWebView(_ message: EditorJSMessage) { + messagesSubject.send(message) + } +} + +// MARK: - WKWebView support (UIKit) + +extension WebBridge { + func configure(with configuration: WKWebViewConfiguration) { + configuration.userContentController.addUserScript(getEditorConfiguration()) + + configuration.userContentController.add(editorDelegate, name: "editorDelegate") + + configuration.userContentController.addScriptMessageHandler( + EditorAssetsProvider(library: assetsLibrary), + contentWorld: .page, + name: "loadFetchedEditorAssets" + ) + + let schemeHandler = CachedAssetSchemeHandler(library: assetsLibrary) + for scheme in CachedAssetSchemeHandler.supportedURLSchemes { + configuration.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) + } + } +} + +// MARK: - WebPage support (SwiftUI) + +@available(iOS 26.0, *) +extension WebBridge { + func configure(with configuration: inout WebPage.Configuration) { + configuration.userContentController.addUserScript(getEditorConfiguration()) + + configuration.userContentController.add(editorDelegate, name: "editorDelegate") + + configuration.userContentController.addScriptMessageHandler( + EditorAssetsProvider(library: assetsLibrary), + contentWorld: .page, + name: "loadFetchedEditorAssets" + ) + + let schemeHandler = CachedAssetSchemeHandler(library: assetsLibrary) + for scheme in CachedAssetSchemeHandler.supportedURLSchemes { + if let urlScheme = URLScheme(scheme) { + configuration.urlSchemeHandlers[urlScheme] = schemeHandler + } + } + } +} + +private extension WebBridge { + func getEditorConfiguration() -> WKUserScript { + let escapedTitle = configuration.title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let escapedContent = configuration.content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + + // Convert editor settings to JSON string if available + var editorSettingsJS = "undefined" + if let settings = configuration.editorSettings { + do { + let jsonData = try JSONSerialization.data(withJSONObject: settings, options: []) + if let jsonString = String(data: jsonData, encoding: .utf8) { + editorSettingsJS = jsonString + } + } catch { + NSLog("Failed to serialize editor settings: \(error)") + } + } + + let jsCode = """ + window.GBKit = { + siteURL: '\(configuration.siteURL)', + siteApiRoot: '\(configuration.siteApiRoot)', + siteApiNamespace: \(Array(configuration.siteApiNamespace)), + namespaceExcludedPaths: \(Array(configuration.namespaceExcludedPaths)), + authHeader: '\(configuration.authHeader)', + themeStyles: \(configuration.themeStyles), + hideTitle: \(configuration.hideTitle), + editorSettings: \(editorSettingsJS), + locale: '\(configuration.locale)', + post: { + id: \(configuration.postID ?? -1), + title: '\(escapedTitle)', + content: '\(escapedContent)' + }, + }; + + localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); + + "done"; + """ + + let editorScript = WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) + return editorScript + } +} + +private class EditorDelegate: NSObject, WKScriptMessageHandler { + weak var bridge: WebBridge? + + func attach(to controller: WKUserContentController) { + controller.add(self, name: "editorDelegate") + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let message = EditorJSMessage(message: message) else { + return NSLog("Unsupported message: \(message.body)") + } + bridge?.didReceiveMessageFromWebView(message) + } +}