Skip to content

Commit 611ac1f

Browse files
committed
Add PhotosKit integration
1 parent d850330 commit 611ac1f

File tree

6 files changed

+341
-37
lines changed

6 files changed

+341
-37
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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ struct BlockInserterView: View {
1313
@StateObject private var viewModel: BlockInserterViewModel
1414
@StateObject private var iconCache = BlockIconCache()
1515

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

1820
@Environment(\.dismiss) private var dismiss
@@ -41,10 +43,17 @@ struct BlockInserterView: View {
4143
.background(Material.ultraThin)
4244
.searchable(text: $viewModel.searchText)
4345
.navigationBarTitleDisplayMode(.inline)
46+
.disabled(viewModel.isProcessingMedia)
47+
.animation(.smooth(duration: 2), value: viewModel.isProcessingMedia)
4448
.environmentObject(iconCache)
4549
.toolbar {
4650
toolbar
4751
}
52+
.onDisappear {
53+
if viewModel.isProcessingMedia {
54+
viewModel.cancelProcessing()
55+
}
56+
}
4857
}
4958

5059
private var content: some View {
@@ -74,6 +83,14 @@ struct BlockInserterView: View {
7483
}
7584

7685
ToolbarItemGroup(placement: .topBarTrailing) {
86+
PhotosPicker(selection: $selectedMediaItems) {
87+
Image(systemName: "photo.on.rectangle.angled")
88+
}
89+
.onChange(of: selectedMediaItems) { _, selection in
90+
insertMedia(selection)
91+
selectedMediaItems = []
92+
}
93+
7794
if let mediaPicker {
7895
MediaPickerMenu(picker: mediaPicker, context: presentationContext) {
7996
dismiss()
@@ -89,6 +106,16 @@ struct BlockInserterView: View {
89106
dismiss()
90107
onBlockSelected(block)
91108
}
109+
110+
private func insertMedia(_ items: [PhotosPickerItem]) {
111+
Task {
112+
let items = await viewModel.processSelectedPhotosPickerItems(items)
113+
if !items.isEmpty {
114+
dismiss()
115+
onMediaSelected(items)
116+
}
117+
}
118+
}
92119
}
93120

94121
// 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,12 +5,21 @@ 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 blocks: [BlockType]
1113
private let allSections: [BlockInserterSection]
14+
private let fileManager: MediaFileManager = .shared
15+
private var processingTask: Task<[MediaInfo], Never>?
1216
private var cancellables = Set<AnyCancellable>()
1317

18+
struct MediaError: Identifiable {
19+
let id = UUID()
20+
let message: String
21+
}
22+
1423
init(blocks: [BlockType], destinationBlockName: String?) {
1524
let blocks = blocks.filter { $0.name != "core/missing" }
1625

@@ -89,6 +98,49 @@ class BlockInserterViewModel: ObservableObject {
8998

9099
return sections
91100
}
101+
102+
// MARK: - Media Processing
103+
104+
func processSelectedPhotosPickerItems(_ items: [PhotosPickerItem]) async -> [MediaInfo] {
105+
isProcessingMedia = true
106+
defer { isProcessingMedia = false }
107+
108+
let task = Task<[MediaInfo], Never> { @MainActor in
109+
var results: [MediaInfo] = []
110+
var anyError: Error?
111+
await withTaskGroup(of: Void.self) { group in
112+
for item in items {
113+
group.addTask {
114+
do {
115+
let item = try await self.fileManager.import(item)
116+
results.append(item)
117+
} catch {
118+
anyError = error
119+
}
120+
}
121+
}
122+
}
123+
124+
guard !Task.isCancelled else {
125+
return []
126+
}
127+
128+
if results.isEmpty {
129+
// TODO: CMM-874 add localization
130+
self.error = MediaError(message: anyError?.localizedDescription ?? "Failed to insert media")
131+
}
132+
133+
return results
134+
}
135+
processingTask = task
136+
return await task.value
137+
}
138+
139+
func cancelProcessing() {
140+
processingTask?.cancel()
141+
processingTask = nil
142+
isProcessingMedia = false
143+
}
92144
}
93145

94146
// MARK: Ordering

0 commit comments

Comments
 (0)