diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index a2e0adb2..71370bb8 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -55,6 +55,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro for scheme in CachedAssetSchemeHandler.supportedURLSchemes { config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) } + config.setURLSchemeHandler(MediaFileSchemeHandler(), forURLScheme: MediaFileSchemeHandler.scheme) self.webView = GBWebView(frame: .zero, configuration: config) self.webView.scrollView.keyboardDismissMode = .interactive @@ -263,8 +264,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro onBlockSelected: { [weak self] block in self?.insertBlockFromInserter(block.id) }, - onMediaSelected: { - print("insert media:", $0) + onMediaSelected: { [weak self] selection in + self?.insertMediaFromInserter(selection) } ) }) @@ -278,6 +279,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("window.blockInserter.insertBlock('\(blockID)')") } + private func insertMediaFromInserter(_ selection: [MediaInfo]) { + guard !selection.isEmpty else { return } + + guard let data = try? JSONEncoder().encode(selection), + let string = String(data: data, encoding: .utf8) else { + debugPrint("Failed to serialize media array to JSON") + return + } + evaluate("window.blockInserter.insertMedia(\(string))") + } + private func openMediaLibrary(_ config: OpenMediaLibraryAction) { delegate?.editor(self, didRequestMediaFromSiteMediaLibrary: config) } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift new file mode 100644 index 00000000..8d62e8a6 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift @@ -0,0 +1,83 @@ +import Foundation +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +/// Manages media files for the editor, handling imports, storage, and cleanup. +/// +/// Files are stored in the Library/GutenbergKit/Uploads directory and served via +/// a custom `gbk-media-file://` URL scheme. Old files are automatically cleaned up. +actor MediaFileManager { + /// Shared instance for app-wide media management + static let shared = MediaFileManager() + + private let fileManager = FileManager.default + private let rootURL: URL + private let uploadsDirectory: URL + + init(rootURL: URL = URL.libraryDirectory.appendingPathComponent("GutenbergKit")) { + self.rootURL = rootURL + self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads") + Task { + await cleanupOldFiles() + } + } + + /// Imports a photo picker item and saves it to the uploads directory. + /// + /// - Returns: MediaInfo with a `gbk-media-file://` URL and detected media type + func `import`(_ item: PhotosPickerItem) async throws -> MediaInfo { + guard let data = try await item.loadTransferable(type: Data.self) else { + throw URLError(.unknown) + } + let contentType = item.supportedContentTypes.first + let fileExtension = contentType?.preferredFilenameExtension ?? "jpeg" + + let fileURL = try await writeData(data, withExtension: fileExtension) + return MediaInfo(url: fileURL.absoluteString, type: contentType?.preferredMIMEType) + } + + /// Saves media data to the uploads directory and returns a URL with a + /// custom scheme. + func writeData(_ data: Data, withExtension ext: String) async throws -> URL { + let fileName = "\(UUID().uuidString).\(ext)" + let destinationURL = uploadsDirectory.appendingPathComponent(fileName) + + try fileManager.createDirectory(at: uploadsDirectory, withIntermediateDirectories: true) + try data.write(to: destinationURL) + + return URL(string: "\(MediaFileSchemeHandler.scheme):///Uploads/\(fileName)")! + } + + /// Gets URLResponse and data for a `gbk-media-file` URL + func getData(for url: URL) async throws -> Data { + // Convert `gbk-media-file:///Uploads/filename.jpg` to actual file path + let fileURL = rootURL.appendingPathComponent(url.path) + return try Data(contentsOf: fileURL) + } + + /// 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 { +#if DEBUG + print("Failed to clean up old files: \(error)") +#endif + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift new file mode 100644 index 00000000..57ae1275 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift @@ -0,0 +1,52 @@ +import Foundation +import WebKit + +/// Handles `gbk-media-file://` URL scheme requests in WKWebView. +/// Serves media files from MediaFileManager with appropriate CORS headers. +final class MediaFileSchemeHandler: NSObject, WKURLSchemeHandler { + /// The custom URL scheme handled by this class + nonisolated static let scheme = "gbk-media-file" + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + Task { + do { + let (response, data) = try await getResponse(for: url) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } catch { + urlSchemeTask.didFailWithError(error) + } + } + } + + private func getResponse(for url: URL) async throws -> (URLResponse, Data) { + let data = try await MediaFileManager.shared.getData(for: url) + + 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) + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Nothing to do here for simple file serving + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 663c98c5..95c15eec 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -12,6 +12,8 @@ struct BlockInserterView: View { @StateObject private var viewModel: BlockInserterViewModel @StateObject private var iconCache = BlockIconCache() + @State private var selectedMediaItems: [PhotosPickerItem] = [] + private let maxSelectionCount = 10 @Environment(\.dismiss) private var dismiss @@ -38,10 +40,17 @@ struct BlockInserterView: View { .background(Material.ultraThin) .searchable(text: $viewModel.searchText) .navigationBarTitleDisplayMode(.inline) + .disabled(viewModel.isProcessingMedia) + .animation(.smooth(duration: 2), value: viewModel.isProcessingMedia) .environmentObject(iconCache) .toolbar { toolbar } + .onDisappear { + if viewModel.isProcessingMedia { + viewModel.cancelProcessing() + } + } } private var content: some View { @@ -71,6 +80,16 @@ struct BlockInserterView: View { } ToolbarItemGroup(placement: .topBarTrailing) { + PhotosPicker(selection: $selectedMediaItems, preferredItemEncoding: .compatible) { + Image(systemName: "photo.on.rectangle.angled") + } + .onChange(of: selectedMediaItems) { _, selection in + if !selection.isEmpty { + insertMedia(selection) + } + selectedMediaItems = [] + } + if let mediaPicker { MediaPickerMenu(picker: mediaPicker, context: presentationContext) { dismiss() @@ -86,6 +105,16 @@ struct BlockInserterView: View { dismiss() onBlockSelected(block) } + + private func insertMedia(_ items: [PhotosPickerItem]) { + Task { + let items = await viewModel.processSelectedPhotosPickerItems(items) + if !items.isEmpty { + dismiss() + onMediaSelected(items) + } + } + } } // MARK: - Preview diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift index 6eb0e80b..a6767e6f 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -5,11 +5,20 @@ 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 allSections: [BlockInserterSection] + private let fileManager: MediaFileManager = .shared + private var processingTask: Task<[MediaInfo], Never>? private var cancellables = Set() + struct MediaError: Identifiable { + let id = UUID() + let message: String + } + init(sections: [BlockInserterSection]) { self.allSections = sections self.sections = sections @@ -41,4 +50,47 @@ class BlockInserterViewModel: ObservableObject { } } } + + // MARK: - Media Processing + + func processSelectedPhotosPickerItems(_ items: [PhotosPickerItem]) async -> [MediaInfo] { + isProcessingMedia = true + defer { isProcessingMedia = false } + + let task = Task<[MediaInfo], Never> { @MainActor in + var results: [MediaInfo] = [] + var anyError: Error? + await withTaskGroup(of: Void.self) { group in + for item in items { + group.addTask { + do { + let item = try await self.fileManager.import(item) + results.append(item) + } catch { + anyError = error + } + } + } + } + + guard !Task.isCancelled else { + return [] + } + + if results.isEmpty { + // TODO: CMM-874 add localization + self.error = MediaError(message: anyError?.localizedDescription ?? "Failed to insert media") + } + + return results + } + processingTask = task + return await task.value + } + + func cancelProcessing() { + processingTask?.cancel() + processingTask = nil + isProcessingMedia = false + } } diff --git a/src/components/native-block-inserter-button/index.jsx b/src/components/native-block-inserter-button/index.jsx index 96649fb4..059554a6 100644 --- a/src/components/native-block-inserter-button/index.jsx +++ b/src/components/native-block-inserter-button/index.jsx @@ -1,11 +1,11 @@ /** * WordPress dependencies */ -import { useEffect } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { plus } from '@wordpress/icons'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { findTransform, getBlockTransforms } from '@wordpress/blocks'; // NOTE: These hooks are internal WordPress APIs not available via public exports // or privateApis. We import from build-module as the only way to access the @@ -67,6 +67,10 @@ export default function NativeBlockInserterButton() { [] ); + const { canInsertBlockType } = useSelect( blockEditorStore ); + + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + // When cursor is in title, selectedBlockClientId is null. // Use undefined to insert at the beginning of content. const [ destinationRootClientId, onInsertBlocks ] = useInsertionPoint( { @@ -81,49 +85,127 @@ export default function NativeBlockInserterButton() { false // isQuick ); - // Preprocess blocks into sections for native consumption - // Categories are passed to get localized category names - const sections = preprocessBlockTypesForNativeInserter( - inserterItems, - destinationBlockName, - categories - ); + const insertBlock = ( blockId ) => { + const item = inserterItems.find( ( i ) => i.id === blockId ); + if ( ! item ) { + debug( `Block with id "${ blockId }" not found in inserter items` ); + return false; + } + try { + onSelectItem( item ); + return true; + } catch ( error ) { + debug( 'Failed to insert block:', error ); + return false; + } + }; + + /** + * Insert media blocks from native media picker using block transforms. + * + * This method uses the same file transform system as drag-and-drop, + * ensuring consistent behavior and leveraging WordPress's extensibility: + * - Finds matching transform based on file types (image/video/audio) + * - Respects transform priorities (gallery > single image) + * - Supports third-party block transforms + * - Handles block insertion validation + * + * @param {Array} mediaArray Array of media objects with shape: + * { id?, url, type, caption?, alt?, title?, metadata? } + * @return {Promise} True if insertion succeeded, false otherwise + */ + const insertMedia = async ( mediaArray ) => { + if ( ! Array.isArray( mediaArray ) || mediaArray.length === 0 ) { + return false; + } + try { + // Convert media objects to File objects + // This allows us to use the existing file transform system + const files = await Promise.all( + mediaArray.map( async ( media ) => { + try { + const response = await fetch( media.url ); + const blob = await response.blob(); + const filename = + media.url.split( '/' ).pop() || 'media'; + return new File( [ blob ], filename, { + type: media.type ?? 'application/octet-stream', + } ); + } catch ( error ) { + debug( + `insertMedia: Failed to fetch media: ${ media.url }`, + error + ); + return null; + } + } ) + ); + + // Filter out any failed fetches + const validFiles = files.filter( ( f ) => f !== null ); + + if ( validFiles.length === 0 ) { + debug( 'insertMedia: No valid files to insert' ); + return false; + } + + // Find matching transform using WordPress's transform system + // This is the same logic as onFilesDrop in use-on-block-drop + const transformation = findTransform( + getBlockTransforms( 'from' ), + ( transform ) => + transform.type === 'files' && + canInsertBlockType( + transform.blockName, + destinationRootClientId + ) && + transform.isMatch( validFiles ) + ); + + if ( ! transformation ) { + debug( 'insertMedia: No matching transform found', { + fileCount: validFiles.length, + fileTypes: validFiles.map( ( f ) => f.type ), + } ); + return false; + } + + const blocks = transformation.transform( + validFiles, + updateBlockAttributes + ); + + if ( + ! blocks || + ( Array.isArray( blocks ) && blocks.length === 0 ) + ) { + debug( 'insertMedia: Transform produced no blocks' ); + return false; + } - // Expose the current inserter state globally for native access - // This automatically stays in sync with editor state via hooks - useEffect( () => { - window.blockInserter = { - sections, - insertBlock: ( blockId ) => { - const item = inserterItems.find( ( i ) => i.id === blockId ); - if ( ! item ) { - debug( - `Block with ID "${ blockId }" not found in inserter items` - ); - return false; - } - - try { - // Use the hook's onSelectItem which handles all insertion logic - onSelectItem( item ); - return true; - } catch ( error ) { - debug( 'Failed to insert block:', error ); - return false; - } - }, - }; - - return () => { - delete window.blockInserter; - }; - }, [ sections, inserterItems, categories, onSelectItem ] ); + onInsertBlocks( blocks ); + return true; + } catch ( error ) { + debug( 'insertMedia: Failed to insert media blocks', error ); + return false; + } + }; return (