From 77728baf3d0ad48b467dd311b993d3e08e746349 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 29 Oct 2025 10:12:16 -0400 Subject: [PATCH 01/12] Add a note about serializeBlocksForNative --- src/utils/blocks.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utils/blocks.js b/src/utils/blocks.js index cb6676f45..4e39adca1 100644 --- a/src/utils/blocks.js +++ b/src/utils/blocks.js @@ -70,6 +70,11 @@ export function getBlockIcon( item ) { * Extracts only the properties needed by the native side and ensures * proper formatting (e.g., converting React icon elements to SVG strings). * + * WARNING: This function eliminates 90+% of JSON payload by compacting + * otherwise duplicated block variants. Do not add unnecessary properties + * or modify the compact format without careful consideration of the + * performance impact on the native bridge. + * * @param {Array} inserterItems Array of block inserter items from WordPress. * * @return {Array} Array of serialized block objects for native consumption. From 16cc6f34397d8e04b6f45278b49669a9292e69e8 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 29 Oct 2025 11:29:28 -0400 Subject: [PATCH 02/12] Remove now redundant core/missing check --- .../Sources/Views/BlockInserter/BlockInserterViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift index 2b21bea43..89d15765b 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -12,8 +12,6 @@ class BlockInserterViewModel: ObservableObject { private var cancellables = Set() init(blocks: [BlockType], destinationBlockName: String?) { - let blocks = blocks.filter { $0.name != "core/missing" } - self.blocks = blocks self.allSections = BlockInserterViewModel.createSections(from: blocks, destinationBlockName: destinationBlockName) From f2a7b826baaef408c3db77d9379c73e18246041b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 29 Oct 2025 11:36:04 -0400 Subject: [PATCH 03/12] Move ordering to the JS land --- .../BlockInserterViewModel.swift | 131 +++---------- .../Views/BlockInserter/BlockType.swift | 4 + .../native-block-inserter-button/index.jsx | 5 +- src/utils/blocks.js | 182 +++++++++++++++++- 4 files changed, 218 insertions(+), 104 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift index 89d15765b..fbb9fbb10 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -47,110 +47,43 @@ class BlockInserterViewModel: ObservableObject { private static func createSections(from blocks: [BlockType], 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)) - } - - // 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 = blocksByCategory[category] { - let sortedBlocks = orderBlocks(blocks, category: category) - // 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)) + var currentSection: BlockInserterSection? + + // Blocks are already ordered and have section metadata from JS + // Simply iterate and create sections when the category changes + for block in blocks { + let category = block.sectionCategory ?? block.category?.lowercased() ?? "common" + let sectionName = block.sectionName + + // Check if we need to start a new section + if let current = currentSection, current.category == category { + // Add block to current section + var updatedBlocks = current.blocks + updatedBlocks.append(block) + currentSection = BlockInserterSection( + category: current.category, + name: current.name, + blocks: updatedBlocks + ) + } else { + // Save previous section if it exists + if let current = currentSection { + sections.append(current) + } + // Start new section + currentSection = BlockInserterSection( + category: category, + name: sectionName, + blocks: [block] + ) } } - // Add any remaining categories - for (category, blocks) in blocksByCategory { - let isStandardCategory = categories.contains { $0.key == category } - if !isStandardCategory { - sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) - } + // Add the last section + if let current = currentSection { + sections.append(current) } return sections } } - -// MARK: Ordering - -private func orderBlocks(_ blocks: [BlockType], category: String) -> [BlockType] { - switch category { - case "text": - return _orderBlocks(blocks, order: [ - "core/paragraph", - "core/heading", - "core/list", - "core/list-item", - "core/quote", - "core/code", - "core/preformatted", - "core/verse", - "core/table" - ]) - case "media": - return _orderBlocks(blocks, order: [ - "core/image", - "core/video", - "core/gallery", - "core/embed", - "core/audio", - "core/file" - ]) - case "design": - return _orderBlocks(blocks, order: [ - "core/separator", - "core/spacer", - "core/columns", - "core/column" - ]) - default: - return blocks - } -} - -private func _orderBlocks(_ blocks: [BlockType], order: [String]) -> [BlockType] { - var orderedBlocks: [BlockType] = [] - - // Add blocks in a predefined order - for name in order { - if let block = blocks.first(where: { $0.name == name }) { - orderedBlocks.append(block) - } - } - - // Add remaining blocks in their original order - let remainingBlocks = blocks.filter { block in - !order.contains(block.name) - } - - return orderedBlocks + remainingBlocks -} - -private enum Constants { - static let orderedCategories: [(key: String, displayName: String)] = [ - ("text", "Text"), - ("media", "Media"), - ("design", "Design"), - ("widgets", "Widgets"), - ("theme", "Theme"), - ("embed", "Embeds") - ] -} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift index d616ddf8f..61d0bb24c 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift @@ -15,6 +15,10 @@ struct BlockType: Decodable, Identifiable { var frecency: Double = 0.0 var isDisabled = false var parents: [String] = [] + + // Section metadata provided by JavaScript + var sectionCategory: String? + var sectionName: String? } extension BlockType: Searchable { diff --git a/src/components/native-block-inserter-button/index.jsx b/src/components/native-block-inserter-button/index.jsx index 1bc5ad834..259821fc3 100644 --- a/src/components/native-block-inserter-button/index.jsx +++ b/src/components/native-block-inserter-button/index.jsx @@ -81,7 +81,10 @@ export default function NativeBlockInserterButton() { ); // Serialize blocks for native consumption - const blocks = serializeBlocksForNative( inserterItems ); + const blocks = serializeBlocksForNative( + inserterItems, + destinationBlockName + ); // Expose the current inserter state globally for native access // This automatically stays in sync with editor state via hooks diff --git a/src/utils/blocks.js b/src/utils/blocks.js index 4e39adca1..52f87f171 100644 --- a/src/utils/blocks.js +++ b/src/utils/blocks.js @@ -65,22 +65,116 @@ export function getBlockIcon( item ) { return null; } +/** + * Predefined category ordering. + */ +const ORDERED_CATEGORIES = [ + { key: 'text', displayName: 'Text' }, + { key: 'media', displayName: 'Media' }, + { key: 'design', displayName: 'Design' }, + { key: 'widgets', displayName: 'Widgets' }, + { key: 'theme', displayName: 'Theme' }, + { key: 'embed', displayName: 'Embeds' }, +]; + +/** + * Predefined block ordering within categories. + */ +const BLOCK_ORDER_BY_CATEGORY = { + text: [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/list-item', + 'core/quote', + 'core/code', + 'core/preformatted', + 'core/verse', + 'core/table', + ], + media: [ + 'core/image', + 'core/video', + 'core/gallery', + 'core/embed', + 'core/audio', + 'core/file', + ], + design: [ + 'core/separator', + 'core/spacer', + 'core/columns', + 'core/column', + ], +}; + +/** + * Most used blocks to show when there are no contextual blocks. + */ +const MOST_USED_BLOCKS = [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/quote', +]; + +/** + * Orders blocks within a category according to predefined ordering. + * + * @param {Array} blocks Blocks to order. + * @param {string} category Category key. + * + * @return {Array} Ordered blocks. + */ +function orderBlocksInCategory( blocks, category ) { + const order = BLOCK_ORDER_BY_CATEGORY[ category ]; + if ( ! order ) { + return blocks; + } + + const orderedBlocks = []; + + // Add blocks in predefined order + for ( const name of order ) { + const block = blocks.find( ( b ) => b.name === name ); + if ( block ) { + orderedBlocks.push( block ); + } + } + + // Add remaining blocks in their original order + const remainingBlocks = blocks.filter( + ( block ) => ! order.includes( block.name ) + ); + + return [ ...orderedBlocks, ...remainingBlocks ]; +} + /** * 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). * + * This function also handles ordering of blocks by category and within categories, + * as well as creating a contextual section for blocks that are specifically allowed + * in the current parent block. + * * WARNING: This function eliminates 90+% of JSON payload by compacting * otherwise duplicated block variants. Do not add unnecessary properties * or modify the compact format without careful consideration of the * performance impact on the native bridge. * - * @param {Array} inserterItems Array of block inserter items from WordPress. + * @param {Array} inserterItems Array of block inserter items from WordPress. + * @param {string} destinationBlockName Name of the parent block where new blocks will be inserted. * - * @return {Array} Array of serialized block objects for native consumption. + * @return {Array} Array of serialized block objects for native consumption, ordered and with section metadata. */ -export function serializeBlocksForNative( inserterItems ) { - return inserterItems.map( ( item ) => { +export function serializeBlocksForNative( + inserterItems, + destinationBlockName = null +) { + // First, serialize all blocks + const serializedBlocks = inserterItems.map( ( item ) => { return { id: item.id, name: item.name, @@ -94,4 +188,84 @@ export function serializeBlocksForNative( inserterItems ) { parents: item.parent || [], }; } ); + + // Separate contextual blocks (specifically allowed in current parent block) + const contextualBlocks = serializedBlocks.filter( ( block ) => { + if ( ! destinationBlockName ) { + return false; + } + return ( + block.parents.length > 0 && + block.parents.includes( destinationBlockName ) + ); + } ); + + // Determine blocks to show in contextual section + const contextualSectionBlocks = + contextualBlocks.length > 0 + ? contextualBlocks + : serializedBlocks.filter( ( block ) => + MOST_USED_BLOCKS.includes( block.name ) + ); + + // Group regular blocks by category + const blocksByCategory = {}; + for ( const block of serializedBlocks ) { + const category = block.category?.toLowerCase() || 'common'; + if ( ! blocksByCategory[ category ] ) { + blocksByCategory[ category ] = []; + } + blocksByCategory[ category ].push( block ); + } + + const result = []; + + // Add contextual section + const hasContextualBlocks = contextualBlocks.length > 0; + for ( const block of contextualSectionBlocks ) { + result.push( { + ...block, + sectionCategory: 'gbk-contextual', + sectionName: null, + } ); + } + + // Add blocks by category in predefined order + for ( const { key: category, displayName } of ORDERED_CATEGORIES ) { + const blocks = blocksByCategory[ category ]; + if ( blocks ) { + const orderedBlocks = orderBlocksInCategory( blocks, category ); + // Use null for text category name if no contextual blocks, otherwise use display name + const sectionName = + category === 'text' && ! hasContextualBlocks + ? null + : displayName; + + for ( const block of orderedBlocks ) { + result.push( { + ...block, + sectionCategory: category, + sectionName, + } ); + } + } + } + + // Add any remaining categories + const knownCategories = ORDERED_CATEGORIES.map( ( c ) => c.key ); + for ( const [ category, blocks ] of Object.entries( blocksByCategory ) ) { + if ( ! knownCategories.includes( category ) ) { + for ( const block of blocks ) { + result.push( { + ...block, + sectionCategory: category, + sectionName: + category.charAt( 0 ).toUpperCase() + + category.slice( 1 ), + } ); + } + } + } + + return result; } From d15b78ee0eb85f9d9ef13e9af15c4ddbc1f68802 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 29 Oct 2025 11:40:37 -0400 Subject: [PATCH 04/12] Further refactoring --- .../Sources/EditorJSMessage.swift | 3 +- .../Sources/EditorViewController.swift | 3 +- .../BlockInserterSectionView.swift | 2 +- .../BlockInserter/BlockInserterView.swift | 16 +++--- .../BlockInserterViewModel.swift | 53 ++--------------- .../Views/BlockInserter/BlockType.swift | 4 -- .../native-block-inserter-button/index.jsx | 11 ++-- src/utils/blocks.js | 57 +++++++++---------- src/utils/bridge.js | 10 ++-- 9 files changed, 50 insertions(+), 109 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 9eceb5076..cd3934565 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -67,7 +67,6 @@ struct EditorJSMessage { } struct ShowBlockInserterBody: Decodable { - let blocks: [BlockType] - let destinationBlockName: String? + let sections: [BlockInserterSection] } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 373c0924f..e52a6a938 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -256,8 +256,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro let host = UIHostingController(rootView: NavigationStack { BlockInserterView( - blocks: data.blocks, - destinationBlockName: data.destinationBlockName, + sections: data.sections, mediaPicker: mediaPicker, presentationContext: context, onBlockSelected: { [weak self] block in diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift index d1db868cc..a6975c933 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct BlockInserterSection: Identifiable { +struct BlockInserterSection: Identifiable, Decodable { var id: String { category } let category: String let name: String? diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 292ce0b32..663c98c54 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -3,8 +3,7 @@ import PhotosUI import UIKit struct BlockInserterView: View { - let blocks: [BlockType] - let destinationBlockName: String? + let sections: [BlockInserterSection] let mediaPicker: MediaPickerController? let presentationContext: MediaPickerPresentationContext let onBlockSelected: (BlockType) -> Void @@ -18,21 +17,19 @@ struct BlockInserterView: View { @Environment(\.dismiss) private var dismiss init( - blocks: [BlockType], - destinationBlockName: String?, + sections: [BlockInserterSection], mediaPicker: MediaPickerController?, presentationContext: MediaPickerPresentationContext, onBlockSelected: @escaping (BlockType) -> Void, onMediaSelected: @escaping ([MediaInfo]) -> Void ) { - self.blocks = blocks - self.destinationBlockName = destinationBlockName + self.sections = sections self.mediaPicker = mediaPicker self.presentationContext = presentationContext self.onBlockSelected = onBlockSelected self.onMediaSelected = onMediaSelected - let viewModel = BlockInserterViewModel(blocks: blocks, destinationBlockName: destinationBlockName) + let viewModel = BlockInserterViewModel(sections: sections) self._viewModel = StateObject(wrappedValue: viewModel) } @@ -97,8 +94,9 @@ struct BlockInserterView: View { #Preview { NavigationStack { BlockInserterView( - blocks: BlockType.mocks, - destinationBlockName: nil, + sections: [ + BlockInserterSection(category: "text", name: "Text", blocks: BlockType.mocks) + ], 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 fbb9fbb10..6eb0e80be 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -7,19 +7,16 @@ class BlockInserterViewModel: ObservableObject { @Published var searchText = "" @Published private(set) var sections: [BlockInserterSection] = [] - private let blocks: [BlockType] private let allSections: [BlockInserterSection] private var cancellables = Set() - init(blocks: [BlockType], destinationBlockName: String?) { - self.blocks = blocks - - self.allSections = BlockInserterViewModel.createSections(from: blocks, destinationBlockName: destinationBlockName) - self.sections = allSections + init(sections: [BlockInserterSection]) { + self.allSections = sections + self.sections = sections setupSearchObserver() } - + private func setupSearchObserver() { $searchText .debounce(for: .milliseconds(200), scheduler: RunLoop.main) @@ -44,46 +41,4 @@ class BlockInserterViewModel: ObservableObject { } } } - - private static func createSections(from blocks: [BlockType], destinationBlockName: String?) -> [BlockInserterSection] { - var sections: [BlockInserterSection] = [] - var currentSection: BlockInserterSection? - - // Blocks are already ordered and have section metadata from JS - // Simply iterate and create sections when the category changes - for block in blocks { - let category = block.sectionCategory ?? block.category?.lowercased() ?? "common" - let sectionName = block.sectionName - - // Check if we need to start a new section - if let current = currentSection, current.category == category { - // Add block to current section - var updatedBlocks = current.blocks - updatedBlocks.append(block) - currentSection = BlockInserterSection( - category: current.category, - name: current.name, - blocks: updatedBlocks - ) - } else { - // Save previous section if it exists - if let current = currentSection { - sections.append(current) - } - // Start new section - currentSection = BlockInserterSection( - category: category, - name: sectionName, - blocks: [block] - ) - } - } - - // Add the last section - if let current = currentSection { - sections.append(current) - } - - return sections - } } diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift index 61d0bb24c..d616ddf8f 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockType.swift @@ -15,10 +15,6 @@ struct BlockType: Decodable, Identifiable { var frecency: Double = 0.0 var isDisabled = false var parents: [String] = [] - - // Section metadata provided by JavaScript - var sectionCategory: String? - var sectionName: String? } extension BlockType: Searchable { diff --git a/src/components/native-block-inserter-button/index.jsx b/src/components/native-block-inserter-button/index.jsx index 259821fc3..1b7aa6286 100644 --- a/src/components/native-block-inserter-button/index.jsx +++ b/src/components/native-block-inserter-button/index.jsx @@ -32,7 +32,7 @@ import useInsertionPoint from '@wordpress/block-editor/build-module/components/i 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'; +import { preprocessBlockTypesForNativeInserter } from '../../utils/blocks'; import { showBlockInserter } from '../../utils/bridge'; /** @@ -80,8 +80,8 @@ export default function NativeBlockInserterButton() { false // isQuick ); - // Serialize blocks for native consumption - const blocks = serializeBlocksForNative( + // Preprocess blocks into sections for native consumption + const sections = preprocessBlockTypesForNativeInserter( inserterItems, destinationBlockName ); @@ -90,8 +90,7 @@ export default function NativeBlockInserterButton() { // This automatically stays in sync with editor state via hooks useEffect( () => { window.blockInserter = { - blocks, - destinationBlockName, + sections, insertBlock: ( blockId ) => { const item = inserterItems.find( ( i ) => i.id === blockId ); if ( ! item ) { @@ -115,7 +114,7 @@ export default function NativeBlockInserterButton() { return () => { delete window.blockInserter; }; - }, [ blocks, destinationBlockName, inserterItems, onSelectItem ] ); + }, [ sections, inserterItems, onSelectItem ] ); return (