Skip to content

Commit e96f2d0

Browse files
authored
Native Inserter: Add initial Photos integration (#203)
* Add PhotosKit integration * Add canInsertBlockType
1 parent 9e94f49 commit e96f2d0

File tree

6 files changed

+350
-40
lines changed

6 files changed

+350
-40
lines changed

ios/Sources/GutenbergKit/Sources/EditorViewController.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
5555
for scheme in CachedAssetSchemeHandler.supportedURLSchemes {
5656
config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme)
5757
}
58+
config.setURLSchemeHandler(MediaFileSchemeHandler(), forURLScheme: MediaFileSchemeHandler.scheme)
5859

5960
self.webView = GBWebView(frame: .zero, configuration: config)
6061
self.webView.scrollView.keyboardDismissMode = .interactive
@@ -263,8 +264,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
263264
onBlockSelected: { [weak self] block in
264265
self?.insertBlockFromInserter(block.id)
265266
},
266-
onMediaSelected: {
267-
print("insert media:", $0)
267+
onMediaSelected: { [weak self] selection in
268+
self?.insertMediaFromInserter(selection)
268269
}
269270
)
270271
})
@@ -278,6 +279,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
278279
evaluate("window.blockInserter.insertBlock('\(blockID)')")
279280
}
280281

282+
private func insertMediaFromInserter(_ selection: [MediaInfo]) {
283+
guard !selection.isEmpty else { return }
284+
285+
guard let data = try? JSONEncoder().encode(selection),
286+
let string = String(data: data, encoding: .utf8) else {
287+
debugPrint("Failed to serialize media array to JSON")
288+
return
289+
}
290+
evaluate("window.blockInserter.insertMedia(\(string))")
291+
}
292+
281293
private func openMediaLibrary(_ config: OpenMediaLibraryAction) {
282294
delegate?.editor(self, didRequestMediaFromSiteMediaLibrary: config)
283295
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import Foundation
2+
import PhotosUI
3+
import SwiftUI
4+
import UniformTypeIdentifiers
5+
6+
/// Manages media files for the editor, handling imports, storage, and cleanup.
7+
///
8+
/// Files are stored in the Library/GutenbergKit/Uploads directory and served via
9+
/// a custom `gbk-media-file://` URL scheme. Old files are automatically cleaned up.
10+
actor MediaFileManager {
11+
/// Shared instance for app-wide media management
12+
static let shared = MediaFileManager()
13+
14+
private let fileManager = FileManager.default
15+
private let rootURL: URL
16+
private let uploadsDirectory: URL
17+
18+
init(rootURL: URL = URL.libraryDirectory.appendingPathComponent("GutenbergKit")) {
19+
self.rootURL = rootURL
20+
self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads")
21+
Task {
22+
await cleanupOldFiles()
23+
}
24+
}
25+
26+
/// Imports a photo picker item and saves it to the uploads directory.
27+
///
28+
/// - Returns: MediaInfo with a `gbk-media-file://` URL and detected media type
29+
func `import`(_ item: PhotosPickerItem) async throws -> MediaInfo {
30+
guard let data = try await item.loadTransferable(type: Data.self) else {
31+
throw URLError(.unknown)
32+
}
33+
let contentType = item.supportedContentTypes.first
34+
let fileExtension = contentType?.preferredFilenameExtension ?? "jpeg"
35+
36+
let fileURL = try await writeData(data, withExtension: fileExtension)
37+
return MediaInfo(url: fileURL.absoluteString, type: contentType?.preferredMIMEType)
38+
}
39+
40+
/// Saves media data to the uploads directory and returns a URL with a
41+
/// custom scheme.
42+
func writeData(_ data: Data, withExtension ext: String) async throws -> URL {
43+
let fileName = "\(UUID().uuidString).\(ext)"
44+
let destinationURL = uploadsDirectory.appendingPathComponent(fileName)
45+
46+
try fileManager.createDirectory(at: uploadsDirectory, withIntermediateDirectories: true)
47+
try data.write(to: destinationURL)
48+
49+
return URL(string: "\(MediaFileSchemeHandler.scheme):///Uploads/\(fileName)")!
50+
}
51+
52+
/// Gets URLResponse and data for a `gbk-media-file` URL
53+
func getData(for url: URL) async throws -> Data {
54+
// Convert `gbk-media-file:///Uploads/filename.jpg` to actual file path
55+
let fileURL = rootURL.appendingPathComponent(url.path)
56+
return try Data(contentsOf: fileURL)
57+
}
58+
59+
/// Cleans up files older than 2 days
60+
private func cleanupOldFiles() {
61+
let sevenDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60)
62+
63+
do {
64+
let contents = try fileManager.contentsOfDirectory(
65+
at: uploadsDirectory,
66+
includingPropertiesForKeys: [.creationDateKey],
67+
options: .skipsHiddenFiles
68+
)
69+
70+
for fileURL in contents {
71+
if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
72+
let creationDate = attributes[.creationDate] as? Date,
73+
creationDate < sevenDaysAgo {
74+
try? fileManager.removeItem(at: fileURL)
75+
}
76+
}
77+
} catch {
78+
#if DEBUG
79+
print("Failed to clean up old files: \(error)")
80+
#endif
81+
}
82+
}
83+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Foundation
2+
import WebKit
3+
4+
/// Handles `gbk-media-file://` URL scheme requests in WKWebView.
5+
/// Serves media files from MediaFileManager with appropriate CORS headers.
6+
final class MediaFileSchemeHandler: NSObject, WKURLSchemeHandler {
7+
/// The custom URL scheme handled by this class
8+
nonisolated static let scheme = "gbk-media-file"
9+
10+
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
11+
guard let url = urlSchemeTask.request.url else {
12+
urlSchemeTask.didFailWithError(URLError(.badURL))
13+
return
14+
}
15+
Task {
16+
do {
17+
let (response, data) = try await getResponse(for: url)
18+
urlSchemeTask.didReceive(response)
19+
urlSchemeTask.didReceive(data)
20+
urlSchemeTask.didFinish()
21+
} catch {
22+
urlSchemeTask.didFailWithError(error)
23+
}
24+
}
25+
}
26+
27+
private func getResponse(for url: URL) async throws -> (URLResponse, Data) {
28+
let data = try await MediaFileManager.shared.getData(for: url)
29+
30+
let headers = [
31+
"Access-Control-Allow-Origin": "*",
32+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
33+
"Access-Control-Allow-Headers": "*",
34+
"Cache-Control": "no-cache"
35+
]
36+
37+
guard let response = HTTPURLResponse(
38+
url: url,
39+
statusCode: 200,
40+
httpVersion: "HTTP/1.1",
41+
headerFields: headers
42+
) else {
43+
throw URLError(.unknown)
44+
}
45+
46+
return (response, data)
47+
}
48+
49+
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
50+
// Nothing to do here for simple file serving
51+
}
52+
}

ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ struct BlockInserterView: View {
1212
@StateObject private var viewModel: BlockInserterViewModel
1313
@StateObject private var iconCache = BlockIconCache()
1414

15+
@State private var selectedMediaItems: [PhotosPickerItem] = []
16+
1517
private let maxSelectionCount = 10
1618

1719
@Environment(\.dismiss) private var dismiss
@@ -38,10 +40,17 @@ struct BlockInserterView: View {
3840
.background(Material.ultraThin)
3941
.searchable(text: $viewModel.searchText)
4042
.navigationBarTitleDisplayMode(.inline)
43+
.disabled(viewModel.isProcessingMedia)
44+
.animation(.smooth(duration: 2), value: viewModel.isProcessingMedia)
4145
.environmentObject(iconCache)
4246
.toolbar {
4347
toolbar
4448
}
49+
.onDisappear {
50+
if viewModel.isProcessingMedia {
51+
viewModel.cancelProcessing()
52+
}
53+
}
4554
}
4655

4756
private var content: some View {
@@ -71,6 +80,16 @@ struct BlockInserterView: View {
7180
}
7281

7382
ToolbarItemGroup(placement: .topBarTrailing) {
83+
PhotosPicker(selection: $selectedMediaItems, preferredItemEncoding: .compatible) {
84+
Image(systemName: "photo.on.rectangle.angled")
85+
}
86+
.onChange(of: selectedMediaItems) { _, selection in
87+
if !selection.isEmpty {
88+
insertMedia(selection)
89+
}
90+
selectedMediaItems = []
91+
}
92+
7493
if let mediaPicker {
7594
MediaPickerMenu(picker: mediaPicker, context: presentationContext) {
7695
dismiss()
@@ -86,6 +105,16 @@ struct BlockInserterView: View {
86105
dismiss()
87106
onBlockSelected(block)
88107
}
108+
109+
private func insertMedia(_ items: [PhotosPickerItem]) {
110+
Task {
111+
let items = await viewModel.processSelectedPhotosPickerItems(items)
112+
if !items.isEmpty {
113+
dismiss()
114+
onMediaSelected(items)
115+
}
116+
}
117+
}
89118
}
90119

91120
// MARK: - Preview

ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,20 @@ import Combine
55
@MainActor
66
class BlockInserterViewModel: ObservableObject {
77
@Published var searchText = ""
8+
@Published var error: MediaError?
89
@Published private(set) var sections: [BlockInserterSection] = []
10+
@Published private(set) var isProcessingMedia = false
911

1012
private let allSections: [BlockInserterSection]
13+
private let fileManager: MediaFileManager = .shared
14+
private var processingTask: Task<[MediaInfo], Never>?
1115
private var cancellables = Set<AnyCancellable>()
1216

17+
struct MediaError: Identifiable {
18+
let id = UUID()
19+
let message: String
20+
}
21+
1322
init(sections: [BlockInserterSection]) {
1423
self.allSections = sections
1524
self.sections = sections
@@ -41,4 +50,47 @@ class BlockInserterViewModel: ObservableObject {
4150
}
4251
}
4352
}
53+
54+
// MARK: - Media Processing
55+
56+
func processSelectedPhotosPickerItems(_ items: [PhotosPickerItem]) async -> [MediaInfo] {
57+
isProcessingMedia = true
58+
defer { isProcessingMedia = false }
59+
60+
let task = Task<[MediaInfo], Never> { @MainActor in
61+
var results: [MediaInfo] = []
62+
var anyError: Error?
63+
await withTaskGroup(of: Void.self) { group in
64+
for item in items {
65+
group.addTask {
66+
do {
67+
let item = try await self.fileManager.import(item)
68+
results.append(item)
69+
} catch {
70+
anyError = error
71+
}
72+
}
73+
}
74+
}
75+
76+
guard !Task.isCancelled else {
77+
return []
78+
}
79+
80+
if results.isEmpty {
81+
// TODO: CMM-874 add localization
82+
self.error = MediaError(message: anyError?.localizedDescription ?? "Failed to insert media")
83+
}
84+
85+
return results
86+
}
87+
processingTask = task
88+
return await task.value
89+
}
90+
91+
func cancelProcessing() {
92+
processingTask?.cancel()
93+
processingTask = nil
94+
isProcessingMedia = false
95+
}
4496
}

0 commit comments

Comments
 (0)