From c8fa3dc3ebfa61262b61156e93a36546d104e1a0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 20 Oct 2025 12:19:56 -0400 Subject: [PATCH 01/14] Add basic block inserter features: show blocks, search --- Package.swift | 3 +- .../xcshareddata/swiftpm/Package.resolved | 27 ++ .../GutenbergKit/Sources/EditorTypes.swift | 36 +- .../Sources/EditorViewController.swift | 4 +- .../Sources/Helpers/BlockIconCache.swift | 37 ++ .../Sources/Helpers/SearchEngine.swift | 190 +++++++++ .../Sources/Modifiers/CardModifier.swift | 19 + .../Views/BlockInserter/BlockIconView.swift | 43 ++ .../BlockInserterBlockView.swift | 91 +++++ .../BlockInserterSectionView.swift | 37 ++ .../BlockInserterView+PreviewData.swift | 382 ++++++++++++++++++ .../BlockInserter/BlockInserterView.swift | 89 ++++ .../BlockInserterViewModel.swift | 190 +++++++++ src/utils/bridge.js | 31 ++ 14 files changed, 1175 insertions(+), 4 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift diff --git a/Package.swift b/Package.swift index 1fdb8300d..2038bf5bd 100644 --- a/Package.swift +++ b/Package.swift @@ -11,11 +11,12 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.5"), + .package(url: "https://github.com/SVGKit/SVGKit", from: "3.0.0"), ], targets: [ .target( name: "GutenbergKit", - dependencies: ["SwiftSoup"], + dependencies: ["SwiftSoup", "SVGKit"], path: "ios/Sources/GutenbergKit", exclude: [], resources: [.copy("Gutenberg")] diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 724d44ffe..b9210a8e9 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,32 @@ { "pins" : [ + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114", + "version" : "3.9.0" + } + }, + { + "identity" : "svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SVGKit/SVGKit", + "state" : { + "revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666", + "version" : "3.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", diff --git a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift index 24bdd1ab9..457d10476 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -1,6 +1,6 @@ import Foundation -struct EditorBlock: Decodable, Identifiable { +struct EditorBlock: Codable, Identifiable { var id: String { name } let name: String @@ -8,6 +8,7 @@ struct EditorBlock: Decodable, Identifiable { let description: String? let category: String? let keywords: [String]? + var icon: String? } public struct EditorTitleAndContent: Decodable { @@ -16,3 +17,36 @@ public struct EditorTitleAndContent: Decodable { public let changed: Bool } +extension EditorBlock: Searchable { + func searchableFields() -> [SearchableField] { + var fields: [SearchableField] = [] + + // Title - highest weight + if let title = title { + fields.append(SearchableField(content: title, weight: 10.0, allowFuzzyMatch: true)) + } + + // Name - high weight, strip namespace for better matching + let simplifiedName = name.components(separatedBy: "/").last ?? name + fields.append(SearchableField(content: simplifiedName, weight: 8.0, allowFuzzyMatch: true)) + + // Keywords - medium weight + if let keywords = keywords { + keywords.forEach { keyword in + fields.append(SearchableField( content: keyword, weight: 5.0, allowFuzzyMatch: true)) + } + } + + // Description - lower weight, no fuzzy matching + if let description = description { + fields.append(SearchableField(content: description, weight: 3.0, allowFuzzyMatch: false)) + } + + // Category - lowest weight + if let category = category { + fields.append(SearchableField(content: category, weight: 2.0, allowFuzzyMatch: true)) + } + + return fields + } +} diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index a62f299e6..eb7272aac 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -241,8 +241,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private func showBlockInserter(blocks: [EditorBlock]) { present(UIHostingController(rootView: NavigationView { - List(blocks) { - Text($0.name) + BlockInserterView(blocks: blocks) { + print("did select:", $0) } }), animated: true) } diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift b/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift new file mode 100644 index 000000000..c0ac63e9a --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift @@ -0,0 +1,37 @@ +import Foundation +import SVGKit + +@MainActor +final class BlockIconCache: ObservableObject { + var icons: [String: Result] = [:] + + func getIcon(for block: EditorBlock) -> SVGKImage? { + if let result = icons[block.id] { + return try? result.get() + } + let result = Result { try _getIcon(for: block) } + icons[block.id] = result + return try? result.get() + } + + private func _getIcon(for block: EditorBlock) throws -> SVGKImage { + guard let svg = block.icon, + !svg.isEmpty, + let source = SVGKSourceString.source(fromContentsOf: svg), + let image = SVGKImage(source: source) else { + throw BlockIconCacheError.unknown + } + if let result = image.parseErrorsAndWarnings, + let error = result.errorsFatal.firstObject { +#if DEBUG + debugPrint("failed to parse SVG for block: \(block.name) with errors: \(String(describing: result.errorsFatal))") +#endif + throw (error as? Error) ?? BlockIconCacheError.unknown + } + return image + } +} + +private enum BlockIconCacheError: Error { + case unknown +} diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift b/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift new file mode 100644 index 000000000..e29251865 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift @@ -0,0 +1,190 @@ +import Foundation + +/// A protocol for items that can be searched +protocol Searchable { + /// Extract searchable fields with their weights + func searchableFields() -> [SearchableField] +} + +/// A field that can be searched with an associated weight +struct SearchableField { + let content: String + let weight: Double + let allowFuzzyMatch: Bool + + init(content: String, weight: Double, allowFuzzyMatch: Bool = true) { + self.content = content + self.weight = weight + self.allowFuzzyMatch = allowFuzzyMatch + } +} + +/// Configuration for the search engine +struct SearchConfiguration { + /// Maximum allowed edit distance for fuzzy matching + let maxEditDistance: Int + + /// Minimum similarity threshold (0-1) for fuzzy matches + let minSimilarityThreshold: Double + + /// Multiplier for exact matches + let exactMatchMultiplier: Double + + /// Multiplier for prefix matches + let prefixMatchMultiplier: Double + + /// Multiplier for word prefix matches + let wordPrefixMatchMultiplier: Double + + /// Multiplier for fuzzy matches (applied to similarity score) + let fuzzyMatchMultiplier: Double + + static let `default` = SearchConfiguration( + maxEditDistance: 2, + minSimilarityThreshold: 0.7, + exactMatchMultiplier: 2.0, + prefixMatchMultiplier: 1.5, + wordPrefixMatchMultiplier: 0.8, + fuzzyMatchMultiplier: 0.6 + ) +} + +/// A generic search engine that performs weighted fuzzy search +struct SearchEngine { + + /// Search result with relevance score + struct SearchResult { + let item: Item + let score: Double + } + + let configuration: SearchConfiguration + + init(configuration: SearchConfiguration = .default) { + self.configuration = configuration + } + + /// Search items with weighted fuzzy matching + func search(query: String, in items: [Item]) -> [Item] { + let normalizedQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Empty query returns all items + guard !normalizedQuery.isEmpty else { + return items + } + + // Calculate scores for all items + let results: [SearchResult] = items.compactMap { item in + let score = calculateScore(for: item, query: normalizedQuery) + return score > 0 ? SearchResult(item: item, score: score) : nil + } + + // Sort by score (highest first) and return items + return results + .sorted { $0.score > $1.score } + .map { $0.item } + } + + /// Calculate weighted score for an item based on query match + private func calculateScore(for item: Item, query: String) -> Double { + let fields = item.searchableFields() + + return fields.reduce(0.0) { totalScore, field in + totalScore + calculateFieldScore( + field: field.content.lowercased(), + query: query, + weight: field.weight, + allowFuzzy: field.allowFuzzyMatch + ) + } + } + + /// Calculate score for a single field + private func calculateFieldScore(field: String, query: String, weight: Double, allowFuzzy: Bool) -> Double { + // Exact match + if field == query { + return weight * configuration.exactMatchMultiplier + } + + // Contains match + if field.contains(query) { + // Higher score if it starts with the query + if field.hasPrefix(query) { + return weight * configuration.prefixMatchMultiplier + } + return weight + } + + // Fuzzy match if allowed + if allowFuzzy { + // Check each word in the field + let fieldWords = field.split(separator: " ").map(String.init) + for word in fieldWords { + // Word starts with query + if word.hasPrefix(query) { + return weight * configuration.wordPrefixMatchMultiplier + } + + // Calculate similarity + let similarity = calculateSimilarity(word, query) + if similarity >= configuration.minSimilarityThreshold { + return weight * similarity * configuration.fuzzyMatchMultiplier + } + } + + // Try full field fuzzy match for short queries + if query.count <= 10 { + let similarity = calculateSimilarity(field, query) + if similarity >= configuration.minSimilarityThreshold { + return weight * similarity * configuration.fuzzyMatchMultiplier * 0.7 + } + } + } + + return 0 + } + + /// Calculate similarity between two strings using normalized edit distance + private func calculateSimilarity(_ str1: String, _ str2: String) -> Double { + let distance = levenshteinDistance(str1, str2) + let maxLength = max(str1.count, str2.count) + + // Don't allow too many edits relative to string length + if distance > min(configuration.maxEditDistance, maxLength / 3) { + return 0 + } + + return 1.0 - (Double(distance) / Double(maxLength)) + } + + /// Calculate Levenshtein edit distance between two strings + private func levenshteinDistance(_ str1: String, _ str2: String) -> Int { + let str1Array = Array(str1) + let str2Array = Array(str2) + + // Create matrix + var matrix = Array(repeating: Array(repeating: 0, count: str2Array.count + 1), count: str1Array.count + 1) + + // Initialize first row and column + for i in 0...str1Array.count { + matrix[i][0] = i + } + for j in 0...str2Array.count { + matrix[0][j] = j + } + + // Fill matrix + for i in 1...str1Array.count { + for j in 1...str2Array.count { + let cost = str1Array[i-1] == str2Array[j-1] ? 0 : 1 + matrix[i][j] = min( + matrix[i-1][j] + 1, // deletion + matrix[i][j-1] + 1, // insertion + matrix[i-1][j-1] + cost // substitution + ) + } + } + + return matrix[str1Array.count][str2Array.count] + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift b/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift new file mode 100644 index 000000000..fe4cdb9b3 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 26) + .stroke(Color(.opaqueSeparator), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 26)) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardModifier()) + } + } diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift new file mode 100644 index 000000000..0931feb98 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift @@ -0,0 +1,43 @@ +import SwiftUI +import SVGKit + +struct BlockIconView: View { + let block: EditorBlock + let size: CGFloat + + @EnvironmentObject private var cache: BlockIconCache + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(uiColor: .secondarySystemFill)) + .frame(width: size, height: size) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + + if let image = cache.getIcon(for: block), + let view = SVGKFastImageView(svgkImage: image) { + SVGIconView(view: view) + .frame(width: size * 0.5, height: size * 0.5) + } else { + Image(systemName: "square") + .font(.system(size: size * 0.5)) + .foregroundStyle(Color.black) + .symbolRenderingMode(.hierarchical) + } + } + } +} + +private struct SVGIconView: UIViewRepresentable { + let view: SVGKFastImageView + + func makeUIView(context: Context) -> SVGKFastImageView { + view.contentMode = .scaleAspectFit + view.tintColor = .red + return view + } + + func updateUIView(_ uiView: SVGKFastImageView, context: Context) { + // No special handling needed - SVGKit will render as-is + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift new file mode 100644 index 000000000..b251c9b88 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import SVGKit + +struct BlockInserterBlockView: View { + let block: EditorBlock + let action: () -> Void + + @State private var isPressed = false + @State private var isHovered = false + + @ScaledMetric(relativeTo: .largeTitle) private var iconSize = 44 + + var body: some View { + Button(action: { + onSelected() + }) { + VStack(spacing: 8) { + BlockIconView(block: block, size: iconSize) + Text(title) + .font(.caption) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.center) + } + .foregroundStyle(Color.primary) + .scaleEffect(isPressed ? 0.9 : 1.0) + .animation(.spring, value: isPressed) + .padding(.horizontal, 4) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .center) + .contextMenu { + Button { + onSelected() + } label: { + Label(title, systemImage: "plus") + } + } preview: { + BlockDetailedView(block: block) + } + } + + private func onSelected() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + isPressed = true + action() + } + + private var title: String { + block.title ?? block.name + .split(separator: "/") + .last + .map(String.init) ?? "-" + } +} + +private struct BlockDetailedView: View { + let block: EditorBlock + + var body: some View { + HStack(spacing: 16) { + BlockIconView(block: block, size: 56) + + VStack(alignment: .leading, spacing: 2) { + if let title = block.title { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Text(block.name) + .font(.footnote) + .foregroundColor(.secondary) + } else { + Text(block.name) + .font(.headline) + .foregroundColor(.primary) + } + + if let description = block.description { + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(4) + .padding(.top, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .frame(width: 360) + .background(Color(uiColor: .systemBackground)) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift new file mode 100644 index 000000000..96ad79417 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -0,0 +1,37 @@ +import SwiftUI +import PhotosUI + +struct BlockInserterSectionView: View { + let section: BlockInserterSection + let onBlockSelected: (EditorBlock) -> Void + + @ScaledMetric(relativeTo: .largeTitle) private var miniumSize = 80 + @ScaledMetric(relativeTo: .largeTitle) private var padding = 20 + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if section.category != "text" { + Text(section.name) + .font(.headline) + .foregroundStyle(Color.secondary) + .padding(.leading, padding) + .frame(maxWidth: .infinity, alignment: .leading) + } + grid + } + .padding(.top, section.category != "text" ? 20 : 24) + .padding(.bottom, 10) + .cardStyle() + } + + private var grid: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: miniumSize, maximum: miniumSize * 1.5), spacing: 0)]) { + ForEach(section.blocks) { block in + BlockInserterBlockView(block: block) { + onBlockSelected(block) + } + } + } + .padding(.horizontal, 12) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift new file mode 100644 index 000000000..e416237a6 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift @@ -0,0 +1,382 @@ +#if DEBUG +import Foundation + +extension EditorBlock { + static let mocks: [EditorBlock] = [ + // Text blocks + EditorBlock( + name: "core/paragraph", + title: "Paragraph", + description: "Start with the basic building block of all narrative.", + category: "text", + keywords: ["text", "paragraph"], + icon: paragraphSVG + ), + EditorBlock( + name: "core/heading", + title: "Heading", + description: "Introduce new sections and organize content to help visitors find what they need.", + category: "text", + keywords: ["title", "heading"], + icon: headingSVG + ), + EditorBlock( + name: "core/list", + title: "List", + description: "Create a bulleted or numbered list.", + category: "text", + keywords: ["bullet", "number", "list"], + icon: listSVG + ), + EditorBlock( + name: "core/quote", + title: "Quote", + description: "Give quoted text visual emphasis.", + category: "text", + keywords: ["quote", "citation"], + icon: quoteSVG + ), + EditorBlock( + name: "core/code", + title: "Code", + description: "Display code snippets that respect your spacing and tabs.", + category: "text", + keywords: ["code", "programming"], + icon: codeSVG + ), + EditorBlock( + name: "core/preformatted", + title: "Preformatted", + description: "Add text that respects your spacing and tabs, and also allows styling.", + category: "text", + keywords: ["preformatted", "monospace"], + icon: nil + ), + EditorBlock( + name: "core/pullquote", + title: "Pullquote", + description: "Give special visual emphasis to a quote from your text.", + category: "text", + keywords: ["pullquote", "quote"], + icon: quoteSVG + ), + EditorBlock( + name: "core/verse", + title: "Verse", + description: "Insert poetry. Use special spacing formats. Or quote song lyrics.", + category: "text", + keywords: ["poetry", "verse"], + icon: nil + ), + EditorBlock( + name: "core/table", + title: "Table", + description: "Create structured content in rows and columns to display information.", + category: "text", + keywords: ["table", "rows", "columns"], + icon: nil + ), + + // Media blocks + EditorBlock( + name: "core/image", + title: "Image", + description: "Insert an image to make a visual statement.", + category: "media", + keywords: ["photo", "picture"], + icon: imageSVG + ), + EditorBlock( + name: "core/gallery", + title: "Gallery", + description: "Display multiple images in a rich gallery.", + category: "media", + keywords: ["images", "photos"], + icon: imageSVG + ), + EditorBlock( + name: "core/audio", + title: "Audio", + description: "Embed a simple audio player.", + category: "media", + keywords: ["music", "sound", "podcast"], + icon: nil + ), + EditorBlock( + name: "core/video", + title: "Video", + description: "Embed a video from your media library or upload a new one.", + category: "media", + keywords: ["movie", "film"], + icon: videoSVG + ), + EditorBlock( + name: "core/cover", + title: "Cover", + description: "Add an image or video with a text overlay.", + category: "media", + keywords: ["banner", "hero", "cover"], + icon: nil + ), + EditorBlock( + name: "core/file", + title: "File", + description: "Add a link to a downloadable file.", + category: "media", + keywords: ["download", "pdf", "document"], + icon: nil + ), + EditorBlock( + name: "core/media-text", + title: "Media & Text", + description: "Set media and words side-by-side for a richer layout.", + category: "media", + keywords: ["image", "video", "layout"], + icon: nil + ), + + // Design blocks + EditorBlock( + name: "core/columns", + title: "Columns", + description: "Display content in multiple columns.", + category: "design", + keywords: ["layout", "columns"], + icon: nil + ), + EditorBlock( + name: "core/group", + title: "Group", + description: "Gather blocks in a container.", + category: "design", + keywords: ["container", "wrapper", "group"], + icon: nil + ), + EditorBlock( + name: "core/separator", + title: "Separator", + description: "Create a break between ideas or sections.", + category: "design", + keywords: ["divider", "hr"], + icon: nil + ), + EditorBlock( + name: "core/spacer", + title: "Spacer", + description: "Add white space between blocks.", + category: "design", + keywords: ["space", "gap"], + icon: nil + ), + EditorBlock( + name: "core/buttons", + title: "Buttons", + description: "Prompt visitors to take action with a group of button-style links.", + category: "design", + keywords: ["button", "link", "cta"], + icon: buttonSVG + ), + EditorBlock( + name: "core/more", + title: "More", + description: "Content before this block will be shown in the excerpt on your archives page.", + category: "design", + keywords: ["read more", "excerpt"], + icon: nil + ), + + // Widget blocks + EditorBlock( + name: "core/search", + title: "Search", + description: "Help visitors find your content.", + category: "widgets", + keywords: ["find", "search"], + ), + EditorBlock( + name: "core/archives", + title: "Archives", + description: "Display a date archive of your posts.", + category: "widgets", + keywords: ["archive", "history"], + icon: nil + ), + EditorBlock( + name: "core/categories", + title: "Categories", + description: "Display a list of all categories.", + category: "widgets", + keywords: ["category", "taxonomy"], + icon: nil + ), + + // Embed blocks + EditorBlock( + name: "core-embed/youtube", + title: "YouTube", + description: "Embed a YouTube video.", + category: "embed", + keywords: ["video", "youtube"], + icon: nil + ), + EditorBlock( + name: "core-embed/twitter", + title: "Twitter", + description: "Embed a tweet.", + category: "embed", + keywords: ["tweet", "twitter"], + icon: nil + ), + EditorBlock( + name: "core-embed/vimeo", + title: "Vimeo", + description: "Embed a Vimeo video.", + category: "embed", + keywords: ["video", "vimeo"], + icon: nil + ), + EditorBlock( + name: "core-embed/instagram", + title: "Instagram", + description: "Embed an Instagram post.", + category: "embed", + keywords: ["instagram", "photo"], + icon: nil + ), + + // Jetpack blocks + EditorBlock( + name: "jetpack/ai-assistant", + title: "AI Assistant", + description: "Generate text, edit content, and get suggestions using AI.", + category: "text", + keywords: ["ai", "artificial intelligence", "generate", "write"], + icon: nil + ), + EditorBlock( + name: "jetpack/contact-form", + title: "Contact Form", + description: "Add a customizable contact form.", + category: "widgets", + keywords: ["form", "contact", "email"], + icon: nil + ), + EditorBlock( + name: "jetpack/markdown", + title: "Markdown", + description: "Write posts or pages in plain-text Markdown syntax.", + category: "text", + keywords: ["markdown", "md", "formatting"], + icon: nil + ), + EditorBlock( + name: "jetpack/tiled-gallery", + title: "Tiled Gallery", + description: "Display multiple images in an elegantly organized tiled layout.", + category: "media", + keywords: ["gallery", "images", "photos", "tiled"], + icon: nil + ), + EditorBlock( + name: "jetpack/slideshow", + title: "Slideshow", + description: "Display multiple images in a slideshow.", + category: "media", + keywords: ["slideshow", "carousel", "gallery"], + icon: nil + ), + EditorBlock( + name: "jetpack/map", + title: "Map", + description: "Add an interactive map showing one or more locations.", + category: "widgets", + keywords: ["map", "location", "address"], + icon: nil + ), + EditorBlock( + name: "jetpack/business-hours", + title: "Business Hours", + description: "Display your business opening hours.", + category: "widgets", + keywords: ["hours", "schedule", "business"], + icon: nil + ), + EditorBlock( + name: "jetpack/subscriptions", + title: "Subscriptions", + description: "Let visitors subscribe to your blog posts.", + category: "widgets", + keywords: ["subscribe", "email", "newsletter"], + icon: nil + ), + EditorBlock( + name: "jetpack/related-posts", + title: "Related Posts", + description: "Display a list of related posts.", + category: "widgets", + keywords: ["related", "posts", "similar"], + icon: nil + ), + + // Additional common blocks + EditorBlock( + name: "core/html", + title: "Custom HTML", + description: "Add custom HTML code and preview it as you edit.", + category: "widgets", + keywords: ["html", "code", "custom"], + icon: codeSVG + ), + EditorBlock( + name: "core/shortcode", + title: "Shortcode", + description: "Insert additional custom elements with WordPress shortcodes.", + category: "widgets", + keywords: ["shortcode", "custom"], + icon: nil + ), + EditorBlock( + name: "core/social-links", + title: "Social Icons", + description: "Display icons linking to your social media profiles.", + category: "widgets", + keywords: ["social", "links", "icons"], + icon: nil + ) + ] + + // MARK: - Placeholder SVG Icons + + private static let paragraphSVG = """ + + """ + + private static let headingSVG = """ + + """ + + private static let listSVG = """ + + """ + + private static let quoteSVG = """ + + """ + + private static let imageSVG = """ + + """ + + private static let videoSVG = """ + + """ + + private static let buttonSVG = """ + + """ + + private static let codeSVG = """ + + """ +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift new file mode 100644 index 000000000..75aab5417 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -0,0 +1,89 @@ +import SwiftUI +import PhotosUI +import UIKit + +struct BlockInserterView: View { + let onBlockSelected: (EditorBlock) -> Void + + @StateObject private var viewModel: BlockInserterViewModel + @StateObject private var iconCache = BlockIconCache() + + private let maxSelectionCount = 10 + + @Environment(\.dismiss) private var dismiss + + init( + blocks: [EditorBlock], + onBlockSelected: @escaping (EditorBlock) -> Void, + ) { + self.onBlockSelected = onBlockSelected + let viewModel = BlockInserterViewModel(blocks: blocks) + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + content + .background(Material.ultraThin) + .searchable(text: $viewModel.searchText) + .navigationBarTitleDisplayMode(.inline) + .environmentObject(iconCache) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ForEach(viewModel.sections) { section in + BlockInserterSectionView(section: section, onBlockSelected: insertBlock) + .padding(.horizontal) + } + } + .padding(.vertical, 8) + .dynamicTypeSize(...(.accessibility3)) + } + .scrollContentBackground(.hidden) + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .tint(Color.primary) + } + } + + // MARK: - Actions + + private func insertBlock(_ blockType: EditorBlock) { + dismiss() + onBlockSelected(blockType) + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + NavigationStack { + BlockInserterView( + blocks: EditorBlock.mocks, + onBlockSelected: { + print("block selected: \($0.name)") + } + ) + } +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift new file mode 100644 index 000000000..d51054e3a --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -0,0 +1,190 @@ +import SwiftUI +import PhotosUI +import Combine + +@MainActor +class BlockInserterViewModel: ObservableObject { + @Published var searchText = "" + @Published private(set) var sections: [BlockInserterSection] = [] + + private let blocks: [EditorBlock] + private let allSections: [BlockInserterSection] + private var cancellables = Set() + + init(blocks: [EditorBlock]) { + let blocks = blocks.filter { $0.name != "core/missing" } + + self.blocks = blocks + + self.allSections = BlockInserterViewModel.createSections(from: blocks) + self.sections = allSections + + setupSearchObserver() + } + + private func setupSearchObserver() { + $searchText + .debounce(for: .milliseconds(200), scheduler: RunLoop.main) + .sink { [weak self] searchText in + self?.updateFilteredSections(searchText: searchText) + } + .store(in: &cancellables) + } + + private func updateFilteredSections(searchText: String) { + if searchText.isEmpty { + sections = allSections + } else { + sections = allSections.compactMap { section in + let filtered = filterBlocks(in: section, searchText: searchText) + return filtered.isEmpty ? nil : BlockInserterSection( + category: section.category, + name: section.name, + blocks: filtered + ) + } + } + } + + private func filterBlocks(in section: BlockInserterSection, searchText: String) -> [EditorBlock] { + let searchEngine = SearchEngine() + let filtered = searchEngine.search(query: searchText, in: section.blocks) + + // Maintain paragraph first in text category when showing all blocks + if searchText.isEmpty && section.name == "Text" && filtered.count > 1 { + return sortTextBlocks(filtered) + } + + return filtered + } + + private func sortTextBlocks(_ blocks: [EditorBlock]) -> [EditorBlock] { + let paragraphBlocks = blocks.filter { $0.name == "core/paragraph" } + let otherBlocks = blocks.filter { $0.name != "core/paragraph" } + return paragraphBlocks + otherBlocks + } + + private static func createSections(from blockTypes: [EditorBlock]) -> [BlockInserterSection] { + let categoryOrder = BlockInserterConstants.categoryOrder + var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } + + // Move core/embed from embed category to media category + if let embedBlocks = grouped["embed"], + let embedBlock = embedBlocks.first(where: { $0.name == "core/embed" }) { + // Add to media category + if var mediaBlocks = grouped["media"] { + mediaBlocks.append(embedBlock) + grouped["media"] = mediaBlocks + } else { + grouped["media"] = [embedBlock] + } + + // Remove from embed category + grouped["embed"] = embedBlocks.filter { $0.name != "core/embed" } + if grouped["embed"]?.isEmpty == true { + grouped.removeValue(forKey: "embed") + } + } + + var sections: [BlockInserterSection] = [] + + // Add sections in WordPress standard order + for (categoryKey, displayName) in categoryOrder { + if let blocks = grouped[categoryKey] { + let sortedBlocks = sortBlocks(blocks, category: categoryKey) + sections.append(BlockInserterSection(category: categoryKey, name: displayName, blocks: sortedBlocks)) + } + } + + // Add any remaining categories + for (category, blocks) in grouped { + let isStandardCategory = categoryOrder.contains { $0.key == category } + if !isStandardCategory { + sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) + } + } + + return sections + } + + private static func sortBlocks(_ blocks: [EditorBlock], category: String) -> [EditorBlock] { + switch category { + case "text": + return sortWithOrder(blocks, order: BlockInserterConstants.textBlockOrder) + case "media": + return sortWithOrder(blocks, order: BlockInserterConstants.mediaBlockOrder) + case "design": + return sortWithOrder(blocks, order: BlockInserterConstants.designBlockOrder) + default: + return blocks + } + } + + private static func sortWithOrder(_ blocks: [EditorBlock], order: [String]) -> [EditorBlock] { + var orderedBlocks: [EditorBlock] = [] + + // Add blocks in defined order + for blockName in order { + if let block = blocks.first(where: { $0.name == blockName }) { + orderedBlocks.append(block) + } + } + + // Add remaining blocks in their original order + let remainingBlocks = blocks.filter { block in + !order.contains(block.name) + } + + return orderedBlocks + remainingBlocks + } +} + +// MARK: - Constants + +enum BlockInserterConstants { + static let categoryOrder: [(key: String, displayName: String)] = [ + ("text", "Text"), + ("media", "Media"), + ("design", "Design"), + ("widgets", "Widgets"), + ("theme", "Theme"), + ("embed", "Embeds") + ] + + static let textBlockOrder = [ + "core/paragraph", + "core/heading", + "core/list", + "core/list-item", + "core/quote", + "core/code", + "core/preformatted", + "core/verse", + "core/table" + ] + + static let mediaBlockOrder = [ + "core/image", + "core/video", + "core/gallery", + "core/embed", + "core/audio", + "core/file" + ] + + static let designBlockOrder = [ + "core/separator", + "core/spacer", + "core/columns", + "core/column" + ] +} + +// MARK: - Supporting Types + +struct BlockInserterSection: Identifiable { + var id: String { category } + let category: String + let name: String + let blocks: [EditorBlock] +} diff --git a/src/utils/bridge.js b/src/utils/bridge.js index e9aa472a7..a7227c3dd 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -97,12 +97,43 @@ export async function showBlockInserter() { // window.wp.blocks is defined before we access it. const { getBlockTypes } = await import( '@wordpress/blocks' ); const blocks = getBlockTypes().map( ( blockType ) => { + // Extract and serialize icon + let icon = null; + if ( blockType.icon ) { + let iconSource = blockType.icon; + + // If icon is an object with src property, extract src + if ( typeof iconSource === 'object' && iconSource.src ) { + iconSource = iconSource.src; + } + + // Convert React element to SVG string + if ( + typeof iconSource === 'object' && + iconSource !== null && + typeof iconSource.type !== 'undefined' + ) { + try { + icon = renderToString( iconSource ); + } catch ( error ) { + // If rendering fails, ignore the icon + debug( + `Failed to render icon for block ${ blockType.name }`, + error + ); + } + } else if ( typeof iconSource === 'string' ) { + icon = iconSource; + } + } + return { name: blockType.name, title: blockType.title, description: blockType.description, category: blockType.category, keywords: blockType.keywords || [], + icon, }; } ); dispatchToBridge( 'showBlockInserter', { blocks } ); From cbca6ef4d20630c53cb230ca0eceada0b7684d9c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 11:58:41 -0400 Subject: [PATCH 02/14] Add dark mode support --- .../Views/BlockInserter/BlockIconView.swift | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift index 0931feb98..0acb3c66e 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift @@ -21,7 +21,7 @@ struct BlockIconView: View { } else { Image(systemName: "square") .font(.system(size: size * 0.5)) - .foregroundStyle(Color.black) + .foregroundStyle(Color(.label)) .symbolRenderingMode(.hierarchical) } } @@ -31,13 +31,38 @@ struct BlockIconView: View { private struct SVGIconView: UIViewRepresentable { let view: SVGKFastImageView + @Environment(\.colorScheme) private var colorScheme + func makeUIView(context: Context) -> SVGKFastImageView { view.contentMode = .scaleAspectFit - view.tintColor = .red return view } func updateUIView(_ uiView: SVGKFastImageView, context: Context) { - // No special handling needed - SVGKit will render as-is + view.image?.fillColor(color: UIColor.label) + } +} + +private extension SVGKImage { + /// SVGKit maintains two parallel representations of every SVG file: a + /// DOMTree following W3C SVG specifications and a CALayerTree for native + /// iOS rendering. The easiest and fastest way to change the colors of the + /// shapes it creates is by recursively traversing the layers. + private func fillColorForSubLayer(layer: CALayer, color: UIColor, opacity: Float) { + if let shapeLayer = layer as? CAShapeLayer { + shapeLayer.fillColor = color.cgColor + shapeLayer.opacity = opacity + } + if let sublayers = layer.sublayers { + for subLayer in sublayers { + fillColorForSubLayer(layer: subLayer, color: color, opacity: opacity) + } + } + } + + func fillColor(color: UIColor, opacity: Float = 1.0) { + if let layer = caLayerTree { + fillColorForSubLayer(layer: layer, color: color, opacity: opacity) + } } } From 0c9b1320c729955a5af048572d0b27803922f3ad Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 12:05:08 -0400 Subject: [PATCH 03/14] Update logging --- ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift b/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift index c0ac63e9a..33d1fb1e8 100644 --- a/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift +++ b/ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift @@ -24,7 +24,7 @@ final class BlockIconCache: ObservableObject { if let result = image.parseErrorsAndWarnings, let error = result.errorsFatal.firstObject { #if DEBUG - debugPrint("failed to parse SVG for block: \(block.name) with errors: \(String(describing: result.errorsFatal))") + debugPrint("failed to parse SVG for block: \(block.name) with errors: \(String(describing: result.errorsFatal))\n\n\(svg)") #endif throw (error as? Error) ?? BlockIconCacheError.unknown } From 4f8dfc02c8bee8b9a18b1073fbef37faf013fc6b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 12:05:37 -0400 Subject: [PATCH 04/14] Remove duplicated toolbar content --- .../Views/BlockInserter/BlockInserterView.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 75aab5417..b159b6360 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -28,13 +28,7 @@ struct BlockInserterView: View { .navigationBarTitleDisplayMode(.inline) .environmentObject(iconCache) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - } + toolbar } } @@ -54,7 +48,7 @@ struct BlockInserterView: View { } @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { + private var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { Button { dismiss() From fe6896ce443b78c71dc013eecc2eae8d1e9101b5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 12:42:05 -0400 Subject: [PATCH 05/14] Remove sortTextBlocks --- .../BlockInserterViewModel.swift | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift index d51054e3a..e5c2fcd60 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -36,7 +36,8 @@ class BlockInserterViewModel: ObservableObject { sections = allSections } else { sections = allSections.compactMap { section in - let filtered = filterBlocks(in: section, searchText: searchText) + let filtered = SearchEngine() + .search(query: searchText, in: section.blocks) return filtered.isEmpty ? nil : BlockInserterSection( category: section.category, name: section.name, @@ -45,25 +46,7 @@ class BlockInserterViewModel: ObservableObject { } } } - - private func filterBlocks(in section: BlockInserterSection, searchText: String) -> [EditorBlock] { - let searchEngine = SearchEngine() - let filtered = searchEngine.search(query: searchText, in: section.blocks) - - // Maintain paragraph first in text category when showing all blocks - if searchText.isEmpty && section.name == "Text" && filtered.count > 1 { - return sortTextBlocks(filtered) - } - - return filtered - } - - private func sortTextBlocks(_ blocks: [EditorBlock]) -> [EditorBlock] { - let paragraphBlocks = blocks.filter { $0.name == "core/paragraph" } - let otherBlocks = blocks.filter { $0.name != "core/paragraph" } - return paragraphBlocks + otherBlocks - } - + private static func createSections(from blockTypes: [EditorBlock]) -> [BlockInserterSection] { let categoryOrder = BlockInserterConstants.categoryOrder var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } From cb83890c595bc414ffa4b0bd2545a974714bd947 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 12:51:41 -0400 Subject: [PATCH 06/14] Simplify ordering for categories --- .../BlockInserterSectionView.swift | 8 +- .../BlockInserterViewModel.swift | 166 +++++++----------- 2 files changed, 75 insertions(+), 99 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift index 96ad79417..4b4d4b905 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -1,5 +1,11 @@ import SwiftUI -import PhotosUI + +struct BlockInserterSection: Identifiable { + var id: String { category } + let category: String + let name: String + let blocks: [EditorBlock] +} struct BlockInserterSectionView: View { let section: BlockInserterSection diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift index e5c2fcd60..934e981bd 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -47,41 +47,26 @@ class BlockInserterViewModel: ObservableObject { } } - private static func createSections(from blockTypes: [EditorBlock]) -> [BlockInserterSection] { - let categoryOrder = BlockInserterConstants.categoryOrder - var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } - - // Move core/embed from embed category to media category - if let embedBlocks = grouped["embed"], - let embedBlock = embedBlocks.first(where: { $0.name == "core/embed" }) { - // Add to media category - if var mediaBlocks = grouped["media"] { - mediaBlocks.append(embedBlock) - grouped["media"] = mediaBlocks - } else { - grouped["media"] = [embedBlock] - } - - // Remove from embed category - grouped["embed"] = embedBlocks.filter { $0.name != "core/embed" } - if grouped["embed"]?.isEmpty == true { - grouped.removeValue(forKey: "embed") - } + private static func createSections(from blocks: [EditorBlock]) -> [BlockInserterSection] { + let blocks = Dictionary(grouping: blocks) { + $0.category?.lowercased() ?? "common" } - + var sections: [BlockInserterSection] = [] - - // Add sections in WordPress standard order - for (categoryKey, displayName) in categoryOrder { - if let blocks = grouped[categoryKey] { - let sortedBlocks = sortBlocks(blocks, category: categoryKey) - sections.append(BlockInserterSection(category: categoryKey, name: displayName, blocks: sortedBlocks)) + + let categories = Constants.orderedCategories + + // Add known categories in a predefined order + for (category, name) in categories { + if let blocks = blocks[category] { + let sortedBlocks = orderBlocks(blocks, category: category) + sections.append(BlockInserterSection(category: category, name: name, blocks: sortedBlocks)) } } // Add any remaining categories - for (category, blocks) in grouped { - let isStandardCategory = categoryOrder.contains { $0.key == category } + for (category, blocks) in blocks { + let isStandardCategory = categories.contains { $0.key == category } if !isStandardCategory { sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) } @@ -89,43 +74,65 @@ class BlockInserterViewModel: ObservableObject { return sections } - - private static func sortBlocks(_ blocks: [EditorBlock], category: String) -> [EditorBlock] { - switch category { - case "text": - return sortWithOrder(blocks, order: BlockInserterConstants.textBlockOrder) - case "media": - return sortWithOrder(blocks, order: BlockInserterConstants.mediaBlockOrder) - case "design": - return sortWithOrder(blocks, order: BlockInserterConstants.designBlockOrder) - default: - return blocks - } +} + +// MARK: Ordering + +private func orderBlocks(_ blocks: [EditorBlock], category: String) -> [EditorBlock] { + 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 static func sortWithOrder(_ blocks: [EditorBlock], order: [String]) -> [EditorBlock] { - var orderedBlocks: [EditorBlock] = [] - - // Add blocks in defined order - for blockName in order { - if let block = blocks.first(where: { $0.name == blockName }) { - orderedBlocks.append(block) - } - } - - // Add remaining blocks in their original order - let remainingBlocks = blocks.filter { block in - !order.contains(block.name) +} + +private func _orderBlocks(_ blocks: [EditorBlock], order: [String]) -> [EditorBlock] { + var orderedBlocks: [EditorBlock] = [] + + // Add blocks in a predefined order + for name in order { + if let block = blocks.first(where: { $0.name == name }) { + orderedBlocks.append(block) } - - return orderedBlocks + remainingBlocks } -} -// MARK: - Constants + // Add remaining blocks in their original order + let remainingBlocks = blocks.filter { block in + !order.contains(block.name) + } + + return orderedBlocks + remainingBlocks +} -enum BlockInserterConstants { - static let categoryOrder: [(key: String, displayName: String)] = [ +private enum Constants { + static let orderedCategories: [(key: String, displayName: String)] = [ ("text", "Text"), ("media", "Media"), ("design", "Design"), @@ -133,41 +140,4 @@ enum BlockInserterConstants { ("theme", "Theme"), ("embed", "Embeds") ] - - static let textBlockOrder = [ - "core/paragraph", - "core/heading", - "core/list", - "core/list-item", - "core/quote", - "core/code", - "core/preformatted", - "core/verse", - "core/table" - ] - - static let mediaBlockOrder = [ - "core/image", - "core/video", - "core/gallery", - "core/embed", - "core/audio", - "core/file" - ] - - static let designBlockOrder = [ - "core/separator", - "core/spacer", - "core/columns", - "core/column" - ] -} - -// MARK: - Supporting Types - -struct BlockInserterSection: Identifiable { - var id: String { category } - let category: String - let name: String - let blocks: [EditorBlock] } From 0b20edb16788628d094d0a8c143b135c8a3f435f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 13:03:00 -0400 Subject: [PATCH 07/14] Fix crash when showing BlockInserterBlockView --- .../Sources/Views/BlockInserter/BlockIconView.swift | 1 + .../Sources/Views/BlockInserter/BlockInserterBlockView.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift index 0acb3c66e..d59162445 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift @@ -40,6 +40,7 @@ private struct SVGIconView: UIViewRepresentable { func updateUIView(_ uiView: SVGKFastImageView, context: Context) { view.image?.fillColor(color: UIColor.label) + view.setNeedsDisplay() } } diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift index b251c9b88..ed6990f40 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift @@ -10,6 +10,8 @@ struct BlockInserterBlockView: View { @ScaledMetric(relativeTo: .largeTitle) private var iconSize = 44 + @EnvironmentObject private var iconCache: BlockIconCache + var body: some View { Button(action: { onSelected() @@ -36,6 +38,7 @@ struct BlockInserterBlockView: View { } } preview: { BlockDetailedView(block: block) + .environmentObject(iconCache) } } From 5f060b16a1c5b62094e043612c9325ff793b0ac1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 13:07:42 -0400 Subject: [PATCH 08/14] Make EditorBlock just Decodable again --- ios/Sources/GutenbergKit/Sources/EditorTypes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift index 457d10476..7652b707f 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -1,6 +1,6 @@ import Foundation -struct EditorBlock: Codable, Identifiable { +struct EditorBlock: Decodable, Identifiable { var id: String { name } let name: String From 6e32f2ba19256ab7dd8b8db2340b2974c03516f7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 13:09:49 -0400 Subject: [PATCH 09/14] Cleanup searchableFields --- .../GutenbergKit/Sources/EditorTypes.swift | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift index 7652b707f..b3766b378 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -18,32 +18,25 @@ public struct EditorTitleAndContent: Decodable { } extension EditorBlock: Searchable { + /// Sets the searchable fields in the order of priority func searchableFields() -> [SearchableField] { var fields: [SearchableField] = [] - // Title - highest weight - if let title = title { + if let title, !title.isEmpty { fields.append(SearchableField(content: title, weight: 10.0, allowFuzzyMatch: true)) } - // Name - high weight, strip namespace for better matching - let simplifiedName = name.components(separatedBy: "/").last ?? name - fields.append(SearchableField(content: simplifiedName, weight: 8.0, allowFuzzyMatch: true)) + fields.append(SearchableField(content: name, weight: 8.0, allowFuzzyMatch: false)) - // Keywords - medium weight - if let keywords = keywords { - keywords.forEach { keyword in - fields.append(SearchableField( content: keyword, weight: 5.0, allowFuzzyMatch: true)) - } + (keywords ?? []).forEach { keyword in + fields.append(SearchableField( content: keyword, weight: 5.0, allowFuzzyMatch: true)) } - // Description - lower weight, no fuzzy matching - if let description = description { - fields.append(SearchableField(content: description, weight: 3.0, allowFuzzyMatch: false)) + if let description, !description.isEmpty { + fields.append(SearchableField(content: description, weight: 2.0, allowFuzzyMatch: false)) } - // Category - lowest weight - if let category = category { + if let category, !category.isEmpty { fields.append(SearchableField(content: category, weight: 2.0, allowFuzzyMatch: true)) } From b3dd22079f09dfbf986b38ed4d8eb764ef24432c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 13:12:50 -0400 Subject: [PATCH 10/14] Fewer blocks in the preview --- .../BlockInserterView+PreviewData.swift | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift index e416237a6..d6c025aa6 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift @@ -244,80 +244,6 @@ extension EditorBlock { icon: nil ), - // Jetpack blocks - EditorBlock( - name: "jetpack/ai-assistant", - title: "AI Assistant", - description: "Generate text, edit content, and get suggestions using AI.", - category: "text", - keywords: ["ai", "artificial intelligence", "generate", "write"], - icon: nil - ), - EditorBlock( - name: "jetpack/contact-form", - title: "Contact Form", - description: "Add a customizable contact form.", - category: "widgets", - keywords: ["form", "contact", "email"], - icon: nil - ), - EditorBlock( - name: "jetpack/markdown", - title: "Markdown", - description: "Write posts or pages in plain-text Markdown syntax.", - category: "text", - keywords: ["markdown", "md", "formatting"], - icon: nil - ), - EditorBlock( - name: "jetpack/tiled-gallery", - title: "Tiled Gallery", - description: "Display multiple images in an elegantly organized tiled layout.", - category: "media", - keywords: ["gallery", "images", "photos", "tiled"], - icon: nil - ), - EditorBlock( - name: "jetpack/slideshow", - title: "Slideshow", - description: "Display multiple images in a slideshow.", - category: "media", - keywords: ["slideshow", "carousel", "gallery"], - icon: nil - ), - EditorBlock( - name: "jetpack/map", - title: "Map", - description: "Add an interactive map showing one or more locations.", - category: "widgets", - keywords: ["map", "location", "address"], - icon: nil - ), - EditorBlock( - name: "jetpack/business-hours", - title: "Business Hours", - description: "Display your business opening hours.", - category: "widgets", - keywords: ["hours", "schedule", "business"], - icon: nil - ), - EditorBlock( - name: "jetpack/subscriptions", - title: "Subscriptions", - description: "Let visitors subscribe to your blog posts.", - category: "widgets", - keywords: ["subscribe", "email", "newsletter"], - icon: nil - ), - EditorBlock( - name: "jetpack/related-posts", - title: "Related Posts", - description: "Display a list of related posts.", - category: "widgets", - keywords: ["related", "posts", "similar"], - icon: nil - ), - // Additional common blocks EditorBlock( name: "core/html", From 3840538cc4f6d7e59364ede957a9ae436ebb50dd Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 14:34:18 -0400 Subject: [PATCH 11/14] Move getSerializedBlocks to blocks.js --- src/utils/blocks.js | 66 ++++++++++++++++++++++++++++++++++++++++++++- src/utils/bridge.js | 48 +++------------------------------ 2 files changed, 68 insertions(+), 46 deletions(-) diff --git a/src/utils/blocks.js b/src/utils/blocks.js index 51eced652..9f641ca75 100644 --- a/src/utils/blocks.js +++ b/src/utils/blocks.js @@ -1,7 +1,8 @@ /** * WordPress dependencies */ -import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; +import { unregisterBlockType } from '@wordpress/blocks'; +import { renderToString } from '@wordpress/element'; import { debug } from './logger'; /** @@ -24,3 +25,66 @@ export function unregisterDisallowedBlocks( allowedBlockTypes ) { debug( 'Blocks unregistered:', unregisteredBlocks ); } + +/** + * Extract and serialize a block's icon. + * + * @param {Object} blockType The block type object. + * + * @return {string|null} The serialized icon string or null. + */ +function getBlockIcon( blockType ) { + if ( ! blockType.icon ) { + return null; + } + + let iconSource = blockType.icon; + + // If icon is an object with src property, extract src + if ( typeof iconSource === 'object' && iconSource.src ) { + iconSource = iconSource.src; + } + + // Convert React element to SVG string + if ( + typeof iconSource === 'object' && + iconSource !== null && + typeof iconSource.type !== 'undefined' + ) { + try { + return renderToString( iconSource ); + } catch ( error ) { + // If rendering fails, ignore the icon + debug( + `Failed to render icon for block ${ blockType.name }`, + error + ); + return null; + } + } + + else if ( typeof iconSource === 'string' ) { + return iconSource; + } + + return null; +} + +/** + * Get serialized block data for all registered block types. + * + * @return {Array} Array of serialized block objects. + */ +export function getSerializedBlocks() { + const { getBlockTypes } = await import( '@wordpress/blocks' ); + return getBlockTypes().map( ( blockType ) => { + return { + name: blockType.name, + title: blockType.title, + description: blockType.description, + category: blockType.category, + keywords: blockType.keywords || [], + icon: getBlockIcon( blockType ), + }; + } ); +} diff --git a/src/utils/bridge.js b/src/utils/bridge.js index a7227c3dd..aadd61c54 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -5,6 +5,7 @@ import parseException from './exception-parser'; import { debug } from './logger'; import { isDevMode } from './dev-mode'; import { basicFetch } from './fetch'; +import { getSerializedBlocks } from './blocks'; /** * Generic function to dispatch messages to both Android and iOS bridges. @@ -91,51 +92,8 @@ export function onBlocksChanged( isEmpty = false ) { * * @return {void} */ -export async function showBlockInserter() { - // Lazy-load getBlockTypes to defer the import until this function is called. - // In the remote editor, dependencies are loaded asynchronously, so this ensures - // window.wp.blocks is defined before we access it. - const { getBlockTypes } = await import( '@wordpress/blocks' ); - const blocks = getBlockTypes().map( ( blockType ) => { - // Extract and serialize icon - let icon = null; - if ( blockType.icon ) { - let iconSource = blockType.icon; - - // If icon is an object with src property, extract src - if ( typeof iconSource === 'object' && iconSource.src ) { - iconSource = iconSource.src; - } - - // Convert React element to SVG string - if ( - typeof iconSource === 'object' && - iconSource !== null && - typeof iconSource.type !== 'undefined' - ) { - try { - icon = renderToString( iconSource ); - } catch ( error ) { - // If rendering fails, ignore the icon - debug( - `Failed to render icon for block ${ blockType.name }`, - error - ); - } - } else if ( typeof iconSource === 'string' ) { - icon = iconSource; - } - } - - return { - name: blockType.name, - title: blockType.title, - description: blockType.description, - category: blockType.category, - keywords: blockType.keywords || [], - icon, - }; - } ); +export function showBlockInserter() { + const blocks = getSerializedBlocks(); dispatchToBridge( 'showBlockInserter', { blocks } ); } From e6393c808736623a16c9a9239a5426adde2c85f9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 21 Oct 2025 15:28:38 -0400 Subject: [PATCH 12/14] Fix lint errors --- src/utils/blocks.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/blocks.js b/src/utils/blocks.js index 9f641ca75..00c8dd2bf 100644 --- a/src/utils/blocks.js +++ b/src/utils/blocks.js @@ -61,9 +61,7 @@ function getBlockIcon( blockType ) { ); return null; } - } - - else if ( typeof iconSource === 'string' ) { + } else if ( typeof iconSource === 'string' ) { return iconSource; } From a55adca083d60deba0d68edba80d1a780054dfd6 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 22 Oct 2025 06:56:39 -0400 Subject: [PATCH 13/14] Add missing async --- src/utils/blocks.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/blocks.js b/src/utils/blocks.js index 00c8dd2bf..5b30549f9 100644 --- a/src/utils/blocks.js +++ b/src/utils/blocks.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { unregisterBlockType } from '@wordpress/blocks'; +import { unregisterBlockType, getBlockTypes } from '@wordpress/blocks'; import { renderToString } from '@wordpress/element'; import { debug } from './logger'; @@ -74,7 +74,6 @@ function getBlockIcon( blockType ) { * @return {Array} Array of serialized block objects. */ export function getSerializedBlocks() { - const { getBlockTypes } = await import( '@wordpress/blocks' ); return getBlockTypes().map( ( blockType ) => { return { name: blockType.name, From 78070b0700a037e490e1aca411e163b167e0eef1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Oct 2025 10:48:23 -0400 Subject: [PATCH 14/14] fix: Defer remote editor references to `@wordpress` module globals The static import resulted in `window.wp` references prior them being defined. This problem is unique to the remote editor, which asynchronously loads site-specific `@wordpress` module scripts. Using a dynamic import within the function ensures the reference does occur until after the globals are defined. --- src/utils/bridge.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index aadd61c54..7f9e099dc 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -5,7 +5,6 @@ import parseException from './exception-parser'; import { debug } from './logger'; import { isDevMode } from './dev-mode'; import { basicFetch } from './fetch'; -import { getSerializedBlocks } from './blocks'; /** * Generic function to dispatch messages to both Android and iOS bridges. @@ -92,8 +91,11 @@ export function onBlocksChanged( isEmpty = false ) { * * @return {void} */ -export function showBlockInserter() { - const blocks = getSerializedBlocks(); +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 } ); }