Skip to content

Commit b378b0b

Browse files
keandcalhoun
andauthored
Integrate Native Block Inserter (#24708)
* Add native block inserter * build: Update GutenbergKit version * fix: Remove duplicate method from poor merge conflict resolution * build: Update GutenbergKit version --------- Co-authored-by: David Calhoun <github@davidcalhoun.me>
1 parent 46c9fa7 commit b378b0b

File tree

9 files changed

+228
-12
lines changed

9 files changed

+228
-12
lines changed

Modules/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ let package = Package(
5353
.package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"),
5454
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
5555
// We can't use wordpress-rs branches nor commits here. Only tags work.
56-
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.10.0-alpha.0"),
56+
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.10.1"),
5757
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20251101"),
5858
.package(
5959
url: "https://github.com/Automattic/color-studio",

WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public enum FeatureFlag: Int, CaseIterable {
2727
case mediaQuotaView
2828
case intelligence
2929
case newSupport
30+
case nativeBlockInserter
3031

3132
/// Returns a boolean indicating if the feature is enabled.
3233
///
@@ -86,6 +87,8 @@ public enum FeatureFlag: Int, CaseIterable {
8687
return (languageCode ?? "en").hasPrefix("en")
8788
case .newSupport:
8889
return false
90+
case .nativeBlockInserter:
91+
return false
8992
}
9093
}
9194

@@ -130,6 +133,7 @@ extension FeatureFlag {
130133
case .mediaQuotaView: "Media Quota"
131134
case .intelligence: "Intelligence"
132135
case .newSupport: "New Support"
136+
case .nativeBlockInserter: "Native Block Inserter"
133137
}
134138
}
135139
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import UIKit
2+
import GutenbergKit
3+
import WordPressData
4+
import WordPressShared
5+
6+
/// A adapter for GutenbergKit that manages media picker sources the editor.
7+
final class MediaPickerController: GutenbergKit.MediaPickerController {
8+
private let blog: Blog
9+
10+
init(blog: Blog) {
11+
self.blog = blog
12+
}
13+
14+
func getActions(for parameters: MediaPickerParameters) -> [MediaPickerActionGroup] {
15+
let menu = MediaPickerMenu(
16+
filter: convertFilter(parameters.filter),
17+
isMultipleSelectionEnabled: parameters.isMultipleSelectionEnabled
18+
)
19+
20+
// Create a temporary controller just to extract action metadata
21+
let tempController = MediaPickerMenuController()
22+
23+
// Define media sources with their identifiers
24+
let sources: [(source: MediaPickerSource, id: MediaPickerID)] = [
25+
(.siteMedia(blog: blog), .siteMedia),
26+
(.freePhotos(blog: blog), .freePhotos),
27+
(.freeGIFs(blog: blog), .freeGIFs)
28+
]
29+
30+
// Create actions from enabled sources
31+
let actions = sources.compactMap { source, id -> MediaPickerAction? in
32+
guard source.isEnabled else { return nil }
33+
34+
let uiAction = createUIAction(for: source, menu: menu, controller: tempController)
35+
guard let uiAction else { return nil }
36+
37+
return MediaPickerAction(
38+
id: id.rawValue,
39+
title: uiAction.title,
40+
image: uiAction.image ?? UIImage()
41+
)
42+
}
43+
44+
return [MediaPickerActionGroup(id: "primary", actions: actions)]
45+
.filter { !$0.actions.isEmpty }
46+
}
47+
48+
func perform(_ action: MediaPickerAction, parameters: MediaPickerParameters, from presentingViewController: UIViewController) async -> [MediaInfo] {
49+
// Find the source for this action
50+
guard let pickerID = MediaPickerID(rawValue: action.id) else {
51+
return []
52+
}
53+
54+
let source = getSource(for: pickerID)
55+
guard source.isEnabled else {
56+
return []
57+
}
58+
59+
// Create menu and controller
60+
let menu = MediaPickerMenu(
61+
filter: convertFilter(parameters.filter),
62+
isMultipleSelectionEnabled: parameters.isMultipleSelectionEnabled
63+
)
64+
65+
let controller = MediaPickerMenuController()
66+
67+
// Use continuation to wait for the selection
68+
return await withCheckedContinuation { continuation in
69+
controller.onSelection = { [weak self] selection in
70+
guard let self else {
71+
continuation.resume(returning: [])
72+
return
73+
}
74+
let mediaInfos = self.convertSelectionToMediaInfo(selection)
75+
continuation.resume(returning: mediaInfos)
76+
}
77+
78+
// Create and perform the UIAction
79+
if let uiAction = createUIAction(for: source, menu: menu, controller: controller) {
80+
MainActor.assumeIsolated {
81+
uiAction.performWithSender(nil, target: nil)
82+
}
83+
} else {
84+
continuation.resume(returning: [])
85+
}
86+
}
87+
}
88+
89+
// MARK: - Private Methods
90+
91+
private func getSource(for id: MediaPickerID) -> MediaPickerSource {
92+
switch id {
93+
case .imagePlayground: .playground
94+
case .siteMedia: .siteMedia(blog: blog)
95+
case .applePhotos: .photos
96+
case .freePhotos: .freePhotos(blog: blog)
97+
case .freeGIFs: .freeGIFs(blog: blog)
98+
default: fatalError("Unsupported: \(id)")
99+
}
100+
}
101+
102+
private func convertFilter(_ filter: MediaPickerParameters.MediaFilter?) -> MediaPickerMenu.MediaFilter? {
103+
guard let filter else { return nil }
104+
switch filter {
105+
case .images: return .images
106+
case .videos: return .videos
107+
case .all: return nil
108+
}
109+
}
110+
111+
private func createUIAction(for source: MediaPickerSource, menu: MediaPickerMenu, controller: MediaPickerMenuController) -> UIAction? {
112+
switch source {
113+
case .playground: menu.makeImagePlaygroundAction(delegate: controller)
114+
case .siteMedia: menu.makeSiteMediaAction(blog: blog, delegate: controller)
115+
case .photos: menu.makePhotosAction(delegate: controller)
116+
case .freePhotos: menu.makeStockPhotos(blog: blog, delegate: controller)
117+
case .freeGIFs: menu.makeFreeGIFAction(blog: blog, delegate: controller)
118+
default: nil
119+
}
120+
}
121+
122+
private func convertSelectionToMediaInfo(_ selection: MediaPickerSelection) -> [MediaInfo] {
123+
var output: [MediaInfo] = []
124+
125+
for item in selection.items {
126+
switch item {
127+
case .media(let media):
128+
var metadata: [String: String] = [:]
129+
if let videopressGUID = media.videopressGUID {
130+
metadata["videopressGUID"] = videopressGUID
131+
}
132+
let mediaInfo = MediaInfo(
133+
id: media.mediaID?.int32Value,
134+
url: media.remoteURL,
135+
type: media.mimeType,
136+
caption: media.caption,
137+
title: media.filename,
138+
alt: media.alt,
139+
metadata: metadata
140+
)
141+
output.append(mediaInfo)
142+
143+
case .external(let asset):
144+
let mediaInfo = MediaInfo(
145+
id: nil,
146+
url: asset.largeURL.absoluteString,
147+
type: asset.largeURL.preferredMimeType,
148+
caption: asset.caption,
149+
title: asset.name,
150+
alt: nil,
151+
metadata: [:]
152+
)
153+
output.append(mediaInfo)
154+
155+
case .image, .pickerResult:
156+
wpAssertionFailure("unused case")
157+
break
158+
}
159+
}
160+
161+
return output
162+
}
163+
}
164+
165+
private extension URL {
166+
var preferredMimeType: String {
167+
if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType {
168+
return mimeType
169+
} else {
170+
return "application/octet-stream"
171+
}
172+
}
173+
}

WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import WordPressData
66
final class MediaPickerMenuController: NSObject {
77
var onSelection: ((MediaPickerSelection) -> Void)?
88

9-
fileprivate func didSelect(_ items: [MediaPickerItem], source: String) {
9+
fileprivate func didSelect(_ items: [MediaPickerItem], source: MediaPickerID) {
1010
let selection = MediaPickerSelection(items: items, source: source)
1111
DispatchQueue.main.async {
1212
self.onSelection?(selection)
@@ -18,7 +18,7 @@ extension MediaPickerMenuController: PHPickerViewControllerDelegate {
1818
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
1919
picker.presentingViewController?.dismiss(animated: true)
2020
if !results.isEmpty {
21-
self.didSelect(results.map(MediaPickerItem.pickerResult), source: "apple_photos")
21+
self.didSelect(results.map(MediaPickerItem.pickerResult), source: .applePhotos)
2222
}
2323
}
2424
}
@@ -27,7 +27,7 @@ extension MediaPickerMenuController: ImagePickerControllerDelegate {
2727
func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
2828
picker.presentingViewController?.dismiss(animated: true)
2929
if let image = info[.originalImage] as? UIImage {
30-
self.didSelect([.image(image)], source: "camera")
30+
self.didSelect([.image(image)], source: .camera)
3131
}
3232
}
3333
}
@@ -36,7 +36,7 @@ extension MediaPickerMenuController: SiteMediaPickerViewControllerDelegate {
3636
func siteMediaPickerViewController(_ viewController: SiteMediaPickerViewController, didFinishWithSelection selection: [Media]) {
3737
viewController.presentingViewController?.dismiss(animated: true)
3838
if !selection.isEmpty {
39-
self.didSelect(selection.map(MediaPickerItem.media), source: "site_media")
39+
self.didSelect(selection.map(MediaPickerItem.media), source: .siteMedia)
4040
}
4141
}
4242
}
@@ -46,7 +46,7 @@ extension MediaPickerMenuController: ImagePlaygroundPickerDelegate {
4646

4747
viewController.presentingViewController?.dismiss(animated: true)
4848
if let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) {
49-
self.didSelect([.image(image)], source: "image_playground")
49+
self.didSelect([.image(image)], source: .imagePlayground)
5050
} else {
5151
wpAssertionFailure("failed to read the image created by ImagePlayground")
5252
}
@@ -57,7 +57,7 @@ extension MediaPickerMenuController: ExternalMediaPickerViewDelegate {
5757
func externalMediaPickerViewController(_ viewController: ExternalMediaPickerViewController, didFinishWithSelection selection: [ExternalMediaAsset]) {
5858
viewController.presentingViewController?.dismiss(animated: true)
5959
if !selection.isEmpty {
60-
let source = viewController.source == .tenor ? "free_gifs" : "free_photos"
60+
let source: MediaPickerID = viewController.source == .tenor ? .freeGIFs : .freePhotos
6161
self.didSelect(selection.map(MediaPickerItem.external), source: source)
6262
}
6363
}

WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ enum MediaPickerSource {
101101

102102
struct MediaPickerSelection {
103103
var items: [MediaPickerItem]
104-
var source: String
104+
var source: MediaPickerID
105105
}
106106

107107
enum MediaPickerItem {

WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,12 @@ extension MediaPickerMenu.MediaFilter {
5353
}
5454
}
5555
}
56+
57+
enum MediaPickerID: String {
58+
case applePhotos = "apple_photos"
59+
case camera = "camera"
60+
case siteMedia = "site_media"
61+
case imagePlayground = "image_playground"
62+
case freeGIFs = "free_gifs"
63+
case freePhotos = "free_photos"
64+
}

WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import UIKit
22
import WordPressUI
33
import AsyncImageKit
4+
import BuildSettingsKit
45
import AutomatticTracks
56
import GutenbergKit
67
import SafariServices
78
import WordPressData
89
import WordPressShared
910
import WebKit
1011
import CocoaLumberjackSwift
12+
import Photos
1113

1214
class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor {
1315

@@ -180,8 +182,13 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
180182
self.editorSession = PostEditorAnalyticsSession(editor: .gutenbergKit, post: post)
181183
self.navigationBarManager = navigationBarManager ?? PostEditorNavigationBarManager()
182184

185+
EditorLocalization.localize = getLocalizedString
186+
183187
let editorConfiguration = EditorConfiguration(blog: post.blog)
184-
self.editorViewController = GutenbergKit.EditorViewController(configuration: editorConfiguration)
188+
self.editorViewController = GutenbergKit.EditorViewController(
189+
configuration: editorConfiguration,
190+
mediaPicker: MediaPickerController(blog: post.blog)
191+
)
185192

186193
self.blockEditorSettingsService = RawBlockEditorSettingsService(blog: post.blog)
187194

@@ -397,6 +404,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
397404
.apply(settings) { $0.setEditorSettings($1) }
398405
.setTitle(post.postTitle ?? "")
399406
.setContent(post.content ?? "")
407+
.setNativeInserterEnabled(FeatureFlag.nativeBlockInserter.enabled)
400408
.build()
401409

402410
self.editorViewController.updateConfiguration(updatedConfiguration)
@@ -569,6 +577,8 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate
569577
// Do nothing
570578
}
571579

580+
// MARK: - Media Picker Helpers
581+
572582
func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) {
573583
let flags = mediaFilterFlags(using: config.allowedTypes ?? [])
574584

@@ -1099,3 +1109,18 @@ private extension NewGutenbergViewController {
10991109
// Extend Gutenberg JavaScript exception struct to conform the protocol defined in the Crash Logging service
11001110
extension GutenbergJSException.StacktraceLine: @retroactive AutomatticTracks.JSStacktraceLine {}
11011111
extension GutenbergJSException: @retroactive AutomatticTracks.JSException {}
1112+
1113+
private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) -> String {
1114+
switch value {
1115+
case .showMore: NSLocalizedString("editor.blockInserter.showMore", value: "Show More", comment: "Button title to expand and show more blocks")
1116+
case .showLess: NSLocalizedString("editor.blockInserter.showLess", value: "Show Less", comment: "Button title to collapse and show fewer blocks")
1117+
case .search: NSLocalizedString("editor.blockInserter.search", value: "Search", comment: "Placeholder text for block search field")
1118+
case .insertBlock: NSLocalizedString("editor.blockInserter.insertBlock", value: "Insert Block", comment: "Context menu action to insert a block")
1119+
case .failedToInsertMedia: NSLocalizedString("editor.media.failedToInsert", value: "Failed to insert media", comment: "Error message when media insertion fails")
1120+
case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view")
1121+
case .noPatternsFound: NSLocalizedString("editor.patterns.noPatternsFound", value: "No Patterns Found", comment: "Title shown when no patterns match the search")
1122+
case .insertPattern: NSLocalizedString("editor.patterns.insertPattern", value: "Insert Pattern", comment: "Context menu action to insert a pattern")
1123+
case .patternsCategoryUncategorized: NSLocalizedString("editor.patterns.uncategorized", value: "Uncategorized", comment: "Category name for patterns without a category")
1124+
case .patternsCategoryAll: NSLocalizedString("editor.patterns.all", value: "All", comment: "Category name for section showing all patterns")
1125+
}
1126+
}

WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@
122122
value = "disable"
123123
isEnabled = "NO">
124124
</EnvironmentVariable>
125+
<EnvironmentVariable
126+
key = "GUTENBERG_EDITOR_URL"
127+
value = "http://localhost:5173/"
128+
isEnabled = "NO">
129+
</EnvironmentVariable>
125130
</EnvironmentVariables>
126131
<AdditionalOptions>
127132
<AdditionalOption

0 commit comments

Comments
 (0)