-
Notifications
You must be signed in to change notification settings - Fork 3
Add basic block inserter features: grid, search, icons, ordering #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
c8fa3dc
Add basic block inserter features: show blocks, search
kean cbca6ef
Add dark mode support
kean 0c9b132
Update logging
kean 4f8dfc0
Remove duplicated toolbar content
kean fe6896c
Remove sortTextBlocks
kean cb83890
Simplify ordering for categories
kean 0b20edb
Fix crash when showing BlockInserterBlockView
kean 5f060b1
Make EditorBlock just Decodable again
kean 6e32f2b
Cleanup searchableFields
kean b3dd220
Fewer blocks in the preview
kean 3840538
Move getSerializedBlocks to blocks.js
kean e6393c8
Fix lint errors
kean a55adca
Add missing async
kean 78070b0
fix: Defer remote editor references to `@wordpress` module globals
dcalhoun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
27 changes: 27 additions & 0 deletions
27
ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
37 changes: 37 additions & 0 deletions
37
ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import Foundation | ||
| import SVGKit | ||
|
|
||
| @MainActor | ||
| final class BlockIconCache: ObservableObject { | ||
| var icons: [String: Result<SVGKImage, Error>] = [:] | ||
|
|
||
| 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 | ||
| } |
190 changes: 190 additions & 0 deletions
190
ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Item: Searchable> { | ||
|
|
||
| /// 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] | ||
| } | ||
| } | ||
19 changes: 19 additions & 0 deletions
19
ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disclaimer: other code in PR is written by hand, but this file is entirely generated. It uses Levenshtein distance for fuzzy matching, and does scoring depending on weight of searchable fields. It seems to work pretty well, and we do need it in the inserter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Searching worked relatively well from my testing. I noted one thing while searching: we are not honoring the
insertersupports attribute for blocks. Blocks setting this tofalseshould not appear in the block inserter or its search results.