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 (