From bb72cb81e13d583fda6399893eff5d5609277b38 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:18:01 -0600 Subject: [PATCH] Add macOS support --- Demo-iOS/Gutenberg.xcodeproj/project.pbxproj | 6 + Demo-iOS/Sources/ContentView.swift | 6 +- Demo-iOS/Sources/EditorView.swift | 55 ++- .../Sources/EditorBlockPicker.swift | 30 +- .../Sources/EditorJSMessage.swift | 4 +- .../Sources/EditorViewController.swift | 421 ++++++------------ .../Sources/EditorViewModel.swift | 42 ++ .../Sources/Extensions/Color.swift | 31 ++ Sources/GutenbergKit/Sources/GBWebView.swift | 66 ++- 9 files changed, 353 insertions(+), 308 deletions(-) create mode 100644 Sources/GutenbergKit/Sources/EditorViewModel.swift create mode 100644 Sources/GutenbergKit/Sources/Extensions/Color.swift diff --git a/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 21e9e715..d44533aa 100644 --- a/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -318,6 +318,9 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Gutenberg Development"; + REGISTER_APP_GROUPS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -348,6 +351,9 @@ PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.gutenbergkit; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/Demo-iOS/Sources/ContentView.swift b/Demo-iOS/Sources/ContentView.swift index e43f9a9b..31710ec3 100644 --- a/Demo-iOS/Sources/ContentView.swift +++ b/Demo-iOS/Sources/ContentView.swift @@ -3,9 +3,13 @@ import GutenbergKit struct ContentView: View { var body: some View { + #if os(macOS) + EditorView() + #else NavigationView { - EditorView(editorURL: URL(string: "http://localhost:5173/")!) + EditorView() } + #endif } } diff --git a/Demo-iOS/Sources/EditorView.swift b/Demo-iOS/Sources/EditorView.swift index e62235af..48ffd5cf 100644 --- a/Demo-iOS/Sources/EditorView.swift +++ b/Demo-iOS/Sources/EditorView.swift @@ -1,12 +1,28 @@ import SwiftUI import GutenbergKit +import WebKit + +#if os(iOS) || os(visionOS) +typealias ViewControllerRepresentable = UIViewControllerRepresentable +#elseif os(macOS) +typealias ViewControllerRepresentable = NSViewControllerRepresentable +#endif struct EditorView: View { - var editorURL: URL? + let viewModel = EditorViewModel( + initialTitle: "", + initialContent: "", + siteURL: "", + siteApiRoot: "", + siteApiNamespace: "", + authHeader: "", + type: "" + ) var body: some View { - _EditorView(editorURL: editorURL) + EditorViewWrapper(viewModel: viewModel) .toolbar { + #if canImport(UIKit) ToolbarItemGroup(placement: .topBarLeading) { Button(action: {}, label: { Image(systemName: "xmark") @@ -28,6 +44,7 @@ struct EditorView: View { moreMenu } + #endif } } @@ -68,21 +85,39 @@ struct EditorView: View { } } -private struct _EditorView: UIViewControllerRepresentable { - var editorURL: URL? +private struct EditorViewWrapper: ViewControllerRepresentable { + let viewModel: EditorViewModel + + private func makeViewController() -> EditorViewController { + let viewController = EditorViewController( + viewModel: viewModel, + service: .init(client: Client()) + ) +// viewController.developmentEnvironmentUrl = URL(string: "http://localhost:5173/")! + viewController.isDebuggingEnabled = true - func makeUIViewController(context: Context) -> EditorViewController { - let viewController = EditorViewController(service: .init(client: Client())) - viewController.editorURL = editorURL - if #available(iOS 16.4, *) { - viewController.webView.isInspectable = true - } return viewController } + #if canImport(UIKit) + func makeUIViewController(context: Context) -> EditorViewController { + makeViewController() + } + func updateUIViewController(_ uiViewController: EditorViewController, context: Context) { // Do nothing } + #endif + + #if canImport(AppKit) + func makeNSViewController(context: Context) -> EditorViewController { + makeViewController() + } + + func updateNSViewController(_ nsViewController: EditorViewController, context: Context) { + // Do nothing + } + #endif } struct Client: EditorNetworkingClient { diff --git a/Sources/GutenbergKit/Sources/EditorBlockPicker.swift b/Sources/GutenbergKit/Sources/EditorBlockPicker.swift index 9eb38249..ea1e21db 100644 --- a/Sources/GutenbergKit/Sources/EditorBlockPicker.swift +++ b/Sources/GutenbergKit/Sources/EditorBlockPicker.swift @@ -10,7 +10,6 @@ struct EditorBlockPicker: View { @Environment(\.dismiss) private var dismiss - var body: some View { List { ForEach(viewModel.displayedSections) { section in @@ -22,9 +21,15 @@ struct EditorBlockPicker: View { } } .toolbar(content: { + #if(os(macOS)) + ToolbarItemGroup { + _leadingToolbarItems + } + #elseif(os(iOS)) ToolbarItemGroup(placement: .topBarLeading) { - Button("Close", action: { dismiss() }) + _leadingToolbarItems } + ToolbarItemGroup(placement: .topBarTrailing) { Menu(content: { Button("Blocks", action: {}) @@ -39,8 +44,11 @@ struct EditorBlockPicker: View { } }) } + #endif }) + #if(os(iOS)) .navigationBarTitleDisplayMode(.inline) + #endif // TabView(selection: $group, // content: { @@ -64,6 +72,11 @@ struct EditorBlockPicker: View { // .tint(Color.primary) } + @ViewBuilder + var _leadingToolbarItems: some View { + Button("Close", action: { dismiss() }) + } + @ViewBuilder var _bodyBlocks: some View { VStack(spacing: 0) { @@ -76,8 +89,11 @@ struct EditorBlockPicker: View { // .padding(.bottom, 8) } + #if canImport(UIKit) .background(Color(uiColor: .secondarySystemBackground)) - + #elseif canImport(AppKit) + .background(Color(nsColor: .secondarySystemFill)) + #endif List { Section("Text") { @@ -94,7 +110,9 @@ struct EditorBlockPicker: View { _Label("Audio", systemImage: "waveform") } } + #if canImport(UIKit) .listStyle(.insetGrouped) + #endif } // .safeAreaInset(edge: .bottom) { // HStack(spacing: 30) { @@ -114,6 +132,7 @@ struct EditorBlockPicker: View { // } // .searchable(text: $searchText)//, placement: .navigationBarDrawer(displayMode: .always)) .toolbar(content: { + #if canImport(UIKit) ToolbarItemGroup(placement: .topBarLeading) { Button("Close", action: { dismiss() }) } @@ -131,8 +150,11 @@ struct EditorBlockPicker: View { } }) } + #endif }) + #if canImport(UIKit) .navigationBarTitleDisplayMode(.inline) + #endif // .toolbar(.hidden, for: .navigationBar) .tint(Color.primary) @@ -190,7 +212,7 @@ private struct MenuItem: View { .foregroundStyle(isSelected ? Color.primary : Color.secondary) Rectangle() .frame(height: 2) - .foregroundStyle(isSelected ? Color.black : Color(uiColor: .separator)) + .foregroundStyle(isSelected ? Color.black : Color.separator) .opacity(isSelected ? 1 : 0) } } diff --git a/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 401a6ee0..f64e924d 100644 --- a/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -1,7 +1,7 @@ import WebKit /// A type that represents JavaScript messages send from and to the web view. -struct EditorJSMessage { +public struct EditorJSMessage { let type: MessageType let body: Any? @@ -27,8 +27,6 @@ struct EditorJSMessage { case onEditorLoaded /// The editor content changed. case onEditorContentChanged - /// The user tapped the inserter button. - case showBlockPicker } struct DidUpdateBlocksBody: Decodable { diff --git a/Sources/GutenbergKit/Sources/EditorViewController.swift b/Sources/GutenbergKit/Sources/EditorViewController.swift index 0d0a77a3..77c79109 100644 --- a/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -1,78 +1,32 @@ -import UIKit import WebKit import SwiftUI import Combine +#if canImport(UIKit) +import UIKit +public typealias VCRepresentable = UIViewController +#elseif canImport(AppKit) +import AppKit +public typealias VCRepresentable = NSViewController +#endif + @MainActor -public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { - public let webView: WKWebView - private var initialTitle: String - private var type: String - private var id: Int? - private var themeStyles: Bool? - private var plugins: Bool - private var _initialRawContent: String - private var _isEditorRendered = false - private let controller = GutenbergEditorController() - private let timestampInit = CFAbsoluteTimeGetCurrent() - private let service: EditorService - private let siteURL: String - private let siteApiRoot: String - private let siteApiNamespace: String - private let authHeader: String +public final class EditorViewController: VCRepresentable { + public var developmentEnvironmentUrl: URL? + + fileprivate let webView: GBWebView = GBWebView() + + fileprivate let timestampInit = CFAbsoluteTimeGetCurrent() + fileprivate let service: EditorService public private(set) var state = EditorState() public weak var delegate: EditorViewControllerDelegate? + private let viewModel: EditorViewModel - /// Returns `true` if the editor is loaded and the initial content is displayed. - public var isEditorLoaded: Bool { initialContent != nil } - - /// The content that the editor was initialized with, serialized according - /// to the editor's settings. - /// - /// - warning: Checking raw `content` for equality is not a reliable operation - /// due to the various formatting choices Gutenberg and WordPress make when - /// saving the posts. - public private(set) var initialContent: String? - - /// A custom URL for the editor. - public var editorURL: URL? - - private var cancellables: [AnyCancellable] = [] - - /// Initalizes the editor with the initial content (Gutenberg). - public init(id: Int? = nil, type: String = "", title: String = "", content: String = "", service: EditorService, themeStyles: Bool = false, plugins: Bool = false, siteURL: String = "", siteApiRoot: String = "", siteApiNamespace: String = "", authHeader: String = "") { - self.id = id - self.type = type - self.initialTitle = title - self._initialRawContent = content - self.themeStyles = themeStyles - self.plugins = plugins + public init(viewModel: EditorViewModel, service: EditorService) { + self.viewModel = viewModel self.service = service - self.siteURL = siteURL - self.siteApiRoot = siteApiRoot - self.siteApiNamespace = siteApiNamespace - self.authHeader = authHeader - - Task { - await service.warmup() - } - - // The `allowFileAccessFromFileURLs` allows the web view to access the - // files from the local filesystem. - let config = WKWebViewConfiguration() - 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 - - self.webView = GBWebView(frame: .zero, configuration: config) - super.init(nibName: nil, bundle: nil) } @@ -80,90 +34,142 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro fatalError("init(coder:) has not been implemented") } + func handleError(_ error: Error, isCritical: Bool) { +#if canImport(UIKit) + // These are non-critical errors but they might prevent certain features from working + let alert = UIAlertController(title: error.localizedDescription, message: "\(error)", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + if isCritical { + self.delegate?.editor(self, didEncounterCriticalError: error) + } + }) + present(alert, animated: true) +#endif + } + + public var isDebuggingEnabled: Bool { + get { + if #available(iOS 16.4, *) { + return webView.isInspectable + } else { + return false + } + } + set { + if #available(iOS 16.4, *) { + webView.isInspectable = newValue + } + } + } + public override func viewDidLoad() { super.viewDidLoad() + view.addSubview(webView) - controller.delegate = self - webView.navigationDelegate = controller +#if canImport(UIKit) + view.backgroundColor = .red + webView.backgroundColor = .blue +#else + view.layer?.backgroundColor = CGColor(red: 1.0, green: 0, blue: 0, alpha: 1.0) + webView.layer?.backgroundColor = CGColor(red: 0, green: 1.00, blue: 0, alpha: 1.0) +#endif +#if canImport(UIKit) // FIXME: implement with CSS (bottom toolbar) webView.scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: 47, right: 0) +#endif - view.addSubview(webView) webView.translatesAutoresizingMaskIntoConstraints = false + +#if canImport(UIKit) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor) ]) +#else + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) +#endif - webView.alpha = 0 - - // TODO: register it when editor is loaded -// service.$rawBlockTypesResponseData.compactMap({ $0 }).sink { [weak self] data in -// guard let self else { return } -// assert(Thread.isMainThread) -// -// }.store(in: &cancellables) + self.setUpEditor() + self.loadEditor() + } - setUpEditor() - loadEditor() +#if canImport(UIKit) + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.webView.becomeFirstResponder() } +#endif - // TODO: move - private func registerBlockTypes(data: Data) async { - guard let string = String(data: data, encoding: .utf8), - let escapedString = string.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { - assertionFailure("invalid block types") - return - } - do { - // TODO: simplify this - try await webView.evaluateJavaScript(""" - const blockTypes = JSON.parse(decodeURIComponent('\(escapedString)')); - editor.registerBlocks(blockTypes); - "done"; - """) - } catch { - NSLog("failed to register blocks \(error)") - // TOOD: relay to the client - } +#if canImport(AppKit) + public override func viewDidAppear() { + super.viewDidAppear() + self.webView.becomeFirstResponder() } +#endif private func setUpEditor() { - let webViewConfiguration = webView.configuration - let userContentController = webViewConfiguration.userContentController - let editorInitialConfig = getEditorConfiguration() - userContentController.addUserScript(editorInitialConfig) + self.webView.addUserScript(getEditorConfiguration()) + self.webView.delegate = self } - private func loadEditor() { - if let editorURL = editorURL ?? ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) { - webView.load(URLRequest(url: editorURL)) - } else if plugins, - let editorURL = Bundle.module.url(forResource: "remote", withExtension: "html", subdirectory: "Gutenberg") { - webView.load(URLRequest(url: editorURL)) + func loadEditor() { + var editorUrl = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg")! + + if let developmentEnvironmentUrl { + editorUrl = developmentEnvironmentUrl + } + + if ProcessInfo.processInfo.environment.keys.contains("GUTENBERG_EDITOR_URL") { + if let url = URL(string: ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"]!) { + editorUrl = url + } + } + + if self.viewModel.features.contains(.Plugins) { + if let url = Bundle.module.url(forResource: "remote", withExtension: "html", subdirectory: "Gutenberg") { + editorUrl = url + } + } + + if editorUrl.isFileURL { + webView.loadFileURL(editorUrl, allowingReadAccessTo: Bundle.module.resourceURL!) } else { - let reactAppURL = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg")! - webView.loadFileURL(reactAppURL, allowingReadAccessTo: Bundle.module.resourceURL!) + webView.load(URLRequest(url: editorUrl)) } } + fileprivate func didLoadEditor() { + // guard !_isEditorRendered else { return } + // _isEditorRendered = true + + let duration = CFAbsoluteTimeGetCurrent() - timestampInit + print("gutenbergkit-measure_editor-first-render:", duration) + + } + private func getEditorConfiguration() -> WKUserScript { - let escapedTitle = initialTitle.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - let escapedContent = _initialRawContent.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - let hasThemeStylesEnabled = themeStyles != nil ? themeStyles! : false + + let escapedTitle = viewModel.initialTitle.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let escapedContent = viewModel.initialContent.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let hasThemeStylesEnabled = viewModel.features.contains(.ThemeStyles) let jsCode = """ window.GBKit = { - siteURL: '\(siteURL)', - siteApiRoot: '\(siteApiRoot)', - siteApiNamespace: '\(siteApiNamespace)', - authHeader: '\(authHeader)', + siteURL: '\(viewModel.siteURL)', + siteApiRoot: '\(viewModel.siteApiRoot)', + siteApiNamespace: '\(viewModel.siteApiNamespace)', + authHeader: '\(viewModel.authHeader)', themeStyles: \(hasThemeStylesEnabled), post: { - id: \(id ?? -1), + id: \(viewModel.id ?? -1), title: '\(escapedTitle)', content: '\(escapedContent)' }, @@ -176,26 +182,18 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return editorScript } - // MARK: - Public API - - // TODO: synchronize with the editor user-generated updates - // TODO: convert to a property? - public func setContent(_ content: String) { - _setContent(content) + private func evaluate(_ javascript: String, isCritical: Bool = false) { + webView.evaluateJavaScript(javascript) { [weak self] _, error in + guard let self, let error else { return } + self.handleError(error, isCritical: isCritical) + } } - private func _setContent(_ content: String) { - guard _isEditorRendered else { return } - - let escapedString = content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + public func setContent(_ newValue: String) { + let escapedString = newValue.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! evaluate("editor.setContent(decodeURIComponent('\(escapedString)'));", isCritical: true) } - /// Returns the current editor content. - public func getContent() async throws -> String { - try await webView.evaluateJavaScript("editor.getContent();") as! String - } - /// Returns the current editor title and content. public func getTitleAndContent() async throws -> EditorTitleAndContent { let result = try await webView.evaluateJavaScript("editor.getTitleAndContent();") @@ -206,168 +204,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } return EditorTitleAndContent(title: title, content: content) } +} - /// Enables code editor. - public var isCodeEditorEnabled: Bool = false { - didSet { - guard isCodeEditorEnabled != oldValue else { return } - evaluate("editor.setCodeEditorEnabled(\(isCodeEditorEnabled ? "true" : "false"));") - } - } - - // MARK: - Internal (JavaScript) - - private func evaluate(_ javascript: String, isCritical: Bool = false) { - webView.evaluateJavaScript(javascript) { [weak self] _, error in - guard let self, let error else { return } - self.handleError(error, isCritical: isCritical) - } - } - - private func handleError(_ error: Error, isCritical: Bool) { - // These are non-critical errors but they might prevent certain features from working - let alert = UIAlertController(title: error.localizedDescription, message: "\(error)", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in - if isCritical { - self.delegate?.editor(self, didEncounterCriticalError: error) - } - }) - present(alert, animated: true) - } - - // MARK: - Internal (Block Inserter) - - // TODO: wire with JS and pass blocks - private func showBlockInserter() { - let viewModel = EditorBlockPickerViewModel(blockTypes: service.blockTypes) - let view = NavigationView { - EditorBlockPicker(viewModel: viewModel) - } - let host = UIHostingController(rootView: view) - present(host, animated: true) - } - - // MARK: - Internal (Initial Content) - - private func setInitialContent(_ content: String, _ completion: (() -> Void)? = nil) async { - guard _isEditorRendered else { fatalError("called too early") } - - // let start = CFAbsoluteTimeGetCurrent() - - // TODO: Find a faster and more reliable way to pass large strings to a web view - //let escapedString = content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - - // TODO: Check errors and notify the delegate when the editor is loaded and the content got displayed - do { - self.initialContent = _initialRawContent - /*let serializedContent = try await webView.evaluateJavaScript(""" - editor.setInitialContent(decodeURIComponent('\(escapedString)')); - """) as! String - self.initialContent = serializedContent - delegate?.editor(self, didDisplayInitialContent: serializedContent)*/ - //print("gutenbergkit-set-initial-content:", CFAbsoluteTimeGetCurrent() - start) - - UIView.animate(withDuration: 0.2, delay: 0.1, options: [.allowUserInteraction]) { - self.webView.alpha = 1 - } - } catch { - delegate?.editor(self, didEncounterCriticalError: error) - } - } - - // MARK: - GutenbergEditorControllerDelegate - - fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) { - do { - switch message.type { - case .onEditorLoaded: - didLoadEditor() +extension EditorViewController: GBWebViewDelegate { + func webView(_ webView: GBWebView, didReceiveMessage message: EditorJSMessage) { + switch message.type { + case .onEditorLoaded: didLoadEditor() case .onEditorContentChanged: // TODO: Refactor and remove EditorState entirely? delegate?.editor(self, didUpdateContentWithState: state) - case .showBlockPicker: - showBlockInserter() - } - } catch { - fatalError("failed to decode message: \(error)") } } - - // Only after this point it's safe to use JS `editor` API. - private func didLoadEditor() { - guard !_isEditorRendered else { return } - _isEditorRendered = true - - let duration = CFAbsoluteTimeGetCurrent() - timestampInit - print("gutenbergkit-measure_editor-first-render:", duration) - - // TODO: refactor (perform initial setup with a single JS call) - Task { @MainActor in - /* if let data = service.rawBlockTypesResponseData { - await registerBlockTypes(data: data) - } */ - await setInitialContent(_initialRawContent) - } - } - - // MARK: - Warmup - - /// Calls this at any moment before showing the actual editor. The warmup - /// shaves a couple of hundred milliseconds off the first load. - public static func warmup() { - struct MockClient: EditorNetworkingClient { - func send(_ request: EditorNetworkRequest) async throws -> EditorNetworkResponse { - throw URLError(.unknown) // Unsupported - } - } - let editorViewController = EditorViewController( - content: "", - service: EditorService(client: MockClient()) - ) - _ = editorViewController.view // Trigger viewDidLoad - - // Retain for 5 seconds and let it prefetch stuff - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { - _ = editorViewController - } - } -} - -@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? - - // MARK: - WKNavigationDelegate - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - NSLog("navigation: \(String(describing: navigation))") - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - NSLog("didFailNavigation: \(error)") - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - NSLog("didFailProvisionalNavigation: \(error)") - } - - // MARK: - WKScriptMessageHandler - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let message = EditorJSMessage(message: message) else { - return NSLog("Unsupported message: \(message.body)") - } - MainActor.assumeIsolated { - delegate?.controller(self, didReceiveMessage: message) - } - } -} - -private extension WKWebView { - } diff --git a/Sources/GutenbergKit/Sources/EditorViewModel.swift b/Sources/GutenbergKit/Sources/EditorViewModel.swift new file mode 100644 index 00000000..7b0f1805 --- /dev/null +++ b/Sources/GutenbergKit/Sources/EditorViewModel.swift @@ -0,0 +1,42 @@ +public struct EditorViewModel { + var id: Int? + + let initialTitle: String + let initialContent: String + let siteURL: String + let siteApiRoot: String + let siteApiNamespace: String + let authHeader: String + + + private var type: String + + let features: [Feature] + + public enum Feature: CaseIterable { + case Plugins + case ThemeStyles + } + + public init( + id: Int? = nil, + initialTitle: String, + initialContent: String, + siteURL: String, + siteApiRoot: String, + siteApiNamespace: String, + authHeader: String, + type: String, + features: [Feature] = [] + ) { + self.id = id + self.initialTitle = initialTitle + self.initialContent = initialContent + self.siteURL = siteURL + self.siteApiRoot = siteApiRoot + self.siteApiNamespace = siteApiNamespace + self.authHeader = authHeader + self.type = type + self.features = features + } +} diff --git a/Sources/GutenbergKit/Sources/Extensions/Color.swift b/Sources/GutenbergKit/Sources/Extensions/Color.swift new file mode 100644 index 00000000..2dfdb1d0 --- /dev/null +++ b/Sources/GutenbergKit/Sources/Extensions/Color.swift @@ -0,0 +1,31 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +extension Color { + static var separator: Color { + #if canImport(UIKit) + Color(uiColor: .separator) + #elseif canImport(AppKit) + Color(nsColor: .separatorColor) + #endif + } + + #if canImport(UIKit) + func from(color: UIColor) -> Color { + Color(uiColor: color) + } + #endif + + #if canImport(AppKit) + func from(color: NSColor) -> Color { + Color(nsColor: color) + } + #endif +} diff --git a/Sources/GutenbergKit/Sources/GBWebView.swift b/Sources/GutenbergKit/Sources/GBWebView.swift index 2f13ae87..54a83e0b 100644 --- a/Sources/GutenbergKit/Sources/GBWebView.swift +++ b/Sources/GutenbergKit/Sources/GBWebView.swift @@ -1,10 +1,72 @@ import WebKit -class GBWebView: WKWebView { +protocol GBWebViewDelegate { + @MainActor + func webView(_ webView: GBWebView, didReceiveMessage: EditorJSMessage) +} + +public class GBWebView: WKWebView { + + init() { + let config = WKWebViewConfiguration() + config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + config.setValue(true, forKey: "allowUniversalAccessFromFileURLs") + + #if(os(iOS)) + // This is important so they user can't select anything but text across blocks. + // config.selectionGranularity = WKSelectionGranularity.character + #endif + + super.init(frame: .zero, configuration: config) + self.navigationDelegate = self + + // Set-up communications with the editor. + config.userContentController.add(self, name: "editorDelegate") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + @MainActor + var delegate: GBWebViewDelegate? + + #if canImport(UIKit) /// Disables the default bottom bar that competes with the Gutenberg inserter /// - override var inputAccessoryView: UIView? { + public override var inputAccessoryView: UIView? { nil } + #endif + + func addUserScript(_ script: WKUserScript) { + self.configuration.userContentController.addUserScript(script) + } +} + +extension GBWebView: WKScriptMessageHandler { + public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let message = EditorJSMessage(message: message) else { + return NSLog("Unsupported message: \(message.body)") + } + + MainActor.assumeIsolated { + delegate?.webView(self, didReceiveMessage: message) + } + } +} + +extension GBWebView: WKNavigationDelegate { + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + NSLog("didFinish navigation: \(String(describing: navigation))") + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + NSLog("didFailNavigation: \(error)") + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + NSLog("didFailProvisionalNavigation: \(error)") + } }