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..b3766b378 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -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,29 @@ public struct EditorTitleAndContent: Decodable { public let changed: Bool } +extension EditorBlock: Searchable { + /// Sets the searchable fields in the order of priority + func searchableFields() -> [SearchableField] { + var fields: [SearchableField] = [] + + if let title, !title.isEmpty { + fields.append(SearchableField(content: title, weight: 10.0, allowFuzzyMatch: true)) + } + + fields.append(SearchableField(content: name, weight: 8.0, allowFuzzyMatch: false)) + + (keywords ?? []).forEach { keyword in + fields.append(SearchableField( content: keyword, weight: 5.0, allowFuzzyMatch: true)) + } + + if let description, !description.isEmpty { + fields.append(SearchableField(content: description, weight: 2.0, allowFuzzyMatch: false)) + } + + if let category, !category.isEmpty { + 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..33d1fb1e8 --- /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))\n\n\(svg)") +#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..d59162445 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockIconView.swift @@ -0,0 +1,69 @@ +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(.label)) + .symbolRenderingMode(.hierarchical) + } + } + } +} + +private struct SVGIconView: UIViewRepresentable { + let view: SVGKFastImageView + + @Environment(\.colorScheme) private var colorScheme + + func makeUIView(context: Context) -> SVGKFastImageView { + view.contentMode = .scaleAspectFit + return view + } + + func updateUIView(_ uiView: SVGKFastImageView, context: Context) { + view.image?.fillColor(color: UIColor.label) + view.setNeedsDisplay() + } +} + +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) + } + } +} 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..ed6990f40 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift @@ -0,0 +1,94 @@ +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 + + @EnvironmentObject private var iconCache: BlockIconCache + + 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) + .environmentObject(iconCache) + } + } + + 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..4b4d4b905 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct BlockInserterSection: Identifiable { + var id: String { category } + let category: String + let name: String + let blocks: [EditorBlock] +} + +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..d6c025aa6 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView+PreviewData.swift @@ -0,0 +1,308 @@ +#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 + ), + + // 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..b159b6360 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -0,0 +1,83 @@ +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 { + toolbar + } + } + + 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 toolbar: 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..934e981bd --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -0,0 +1,143 @@ +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 = SearchEngine() + .search(query: searchText, in: section.blocks) + return filtered.isEmpty ? nil : BlockInserterSection( + category: section.category, + name: section.name, + blocks: filtered + ) + } + } + } + + private static func createSections(from blocks: [EditorBlock]) -> [BlockInserterSection] { + let blocks = Dictionary(grouping: blocks) { + $0.category?.lowercased() ?? "common" + } + + var sections: [BlockInserterSection] = [] + + 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 blocks { + let isStandardCategory = categories.contains { $0.key == category } + if !isStandardCategory { + sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) + } + } + + return sections + } +} + +// 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 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) + } + } + + // 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/src/utils/blocks.js b/src/utils/blocks.js index 51eced652..5b30549f9 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, getBlockTypes } from '@wordpress/blocks'; +import { renderToString } from '@wordpress/element'; import { debug } from './logger'; /** @@ -24,3 +25,63 @@ 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() { + 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 e9aa472a7..7f9e099dc 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -92,19 +92,10 @@ 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 ) => { - return { - name: blockType.name, - title: blockType.title, - description: blockType.description, - category: blockType.category, - keywords: blockType.keywords || [], - }; - } ); + // 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 } ); }