From 8a33910b3dc1b1740f9bf5ef94fa4bd5cb58aebe Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Nov 2025 14:05:13 -0500 Subject: [PATCH 1/3] perf: Native inserter disables unnecessary modalize and bridge syncing There is no need to communicate the inserter state via the bridge for disabling native header navigation, as the native inserter already obscures these UI elements. There is no need to mark web content as inert, as the native insert already marks the entire WebView inert. --- src/components/editor-toolbar/index.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 581927b2..a4cd382b 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() { From e34385143521782d0cde61e5660c473401d30554 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Nov 2025 14:06:40 -0500 Subject: [PATCH 2/3] fix: Inline inserter opens the native inserter If we do not respect the `isInserterOpened` state, various editor UI elements cannot control the block inserter state. Align `NativeBlockInserterButton`'s API with core's `Inserter`. --- .../Sources/EditorViewController.swift | 22 +- .../BlockInserter/BlockInserterView.swift | 9 +- src/components/editor-toolbar/index.jsx | 5 +- .../native-block-inserter-button/index.jsx | 537 ++++++++++-------- 4 files changed, 327 insertions(+), 246 deletions(-) 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 a4cd382b..faaa6ce1 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -98,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; - const insertPattern = ( patternName ) => { - const pattern = patterns?.find( ( p ) => p.name === patternName ); - if ( ! pattern ) { - debug( `Pattern "${ patternName }" not found` ); - return false; + return hasIds + ? insertMediaWithIds( mediaArray ) + : await insertMediaWithoutIds( mediaArray ); + } catch ( error ) { + debug( 'Failed to insert media blocks', error ); + return false; + } + }, + [ + canInsertBlockType, + destinationRootClientId, + onInsertBlocks, + updateBlockAttributes, + ] + ); + + 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 (