diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 8bd8b2ee..e2af6fe4 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -7,7 +7,7 @@ import CryptoKit import UIKit @MainActor -public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { +public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate { public let webView: WKWebView let assetsLibrary: EditorAssetsLibrary @@ -288,17 +288,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro patternCategories: data.patternCategories, mediaPicker: mediaPicker, presentationContext: context, - onSelection: { [weak self] in self?.didSelectBlockInserterItem($0) } + onSelection: { [weak self] in self?.didSelectBlockInserterItem($0) }, + onClose: { [weak self] in self?.notifyInserterClosed() } ) .environmentObject(htmlPreviewManager) }) context.viewController = host + // Set presentation delegate to track dismissal + host.presentationController?.delegate = self + if let sourceRect = data.sourceRect { host.modalPresentationStyle = .popover if let popover = host.popoverPresentationController { + popover.delegate = self popover.sourceView = webView popover.sourceRect = CGRect( x: sourceRect.x, @@ -312,6 +317,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } if let sheet = host.popoverPresentationController?.adaptiveSheetPresentationController ?? host.sheetPresentationController { + sheet.delegate = self sheet.detents = [.custom(identifier: .medium, resolver: { context in context.containerTraitCollection.horizontalSizeClass == .compact ? 536 : 900 }), .large()] @@ -323,6 +329,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func didSelectBlockInserterItem(_ selection: BlockInserterSelection) { + notifyInserterClosed() + switch selection { case .block(let block): insertBlockFromInserter(block.id) @@ -335,6 +343,16 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } + // MARK: - UIAdaptivePresentationControllerDelegate + + public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + notifyInserterClosed() + } + + private func notifyInserterClosed() { + evaluate("window.blockInserter?.onClose?.()") + } + private func insertBlockFromInserter(_ blockID: String) { evaluate("window.blockInserter.insertBlock('\(blockID)')") } diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 961a2c12..ac0eecec 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -16,6 +16,7 @@ struct BlockInserterView: View { let mediaPicker: MediaPickerController? let presentationContext: MediaPickerPresentationContext let onSelection: (BlockInserterSelection) -> Void + let onClose: () -> Void @StateObject private var viewModel: BlockInserterViewModel @StateObject private var iconCache = BlockIconCache() @@ -40,7 +41,8 @@ struct BlockInserterView: View { patternCategories: [PatternCategory], mediaPicker: MediaPickerController?, presentationContext: MediaPickerPresentationContext, - onSelection: @escaping (BlockInserterSelection) -> Void + onSelection: @escaping (BlockInserterSelection) -> Void, + onClose: @escaping () -> Void ) { self.sections = sections self.patterns = patterns @@ -48,6 +50,7 @@ struct BlockInserterView: View { self.mediaPicker = mediaPicker self.presentationContext = presentationContext self.onSelection = onSelection + self.onClose = onClose let viewModel = BlockInserterViewModel(sections: sections) self._viewModel = StateObject(wrappedValue: viewModel) @@ -141,6 +144,7 @@ struct BlockInserterView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { Button { + onClose() dismiss() } label: { Image(systemName: "xmark") @@ -325,6 +329,9 @@ private extension View { presentationContext: MediaPickerPresentationContext(), onSelection: { selection in print("on selected: \(selection)") + }, + onClose: { + print("on close") } ) } diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 581927b2..faaa6ce1 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -60,10 +60,13 @@ const EditorToolbar = ( { className } ) => { }, [] ); const { setIsInserterOpened } = useDispatch( editorStore ); - useModalize( isInserterOpened ); + useModalize( isInserterOpened && ! enableNativeBlockInserter ); useModalize( isBlockInspectorShown ); - useModalDialogState( isInserterOpened, 'block-inserter' ); + useModalDialogState( + isInserterOpened && ! enableNativeBlockInserter, + 'block-inserter' + ); useModalDialogState( isBlockInspectorShown, 'block-inspector' ); function openSettings() { @@ -95,7 +98,10 @@ const EditorToolbar = ( { className } ) => { ); const addBlockButton = enableNativeBlockInserter ? ( - + ) : ( { - 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; - } - }; - - /** - * Get media type from MIME type. - * - * @param {string} mimeType The MIME type of the media - * @return {string|null} Media type ('image', 'video', 'audio') or null - */ - const getMediaType = ( mimeType ) => { - if ( ! mimeType ) { - return null; - } - if ( mimeType.startsWith( 'image/' ) ) { - return 'image'; - } - if ( mimeType.startsWith( 'video/' ) ) { - return 'video'; - } - if ( mimeType.startsWith( 'audio/' ) ) { - return 'audio'; - } - return null; - }; - - /** - * Insert media from WordPress media library (with existing IDs). - * Creates blocks directly with media attributes, avoiding re-upload. - * - * @param {Array} mediaArray Array of media objects with IDs - * @return {boolean} True if insertion succeeded - */ - const insertMediaWithIds = ( mediaArray ) => { - const blocks = []; - const allImages = mediaArray.every( - ( media ) => getMediaType( media.type ) === 'image' - ); + const insertBlock = useCallback( + ( 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; + } + }, + [ inserterItems, onSelectItem ] + ); - // Create gallery for multiple images - if ( allImages && mediaArray.length > 1 ) { - if ( - ! canInsertBlockType( 'core/gallery', destinationRootClientId ) - ) { - debug( 'Cannot insert gallery block at this location' ); + const insertPattern = useCallback( + ( patternName ) => { + const pattern = patterns?.find( ( p ) => p.name === patternName ); + if ( ! pattern ) { + debug( `Pattern "${ patternName }" not found` ); return false; } - const galleryBlock = createBlock( 'core/gallery', { - images: mediaArray.map( ( media ) => ( { - id: String( media.id ), - url: media.url, - alt: media.alt ?? '', - caption: media.caption ?? '', - } ) ), - } ); - blocks.push( galleryBlock ); - } else { - // Create individual blocks using WordPress utility - for ( const media of mediaArray ) { - const mediaType = getMediaType( media.type ); - if ( ! mediaType ) { - debug( `Unsupported media type: ${ media.type }` ); - continue; - } + try { + // Parse and insert pattern blocks + const blocks = parse( pattern.content ); + onInsertBlocks( blocks ); + return true; + } catch ( error ) { + debug( 'Failed to insert pattern:', error ); + return false; + } + }, + [ patterns, onInsertBlocks ] + ); - if ( - ! canInsertBlockType( - `core/${ mediaType }`, - destinationRootClientId - ) - ) { - debug( - `Cannot insert core/${ mediaType } block at this location` - ); - continue; + const insertMedia = useCallback( + async ( mediaArray ) => { + if ( ! Array.isArray( mediaArray ) || mediaArray.length === 0 ) { + return false; + } + + /** + * Get media type from MIME type. + * + * @param {string} mimeType The MIME type of the media + * @return {string|null} Media type ('image', 'video', 'audio') or null + */ + const getMediaType = ( mimeType ) => { + if ( ! mimeType ) { + return null; + } + if ( mimeType.startsWith( 'image/' ) ) { + return 'image'; + } + if ( mimeType.startsWith( 'video/' ) ) { + return 'video'; } + if ( mimeType.startsWith( 'audio/' ) ) { + return 'audio'; + } + return null; + }; - const [ block ] = getBlockAndPreviewFromMedia( - media, - mediaType + /** + * Insert media from WordPress media library (with existing IDs). + * Creates blocks directly with media attributes, avoiding re-upload. + * + * @param {Array} items Array of media objects with IDs + * @return {boolean} True if insertion succeeded + */ + const insertMediaWithIds = ( items ) => { + const blocks = []; + const allImages = items.every( + ( media ) => getMediaType( media.type ) === 'image' ); - blocks.push( block ); - } - } - if ( blocks.length === 0 ) { - return false; - } - - onInsertBlocks( blocks ); - return true; - }; - - /** - * Insert new media files (without IDs) using WordPress file transforms. - * Downloads files and uses transform system for block creation and upload. - * - * @param {Array} mediaArray Array of media objects without IDs - * @return {Promise} True if insertion succeeded - */ - const insertMediaWithoutIds = async ( mediaArray ) => { - // Convert media objects to File objects - 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', + // Create gallery for multiple images + if ( allImages && items.length > 1 ) { + if ( + ! canInsertBlockType( + 'core/gallery', + destinationRootClientId + ) + ) { + debug( 'Cannot insert gallery block at this location' ); + return false; + } + + const galleryBlock = createBlock( 'core/gallery', { + images: items.map( ( media ) => ( { + id: String( media.id ), + url: media.url, + alt: media.alt ?? '', + caption: media.caption ?? '', + } ) ), } ); - } catch ( error ) { - debug( `Failed to fetch media: ${ media.url }`, error ); - return null; + blocks.push( galleryBlock ); + } else { + // Create individual blocks using WordPress utility + for ( const media of items ) { + const mediaType = getMediaType( media.type ); + if ( ! mediaType ) { + debug( `Unsupported media type: ${ media.type }` ); + continue; + } + + if ( + ! canInsertBlockType( + `core/${ mediaType }`, + destinationRootClientId + ) + ) { + debug( + `Cannot insert core/${ mediaType } block at this location` + ); + continue; + } + + const [ block ] = getBlockAndPreviewFromMedia( + media, + mediaType + ); + blocks.push( block ); + } } - } ) - ); - const validFiles = files.filter( ( f ) => f !== null ); - if ( validFiles.length === 0 ) { - debug( 'No valid files to insert' ); - return false; - } + if ( blocks.length === 0 ) { + return false; + } - // Find and apply file transform - const transformation = findTransform( - getBlockTransforms( 'from' ), - ( transform ) => - transform.type === 'files' && - canInsertBlockType( - transform.blockName, - destinationRootClientId - ) && - transform.isMatch( validFiles ) - ); + onInsertBlocks( blocks ); + return true; + }; - if ( ! transformation ) { - debug( 'No matching transform found', { - fileCount: validFiles.length, - fileTypes: validFiles.map( ( f ) => f.type ), - } ); - return false; - } + /** + * Insert new media files (without IDs) using WordPress file transforms. + * Downloads files and uses transform system for block creation and upload. + * + * @param {Array} items Array of media objects without IDs + * @return {Promise} True if insertion succeeded + */ + const insertMediaWithoutIds = async ( items ) => { + // Convert media objects to File objects + const files = await Promise.all( + items.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( + `Failed to fetch media: ${ media.url }`, + error + ); + return null; + } + } ) + ); - const blocks = transformation.transform( - validFiles, - updateBlockAttributes - ); + const validFiles = files.filter( ( f ) => f !== null ); + if ( validFiles.length === 0 ) { + debug( 'No valid files to insert' ); + return false; + } - if ( ! blocks || ( Array.isArray( blocks ) && blocks.length === 0 ) ) { - debug( 'Transform produced no blocks' ); - return false; - } + // Find and apply file transform + const transformation = findTransform( + getBlockTransforms( 'from' ), + ( transform ) => + transform.type === 'files' && + canInsertBlockType( + transform.blockName, + destinationRootClientId + ) && + transform.isMatch( validFiles ) + ); - onInsertBlocks( Array.isArray( blocks ) ? blocks : [ blocks ] ); - return true; - }; - - /** - * Insert media blocks from native media picker. - * - * @param {Array} mediaArray Array of media objects with shape: - * { id?, url, type, caption?, alt?, title?, metadata? } - * @return {Promise} True if insertion succeeded - */ - const insertMedia = async ( mediaArray ) => { - if ( ! Array.isArray( mediaArray ) || mediaArray.length === 0 ) { - return false; - } + if ( ! transformation ) { + debug( 'No matching transform found', { + fileCount: validFiles.length, + fileTypes: validFiles.map( ( f ) => f.type ), + } ); + return false; + } - try { - // Assume all media have IDs or all don't (not mixed) - const hasIds = mediaArray[ 0 ]?.id; + const blocks = transformation.transform( + validFiles, + updateBlockAttributes + ); - return hasIds - ? insertMediaWithIds( mediaArray ) - : await insertMediaWithoutIds( mediaArray ); - } catch ( error ) { - debug( 'Failed to insert media blocks', error ); - return false; - } - }; + if ( + ! blocks || + ( Array.isArray( blocks ) && blocks.length === 0 ) + ) { + debug( 'Transform produced no blocks' ); + return false; + } + + onInsertBlocks( Array.isArray( blocks ) ? blocks : [ blocks ] ); + return true; + }; + + try { + // Assume all media have IDs or all don't (not mixed) + const hasIds = mediaArray[ 0 ]?.id; + + return hasIds + ? insertMediaWithIds( mediaArray ) + : await insertMediaWithoutIds( mediaArray ); + } catch ( error ) { + debug( 'Failed to insert media blocks', error ); + return false; + } + }, + [ + canInsertBlockType, + destinationRootClientId, + onInsertBlocks, + updateBlockAttributes, + ] + ); - const insertPattern = ( patternName ) => { - const pattern = patterns?.find( ( p ) => p.name === patternName ); - if ( ! pattern ) { - debug( `Pattern "${ patternName }" not found` ); - return false; + const prepareAndShowInserter = useCallback( () => { + const sections = preprocessBlockTypesForNativeInserter( + inserterItems, + destinationBlockName, + categories + ); + + // Format patterns for native consumption + const formattedPatterns = + patterns?.map( ( pattern ) => ( { + name: pattern.name, + title: pattern.title, + content: pattern.content, + blockTypes: pattern.blockTypes ?? null, + categories: + pattern.categories?.filter( + ( cat ) => ! cat.startsWith( '_' ) + ) ?? null, + description: pattern.description ?? null, + keywords: pattern.keywords ?? null, + source: pattern.source ?? null, + viewportWidth: pattern.viewportWidth ?? null, + } ) ) ?? []; + + const formattedPatternCategories = + patternCategories?.map( ( cat ) => ( { + name: cat.name, + label: cat.label, + } ) ) ?? []; + + window.blockInserter = { + sections, + patterns: formattedPatterns, + patternCategories: formattedPatternCategories, + insertBlock, + insertPattern, + insertMedia, + onClose: () => { + onToggle( false ); + return true; // Return valid result type for the native host + }, + }; + + // Get button position for popover presentation on iPad + let sourceRect; + if ( buttonRef.current ) { + const rect = buttonRef.current.getBoundingClientRect(); + sourceRect = { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; } - try { - // Parse and insert pattern blocks - const blocks = parse( pattern.content ); - onInsertBlocks( blocks ); - return true; - } catch ( error ) { - debug( 'Failed to insert pattern:', error ); - return false; + showBlockInserter( sourceRect ); + }, [ + inserterItems, + destinationBlockName, + categories, + patterns, + patternCategories, + insertBlock, + insertPattern, + insertMedia, + onToggle, + ] ); + + // Watch for controlled open state changes + useEffect( () => { + // Only trigger when transitioning from false to true + if ( open && ! prevOpen.current ) { + prepareAndShowInserter(); } - }; + prevOpen.current = open; + }, [ open, prepareAndShowInserter ] ); return (