From c2b1ad0ddd98367b6816f94331169a48f81fe0c0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 27 Oct 2025 14:45:28 -0400 Subject: [PATCH 1/7] Implement block insertion --- .../Sources/EditorJSMessage.swift | 1 + .../GutenbergKit/Sources/EditorTypes.swift | 3 + .../Sources/EditorViewController.swift | 15 ++- .../BlockInserterSectionView.swift | 8 +- .../BlockInserter/BlockInserterView.swift | 9 +- .../BlockInserterViewModel.swift | 37 ++++-- .../block-inserter-bridge/index.jsx | 117 ++++++++++++++++++ src/components/editor-toolbar/index.jsx | 8 +- src/components/editor/index.jsx | 3 + src/utils/blocks.js | 40 +++--- src/utils/bridge.js | 25 +++- 11 files changed, 216 insertions(+), 50 deletions(-) create mode 100644 src/components/block-inserter-bridge/index.jsx diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index febb2dce9..894074738 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -68,5 +68,6 @@ struct EditorJSMessage { struct ShowBlockInserterBody: Decodable { let blocks: [EditorBlock] + let destinationBlockName: String? } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift index b3766b378..e51e91948 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -9,6 +9,9 @@ struct EditorBlock: Decodable, Identifiable { let category: String? let keywords: [String]? var icon: String? + var frecency: Double = 0.0 + var isDisabled = false + var parents: [String] = [] } public struct EditorTitleAndContent: Decodable { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 8eaec687f..daf3e347b 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -245,16 +245,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Internal (Block Inserter) - private func showBlockInserter(blocks: [EditorBlock]) { + private func showBlockInserter(data: EditorJSMessage.ShowBlockInserterBody) { let context = MediaPickerPresentationContext() let host = UIHostingController(rootView: NavigationStack { BlockInserterView( - blocks: blocks, + blocks: data.blocks, + destinationBlockName: data.destinationBlockName, mediaPicker: mediaPicker, presentationContext: context, - onBlockSelected: { - print("insert blocks:", $0) + onBlockSelected: { [weak self] block in + self?.insertBlockFromInserter(block.name) }, onMediaSelected: { print("insert media:", $0) @@ -267,6 +268,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro present(host, animated: true) } + private func insertBlockFromInserter(_ blockName: String) { + evaluate("window.blockInserter.insertBlock('\(blockName)')") + } + private func openMediaLibrary(_ config: OpenMediaLibraryAction) { delegate?.editor(self, didRequestMediaFromSiteMediaLibrary: config) } @@ -309,7 +314,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro delegate?.editor(self, didLogException: editorException) case .showBlockInserter: let body = try message.decode(EditorJSMessage.ShowBlockInserterBody.self) - showBlockInserter(blocks: body.blocks) + showBlockInserter(data: body) case .openMediaLibrary: let config = try message.decode(OpenMediaLibraryAction.self) openMediaLibrary(config) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift index 4b4d4b905..d39758866 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -3,7 +3,7 @@ import SwiftUI struct BlockInserterSection: Identifiable { var id: String { category } let category: String - let name: String + let name: String? let blocks: [EditorBlock] } @@ -16,8 +16,8 @@ struct BlockInserterSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 20) { - if section.category != "text" { - Text(section.name) + if let name = section.name { + Text(name) .font(.headline) .foregroundStyle(Color.secondary) .padding(.leading, padding) @@ -25,7 +25,7 @@ struct BlockInserterSectionView: View { } grid } - .padding(.top, section.category != "text" ? 20 : 24) + .padding(.top, section.name != nil ? 20 : 24) .padding(.bottom, 10) .cardStyle() } diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 50b376d74..dc0d6b3e6 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -3,6 +3,8 @@ import PhotosUI import UIKit struct BlockInserterView: View { + let blocks: [EditorBlock] + let destinationBlockName: String? let mediaPicker: MediaPickerController? let presentationContext: MediaPickerPresentationContext let onBlockSelected: (EditorBlock) -> Void @@ -17,17 +19,21 @@ struct BlockInserterView: View { init( blocks: [EditorBlock], + destinationBlockName: String?, mediaPicker: MediaPickerController?, presentationContext: MediaPickerPresentationContext, onBlockSelected: @escaping (EditorBlock) -> Void, onMediaSelected: @escaping ([MediaInfo]) -> Void ) { + self.blocks = blocks + self.destinationBlockName = destinationBlockName self.mediaPicker = mediaPicker self.presentationContext = presentationContext self.onBlockSelected = onBlockSelected self.onMediaSelected = onMediaSelected - self._viewModel = StateObject(wrappedValue: BlockInserterViewModel(blocks: blocks)) + let viewModel = BlockInserterViewModel(blocks: blocks, destinationBlockName: destinationBlockName) + self._viewModel = StateObject(wrappedValue: viewModel) } var body: some View { @@ -92,6 +98,7 @@ struct BlockInserterView: View { NavigationStack { BlockInserterView( blocks: EditorBlock.mocks, + destinationBlockName: nil, mediaPicker: MockMediaPickerController(), presentationContext: MediaPickerPresentationContext(), onBlockSelected: { diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift index 934e981bd..a38416611 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -11,12 +11,12 @@ class BlockInserterViewModel: ObservableObject { private let allSections: [BlockInserterSection] private var cancellables = Set() - init(blocks: [EditorBlock]) { + init(blocks: [EditorBlock], destinationBlockName: String?) { let blocks = blocks.filter { $0.name != "core/missing" } self.blocks = blocks - self.allSections = BlockInserterViewModel.createSections(from: blocks) + self.allSections = BlockInserterViewModel.createSections(from: blocks, destinationBlockName: destinationBlockName) self.sections = allSections setupSearchObserver() @@ -47,31 +47,46 @@ class BlockInserterViewModel: ObservableObject { } } - private static func createSections(from blocks: [EditorBlock]) -> [BlockInserterSection] { - let blocks = Dictionary(grouping: blocks) { - $0.category?.lowercased() ?? "common" + private static func createSections(from blocks: [EditorBlock], destinationBlockName: String?) -> [BlockInserterSection] { + var sections: [BlockInserterSection] = [] + + // Separate contextual blocks (specifically allowed in current parent block) + // A block is contextual if the destination block name is in its parents array + let contextualBlocks = blocks.filter { block in + guard let destinationBlockName = destinationBlockName else { return false } + return !block.parents.isEmpty && block.parents.contains(destinationBlockName) + } + + // Add contextual section at the top if there are contextual blocks + if !contextualBlocks.isEmpty { + sections.append(BlockInserterSection(category: "gbk-contextual", name: nil, blocks: contextualBlocks)) } - var sections: [BlockInserterSection] = [] + // Group regular blocks by category + let blocksByCategory = Dictionary(grouping: blocks) { + $0.category?.lowercased() ?? "common" + } let categories = Constants.orderedCategories // Add known categories in a predefined order for (category, name) in categories { - if let blocks = blocks[category] { + if let blocks = blocksByCategory[category] { let sortedBlocks = orderBlocks(blocks, category: category) - sections.append(BlockInserterSection(category: category, name: name, blocks: sortedBlocks)) + // Use nil for text category, otherwise use the display name + let displayName = (category == "text" && contextualBlocks.isEmpty) ? nil : name + sections.append(BlockInserterSection(category: category, name: displayName, blocks: sortedBlocks)) } } - + // Add any remaining categories - for (category, blocks) in blocks { + for (category, blocks) in blocksByCategory { let isStandardCategory = categories.contains { $0.key == category } if !isStandardCategory { sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) } } - + return sections } } diff --git a/src/components/block-inserter-bridge/index.jsx b/src/components/block-inserter-bridge/index.jsx new file mode 100644 index 000000000..e42c30d6c --- /dev/null +++ b/src/components/block-inserter-bridge/index.jsx @@ -0,0 +1,117 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +// 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 +// block insertion logic without reimplementing it ourselves. +// +// Risks: +// - May break with WordPress package updates +// - No stability guarantees across versions +// - Not part of the public API contract +// +// Alternatives considered: +// - privateApis: These specific hooks are not exported there +// - Copying hooks: Too complex, uses multiple internal unlock() calls +// - Using PrivateQuickInserter component: Not suitable for headless bridge +// +// This approach is acceptable because: +// - We need the exact same insertion logic as the WordPress editor +// - The hooks are stable in practice (used by core components) +// - We're building a WordPress editor integration, not a general library +import useInsertionPoint from '@wordpress/block-editor/build-module/components/inserter/hooks/use-insertion-point'; +import useBlockTypesState from '@wordpress/block-editor/build-module/components/inserter/hooks/use-block-types-state'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { debug } from '../../utils/logger'; +import { serializeBlocksForNative } from '../../utils/blocks'; + +/** + * Block Inserter Bridge Component + * + * This component uses WordPress hooks (useInsertionPoint and useBlockTypesState) + * to manage block insertion state, just like QuickInserter does. It exposes the + * current inserter state globally for the native side to access. + * + * The component never renders any UI - it only manages state and provides a bridge. + */ +export default function BlockInserterBridge() { + // Get current selection for insertion context and destination block info + const { selectedBlockClientId, destinationBlockName } = useSelect( + ( select ) => { + const { getSelectedBlockClientId, getBlockRootClientId, getBlock } = + select( blockEditorStore ); + const clientId = getSelectedBlockClientId(); + // Get the parent block's client ID + const parentClientId = clientId + ? getBlockRootClientId( clientId ) + : null; + // Get the parent block object to extract its name + const parentBlock = parentClientId + ? getBlock( parentClientId ) + : null; + return { + selectedBlockClientId: clientId, + destinationBlockName: parentBlock?.name || null, + }; + }, + [] + ); + + // - rootClientId - "insertion will be into the block with this ID." + const [ destinationRootClientId, onInsertBlocks ] = useInsertionPoint( { + rootClientId: selectedBlockClientId, + isAppender: false, + selectBlockOnInsert: true, + } ); + + const [ inserterItems, , , onSelectItem ] = useBlockTypesState( + destinationRootClientId, + onInsertBlocks, + false // isQuick + ); + + // Serialize blocks for native consumption + const blocks = serializeBlocksForNative( inserterItems ); + + // Expose the current inserter state globally for native access + // This automatically stays in sync with editor state via hooks + useEffect( () => { + window.blockInserter = { + blocks, + destinationBlockName, + insertBlock: ( blockName ) => { + const item = inserterItems.find( + ( i ) => i.name === blockName + ); + if ( ! item ) { + debug( + `Block "${ blockName }" 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; + }; + }, [ blocks, destinationBlockName, inserterItems, onSelectItem ] ); + + // This component doesn't render anything + return null; +} diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 7c73eca82..11f5ace92 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -81,13 +81,11 @@ const EditorToolbar = ( { className } ) => { const classes = clsx( 'gutenberg-kit-editor-toolbar', className ); const addBlockButton = enableNativeBlockInserter ? ( - { - if ( isInserterOpened ) { - setIsInserterOpened( false ); - } + onTouchEnd={ ( e ) => { + e.preventDefault(); showBlockInserter(); } } className="gutenberg-kit-add-block-button" diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index 6a97d8413..e430272af 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -21,6 +21,7 @@ import { useSyncFeaturedImage } from './use-sync-featured-image'; import { useDevModeNotice } from './use-dev-mode-notice'; import { useAtAutocompleter } from './use-at-autocompleter'; import { usePlusAutocompleter } from './use-plus-autocompleter'; +import BlockInserterBridge from '../block-inserter-bridge'; /** * @typedef {import('../utils/bridge').Post} Post @@ -90,6 +91,8 @@ export default function Editor( { post, children, hideTitle } ) { settings={ settings } useSubRegistry={ false } > + + { mode === 'visual' && isRichEditingEnabled && ( ) } diff --git a/src/utils/blocks.js b/src/utils/blocks.js index 5b30549f9..de1e01ac1 100644 --- a/src/utils/blocks.js +++ b/src/utils/blocks.js @@ -29,16 +29,16 @@ export function unregisterDisallowedBlocks( allowedBlockTypes ) { /** * Extract and serialize a block's icon. * - * @param {Object} blockType The block type object. + * @param {Object} item The block type or inserter item object. * * @return {string|null} The serialized icon string or null. */ -function getBlockIcon( blockType ) { - if ( ! blockType.icon ) { +export function getBlockIcon( item ) { + if ( ! item.icon ) { return null; } - let iconSource = blockType.icon; + let iconSource = item.icon; // If icon is an object with src property, extract src if ( typeof iconSource === 'object' && iconSource.src ) { @@ -55,10 +55,7 @@ function getBlockIcon( blockType ) { return renderToString( iconSource ); } catch ( error ) { // If rendering fails, ignore the icon - debug( - `Failed to render icon for block ${ blockType.name }`, - error - ); + debug( `Failed to render icon for block ${ item.name }`, error ); return null; } } else if ( typeof iconSource === 'string' ) { @@ -69,19 +66,26 @@ function getBlockIcon( blockType ) { } /** - * Get serialized block data for all registered block types. + * Serializes inserter items to a format suitable for native consumption. + * Extracts only the properties needed by the native side and ensures + * proper formatting (e.g., converting React icon elements to SVG strings). * - * @return {Array} Array of serialized block objects. + * @param {Array} inserterItems Array of block inserter items from WordPress. + * + * @return {Array} Array of serialized block objects for native consumption. */ -export function getSerializedBlocks() { - return getBlockTypes().map( ( blockType ) => { +export function serializeBlocksForNative( inserterItems ) { + return inserterItems.map( ( item ) => { return { - name: blockType.name, - title: blockType.title, - description: blockType.description, - category: blockType.category, - keywords: blockType.keywords || [], - icon: getBlockIcon( blockType ), + name: item.name, + title: item.title, + description: item.description, + category: item.category, + keywords: item.keywords || [], + icon: getBlockIcon( item ), + frecency: item.frecency || 0, + isDisabled: item.isDisabled || false, + parents: item.parent || [], }; } ); } diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 7f9e099dc..ca994263b 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -89,14 +89,27 @@ export function onBlocksChanged( isEmpty = false ) { /** * Requests the native host to show the block picker. * + * The BlockInserterBridge component maintains the current inserter state + * at window.blockInserter, which automatically stays in sync with the editor + * via WordPress hooks (useInsertionPoint and useBlockTypesState). + * * @return {void} */ -export async function showBlockInserter() { - // Lazy load this utility to avoid the remote editor referencing `@wordpress` - // module globals before they are loaded. - const { getSerializedBlocks } = await import( './blocks' ); - const blocks = await getSerializedBlocks(); - dispatchToBridge( 'showBlockInserter', { blocks } ); +export function showBlockInserter() { + if ( ! window.blockInserter ) { + debug( + 'BlockInserterBridge not available. Ensure editor is initialized.' + ); + return; + } + + // Send blocks and destination block name to native + // The native side will use destinationBlockName to determine contextual blocks + // based on the block.parents field + dispatchToBridge( 'showBlockInserter', { + blocks: window.blockInserter.blocks, + destinationBlockName: window.blockInserter.destinationBlockName, + } ); } /** From 103d4117a422f0cfbc538721d93bb43461f5295b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 27 Oct 2025 14:58:23 -0400 Subject: [PATCH 2/7] Load bridge only if enabled --- src/components/editor-toolbar/index.jsx | 2 ++ src/components/editor/index.jsx | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 11f5ace92..cb38329d7 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -28,6 +28,7 @@ import './style.scss'; import { useModalize } from './use-modalize'; import { useModalDialogState } from '../editor/use-modal-dialog-state'; import { showBlockInserter, getGBKit } from '../../utils/bridge'; +import BlockInserterBridge from '../block-inserter-bridge'; /** * Renders the editor toolbar containing block-related actions. @@ -103,6 +104,7 @@ const EditorToolbar = ( { className } ) => { return ( <> + { enableNativeBlockInserter && } - - { mode === 'visual' && isRichEditingEnabled && ( ) } From a044ef0fab716f264c337f5c3bc2a57d4e629288 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 27 Oct 2025 15:42:17 -0400 Subject: [PATCH 3/7] Refactor the component that handles insertion --- src/components/editor-toolbar/index.jsx | 17 +++-------- src/components/editor/index.jsx | 1 - .../index.jsx | 30 +++++++++++++------ 3 files changed, 25 insertions(+), 23 deletions(-) rename src/components/{block-inserter-bridge => native-block-inserter-button}/index.jsx (81%) diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index cb38329d7..81a0a0734 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -17,7 +17,7 @@ import { ToolbarButton, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { close, cog, plus } from '@wordpress/icons'; +import { close, cog } from '@wordpress/icons'; import clsx from 'clsx'; import { store as editorStore } from '@wordpress/editor'; @@ -27,8 +27,8 @@ import { store as editorStore } from '@wordpress/editor'; import './style.scss'; import { useModalize } from './use-modalize'; import { useModalDialogState } from '../editor/use-modal-dialog-state'; -import { showBlockInserter, getGBKit } from '../../utils/bridge'; -import BlockInserterBridge from '../block-inserter-bridge'; +import { getGBKit } from '../../utils/bridge'; +import NativeBlockInserterButton from '../native-block-inserter-button'; /** * Renders the editor toolbar containing block-related actions. @@ -82,15 +82,7 @@ const EditorToolbar = ( { className } ) => { const classes = clsx( 'gutenberg-kit-editor-toolbar', className ); const addBlockButton = enableNativeBlockInserter ? ( -