diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 05d0519d67a2..ec1f7f793b23 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0018852d4b5daf8ccfef53dba310fdb313c4511d7c5ade6fe3ef42fa2490ca4e", + "originHash" : "c309bd4a10359e813e6b0c4b70e353fb5e054a0a93db9045a8dbb3c7ba8b1c05", "pins" : [ { "identity" : "alamofire", @@ -149,8 +149,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "06a322f5fe222c4991abca407b7392f76bcff9d8", - "version" : "0.9.0" + "revision" : "ed8ddd5a8af42b092834e6a3cfbd9944e71b9d59" } }, { @@ -295,6 +294,15 @@ "version" : "8.0.4" } }, + { + "identity" : "svgview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/SVGView.git", + "state" : { + "revision" : "6465962facdd25cb96eaebc35603afa2f15d2c0d", + "version" : "1.0.6" + } + }, { "identity" : "svprogresshud", "kind" : "remoteSourceControl", diff --git a/Modules/Package.swift b/Modules/Package.swift index 6fa743000e7a..d3e7d4fc28ef 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -53,7 +53,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.9.0"), + .package(url: "https://github.com/wordpress-mobile/GutenbergKit", revision: "ed8ddd5a8af42b092834e6a3cfbd9944e71b9d59"), .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20251101"), .package( url: "https://github.com/Automattic/color-studio", diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index e13eb38ce187..80bb1f1046d5 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -27,6 +27,7 @@ public enum FeatureFlag: Int, CaseIterable { case mediaQuotaView case intelligence case newSupport + case nativeBlockInserter /// Returns a boolean indicating if the feature is enabled. /// @@ -86,6 +87,8 @@ public enum FeatureFlag: Int, CaseIterable { return (languageCode ?? "en").hasPrefix("en") case .newSupport: return false + case .nativeBlockInserter: + return false } } @@ -130,6 +133,7 @@ extension FeatureFlag { case .mediaQuotaView: "Media Quota" case .intelligence: "Intelligence" case .newSupport: "New Support" + case .nativeBlockInserter: "Native Block Inserter" } } } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift index a021a6386c88..df7c8f99a240 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift @@ -104,4 +104,8 @@ extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControlle func editor(_ viewController: GutenbergKit.EditorViewController, didCloseModalDialog dialogType: String) { // Do nothing } + + func editor(_ viewController: GutenbergKit.EditorViewController, didLogMessage message: String, level: GutenbergKit.LogLevel) { + // Do nothing + } } diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerController.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerController.swift new file mode 100644 index 000000000000..3e187ceeec2e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerController.swift @@ -0,0 +1,173 @@ +import UIKit +import GutenbergKit +import WordPressData +import WordPressShared + +/// A adapter for GutenbergKit that manages media picker sources the editor. +final class MediaPickerController: GutenbergKit.MediaPickerController { + private let blog: Blog + + init(blog: Blog) { + self.blog = blog + } + + func getActions(for parameters: MediaPickerParameters) -> [MediaPickerActionGroup] { + let menu = MediaPickerMenu( + filter: convertFilter(parameters.filter), + isMultipleSelectionEnabled: parameters.isMultipleSelectionEnabled + ) + + // Create a temporary controller just to extract action metadata + let tempController = MediaPickerMenuController() + + // Define media sources with their identifiers + let sources: [(source: MediaPickerSource, id: MediaPickerID)] = [ + (.siteMedia(blog: blog), .siteMedia), + (.freePhotos(blog: blog), .freePhotos), + (.freeGIFs(blog: blog), .freeGIFs) + ] + + // Create actions from enabled sources + let actions = sources.compactMap { source, id -> MediaPickerAction? in + guard source.isEnabled else { return nil } + + let uiAction = createUIAction(for: source, menu: menu, controller: tempController) + guard let uiAction else { return nil } + + return MediaPickerAction( + id: id.rawValue, + title: uiAction.title, + image: uiAction.image ?? UIImage() + ) + } + + return [MediaPickerActionGroup(id: "primary", actions: actions)] + .filter { !$0.actions.isEmpty } + } + + func perform(_ action: MediaPickerAction, parameters: MediaPickerParameters, from presentingViewController: UIViewController) async -> [MediaInfo] { + // Find the source for this action + guard let pickerID = MediaPickerID(rawValue: action.id) else { + return [] + } + + let source = getSource(for: pickerID) + guard source.isEnabled else { + return [] + } + + // Create menu and controller + let menu = MediaPickerMenu( + filter: convertFilter(parameters.filter), + isMultipleSelectionEnabled: parameters.isMultipleSelectionEnabled + ) + + let controller = MediaPickerMenuController() + + // Use continuation to wait for the selection + return await withCheckedContinuation { continuation in + controller.onSelection = { [weak self] selection in + guard let self else { + continuation.resume(returning: []) + return + } + let mediaInfos = self.convertSelectionToMediaInfo(selection) + continuation.resume(returning: mediaInfos) + } + + // Create and perform the UIAction + if let uiAction = createUIAction(for: source, menu: menu, controller: controller) { + MainActor.assumeIsolated { + uiAction.performWithSender(nil, target: nil) + } + } else { + continuation.resume(returning: []) + } + } + } + + // MARK: - Private Methods + + private func getSource(for id: MediaPickerID) -> MediaPickerSource { + switch id { + case .imagePlayground: .playground + case .siteMedia: .siteMedia(blog: blog) + case .applePhotos: .photos + case .freePhotos: .freePhotos(blog: blog) + case .freeGIFs: .freeGIFs(blog: blog) + default: fatalError("Unsupported: \(id)") + } + } + + private func convertFilter(_ filter: MediaPickerParameters.MediaFilter?) -> MediaPickerMenu.MediaFilter? { + guard let filter else { return nil } + switch filter { + case .images: return .images + case .videos: return .videos + case .all: return nil + } + } + + private func createUIAction(for source: MediaPickerSource, menu: MediaPickerMenu, controller: MediaPickerMenuController) -> UIAction? { + switch source { + case .playground: menu.makeImagePlaygroundAction(delegate: controller) + case .siteMedia: menu.makeSiteMediaAction(blog: blog, delegate: controller) + case .photos: menu.makePhotosAction(delegate: controller) + case .freePhotos: menu.makeStockPhotos(blog: blog, delegate: controller) + case .freeGIFs: menu.makeFreeGIFAction(blog: blog, delegate: controller) + default: nil + } + } + + private func convertSelectionToMediaInfo(_ selection: MediaPickerSelection) -> [MediaInfo] { + var output: [MediaInfo] = [] + + for item in selection.items { + switch item { + case .media(let media): + var metadata: [String: String] = [:] + if let videopressGUID = media.videopressGUID { + metadata["videopressGUID"] = videopressGUID + } + let mediaInfo = MediaInfo( + id: media.mediaID?.int32Value, + url: media.remoteURL, + type: media.mimeType, + caption: media.caption, + title: media.filename, + alt: media.alt, + metadata: metadata + ) + output.append(mediaInfo) + + case .external(let asset): + let mediaInfo = MediaInfo( + id: nil, + url: asset.largeURL.absoluteString, + type: asset.largeURL.preferredMimeType, + caption: asset.caption, + title: asset.name, + alt: nil, + metadata: [:] + ) + output.append(mediaInfo) + + case .image, .pickerResult: + wpAssertionFailure("unused case") + break + } + } + + return output + } +} + +private extension URL { + var preferredMimeType: String { + if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType { + return mimeType + } else { + return "application/octet-stream" + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift index b3541871521c..51d66452422e 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift @@ -6,7 +6,7 @@ import WordPressData final class MediaPickerMenuController: NSObject { var onSelection: ((MediaPickerSelection) -> Void)? - fileprivate func didSelect(_ items: [MediaPickerItem], source: String) { + fileprivate func didSelect(_ items: [MediaPickerItem], source: MediaPickerID) { let selection = MediaPickerSelection(items: items, source: source) DispatchQueue.main.async { self.onSelection?(selection) @@ -18,7 +18,7 @@ extension MediaPickerMenuController: PHPickerViewControllerDelegate { public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.presentingViewController?.dismiss(animated: true) if !results.isEmpty { - self.didSelect(results.map(MediaPickerItem.pickerResult), source: "apple_photos") + self.didSelect(results.map(MediaPickerItem.pickerResult), source: .applePhotos) } } } @@ -27,7 +27,7 @@ extension MediaPickerMenuController: ImagePickerControllerDelegate { func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { picker.presentingViewController?.dismiss(animated: true) if let image = info[.originalImage] as? UIImage { - self.didSelect([.image(image)], source: "camera") + self.didSelect([.image(image)], source: .camera) } } } @@ -36,7 +36,7 @@ extension MediaPickerMenuController: SiteMediaPickerViewControllerDelegate { func siteMediaPickerViewController(_ viewController: SiteMediaPickerViewController, didFinishWithSelection selection: [Media]) { viewController.presentingViewController?.dismiss(animated: true) if !selection.isEmpty { - self.didSelect(selection.map(MediaPickerItem.media), source: "site_media") + self.didSelect(selection.map(MediaPickerItem.media), source: .siteMedia) } } } @@ -46,7 +46,7 @@ extension MediaPickerMenuController: ImagePlaygroundPickerDelegate { viewController.presentingViewController?.dismiss(animated: true) if let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) { - self.didSelect([.image(image)], source: "image_playground") + self.didSelect([.image(image)], source: .imagePlayground) } else { wpAssertionFailure("failed to read the image created by ImagePlayground") } @@ -57,7 +57,7 @@ extension MediaPickerMenuController: ExternalMediaPickerViewDelegate { func externalMediaPickerViewController(_ viewController: ExternalMediaPickerViewController, didFinishWithSelection selection: [ExternalMediaAsset]) { viewController.presentingViewController?.dismiss(animated: true) if !selection.isEmpty { - let source = viewController.source == .tenor ? "free_gifs" : "free_photos" + let source: MediaPickerID = viewController.source == .tenor ? .freeGIFs : .freePhotos self.didSelect(selection.map(MediaPickerItem.external), source: source) } } diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift index cd5fb43356ab..a14709a39caa 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift @@ -101,7 +101,7 @@ enum MediaPickerSource { struct MediaPickerSelection { var items: [MediaPickerItem] - var source: String + var source: MediaPickerID } enum MediaPickerItem { diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift index c4e2f8ddafdb..a6e8dad5a995 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift @@ -53,3 +53,12 @@ extension MediaPickerMenu.MediaFilter { } } } + +enum MediaPickerID: String { + case applePhotos = "apple_photos" + case camera = "camera" + case siteMedia = "site_media" + case imagePlayground = "image_playground" + case freeGIFs = "free_gifs" + case freePhotos = "free_photos" +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index fca8dff431f9..62d4625ab5b0 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -1,6 +1,7 @@ import UIKit import WordPressUI import AsyncImageKit +import BuildSettingsKit import AutomatticTracks import GutenbergKit import SafariServices @@ -8,6 +9,7 @@ import WordPressData import WordPressShared import WebKit import CocoaLumberjackSwift +import Photos class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor { @@ -180,8 +182,13 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor self.editorSession = PostEditorAnalyticsSession(editor: .gutenbergKit, post: post) self.navigationBarManager = navigationBarManager ?? PostEditorNavigationBarManager() + EditorLocalization.localize = getLocalizedString + let editorConfiguration = EditorConfiguration(blog: post.blog) - self.editorViewController = GutenbergKit.EditorViewController(configuration: editorConfiguration) + self.editorViewController = GutenbergKit.EditorViewController( + configuration: editorConfiguration, + mediaPicker: MediaPickerController(blog: post.blog) + ) self.blockEditorSettingsService = RawBlockEditorSettingsService(blog: post.blog) @@ -397,6 +404,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor .apply(settings) { $0.setEditorSettings($1) } .setTitle(post.postTitle ?? "") .setContent(post.content ?? "") + .setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled) .build() self.editorViewController.updateConfiguration(updatedConfiguration) @@ -565,10 +573,12 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate } } - func editor(_ viewController: GutenbergKit.EditorViewController, performRequest: GutenbergKit.EditorNetworkRequest) async throws -> GutenbergKit.EditorNetworkResponse { - throw URLError(.unknown) + func editor(_ viewController: GutenbergKit.EditorViewController, didLogMessage message: String, level: GutenbergKit.LogLevel) { + // Do nothing } + // MARK: - Media Picker Helpers + func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { let flags = mediaFilterFlags(using: config.allowedTypes ?? []) @@ -1099,3 +1109,18 @@ private extension NewGutenbergViewController { // Extend Gutenberg JavaScript exception struct to conform the protocol defined in the Crash Logging service extension GutenbergJSException.StacktraceLine: @retroactive AutomatticTracks.JSStacktraceLine {} extension GutenbergJSException: @retroactive AutomatticTracks.JSException {} + +private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) -> String { + switch value { + case .showMore: NSLocalizedString("editor.blockInserter.showMore", value: "Show More", comment: "Button title to expand and show more blocks") + case .showLess: NSLocalizedString("editor.blockInserter.showLess", value: "Show Less", comment: "Button title to collapse and show fewer blocks") + case .search: NSLocalizedString("editor.blockInserter.search", value: "Search", comment: "Placeholder text for block search field") + case .insertBlock: NSLocalizedString("editor.blockInserter.insertBlock", value: "Insert Block", comment: "Context menu action to insert a block") + case .failedToInsertMedia: NSLocalizedString("editor.media.failedToInsert", value: "Failed to insert media", comment: "Error message when media insertion fails") + case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view") + case .noPatternsFound: NSLocalizedString("editor.patterns.noPatternsFound", value: "No Patterns Found", comment: "Title shown when no patterns match the search") + case .insertPattern: NSLocalizedString("editor.patterns.insertPattern", value: "Insert Pattern", comment: "Context menu action to insert a pattern") + case .patternsCategoryUncategorized: NSLocalizedString("editor.patterns.uncategorized", value: "Uncategorized", comment: "Category name for patterns without a category") + case .patternsCategoryAll: NSLocalizedString("editor.patterns.all", value: "All", comment: "Category name for section showing all patterns") + } +} diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme index 8b7fb8b87607..148389f3fadf 100644 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme @@ -122,6 +122,11 @@ value = "disable" isEnabled = "NO"> + +