diff --git a/Package.swift b/Package.swift index 841672f6..61e72de3 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "GutenbergKit", - platforms: [.iOS(.v15), .macOS(.v14)], + platforms: [.iOS(.v16), .macOS(.v14)], products: [ .library(name: "GutenbergKit", targets: ["GutenbergKit"]) ], diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index 747107a2..56bba4bc 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -7,20 +7,48 @@ struct ContentView: View { let remoteEditorConfigurations: [EditorConfiguration] = [.template] + @State private var isShowingDefaultEditor = false + + // Configuration settings + @AppStorage("enableNativeBlockInserter") private var enableNativeBlockInserter = true + @AppStorage("enablePlugins") private var enablePlugins = false + @AppStorage("hideTitle") private var hideTitle = false + @AppStorage("autoFocusOnLoad") private var autoFocusOnLoad = true + @AppStorage("themeStyles") private var themeStyles = true + var body: some View { List { Section { - NavigationLink { - EditorView(configuration: .default) + Button { + isShowingDefaultEditor = true } label: { Text("Bundled Editor") } } + + Section { + Toggle("Native Block Inserter", isOn: $enableNativeBlockInserter) + Toggle("Enable Plugins", isOn: $enablePlugins) + Toggle("Hide Title", isOn: $hideTitle) + Toggle("Auto Focus on Load", isOn: $autoFocusOnLoad) + Toggle("Theme Styles", isOn: $themeStyles) + } header: { + Text("Configuration") + } footer: { + Button("Reset") { + enableNativeBlockInserter = true + enablePlugins = false + hideTitle = false + autoFocusOnLoad = true + themeStyles = true + } + .padding(.vertical, 12) + } Section { ForEach(remoteEditorConfigurations, id: \.siteURL) { configuration in NavigationLink { - EditorView(configuration: configuration) + EditorView(configuration: configureWithSettings(configuration)) } label: { Text(URL(string: configuration.siteURL)?.host ?? configuration.siteURL) } @@ -39,6 +67,11 @@ struct ContentView: View { } } } + .fullScreenCover(isPresented: $isShowingDefaultEditor) { + NavigationView { + EditorView(configuration: configureWithSettings(.default)) + } + } .toolbar { ToolbarItem(placement: .primaryAction) { Button { @@ -57,10 +90,20 @@ struct ContentView: View { } label: { Image(systemName: "arrow.clockwise") } - } } } + + // Helper function to apply settings to configuration + private func configureWithSettings(_ configuration: EditorConfiguration) -> EditorConfiguration { + var config = configuration + config.enableNativeBlockInserter = enableNativeBlockInserter + config.plugins = enablePlugins + config.hideTitle = hideTitle + config.autoFocusOnLoad = autoFocusOnLoad + config.themeStyles = themeStyles + return config + } } private extension EditorConfiguration { @@ -68,8 +111,6 @@ private extension EditorConfiguration { static var template: Self { var configuration = EditorConfiguration.default - #warning("1. Update the property values below") - #warning("2. Install the Jetpack plugin to the site") configuration.siteURL = "https://modify-me.com" configuration.authHeader = "Insert the Authorization header value here" diff --git a/ios/Demo-iOS/Sources/EditorView.swift b/ios/Demo-iOS/Sources/EditorView.swift index c8afea9e..0fcf3878 100644 --- a/ios/Demo-iOS/Sources/EditorView.swift +++ b/ios/Demo-iOS/Sources/EditorView.swift @@ -4,15 +4,18 @@ import GutenbergKit struct EditorView: View { private let configuration: EditorConfiguration + @Environment(\.dismiss) private var dismiss + init(configuration: EditorConfiguration) { self.configuration = configuration } var body: some View { _EditorView(configuration: configuration) + .background(Color(.systemBackground)) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { - Button(action: {}, label: { + Button(action: { dismiss() }, label: { Image(systemName: "xmark") }) } @@ -33,6 +36,7 @@ struct EditorView: View { moreMenu } } + .tint(Color.primary) } private var moreMenu: some View { @@ -79,9 +83,13 @@ private struct _EditorView: UIViewControllerRepresentable { self.configuration = configuration } + func makeCoordinator() -> Coordinator { + Coordinator() + } + func makeUIViewController(context: Context) -> EditorViewController { let viewController = EditorViewController(configuration: configuration) - + viewController.delegate = context.coordinator if #available(iOS 16.4, *) { viewController.webView.isInspectable = true } @@ -92,6 +100,99 @@ private struct _EditorView: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: EditorViewController, context: Context) { // Do nothing } + + class Coordinator: NSObject, EditorViewControllerDelegate { + func editorDidLoad(_ viewContoller: EditorViewController) { + print("Editor did load") + } + + func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { + print("Editor displayed initial content") + } + + func editor(_ viewContoller: EditorViewController, didEncounterCriticalError error: Error) { + print("Editor critical error: \(error)") + } + + func editor(_ viewController: EditorViewController, didUpdateContentWithState state: EditorState) { + // Handle content updates + } + + func editor(_ viewController: EditorViewController, didUpdateHistoryState state: EditorState) { + // Handle history updates + } + + func editor(_ viewController: EditorViewController, didUpdateFeaturedImage mediaID: Int) { + print("Featured image updated: \(mediaID)") + } + + func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) { + print("JS Exception: \(error.message)") + } + + func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { + print("Requested site media library") + } + + func getMediaPickerController(for viewController: EditorViewController, parameters: MediaPickerParameters) -> (any MediaPickerController)? { + MockMediaPickerController() + } + } +} + +private struct MockMediaPickerController: MediaPickerController { + var actions: [[MediaPickerAction]] = [[ + MediaPickerAction( + id: "image-playground", + title: "Image Playground", + image: UIImage(systemName: "apple.image.playground")! + ) { presentingViewController, completion in + showPickerAlert(for: "Image Playground", in: presentingViewController, completion: completion) + }, + MediaPickerAction( + id: "files", + title: "Files", + image: UIImage(systemName: "folder")! + ) { presentingViewController, completion in + showPickerAlert(for: "Files", in: presentingViewController, completion: completion) + } + ], [ + MediaPickerAction( + id: "free-photos", + title: "Free Photos Library", + image: UIImage(systemName: "photo.on.rectangle")! + ) { presentingViewController, completion in + showPickerAlert(for: "Free Photos Library", in: presentingViewController, completion: completion) + }, + MediaPickerAction( + id: "free-gifs", + title: "Free GIF Library", + image: UIImage(systemName: "photo.stack")! + ) { presentingViewController, completion in + showPickerAlert(for: "Free GIF Library", in: presentingViewController, completion: completion) + } + ]] +} + +private func showPickerAlert(for pickerName: String, in viewController: UIViewController, completion: @escaping ([MediaInfo]) -> Void) { + let alert = UIAlertController( + title: "Media Picker", + message: "Picker not implemented: \(pickerName)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + completion([]) + }) + viewController.present(alert, animated: true) +} + +private extension UIViewController { + var topPresentedViewController: UIViewController { + guard let presentedViewController else { + return self + } + return presentedViewController.topPresentedViewController + } } #Preview { diff --git a/ios/Sources/GutenbergKit/Sources/EditorBlock.swift b/ios/Sources/GutenbergKit/Sources/EditorBlock.swift deleted file mode 100644 index 1ad139c4..00000000 --- a/ios/Sources/GutenbergKit/Sources/EditorBlock.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -// TODO: hide AnyDecodable -final class EditorBlock: Decodable { - /// The name of the block, e.g. `core/paragraph`. - var name: String - /// The attributes of the block. - var attributes: [String: AnyDecodable] - /// The nested blocks. - var innerBlocks: [EditorBlock] -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift b/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift deleted file mode 100644 index 9eb38249..00000000 --- a/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -// TODO: Add search -// TODO: Group these properly -struct EditorBlockPicker: View { - @StateObject var viewModel: EditorBlockPickerViewModel - - @State private var searchText = "" - @State private var group = "Blocks" - - @Environment(\.dismiss) private var dismiss - - - var body: some View { - List { - ForEach(viewModel.displayedSections) { section in - Section(section.name) { - ForEach(section.blockTypes) { blockType in - _Label(blockType.name, systemImage: "paragraphsign") - } - } - } - } - .toolbar(content: { - ToolbarItemGroup(placement: .topBarLeading) { - Button("Close", action: { dismiss() }) - } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu(content: { - Button("Blocks", action: {}) - Button("Patterns", action: {}) - }, label: { - HStack(alignment: .firstTextBaseline) { - Text("Blocks") - .font(.body.weight(.medium)) - Image(systemName: "chevron.down.circle.fill") - .font(.footnote.weight(.bold)) - .foregroundStyle(.secondary, .secondary.opacity(0.3)) - } - }) - } - }) - .navigationBarTitleDisplayMode(.inline) - -// TabView(selection: $group, -// content: { -// _bodyBlocks -// .tabItem { -// Label("Blocks", systemImage: "batteryblock") -// } -// .tag("Blocks") -// Text("Tab Content 2") -// .tabItem { -// Label("Patterns", systemImage: "rectangle.3.group") -// } -// .tag("Patterns") -//// PhotosPicker("Media", selection: $selectedItems) -//// .photosPickerStyle(.inline) -//// .tabItem { -//// Label("Media", systemImage: "photo.on.rectangle") -//// } -//// .tag("Media") -// }) -// .tint(Color.primary) - } - - @ViewBuilder - var _bodyBlocks: some View { - VStack(spacing: 0) { - VStack(spacing: 0) { -// SearchView(text: $searchText) -// .padding(.horizontal, 12) -// .padding(.bottom, 8) -// filters -// .padding(.horizontal) -// .padding(.bottom, 8) - - } - .background(Color(uiColor: .secondarySystemBackground)) - - - List { - Section("Text") { - _Label("Paragraph", systemImage: "paragraphsign") - _Label("Heading", systemImage: "bookmark") - _Label("List", systemImage: "list.triangle") - _Label("Quote", systemImage: "text.quote") - _Label("Table", systemImage: "tablecells") - } - Section("Media") { - _Label("Image", systemImage: "photo") - _Label("Gallery", systemImage: "rectangle.3.group") - _Label("Video", systemImage: "video") - _Label("Audio", systemImage: "waveform") - } - } - .listStyle(.insetGrouped) - } -// .safeAreaInset(edge: .bottom) { -// HStack(spacing: 30) { -// Image(systemName: "paragraphsign") -// Image(systemName: "bookmark") -// Image(systemName: "photo") -// Image(systemName: "rectangle.3.group") -// Image(systemName: "video") -// Image(systemName: "link") -// } -// .padding() -// .background(Color.black.opacity(0.5)) -// .background(Material.thick) -// .foregroundStyle(.white) -// .cornerRadius(16) -// } -// } -// .searchable(text: $searchText)//, placement: .navigationBarDrawer(displayMode: .always)) - .toolbar(content: { - ToolbarItemGroup(placement: .topBarLeading) { - Button("Close", action: { dismiss() }) - } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu(content: { - Button("Blocks", action: {}) - Button("Patterns", action: {}) - }, label: { - HStack(alignment: .firstTextBaseline) { - Text("Blocks") - .font(.body.weight(.medium)) - Image(systemName: "chevron.down.circle.fill") - .font(.footnote.weight(.bold)) - .foregroundStyle(.secondary, .secondary.opacity(0.3)) - } - }) - } - }) - .navigationBarTitleDisplayMode(.inline) -// .toolbar(.hidden, for: .navigationBar) - - .tint(Color.primary) - } - - private var filters: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - MenuItem("Blocks", isSelected: true) - MenuItem("Patterns", isSelected: false) -// MenuItem("Media") - Spacer() - } - .font(.subheadline) - Divider() - } - } -} - -private struct _Label: View { - let title: String - let systemImage: String - - init(_ title: String, systemImage: String) { - self.title = title - self.systemImage = systemImage - } - - var body: some View { - HStack(spacing: 16) { - Image(systemName: systemImage) - .frame(width: 26) -// .foregroundStyle(.secondary) - Text(title) - Spacer() -// Image(systemName: "info.circle") -// .foregroundColor(.secondary) - } - } -} - -private struct MenuItem: View { - let title: String - let isSelected: Bool - - init(_ title: String, isSelected: Bool = false) { - self.title = title - self.isSelected = isSelected - } - - var body: some View { - VStack { - Text(title) - .fontWeight(isSelected ? .bold : .regular) - .foregroundStyle(isSelected ? Color.primary : Color.secondary) - Rectangle() - .frame(height: 2) - .foregroundStyle(isSelected ? Color.black : Color(uiColor: .separator)) - .opacity(isSelected ? 1 : 0) - } - } -} - -final class EditorBlockPickerViewModel: ObservableObject { - private let sections: [EditorBlockPickerSection] - @Published private(set) var displayedSections: [EditorBlockPickerSection] = [] - - private let blockTypes: [EditorBlockType] = [] - - init(blockTypes: [EditorBlockType]) { - self.sections = Dictionary(grouping: blockTypes, by: \.category) - .map { category, blockTypes in - EditorBlockPickerSection(name: category ?? "–", blockTypes: blockTypes) - } - self.displayedSections = sections - } -} - -struct EditorBlockPickerSection: Identifiable { - var id: String { name } // Guranteed to be unique - - let name: String - let blockTypes: [EditorBlockType] -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index 87c3446e..3db6923a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -22,6 +22,10 @@ public struct EditorConfiguration { /// The locale to use for translations public var locale = "en" public var editorAssetsEndpoint: URL? + /// Enable native block inserter UI. + public var enableNativeBlockInserter = false + /// Auto-focus the editor when it loads. + public var autoFocusOnLoad = true public init(title: String = "", content: String = "") { self.title = title diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 9f111647..9c89230d 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -51,4 +51,8 @@ struct EditorJSMessage { struct DidUpdateFeaturedImageBody: Decodable { let mediaID: Int } + + struct ShowBlockPickerBody: Decodable { + let blockTypes: [EditorBlock] + } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorService.swift b/ios/Sources/GutenbergKit/Sources/EditorService.swift deleted file mode 100644 index a340bc2c..00000000 --- a/ios/Sources/GutenbergKit/Sources/EditorService.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -/// A service that manages the editor backend. -/// -/// - note: The service can be instantiated and warmed-up before the editor -/// is presented. -@MainActor -public final class EditorService { - private let client: EditorNetworkingClient - - @Published private(set) var blockTypes: [EditorBlockType] = [] - @Published private(set) var rawBlockTypesResponseData: Data? - - private var refreshBlockTypesTask: Task<[EditorBlockType], Error>? - - public init(client: EditorNetworkingClient) { - self.client = client - } - - /// Prefetches the settings used by the editor. - public func warmup() async { - _ = try? await refreshBlockTypes() - } - - func refreshBlockTypes() async throws -> [EditorBlockType] { - if let task = refreshBlockTypesTask { - return try await task.value - } - let task = Task { - let request = EditorNetworkRequest(method: "GET", url: URL(string: "./wp-json/wp/v2/block-types")!) - let response = try await self.client.send(request) - try validate(response.urlResponse) - self.blockTypes = try await decode(response.data ?? Data()) - self.rawBlockTypesResponseData = response.data ?? Data() - return blockTypes - } - self.refreshBlockTypesTask = task - return try await task.value - } -} - -// MARK: - Helpers - -private func decode(_ data: Data, using decoder: JSONDecoder = JSONDecoder()) async throws -> T { - try await Task.detached { - try decoder.decode(T.self, from: data) - }.value -} - -private func validate(_ response: URLResponse) throws { - guard let response = response as? HTTPURLResponse else { - throw EditorRequestError.invalidResponseType - } - guard (200..<300).contains(response.statusCode) else { - throw EditorRequestError.unacceptableStatusCode(response.statusCode) - } -} - -enum EditorRequestError: Error { - case invalidResponseType - case unacceptableStatusCode(Int) -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift index f209ae89..24bdd1ab 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -1,6 +1,6 @@ import Foundation -struct EditorBlockType: Decodable, Identifiable { +struct EditorBlock: Decodable, Identifiable { var id: String { name } let name: String diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dcfea9a8..1058b512 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -3,11 +3,13 @@ import UIKit import SwiftUI import Combine import CryptoKit +import PhotosUI @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { public let webView: WKWebView let assetsLibrary: EditorAssetsLibrary + let fileManager = EditorFileManager.shared public var configuration: EditorConfiguration private var _isEditorRendered = false @@ -47,6 +49,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro for scheme in CachedAssetSchemeHandler.supportedURLSchemes { config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) } + + // Register local media scheme handler + let localMediaHandler = EditorFileSchemeHandler() + config.setURLSchemeHandler(localMediaHandler, forURLScheme: EditorFileSchemeHandler.scheme) self.webView = GBWebView(frame: .zero, configuration: config) self.webView.scrollView.keyboardDismissMode = .interactive @@ -84,33 +90,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro setUpEditor() loadEditor() } - - // 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) - } - - // 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 - } } private func setUpEditor() { @@ -177,13 +156,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro hideTitle: \(configuration.hideTitle), editorSettings: \(editorSettingsJS), locale: '\(configuration.locale)', + enableNativeBlockInserter: \(configuration.enableNativeBlockInserter), post: { id: \(configuration.postID ?? -1), title: '\(escapedTitle)', content: '\(escapedContent)' }, }; - + localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); "done"; @@ -196,7 +176,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Public API // TODO: synchronize with the editor user-generated updates - // TODO: convert to a property? public func setContent(_ content: String) { _setContent(content) } @@ -279,14 +258,63 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // 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) + private func showBlockInserter(blocks: [EditorBlock]) { + let parameters = MediaPickerParameters(filter: .all, isMultipleSelectionEnabled: true) + let mediaPicker = delegate?.getMediaPickerController(for: self, parameters: parameters) + + let host = UIHostingController(rootView: AnyView(EmptyView())) + + let view = BlockInserterView( + blocks: blocks, + mediaPicker: mediaPicker, + presentingViewController: host, + onBlockSelected: { [weak self] in + self?.insertBlock($0) + }, + onMediaSelected: { [weak self] in + self?.insertMedia($0) + } + ) + + host.rootView = AnyView(NavigationStack { view }) + host.view.backgroundColor = .clear + + // Configure sheet presentation with medium detent + if let sheet = host.sheetPresentationController { + let compactHeight: CGFloat + if #available(iOS 26, *) { + compactHeight = 512 + } else { + compactHeight = 528 + } + + sheet.detents = [.custom(identifier: .medium, resolver: { context in + context.containerTraitCollection.horizontalSizeClass == .compact ? compactHeight : 900 + }), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 26 + } + + present(host, animated: true) + } + + private func insertBlock(_ blockType: EditorBlock) { + evaluate("window.editor.insertBlock('\(blockType.name)');") + } + + private func insertMedia(_ mediaInfo: [MediaInfo]) { + guard !mediaInfo.isEmpty else { return } + + // Encode MediaInfo array directly since it's Codable + do { + let encoder = JSONEncoder() + let jsonData = try encoder.encode(mediaInfo) + if let jsonString = String(data: jsonData, encoding: .utf8) { + evaluate("window.editor.insertMediaFromFiles(\(jsonString));") + } + } catch { + assertionFailure("Failed to serialize media items: \(error)") + } } private func openMediaLibrary(_ config: OpenMediaLibraryAction) { @@ -322,7 +350,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } delegate?.editor(self, didLogException: editorException) case .showBlockPicker: - showBlockInserter() + do { + let body = try message.decode(EditorJSMessage.ShowBlockPickerBody.self) + showBlockInserter(blocks: body.blockTypes) + } catch { + showBlockInserter(blocks: []) + } case .openMediaLibrary: let config = try message.decode(OpenMediaLibraryAction.self) openMediaLibrary(config) @@ -344,6 +377,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro let duration = CFAbsoluteTimeGetCurrent() - timestampInit print("gutenbergkit-measure_editor-first-render:", duration) delegate?.editorDidLoad(self) + + if configuration.autoFocusOnLoad, configuration.content.isEmpty { + self.autoFocusEditor() + } + } + + private func autoFocusEditor() { + evaluate(""" + (function() { + const editable = document.querySelector('[contenteditable="true"]'); + if (editable) { + editable.focus(); + editable.click(); + } + })(); + """) } // MARK: - Warmup @@ -561,3 +610,7 @@ class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { } } } + +// MARK: - LocalMediaSchemeHandler + + diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index d8a87c84..98b239a0 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -34,6 +34,10 @@ public protocol EditorViewControllerDelegate: AnyObject { func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) + + /// Returns the available media picker sources for the given configuration. + /// The controller is retained by the editor. + func getMediaPickerController(for viewController: EditorViewController, parameters: MediaPickerParameters) -> (any MediaPickerController)? } public struct EditorState { @@ -132,27 +136,3 @@ public struct OpenMediaLibraryAction: Codable { case multiple([Int]) } } - -public struct MediaInfo: Codable { - public let id: Int32? - public let url: String? - public let type: String? - public let title: String? - public let caption: String? - public let alt: String? - public let metadata: [String: String] - - private enum CodingKeys: String, CodingKey { - case id, url, type, title, caption, alt, metadata - } - - public init(id: Int32?, url: String?, type: String?, caption: String? = nil, title: String? = nil, alt: String? = nil, metadata: [String: String] = [:]) { - self.id = id - self.url = url - self.type = type - self.caption = caption - self.title = title - self.alt = alt - self.metadata = metadata - } -} diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift new file mode 100644 index 00000000..879f8304 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift @@ -0,0 +1,94 @@ +import Foundation +import UniformTypeIdentifiers + +/// Manages editor files and media uploads in the Documents directory +actor EditorFileManager { + static let shared = EditorFileManager() + + private let fileManager = FileManager.default + + /// Base directory for all GutenbergKit files + let rootURL: URL + + /// Uploads directory URL + private let uploadsDirectory: URL + + init(rootURL: URL? = nil) { + if let rootURL { + self.rootURL = rootURL + } else { + self.rootURL = URL.libraryDirectory.appendingPathComponent("GutenbergKit") + } + self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads") + + // Create directories synchronously during init + try? fileManager.createDirectory(at: self.rootURL, withIntermediateDirectories: true) + try? fileManager.createDirectory(at: self.uploadsDirectory, withIntermediateDirectories: true) + + // Schedule cleanup of old files + Task { + await cleanupOldFiles() + } + } + + /// Saves media data to the uploads directory and returns its custom scheme URL + func saveMediaData(_ data: Data, withExtension ext: String) async throws -> URL { + let fileName = "\(UUID().uuidString).\(ext)" + let destinationURL = uploadsDirectory.appendingPathComponent(fileName) + + try data.write(to: destinationURL) + + // Return custom scheme URL: gbk-file:///Uploads/filename.ext + return URL(string: "\(EditorFileSchemeHandler.scheme):///Uploads/\(fileName)")! + } + + /// Gets URLResponse and data for a gbk-file URL + func getResponse(for url: URL) async throws -> (URLResponse, Data) { + // Convert `gbk-file:///Uploads/filename.jpg` to actual file path + let path = url.path + let fileURL = rootURL.appendingPathComponent(path) + + let data = try Data(contentsOf: fileURL) + + let headers = [ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Cache-Control": "no-cache" + ] + + guard let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: headers + ) else { + throw URLError(.unknown) + } + + return (response, data) + } + + /// Cleans up files older than 2 days + private func cleanupOldFiles() { + let sevenDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + do { + let contents = try fileManager.contentsOfDirectory( + at: uploadsDirectory, + includingPropertiesForKeys: [.creationDateKey], + options: .skipsHiddenFiles + ) + + for fileURL in contents { + if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let creationDate = attributes[.creationDate] as? Date, + creationDate < sevenDaysAgo { + try? fileManager.removeItem(at: fileURL) + } + } + } catch { + print("Failed to clean up old files: \(error)") + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift new file mode 100644 index 00000000..3e851eed --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift @@ -0,0 +1,29 @@ +import WebKit + +class EditorFileSchemeHandler: NSObject, WKURLSchemeHandler { + nonisolated static let scheme = "gbk-file" + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + Task { + do { + let fileManager = EditorFileManager.shared + let (response, data) = try await fileManager.getResponse(for: url) + + // Send response and data + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } catch { + urlSchemeTask.didFailWithError(error) + } + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Nothing to do here for simple file serving + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift b/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift new file mode 100644 index 00000000..a78d489f --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift @@ -0,0 +1,190 @@ +import Foundation + +/// A protocol for items that can be searched +protocol Searchable { + /// Extract searchable fields with their weights + func searchableFields() -> [SearchableField] +} + +/// A field that can be searched with an associated weight +struct SearchableField { + let content: String + let weight: Double + let allowFuzzyMatch: Bool + + init(content: String, weight: Double, allowFuzzyMatch: Bool = true) { + self.content = content + self.weight = weight + self.allowFuzzyMatch = allowFuzzyMatch + } +} + +/// Configuration for the search engine +struct SearchConfiguration { + /// Maximum allowed edit distance for fuzzy matching + let maxEditDistance: Int + + /// Minimum similarity threshold (0-1) for fuzzy matches + let minSimilarityThreshold: Double + + /// Multiplier for exact matches + let exactMatchMultiplier: Double + + /// Multiplier for prefix matches + let prefixMatchMultiplier: Double + + /// Multiplier for word prefix matches + let wordPrefixMatchMultiplier: Double + + /// Multiplier for fuzzy matches (applied to similarity score) + let fuzzyMatchMultiplier: Double + + static let `default` = SearchConfiguration( + maxEditDistance: 2, + minSimilarityThreshold: 0.7, + exactMatchMultiplier: 2.0, + prefixMatchMultiplier: 1.5, + wordPrefixMatchMultiplier: 0.8, + fuzzyMatchMultiplier: 0.6 + ) +} + +/// A generic search engine that performs weighted fuzzy search +struct SearchEngine { + + /// Search result with relevance score + struct SearchResult { + let item: Item + let score: Double + } + + let configuration: SearchConfiguration + + init(configuration: SearchConfiguration = .default) { + self.configuration = configuration + } + + /// Search items with weighted fuzzy matching + func search(query: String, in items: [Item]) -> [Item] { + let normalizedQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Empty query returns all items + guard !normalizedQuery.isEmpty else { + return items + } + + // Calculate scores for all items + let results: [SearchResult] = items.compactMap { item in + let score = calculateScore(for: item, query: normalizedQuery) + return score > 0 ? SearchResult(item: item, score: score) : nil + } + + // Sort by score (highest first) and return items + return results + .sorted { $0.score > $1.score } + .map { $0.item } + } + + /// Calculate weighted score for an item based on query match + private func calculateScore(for item: Item, query: String) -> Double { + let fields = item.searchableFields() + + return fields.reduce(0.0) { totalScore, field in + totalScore + calculateFieldScore( + field: field.content.lowercased(), + query: query, + weight: field.weight, + allowFuzzy: field.allowFuzzyMatch + ) + } + } + + /// Calculate score for a single field + private func calculateFieldScore(field: String, query: String, weight: Double, allowFuzzy: Bool) -> Double { + // Exact match + if field == query { + return weight * configuration.exactMatchMultiplier + } + + // Contains match + if field.contains(query) { + // Higher score if it starts with the query + if field.hasPrefix(query) { + return weight * configuration.prefixMatchMultiplier + } + return weight + } + + // Fuzzy match if allowed + if allowFuzzy { + // Check each word in the field + let fieldWords = field.split(separator: " ").map(String.init) + for word in fieldWords { + // Word starts with query + if word.hasPrefix(query) { + return weight * configuration.wordPrefixMatchMultiplier + } + + // Calculate similarity + let similarity = calculateSimilarity(word, query) + if similarity >= configuration.minSimilarityThreshold { + return weight * similarity * configuration.fuzzyMatchMultiplier + } + } + + // Try full field fuzzy match for short queries + if query.count <= 10 { + let similarity = calculateSimilarity(field, query) + if similarity >= configuration.minSimilarityThreshold { + return weight * similarity * configuration.fuzzyMatchMultiplier * 0.7 + } + } + } + + return 0 + } + + /// Calculate similarity between two strings using normalized edit distance + private func calculateSimilarity(_ str1: String, _ str2: String) -> Double { + let distance = levenshteinDistance(str1, str2) + let maxLength = max(str1.count, str2.count) + + // Don't allow too many edits relative to string length + if distance > min(configuration.maxEditDistance, maxLength / 3) { + return 0 + } + + return 1.0 - (Double(distance) / Double(maxLength)) + } + + /// Calculate Levenshtein edit distance between two strings + private func levenshteinDistance(_ str1: String, _ str2: String) -> Int { + let str1Array = Array(str1) + let str2Array = Array(str2) + + // Create matrix + var matrix = Array(repeating: Array(repeating: 0, count: str2Array.count + 1), count: str1Array.count + 1) + + // Initialize first row and column + for i in 0...str1Array.count { + matrix[i][0] = i + } + for j in 0...str2Array.count { + matrix[0][j] = j + } + + // Fill matrix + for i in 1...str1Array.count { + for j in 1...str2Array.count { + let cost = str1Array[i-1] == str2Array[j-1] ? 0 : 1 + matrix[i][j] = min( + matrix[i-1][j] + 1, // deletion + matrix[i][j-1] + 1, // insertion + matrix[i-1][j-1] + cost // substitution + ) + } + } + + return matrix[str1Array.count][str2Array.count] + } +} \ No newline at end of file diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift new file mode 100644 index 00000000..e86a1419 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct MediaInfo: Codable { + public let id: Int32? + public let url: String? + public let type: String? + public let title: String? + public let caption: String? + public let alt: String? + public let metadata: [String: String] + + private enum CodingKeys: String, CodingKey { + case id, url, type, title, caption, alt, metadata + } + + public init(id: Int32? = nil, url: String?, type: String?, caption: String? = nil, title: String? = nil, alt: String? = nil, metadata: [String: String] = [:]) { + self.id = id + self.url = url + self.type = type + self.caption = caption + self.title = title + self.alt = alt + self.metadata = metadata + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift new file mode 100644 index 00000000..90940cc9 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift @@ -0,0 +1,37 @@ +import UIKit + +public struct MediaPickerAction: Identifiable { + public let id: String + public let title: String + public let image: UIImage + public let perform: @MainActor (UIViewController, @escaping ([MediaInfo]) -> Void) -> Void + + public init(id: String, title: String, image: UIImage, perform: @escaping @MainActor (UIViewController, @escaping ([MediaInfo]) -> Void) -> Void) { + self.id = id + self.title = title + self.image = image + self.perform = perform + } +} + +public struct MediaPickerParameters { + public enum MediaFilter { + case images + case videos + case all + } + + public var filter: MediaFilter? + public var isMultipleSelectionEnabled: Bool + + public init(filter: MediaFilter? = nil, isMultipleSelectionEnabled: Bool = false) { + self.filter = filter + self.isMultipleSelectionEnabled = isMultipleSelectionEnabled + } +} + +/// A type that manages media picker sources. +public protocol MediaPickerController { + /// A grouped list of available actions. + var actions: [[MediaPickerAction]] { get } +} diff --git a/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift b/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift new file mode 100644 index 00000000..620db565 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 26) + .stroke(Color(.opaqueSeparator), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 26)) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardModifier()) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift new file mode 100644 index 00000000..3b46c93e --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct BlockInserterBlockView: View { + let block: EditorBlock + let action: () -> Void + + @State private var isPressed = false + @State private var isHovered = false + + @ScaledMetric(relativeTo: .largeTitle) private var iconSize = 44 + + var body: some View { + Button(action: { + onSelected() + }) { + VStack(spacing: 8) { + BlockIconView(block: block, size: iconSize) + Text(title) + .font(.caption) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.center) + } + .foregroundStyle(Color.primary) + .scaleEffect(isPressed ? 0.9 : 1.0) + .animation(.spring, value: isPressed) + .padding(.horizontal, 4) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .center) + .contextMenu { + Button { + onSelected() + } label: { + Label(title, systemImage: "plus") + } + } preview: { + BlockDetailedView(block: block) + } + } + + private func onSelected() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + isPressed = true + action() + } + + private var title: String { + block.title ?? block.name + .split(separator: "/") + .last + .map(String.init) ?? "-" + } +} + +private struct BlockIconView: View { + let block: EditorBlock + let size: CGFloat + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(uiColor: .secondarySystemFill)) + .frame(width: size, height: size) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + + Image(systemName: block.iconName) + .font(.system(size: size * 0.5)) + .foregroundColor(.primary) + .symbolRenderingMode(.hierarchical) + } + } +} + +private struct BlockDetailedView: View { + let block: EditorBlock + + var body: some View { + HStack(spacing: 16) { + BlockIconView(block: block, size: 56) + + VStack(alignment: .leading, spacing: 2) { + if let title = block.title { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Text(block.name) + .font(.footnote) + .foregroundColor(.secondary) + } else { + Text(block.name) + .font(.headline) + .foregroundColor(.primary) + } + + if let description = block.description { + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(4) + .padding(.top, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .frame(width: 360) + .background(Color(uiColor: .systemBackground)) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift new file mode 100644 index 00000000..c28e826b --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import PhotosUI + +struct BlockInserterSectionView: View { + let section: BlockInserterSection + let onBlockSelected: (EditorBlock) -> Void + let onMediaSelected: ([PhotosPickerItem]) -> Void + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @ScaledMetric(relativeTo: .largeTitle) private var miniumSize = 80 + + init( + section: BlockInserterSection, + onBlockSelected: @escaping (EditorBlock) -> Void, + onMediaSelected: @escaping ([PhotosPickerItem]) -> Void + ) { + self.section = section + self.onBlockSelected = onBlockSelected + self.onMediaSelected = onMediaSelected + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if section.category != "text" { + Text(section.name) + .font(.headline) + .foregroundStyle(Color.secondary) + .padding(.leading, 20) + .frame(maxWidth: .infinity, alignment: .leading) + } + grid + } + .padding(.top, section.category != "text" ? 20 : 24) + .padding(.bottom, 10) + .cardStyle() + } + + private var grid: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: miniumSize, maximum: miniumSize + 40), spacing: 0)]) { + ForEach(section.blocks) { block in + BlockInserterBlockView(block: block) { + onBlockSelected(block) + } + } + } + .padding(.horizontal, 12) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift new file mode 100644 index 00000000..f2a05573 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -0,0 +1,243 @@ +import SwiftUI +import PhotosUI +import UIKit + +struct BlockInserterView: View { + let mediaPicker: MediaPickerController? + weak var presentingViewController: UIViewController? + let onBlockSelected: (EditorBlock) -> Void + let onMediaSelected: ([MediaInfo]) -> Void + + @StateObject private var viewModel: BlockInserterViewModel + + @State private var selectedMediaItems: [PhotosPickerItem] = [] + @State private var inlineSelectedMediaItems: [PhotosPickerItem] = [] + @State private var isShowingCamera = false + + @ScaledMetric(relativeTo: .largeTitle) private var inlinePickerHeight = 86 + + private let maxSelectionCount = 10 + + @Environment(\.dismiss) private var dismiss + + init( + blocks: [EditorBlock], + mediaPicker: MediaPickerController?, + presentingViewController: UIViewController? = nil, + onBlockSelected: @escaping (EditorBlock) -> Void, + onMediaSelected: @escaping ([MediaInfo]) -> Void + ) { + self.mediaPicker = mediaPicker + self.presentingViewController = presentingViewController + self.onBlockSelected = onBlockSelected + self.onMediaSelected = onMediaSelected + + self._viewModel = StateObject(wrappedValue: BlockInserterViewModel(blocks: blocks)) + } + + var body: some View { + content + .background(Material.ultraThin) + .searchable(text: $viewModel.searchText) + .navigationBarTitleDisplayMode(.inline) + .disabled(viewModel.isProcessingMedia) + // In most cases, processing is nearly instant – we don't want any jarring changes + .animation(.smooth(duration: 2), value: viewModel.isProcessingMedia) + .toolbar { toolbarContent } + .fullScreenCover(isPresented: $isShowingCamera) { + CameraView { insertMedia($0) } + .ignoresSafeArea() + } + .alert(item: $viewModel.error) { error in + Alert(title: Text(error.message)) + } + .onDisappear { + if viewModel.isProcessingMedia { + viewModel.cancelProcessing() + } + } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if #available(iOS 17, *) { + if viewModel.searchText.isEmpty { + inlinePhotosPickerSection + } + } + ForEach(viewModel.sections) { section in + BlockInserterSectionView( + section: section, + onBlockSelected: insertBlock, + onMediaSelected: insertMedia + ) + .padding(.horizontal) + } + } + .padding(.vertical, 8) + .dynamicTypeSize(...(.accessibility3)) + } + .scrollContentBackground(.hidden) + .animation(.snappy, value: inlineSelectedMediaItems.count) + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .tint(Color.primary) + } + ToolbarItemGroup(placement: .automatic) { + mediaToolbarButtons + .tint(Color.primary) + .disabled(viewModel.isProcessingMedia) + } + } + + @ViewBuilder + private var mediaToolbarButtons: some View { + PhotosPicker(selection: $selectedMediaItems) { + Image(systemName: "photo.on.rectangle.angled") + } + .onChange(of: selectedMediaItems) { + insertMedia($0) + selectedMediaItems = [] + } + + Button(action: { + isShowingCamera = true + }) { + Image(systemName: "camera") + } + + if let groups = mediaPicker?.actions, !groups.isEmpty { + Menu { + ForEach(groups.indices, id: \.self) { groupIndex in + Section { + ForEach(groups[groupIndex]) { picker in + Button(action: { + if let presentingViewController { + picker.perform(presentingViewController) { mediaInfo in + self.onMediaSelected(mediaInfo) + self.dismiss() + } + } + }) { + Label { + Text(picker.title) + } icon: { + Image(uiImage: picker.image) + } + } + } + } + } + } label: { + Image(systemName: "ellipsis") + } + } + } + + // MARK: - Inline PhotosPicker (.compact) + + @available(iOS 17, *) + @ViewBuilder + private var inlinePhotosPickerSection: some View { + PhotosPicker( + "", + selection: $inlineSelectedMediaItems, + maxSelectionCount: maxSelectionCount, + selectionBehavior: .continuousAndOrdered + ) + .photosPickerStyle(.compact) + .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) + .photosPickerAccessoryVisibility(.hidden) + .frame(height: inlinePickerHeight) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: 22, bottomLeadingRadius: 22, bottomTrailingRadius: 0, topTrailingRadius: 0)) + .padding(.leading) + .opacity(viewModel.isProcessingMedia ? 0.5 : 1.0) + .transition(.asymmetric( + insertion: .scale(scale: 0.95, anchor: .leading).combined(with: .opacity), + removal: .scale(scale: 0.95, anchor: .leading).combined(with: .opacity) + )) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: viewModel.searchText.isEmpty) + + if !inlineSelectedMediaItems.isEmpty { + Button { + insertMedia(inlineSelectedMediaItems) + inlineSelectedMediaItems = [] + } label: { + // Making the best of it without using any localizable strings + Image(systemName: "plus") + // Setting max 1 to to prevent it from animating to 0 on disappear + Text("\(max(1, inlineSelectedMediaItems.count))") + .contentTransition(.numericText()) + } + .font(.system(.headline, design: .rounded)) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .tint(.primary) + // It animates as if it was hidden behind the next section + .transition(.offset(y: 28).combined(with: .scale(scale: 0.85))) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + // MARK: - Actions + + private func insertBlock(_ blockType: EditorBlock) { + dismiss() + onBlockSelected(blockType) + } + + private func insertMedia(_ items: [PhotosPickerItem]) { + Task { + await viewModel.processMediaItems(items) { mediaInfo in + dismiss() + onMediaSelected(mediaInfo) + } + } + } + + private func insertMedia(_ media: CameraMedia) { + Task { + await viewModel.processCameraMedia(media) { mediaInfo in + dismiss() + onMediaSelected(mediaInfo) + } + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + NavigationStack { + BlockInserterView( + blocks: PreviewData.sampleBlockTypes, + mediaPicker: MockMediaPickerController(), + onBlockSelected: { blockType in + print("Selected block: \(blockType.name)") + }, + onMediaSelected: { mediaInfo in + print("Selected \(mediaInfo.count) media items") + } + ) + } +} + +struct MockMediaPickerController: MediaPickerController { + let actions: [[MediaPickerAction]] = [[ + MediaPickerAction(id: "files", title: "Files", image: UIImage(systemName: "folder")!) { _, completion in + print("Files tapped") + completion([]) + } + ]] +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift new file mode 100644 index 00000000..b54f9128 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -0,0 +1,330 @@ +import SwiftUI +import PhotosUI +import Combine + +@MainActor +class BlockInserterViewModel: ObservableObject { + @Published var searchText = "" + @Published var error: MediaError? + @Published private(set) var sections: [BlockInserterSection] = [] + @Published private(set) var isProcessingMedia = false + + private let blocks: [EditorBlock] + private let allSections: [BlockInserterSection] + private let fileManager: EditorFileManager + private var processingTask: Task? + private var cancellables = Set() + + struct MediaError: Identifiable { + let id = UUID() + let message: String + } + + init(blocks: [EditorBlock], fileManager: EditorFileManager = .shared) { + let blocks = blocks.filter { $0.name != "core/missing" } + + self.blocks = blocks + self.fileManager = fileManager + + self.allSections = BlockInserterViewModel.createSections(from: blocks) + self.sections = allSections + + setupSearchObserver() + } + + private func setupSearchObserver() { + $searchText + .debounce(for: .milliseconds(200), scheduler: RunLoop.main) + .sink { [weak self] searchText in + self?.updateFilteredSections(searchText: searchText) + } + .store(in: &cancellables) + } + + private func updateFilteredSections(searchText: String) { + if searchText.isEmpty { + sections = allSections + } else { + sections = allSections.compactMap { section in + let filtered = filterBlocks(in: section, searchText: searchText) + return filtered.isEmpty ? nil : BlockInserterSection( + category: section.category, + name: section.name, + blocks: filtered + ) + } + } + } + + private func filterBlocks(in section: BlockInserterSection, searchText: String) -> [EditorBlock] { + let searchEngine = SearchEngine() + let filtered = searchEngine.search(query: searchText, in: section.blocks) + + // Maintain paragraph first in text category when showing all blocks + if searchText.isEmpty && section.name == "Text" && filtered.count > 1 { + return sortTextBlocks(filtered) + } + + return filtered + } + + private func sortTextBlocks(_ blocks: [EditorBlock]) -> [EditorBlock] { + let paragraphBlocks = blocks.filter { $0.name == "core/paragraph" } + let otherBlocks = blocks.filter { $0.name != "core/paragraph" } + return paragraphBlocks + otherBlocks + } + + private static func createSections(from blockTypes: [EditorBlock]) -> [BlockInserterSection] { + let categoryOrder = BlockInserterConstants.categoryOrder + var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } + + // Move core/embed from embed category to media category + if let embedBlocks = grouped["embed"], + let embedBlock = embedBlocks.first(where: { $0.name == "core/embed" }) { + // Add to media category + if var mediaBlocks = grouped["media"] { + mediaBlocks.append(embedBlock) + grouped["media"] = mediaBlocks + } else { + grouped["media"] = [embedBlock] + } + + // Remove from embed category + grouped["embed"] = embedBlocks.filter { $0.name != "core/embed" } + if grouped["embed"]?.isEmpty == true { + grouped.removeValue(forKey: "embed") + } + } + + var sections: [BlockInserterSection] = [] + + // Add sections in WordPress standard order + for (categoryKey, displayName) in categoryOrder { + if let blocks = grouped[categoryKey] { + let sortedBlocks = sortBlocks(blocks, category: categoryKey) + sections.append(BlockInserterSection(category: categoryKey, name: displayName, blocks: sortedBlocks)) + } + } + + // Add any remaining categories + for (category, blocks) in grouped { + let isStandardCategory = categoryOrder.contains { $0.key == category } + if !isStandardCategory { + sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) + } + } + + return sections + } + + private static func sortBlocks(_ blocks: [EditorBlock], category: String) -> [EditorBlock] { + switch category { + case "text": + return sortWithOrder(blocks, order: BlockInserterConstants.textBlockOrder) + case "media": + return sortWithOrder(blocks, order: BlockInserterConstants.mediaBlockOrder) + case "design": + return sortWithOrder(blocks, order: BlockInserterConstants.designBlockOrder) + default: + return blocks + } + } + + private static func sortWithOrder(_ blocks: [EditorBlock], order: [String]) -> [EditorBlock] { + var orderedBlocks: [EditorBlock] = [] + + // Add blocks in defined order + for blockName in order { + if let block = blocks.first(where: { $0.name == blockName }) { + orderedBlocks.append(block) + } + } + + // Add remaining blocks in their original order + let remainingBlocks = blocks.filter { block in + !order.contains(block.name) + } + + return orderedBlocks + remainingBlocks + } + + // MARK: - Media Processing + + func processMediaItems(_ items: [PhotosPickerItem], completion: @escaping ([MediaInfo]) -> Void) async { + isProcessingMedia = true + defer { isProcessingMedia = false } + + var lastError: Error? + + // Store the task so it can be cancelled + processingTask = Task { @MainActor in + // Process all items in parallel + let results = await withTaskGroup(of: MediaInfo?.self) { group in + for item in items { + group.addTask { + // Check for cancellation + if Task.isCancelled { return nil } + + // Load the media data + guard let data = try? await item.loadTransferable(type: Data.self) else { + return nil + } + + // Determine file extension + let contentType = item.supportedContentTypes.first + let fileExtension = contentType?.preferredFilenameExtension ?? "jpg" + + do { + // Save to uploads directory - returns relative URL + let fileURL = try await self.fileManager.saveMediaData(data, withExtension: fileExtension) + + // Determine media type based on content type + let mediaType: String + if contentType?.conforms(to: .image) == true { + mediaType = "image" + } else if contentType?.conforms(to: .movie) == true { + mediaType = "video" + } else if contentType?.conforms(to: .audio) == true { + mediaType = "audio" + } else { + mediaType = "file" + } + + // Create MediaInfo object + return MediaInfo(url: fileURL.absoluteString, type: mediaType) + } catch { + print("Failed to save media file: \(error)") + lastError = error + return nil + } + } + } + + var output: [MediaInfo] = [] + for await result in group { + if let mediaInfo = result { + output.append(mediaInfo) + } + } + return output + } + + // Only call completion if not cancelled + if !Task.isCancelled { + // Show error if we encountered any errors and got no successful results + if let error = lastError, results.isEmpty { + self.error = MediaError(message: error.localizedDescription) + } + + // Still return successfully processed items + completion(results) + } + } + + await processingTask?.value + } + + func processCameraMedia(_ media: CameraMedia, completion: @escaping ([MediaInfo]) -> Void) async { + isProcessingMedia = true + defer { isProcessingMedia = false } + + processingTask = Task { @MainActor in + do { + // Check for cancellation + if Task.isCancelled { return } + + let mediaInfo: MediaInfo + + switch media { + case .photo(let image): + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + return // Should never happen + } + + // Save to uploads directory - returns relative URL + let fileURL = try await fileManager.saveMediaData(imageData, withExtension: "jpg") + + // Create MediaInfo for the captured image + mediaInfo = MediaInfo(url: fileURL.absoluteString, type: "image") + + case .video(let videoURL): + // Copy video to uploads directory + let videoData = try Data(contentsOf: videoURL) + let fileExtension = videoURL.pathExtension.isEmpty ? "mp4" : videoURL.pathExtension + + // Save to uploads directory - returns relative URL + let fileURL = try await fileManager.saveMediaData(videoData, withExtension: fileExtension) + + // Create MediaInfo for the captured video + mediaInfo = MediaInfo(url: fileURL.absoluteString, type: "video") + } + + // Only call completion if not cancelled + if !Task.isCancelled { + completion([mediaInfo]) + } + + } catch { + self.error = MediaError(message: error.localizedDescription) + } + } + + await processingTask?.value + } + + func cancelProcessing() { + processingTask?.cancel() + processingTask = nil + isProcessingMedia = false + } +} + +// MARK: - Constants + +enum BlockInserterConstants { + static let categoryOrder: [(key: String, displayName: String)] = [ + ("text", "Text"), + ("media", "Media"), + ("design", "Design"), + ("widgets", "Widgets"), + ("theme", "Theme"), + ("embed", "Embeds") + ] + + static let textBlockOrder = [ + "core/paragraph", + "core/heading", + "core/list", + "core/list-item", + "core/quote", + "core/code", + "core/preformatted", + "core/verse", + "core/table" + ] + + static let mediaBlockOrder = [ + "core/image", + "core/video", + "core/gallery", + "core/embed", + "core/audio", + "core/file" + ] + + static let designBlockOrder = [ + "core/separator", + "core/spacer", + "core/columns", + "core/column" + ] +} + +// MARK: - Supporting Types + +struct BlockInserterSection: Identifiable { + var id: String { category } + let category: String + let name: String + let blocks: [EditorBlock] +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+Icons.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+Icons.swift new file mode 100644 index 00000000..12f8bd7e --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+Icons.swift @@ -0,0 +1,200 @@ +import Foundation + +extension EditorBlock { + /// Returns the SF Symbol icon name for the block type + var iconName: String { + switch name { + // MARK: - Core Text Blocks + case "core/paragraph": "paragraphsign" + case "core/heading": "bookmark.fill" + case "core/list": "list.bullet" + case "core/list-item": "list.bullet.indent" + case "core/details": "text.line.first.and.arrowtriangle.forward" + case "core/quote": "quote.opening" + case "core/code": "curlybraces" + case "core/preformatted": "text.word.spacing" + case "core/pullquote": "quote.bubble" + case "core/verse": "text.quote" + case "core/table": "tablecells" + case "core/footnotes": "list.number" + case "core/missing": "exclamationmark.triangle" + + // MARK: - Core Media Blocks + case "core/image": "photo" + case "core/gallery": "photo.stack" + case "core/audio": "speaker.wave.2" + case "core/cover": "photo.tv" + case "core/file": "doc.text" + case "core/media-text": "rectangle.split.2x1" + case "core/video": "video" + + // MARK: - Core Design Blocks + case "core/button": "rectangle.fill" + case "core/buttons": "rectangle.3.group" + case "core/column": "rectangle.ratio.9.to.16" + case "core/columns": "rectangle.split.3x1" + case "core/group": "square.on.square" + case "core/more": "ellipsis" + case "core/nextpage": "arrow.right.doc.on.clipboard" + case "core/separator": "minus" + case "core/spacer": "arrow.up.and.down" + case "core/text-columns": "text.justify.left" + + // MARK: - Core Widget Blocks + case "core/archives": "archivebox" + case "core/calendar": "calendar" + case "core/categories": "folder" + case "core/html": "chevron.left.forwardslash.chevron.right" + case "core/latest-comments": "bubble.left.and.bubble.right" + case "core/latest-posts": "doc.plaintext" + case "core/page-list": "list.bullet.rectangle" + case "core/page-list-item": "doc.text" + case "core/rss": "dot.radiowaves.left.and.right" + case "core/search": "magnifyingglass" + case "core/shortcode": "curlybraces.square" + case "core/social-link": "link.circle" + case "core/social-links": "person.2.circle" + case "core/tag-cloud": "tag.circle" + + // MARK: - Core Theme Blocks + case "core/site-logo": "seal" + case "core/site-title": "textformat.size" + case "core/site-tagline": "text.bubble" + case "core/query": "square.grid.2x2" + case "core/post-title": "doc.text" + case "core/post-content": "doc.richtext" + case "core/post-excerpt": "doc.append" + case "core/post-featured-image": "photo.circle" + case "core/post-date": "calendar" + case "core/post-author": "person.circle" + case "core/post-comments": "bubble.left" + case "core/post-navigation-link": "arrow.left.arrow.right" + case "core/post-author-name": "person.text.rectangle" + case "core/post-author-biography": "person.crop.square.filled.and.at.rectangle" + case "core/post-comments-count": "bubble.left.and.text.bubble.right" + case "core/post-comments-link": "bubble.left.and.bubble.right" + case "core/post-comments-form": "text.bubble" + case "core/post-terms": "tag" + case "core/post-template": "doc.on.doc" + case "core/avatar": "person.crop.circle" + case "core/navigation": "line.3.horizontal" + case "core/navigation-link": "link" + case "core/navigation-submenu": "chevron.down.square" + case "core/template-part": "square.split.2x2" + case "core/pattern": "square.grid.3x3.square" + case "core/block": "arrow.triangle.2.circlepath" + case "core/home-link": "house" + case "core/loginout": "person.crop.circle.badge.checkmark" + case "core/term-description": "text.book.closed" + case "core/query-title": "text.badge.checkmark" + case "core/query-pagination": "ellipsis.rectangle" + case "core/query-pagination-next": "chevron.right.square" + case "core/query-pagination-numbers": "number.square" + case "core/query-pagination-previous": "chevron.left.square" + case "core/query-no-results": "xmark.square" + case "core/query-total": "number.circle" + case "core/read-more": "arrow.right.circle" + case "core/comments": "bubble.left.and.bubble.right" + case "core/comment-author-name": "person.bubble" + case "core/comment-content": "text.bubble" + case "core/comment-date": "calendar.badge.clock" + case "core/comment-edit-link": "pencil.circle" + case "core/comment-reply-link": "arrowshape.turn.up.left" + case "core/comment-template": "bubble.left.and.text.bubble.right" + case "core/comments-title": "text.bubble.fill" + case "core/comments-pagination": "ellipsis.bubble" + case "core/comments-pagination-next": "chevron.right.bubble" + case "core/comments-pagination-numbers": "number.square.fill" + case "core/comments-pagination-previous": "chevron.left.bubble" + + // MARK: - Core Embed Blocks + case "core/embed": "chevron.left.forwardslash.chevron.right" + case let name where name.hasPrefix("core-embed/"): "link.circle" + + // MARK: - Jetpack AI & Content + case "jetpack/ai-assistant": "sparkles" + case "jetpack/ai-search": "magnifyingglass.circle" + case "jetpack/markdown": "m.square" + case "jetpack/writing-prompt": "pencil.and.outline" + + // MARK: - Jetpack Contact & Forms + case "jetpack/contact-form": "envelope" + case "jetpack/field-text": "textformat" + case "jetpack/field-textarea": "text.alignleft" + case "jetpack/field-email": "envelope" + case "jetpack/field-name": "person" + case "jetpack/field-url": "link" + case "jetpack/field-date": "calendar" + case "jetpack/field-telephone": "phone" + case "jetpack/field-checkbox": "checkmark.square" + case "jetpack/field-checkbox-multiple": "checklist" + case "jetpack/field-radio": "circle.circle" + case "jetpack/field-select": "list.bullet.rectangle" + + // MARK: - Jetpack Media & Galleries + case "jetpack/image-compare": "arrow.left.and.right" + case "jetpack/tiled-gallery": "rectangle.3.group" + case "jetpack/slideshow": "play.rectangle" + case "jetpack/story": "book.pages" + case "jetpack/gif": "sparkles.rectangle.stack" + + // MARK: - Jetpack Social & Embeds + case "jetpack/instagram-gallery": "camera.fill" + case "jetpack/pinterest": "pin.circle" + case "jetpack/eventbrite": "ticket" + case "jetpack/google-calendar": "calendar.badge.clock" + case "jetpack/podcast-player": "mic.circle" + case "jetpack/map": "map" + + // MARK: - Jetpack Business & Contact + case "jetpack/business-hours": "clock" + case "jetpack/contact-info": "info.circle" + case "jetpack/address": "location" + case "jetpack/email": "envelope" + case "jetpack/phone": "phone" + + // MARK: - Jetpack Payments & E-commerce + case "jetpack/recurring-payments": "arrow.clockwise.circle" + case "jetpack/payment-buttons": "creditcard.circle" + case "jetpack/donations": "heart.circle" + case "jetpack/paywall": "lock.rectangle" + case "jetpack/paid-content": "dollarsign.circle" + + // MARK: - Jetpack Marketing & Growth + case "jetpack/subscriptions": "envelope.badge" + case "jetpack/subscriber-login": "person.badge.key" + case "jetpack/mailchimp": "envelope.open" + case "jetpack/sharing-buttons": "square.and.arrow.up" + case "jetpack/whatsapp-button": "message.circle.fill" + case "jetpack/related-posts": "doc.on.doc" + + // MARK: - Jetpack Widgets & Tools + case "jetpack/rating-star": "star.fill" + case "jetpack/repeat-visitor": "person.2" + case "jetpack/cookie-consent": "shield.checkered" + case "jetpack/top-posts": "chart.bar.fill" + case "jetpack/blog-stats": "chart.line.uptrend.xyaxis" + case "jetpack/like": "heart" + case "jetpack/blogroll": "list.bullet.rectangle" + + // MARK: - Jetpack Booking & Reservations + case "jetpack/calendly": "calendar.badge.plus" + case "jetpack/opentable": "fork.knife" + case "jetpack/tock": "clock.badge.checkmark" + + // MARK: - Jetpack Advertising + case "jetpack/ad": "rectangle.badge.plus" + case "jetpack/ads": "rectangle.stack.badge.plus" + + // MARK: - Jetpack External Services + case "jetpack/revue": "newspaper" + case "jetpack/goodreads": "books.vertical" + case "jetpack/loom": "video.bubble" + case "jetpack/descript": "waveform.circle" + case "jetpack/nextdoor": "house.lodge" + + // MARK: - Default + default: "square" + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+PreviewData.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+PreviewData.swift new file mode 100644 index 00000000..70171fb8 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+PreviewData.swift @@ -0,0 +1,324 @@ +#if DEBUG +import Foundation + +enum PreviewData { + static let sampleBlockTypes: [EditorBlock] = [ + // Text blocks + EditorBlock( + name: "core/paragraph", + title: "Paragraph", + description: "Start with the basic building block of all narrative.", + category: "text", + keywords: ["text", "paragraph"], + ), + EditorBlock( + name: "core/heading", + title: "Heading", + description: "Introduce new sections and organize content to help visitors find what they need.", + category: "text", + keywords: ["title", "heading"], + ), + EditorBlock( + name: "core/list", + title: "List", + description: "Create a bulleted or numbered list.", + category: "text", + keywords: ["bullet", "number", "list"], + ), + EditorBlock( + name: "core/quote", + title: "Quote", + description: "Give quoted text visual emphasis.", + category: "text", + keywords: ["quote", "citation"], + ), + EditorBlock( + name: "core/code", + title: "Code", + description: "Display code snippets that respect your spacing and tabs.", + category: "text", + keywords: ["code", "programming"], + ), + EditorBlock( + name: "core/preformatted", + title: "Preformatted", + description: "Add text that respects your spacing and tabs, and also allows styling.", + category: "text", + keywords: ["preformatted", "monospace"], + ), + EditorBlock( + name: "core/pullquote", + title: "Pullquote", + description: "Give special visual emphasis to a quote from your text.", + category: "text", + keywords: ["pullquote", "quote"], + ), + EditorBlock( + name: "core/verse", + title: "Verse", + description: "Insert poetry. Use special spacing formats. Or quote song lyrics.", + category: "text", + keywords: ["poetry", "verse"], + ), + EditorBlock( + name: "core/table", + title: "Table", + description: "Create structured content in rows and columns to display information.", + category: "text", + keywords: ["table", "rows", "columns"], + ), + + // Media blocks + EditorBlock( + name: "core/image", + title: "Image", + description: "Insert an image to make a visual statement.", + category: "media", + keywords: ["photo", "picture"], + ), + EditorBlock( + name: "core/gallery", + title: "Gallery", + description: "Display multiple images in a rich gallery.", + category: "media", + keywords: ["images", "photos"], + ), + EditorBlock( + name: "core/audio", + title: "Audio", + description: "Embed a simple audio player.", + category: "media", + keywords: ["music", "sound", "podcast"], + ), + EditorBlock( + name: "core/video", + title: "Video", + description: "Embed a video from your media library or upload a new one.", + category: "media", + keywords: ["movie", "film"], + ), + EditorBlock( + name: "core/cover", + title: "Cover", + description: "Add an image or video with a text overlay.", + category: "media", + keywords: ["banner", "hero", "cover"], + ), + EditorBlock( + name: "core/file", + title: "File", + description: "Add a link to a downloadable file.", + category: "media", + keywords: ["download", "pdf", "document"], + ), + EditorBlock( + name: "core/media-text", + title: "Media & Text", + description: "Set media and words side-by-side for a richer layout.", + category: "media", + keywords: ["image", "video", "layout"], + ), + + // Design blocks + EditorBlock( + name: "core/columns", + title: "Columns", + description: "Display content in multiple columns.", + category: "design", + keywords: ["layout", "columns"], + ), + EditorBlock( + name: "core/group", + title: "Group", + description: "Gather blocks in a container.", + category: "design", + keywords: ["container", "wrapper", "group"], + ), + EditorBlock( + name: "core/separator", + title: "Separator", + description: "Create a break between ideas or sections.", + category: "design", + keywords: ["divider", "hr"], + ), + EditorBlock( + name: "core/spacer", + title: "Spacer", + description: "Add white space between blocks.", + category: "design", + keywords: ["space", "gap"], + ), + EditorBlock( + name: "core/buttons", + title: "Buttons", + description: "Prompt visitors to take action with a group of button-style links.", + category: "design", + keywords: ["button", "link", "cta"], + ), + EditorBlock( + name: "core/more", + title: "More", + description: "Content before this block will be shown in the excerpt on your archives page.", + category: "design", + keywords: ["read more", "excerpt"], + ), + + // Widget blocks + EditorBlock( + name: "core/search", + title: "Search", + description: "Help visitors find your content.", + category: "widgets", + keywords: ["find", "search"], + ), + EditorBlock( + name: "core/archives", + title: "Archives", + description: "Display a date archive of your posts.", + category: "widgets", + keywords: ["archive", "history"], + ), + EditorBlock( + name: "core/categories", + title: "Categories", + description: "Display a list of all categories.", + category: "widgets", + keywords: ["category", "taxonomy"], + ), + + // Theme blocks + EditorBlock( + name: "core/site-title", + title: "Site Title", + description: "Display your site's title.", + category: "theme", + keywords: ["title", "site"], + ), + EditorBlock( + name: "core/site-logo", + title: "Site Logo", + description: "Display your site's logo.", + category: "theme", + keywords: ["logo", "brand"], + ), + + // Embed blocks + EditorBlock( + name: "core-embed/youtube", + title: "YouTube", + description: "Embed a YouTube video.", + category: "embed", + keywords: ["video", "youtube"], + ), + EditorBlock( + name: "core-embed/twitter", + title: "Twitter", + description: "Embed a tweet.", + category: "embed", + keywords: ["tweet", "twitter"], + ), + EditorBlock( + name: "core-embed/vimeo", + title: "Vimeo", + description: "Embed a Vimeo video.", + category: "embed", + keywords: ["video", "vimeo"], + ), + EditorBlock( + name: "core-embed/instagram", + title: "Instagram", + description: "Embed an Instagram post.", + category: "embed", + keywords: ["instagram", "photo"], + ), + + // Jetpack blocks + EditorBlock( + name: "jetpack/ai-assistant", + title: "AI Assistant", + description: "Generate text, edit content, and get suggestions using AI.", + category: "text", + keywords: ["ai", "artificial intelligence", "generate", "write"], + ), + EditorBlock( + name: "jetpack/contact-form", + title: "Contact Form", + description: "Add a customizable contact form.", + category: "widgets", + keywords: ["form", "contact", "email"], + ), + EditorBlock( + name: "jetpack/markdown", + title: "Markdown", + description: "Write posts or pages in plain-text Markdown syntax.", + category: "text", + keywords: ["markdown", "md", "formatting"], + ), + EditorBlock( + name: "jetpack/tiled-gallery", + title: "Tiled Gallery", + description: "Display multiple images in an elegantly organized tiled layout.", + category: "media", + keywords: ["gallery", "images", "photos", "tiled"], + ), + EditorBlock( + name: "jetpack/slideshow", + title: "Slideshow", + description: "Display multiple images in a slideshow.", + category: "media", + keywords: ["slideshow", "carousel", "gallery"], + ), + EditorBlock( + name: "jetpack/map", + title: "Map", + description: "Add an interactive map showing one or more locations.", + category: "widgets", + keywords: ["map", "location", "address"], + ), + EditorBlock( + name: "jetpack/business-hours", + title: "Business Hours", + description: "Display your business opening hours.", + category: "widgets", + keywords: ["hours", "schedule", "business"], + ), + EditorBlock( + name: "jetpack/subscriptions", + title: "Subscriptions", + description: "Let visitors subscribe to your blog posts.", + category: "widgets", + keywords: ["subscribe", "email", "newsletter"], + ), + EditorBlock( + name: "jetpack/related-posts", + title: "Related Posts", + description: "Display a list of related posts.", + category: "widgets", + keywords: ["related", "posts", "similar"], + ), + + // Additional common blocks + EditorBlock( + name: "core/html", + title: "Custom HTML", + description: "Add custom HTML code and preview it as you edit.", + category: "widgets", + keywords: ["html", "code", "custom"], + ), + EditorBlock( + name: "core/shortcode", + title: "Shortcode", + description: "Insert additional custom elements with WordPress shortcodes.", + category: "widgets", + keywords: ["shortcode", "custom"], + ), + EditorBlock( + name: "core/social-links", + title: "Social Icons", + description: "Display icons linking to your social media profiles.", + category: "widgets", + keywords: ["social", "links", "icons"], + ) + ] +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/SearchEngine+EditorBlock.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/SearchEngine+EditorBlock.swift new file mode 100644 index 00000000..ed9f64dc --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/SearchEngine+EditorBlock.swift @@ -0,0 +1,60 @@ +import Foundation + +extension EditorBlock: Searchable { + func searchableFields() -> [SearchableField] { + var fields: [SearchableField] = [] + + // Title - highest weight + if let title = title { + fields.append(SearchableField( + content: title, + weight: 10.0, + allowFuzzyMatch: true + )) + } + + // Name - high weight, strip namespace for better matching + let simplifiedName = name.components(separatedBy: "/").last ?? name + fields.append(SearchableField( + content: simplifiedName, + weight: 8.0, + allowFuzzyMatch: true + )) + + // Keywords - medium weight + if let keywords = keywords { + keywords.forEach { keyword in + fields.append(SearchableField( + content: keyword, + weight: 5.0, + allowFuzzyMatch: true + )) + } + } + + // Description - lower weight, no fuzzy matching + if let description = description { + fields.append(SearchableField( + content: description, + weight: 3.0, + allowFuzzyMatch: false + )) + } + + // Category - lowest weight + if let category = category { + fields.append(SearchableField( + content: category, + weight: 2.0, + allowFuzzyMatch: true + )) + } + + return fields + } +} + +extension SearchEngine where Item == EditorBlock { + /// Default search engine for editor blocks + static let blocks = SearchEngine() +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/CameraView.swift b/ios/Sources/GutenbergKit/Sources/Views/CameraView.swift new file mode 100644 index 00000000..bd70a5be --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/CameraView.swift @@ -0,0 +1,51 @@ +import UIKit +import SwiftUI +import UniformTypeIdentifiers + +enum CameraMedia { + case photo(UIImage) + case video(URL) +} + +struct CameraView: UIViewControllerRepresentable { + var onMediaCaptured: ((CameraMedia) -> Void)? + + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier] // Support both photo and video + picker.videoQuality = .typeHigh + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + parent.dismiss() + + if let image = info[.originalImage] as? UIImage { + parent.onMediaCaptured?(.photo(image)) + } else if let videoURL = info[.mediaURL] as? URL { + parent.onMediaCaptured?(.video(videoURL)) + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 67bdd49c..880ffe2c 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -17,7 +17,7 @@ import { ToolbarButton, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { close, cog } from '@wordpress/icons'; +import { close, cog, plus } from '@wordpress/icons'; import clsx from 'clsx'; import { store as editorStore } from '@wordpress/editor'; @@ -26,6 +26,7 @@ import { store as editorStore } from '@wordpress/editor'; */ import './style.scss'; import { useModalize } from './use-modalize'; +import { showBlockPicker, getGBKit } from '../../utils/bridge'; /** * Renders the editor toolbar containing block-related actions. @@ -50,6 +51,10 @@ const EditorToolbar = ( { className } ) => { }, [] ); const { setIsInserterOpened } = useDispatch( editorStore ); + // Check if native block inserter is enabled from configuration + const gbKit = getGBKit(); + const enableNativeBlockInserter = gbKit.enableNativeBlockInserter ?? false; + useModalize( isInserterOpened ); useModalize( isBlockInspectorShown ); @@ -81,14 +86,30 @@ const EditorToolbar = ( { className } ) => { variant="unstyled" > - + { enableNativeBlockInserter ? ( + { + // Close any open web inserter + if ( isInserterOpened ) { + setIsInserterOpened( false ); + } + // Show native block picker + showBlockPicker(); + } } + className="gutenberg-kit-add-block-button" + /> + ) : ( + + ) } { isSelected && ( diff --git a/src/components/editor-toolbar/style.scss b/src/components/editor-toolbar/style.scss index c7d49c8e..c5d8c2f6 100644 --- a/src/components/editor-toolbar/style.scss +++ b/src/components/editor-toolbar/style.scss @@ -43,6 +43,22 @@ $min-touch-target-size: 46px; color: wordpress.$white; } +// Style the add block button with rounded black background +.gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button { + svg { + background: #eae9ec; + border-radius: 18px; + color: wordpress.$black; + padding: 1px; + width: 32px; + height: 32px; + display: block; + } + + // width: 50px; + margin-left: 8px; +} + // Disable scrolling of the block toolbar, rely upon parent container scrolling .gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar { flex-shrink: 0; diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index ed292861..ac7910eb 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -2,12 +2,18 @@ * WordPress dependencies */ import { useEffect, useCallback, useRef } from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch, useSelect, dispatch, select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; -import { parse, serialize } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { parse, serialize, createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ window.editor = window.editor || {}; +window.editor._savedInsertionPoint = null; export function useHostBridge( post, editorRef ) { const { editEntityRecord } = useDispatch( coreStore ); @@ -15,6 +21,19 @@ export function useHostBridge( post, editorRef ) { const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); + // Track the current selection and insertion point + const { selectedBlockClientId, blockInsertionPoint } = useSelect( + ( selectFn ) => { + const { getSelectedBlockClientId, getBlockInsertionPoint } = + selectFn( blockEditorStore ); + return { + selectedBlockClientId: getSelectedBlockClientId(), + blockInsertionPoint: getBlockInsertionPoint(), + }; + }, + [] + ); + const editContent = useCallback( ( edits ) => { editEntityRecord( 'postType', post.type, post.id, edits ); @@ -28,6 +47,33 @@ export function useHostBridge( post, editorRef ) { postContentRef.current = serialize( parse( post.content.raw || '' ) ); } + // Continuously update the saved insertion point whenever selection changes + useEffect( () => { + // Only update if we have a selected block OR if we're clearing selection but already have a saved point + if ( selectedBlockClientId !== null ) { + // We have a selected block, save its position + window.editor._savedInsertionPoint = { + rootClientId: blockInsertionPoint?.rootClientId, + index: blockInsertionPoint?.index || 0, + selectedBlockClientId, + }; + } else if ( + ! window.editor._savedInsertionPoint || + ! window.editor._savedInsertionPoint.selectedBlockClientId + ) { + // Only update to null selection if we don't have a previously selected block + // This prevents overwriting a good insertion point when focus is lost + if ( blockInsertionPoint && blockInsertionPoint.index !== null ) { + window.editor._savedInsertionPoint = { + rootClientId: blockInsertionPoint?.rootClientId, + index: blockInsertionPoint?.index || 0, + selectedBlockClientId: null, + }; + } + } + // If selectedBlockClientId is null but we had a previous selection, keep the old insertion point + }, [ selectedBlockClientId, blockInsertionPoint ] ); + useEffect( () => { window.editor.setContent = ( content ) => { editContent( { content: decodeURIComponent( content ) } ); @@ -79,6 +125,70 @@ export function useHostBridge( post, editorRef ) { switchEditorMode( mode ); }; + window.editor.insertBlock = ( blockName ) => { + try { + const block = createBlock( blockName ); + + // Check if we have a saved insertion point + if ( window.editor._savedInsertionPoint ) { + const { + selectedBlockClientId: savedSelectedBlockClientId, + index, + rootClientId + } = window.editor._savedInsertionPoint; + + // Try to use insertBlocks (plural) which might handle positioning better + const { insertBlocks } = dispatch( blockEditorStore ); + + if ( savedSelectedBlockClientId ) { + // We have a selected block, insert after it + // First, try to get the block directly + const selectedBlock = select( + blockEditorStore + ).getBlock( savedSelectedBlockClientId ); + if ( selectedBlock ) { + const parentClientId = select( + blockEditorStore + ).getBlockRootClientId( savedSelectedBlockClientId ); + const blockIndex = select( + blockEditorStore + ).getBlockIndex( savedSelectedBlockClientId ); + + // Use insertBlocks with explicit position + insertBlocks( + [ block ], + blockIndex + 1, + parentClientId + ); + } else { + insertBlocks( [ block ], index, rootClientId ); + } + } else { + // No selected block, use the saved index + insertBlocks( [ block ], index, rootClientId ); + } + } else { + // No saved insertion point + dispatch( blockEditorStore ).insertBlock( block ); + } + + // Select the newly inserted block to help with focusing + setTimeout( () => { + dispatch( blockEditorStore ).selectBlock( block.clientId ); + }, 100 ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Error in insertBlock:', error ); + } + }; + + window.editor.insertMediaFromFiles = ( mediaItems ) => { + // Do not return the Promise to avoid host errors + import( '../../utils/bridge' ).then( ( { insertMediaFromFiles } ) => { + insertMediaFromFiles( mediaItems ); + } ); + }; + return () => { delete window.editor.setContent; delete window.editor.setTitle; @@ -87,6 +197,9 @@ export function useHostBridge( post, editorRef ) { delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; + delete window.editor.insertBlock; + delete window.editor.insertMediaFromFiles; + window.editor._savedInsertionPoint = null; }; }, [ editorRef, diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 9bcb5da2..af794303 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { getBlockTypes } from '@wordpress/blocks'; /** * Internal dependencies @@ -110,15 +111,38 @@ export function onBlocksChanged( isEmpty = false ) { * @return {void} */ export function showBlockPicker() { - if ( window.editorDelegate ) { - window.editorDelegate.showBlockPicker(); - } + // Get all registered block types + const allBlockTypes = getBlockTypes(); + const blockTypes = allBlockTypes.map( ( blockType ) => { + return { + name: blockType.name, + title: blockType.title, + description: blockType.description, + category: blockType.category, + keywords: blockType.keywords || [], + }; + } ); - if ( window.webkit ) { - window.webkit.messageHandlers.editorDelegate.postMessage( { - message: 'showBlockPicker', - body: {}, - } ); + try { + if ( window.editorDelegate ) { + window.editorDelegate.showBlockPicker( + JSON.stringify( { blockTypes } ) + ); + } + + if ( + window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.editorDelegate + ) { + window.webkit.messageHandlers.editorDelegate.postMessage( { + message: 'showBlockPicker', + body: { blockTypes }, + } ); + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Error sending message to native:', error ); } } @@ -303,3 +327,123 @@ export async function fetchEditorAssets() { url, } ); } + + + +/** + * Inserts multiple media files, creating a gallery if there are multiple images, + * or individual blocks for other media types. + * + * @param {Array} mediaItems Array of MediaInfo entities with { id, url, type, title, caption, alt, metadata } + * @return {Promise} + */ +export async function insertMediaFromFiles( mediaItems ) { + try { + // Import dependencies + const { createBlock } = await import( '@wordpress/blocks' ); + const { dispatch } = await import( '@wordpress/data' ); + const blockEditorStore = ( await import( '@wordpress/block-editor' ) ) + .store; + + // Map media types to block types + const getBlockType = ( mediaType ) => { + switch ( mediaType ) { + case 'image': + return 'core/image'; + case 'video': + return 'core/video'; + case 'audio': + return 'core/audio'; + case 'file': + default: + return 'core/file'; + } + }; + + // Separate images from other media types + const imageItems = mediaItems.filter( item => item.type === 'image' ); + const otherItems = mediaItems.filter( item => item.type !== 'image' ); + + const blocksToInsert = []; + + // If multiple images, create a gallery + if ( imageItems.length > 1 ) { + // Create inner image blocks for the gallery + const innerImageBlocks = imageItems.map( item => + createBlock( 'core/image', { + url: item.url, + id: item.id || undefined, + alt: item.alt || '', + caption: item.caption || '', + title: item.title || undefined, + }) + ); + + // Create gallery block with inner blocks + const galleryBlock = createBlock( + 'core/gallery', + { + columns: Math.min( imageItems.length, 3 ), // Max 3 columns + imageCrop: true, + linkTo: 'none', + }, + innerImageBlocks // Inner blocks parameter + ); + + blocksToInsert.push( galleryBlock ); + } else if ( imageItems.length === 1 ) { + // Single image, create an image block + const item = imageItems[ 0 ]; + const imageBlock = createBlock( 'core/image', { + url: item.url, + id: item.id || undefined, + alt: item.alt || '', + caption: item.caption || '', + title: item.title || undefined, + } ); + blocksToInsert.push( imageBlock ); + } + + // Handle non-image media types individually + for ( const item of otherItems ) { + const blockType = getBlockType( item.type ); + const blockAttributes = { + url: item.url, + id: item.id || undefined, + caption: item.caption || '', + }; + + // Add title for file blocks + if ( blockType === 'core/file' && item.title ) { + blockAttributes.fileName = item.title; + } + + // Add controls for video/audio + if ( blockType === 'core/video' || blockType === 'core/audio' ) { + blockAttributes.controls = true; + } + + const block = createBlock( blockType, blockAttributes ); + blocksToInsert.push( block ); + } + + // Insert all blocks + if ( blocksToInsert.length > 0 ) { + const insertedBlocks = dispatch( blockEditorStore ).insertBlocks( blocksToInsert ); + + if ( !insertedBlocks || insertedBlocks.length === 0 ) { + throw new Error( 'Failed to insert blocks' ); + } + } + + // TODO: this doesn't actually trigger the uploads + + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to insert media:', error ); + logException( error, { + context: { mediaItems }, + tags: { feature: 'media-insert-multiple' }, + } ); + } +} \ No newline at end of file