Skip to content

Commit 7b5cabb

Browse files
committed
Add basic block inserter features: show blocks, search
1 parent 3b8a4ba commit 7b5cabb

File tree

14 files changed

+1176
-4
lines changed

14 files changed

+1176
-4
lines changed

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ let package = Package(
1111
],
1212
dependencies: [
1313
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.5"),
14+
.package(url: "https://github.com/SVGKit/SVGKit", from: "3.0.0"),
1415
],
1516
targets: [
1617
.target(
1718
name: "GutenbergKit",
18-
dependencies: ["SwiftSoup"],
19+
dependencies: ["SwiftSoup", "SVGKit"],
1920
path: "ios/Sources/GutenbergKit",
2021
exclude: [],
2122
resources: [.copy("Gutenberg")]

ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import Foundation
22

3-
struct EditorBlock: Decodable, Identifiable {
3+
struct EditorBlock: Codable, Identifiable {
44
var id: String { name }
55

66
let name: String
77
let title: String?
88
let description: String?
99
let category: String?
1010
let keywords: [String]?
11+
var icon: String?
1112
}
1213

1314
public struct EditorTitleAndContent: Decodable {
@@ -16,3 +17,36 @@ public struct EditorTitleAndContent: Decodable {
1617
public let changed: Bool
1718
}
1819

20+
extension EditorBlock: Searchable {
21+
func searchableFields() -> [SearchableField] {
22+
var fields: [SearchableField] = []
23+
24+
// Title - highest weight
25+
if let title = title {
26+
fields.append(SearchableField(content: title, weight: 10.0, allowFuzzyMatch: true))
27+
}
28+
29+
// Name - high weight, strip namespace for better matching
30+
let simplifiedName = name.components(separatedBy: "/").last ?? name
31+
fields.append(SearchableField(content: simplifiedName, weight: 8.0, allowFuzzyMatch: true))
32+
33+
// Keywords - medium weight
34+
if let keywords = keywords {
35+
keywords.forEach { keyword in
36+
fields.append(SearchableField( content: keyword, weight: 5.0, allowFuzzyMatch: true))
37+
}
38+
}
39+
40+
// Description - lower weight, no fuzzy matching
41+
if let description = description {
42+
fields.append(SearchableField(content: description, weight: 3.0, allowFuzzyMatch: false))
43+
}
44+
45+
// Category - lowest weight
46+
if let category = category {
47+
fields.append(SearchableField(content: category, weight: 2.0, allowFuzzyMatch: true))
48+
}
49+
50+
return fields
51+
}
52+
}

ios/Sources/GutenbergKit/Sources/EditorViewController.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
241241

242242
private func showBlockInserter(blocks: [EditorBlock]) {
243243
present(UIHostingController(rootView: NavigationView {
244-
List(blocks) {
245-
Text($0.name)
244+
BlockInserterView(blocks: blocks) {
245+
print("did select:", $0)
246246
}
247247
}), animated: true)
248248
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Foundation
2+
import SVGKit
3+
4+
@MainActor
5+
final class BlockIconCache: ObservableObject {
6+
var icons: [String: Result<SVGKImage, Error>] = [:]
7+
8+
func getIcon(for block: EditorBlock) -> SVGKImage? {
9+
if let result = icons[block.id] {
10+
return try? result.get()
11+
}
12+
let result = Result { try _getIcon(for: block) }
13+
icons[block.id] = result
14+
return try? result.get()
15+
}
16+
17+
private func _getIcon(for block: EditorBlock) throws -> SVGKImage {
18+
guard let svg = block.icon,
19+
!svg.isEmpty,
20+
let source = SVGKSourceString.source(fromContentsOf: svg),
21+
let image = SVGKImage(source: source) else {
22+
throw BlockIconCacheError.unknown
23+
}
24+
if let result = image.parseErrorsAndWarnings,
25+
let error = result.errorsFatal.firstObject {
26+
#if DEBUG
27+
debugPrint("failed to parse SVG for block: \(block.name) with errors: \(String(describing: result.errorsFatal))")
28+
#endif
29+
throw (error as? Error) ?? BlockIconCacheError.unknown
30+
}
31+
return image
32+
}
33+
}
34+
35+
private enum BlockIconCacheError: Error {
36+
case unknown
37+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import Foundation
2+
3+
/// A protocol for items that can be searched
4+
protocol Searchable {
5+
/// Extract searchable fields with their weights
6+
func searchableFields() -> [SearchableField]
7+
}
8+
9+
/// A field that can be searched with an associated weight
10+
struct SearchableField {
11+
let content: String
12+
let weight: Double
13+
let allowFuzzyMatch: Bool
14+
15+
init(content: String, weight: Double, allowFuzzyMatch: Bool = true) {
16+
self.content = content
17+
self.weight = weight
18+
self.allowFuzzyMatch = allowFuzzyMatch
19+
}
20+
}
21+
22+
/// Configuration for the search engine
23+
struct SearchConfiguration {
24+
/// Maximum allowed edit distance for fuzzy matching
25+
let maxEditDistance: Int
26+
27+
/// Minimum similarity threshold (0-1) for fuzzy matches
28+
let minSimilarityThreshold: Double
29+
30+
/// Multiplier for exact matches
31+
let exactMatchMultiplier: Double
32+
33+
/// Multiplier for prefix matches
34+
let prefixMatchMultiplier: Double
35+
36+
/// Multiplier for word prefix matches
37+
let wordPrefixMatchMultiplier: Double
38+
39+
/// Multiplier for fuzzy matches (applied to similarity score)
40+
let fuzzyMatchMultiplier: Double
41+
42+
static let `default` = SearchConfiguration(
43+
maxEditDistance: 2,
44+
minSimilarityThreshold: 0.7,
45+
exactMatchMultiplier: 2.0,
46+
prefixMatchMultiplier: 1.5,
47+
wordPrefixMatchMultiplier: 0.8,
48+
fuzzyMatchMultiplier: 0.6
49+
)
50+
}
51+
52+
/// A generic search engine that performs weighted fuzzy search
53+
struct SearchEngine<Item: Searchable> {
54+
55+
/// Search result with relevance score
56+
struct SearchResult {
57+
let item: Item
58+
let score: Double
59+
}
60+
61+
let configuration: SearchConfiguration
62+
63+
init(configuration: SearchConfiguration = .default) {
64+
self.configuration = configuration
65+
}
66+
67+
/// Search items with weighted fuzzy matching
68+
func search(query: String, in items: [Item]) -> [Item] {
69+
let normalizedQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
70+
71+
// Empty query returns all items
72+
guard !normalizedQuery.isEmpty else {
73+
return items
74+
}
75+
76+
// Calculate scores for all items
77+
let results: [SearchResult] = items.compactMap { item in
78+
let score = calculateScore(for: item, query: normalizedQuery)
79+
return score > 0 ? SearchResult(item: item, score: score) : nil
80+
}
81+
82+
// Sort by score (highest first) and return items
83+
return results
84+
.sorted { $0.score > $1.score }
85+
.map { $0.item }
86+
}
87+
88+
/// Calculate weighted score for an item based on query match
89+
private func calculateScore(for item: Item, query: String) -> Double {
90+
let fields = item.searchableFields()
91+
92+
return fields.reduce(0.0) { totalScore, field in
93+
totalScore + calculateFieldScore(
94+
field: field.content.lowercased(),
95+
query: query,
96+
weight: field.weight,
97+
allowFuzzy: field.allowFuzzyMatch
98+
)
99+
}
100+
}
101+
102+
/// Calculate score for a single field
103+
private func calculateFieldScore(field: String, query: String, weight: Double, allowFuzzy: Bool) -> Double {
104+
// Exact match
105+
if field == query {
106+
return weight * configuration.exactMatchMultiplier
107+
}
108+
109+
// Contains match
110+
if field.contains(query) {
111+
// Higher score if it starts with the query
112+
if field.hasPrefix(query) {
113+
return weight * configuration.prefixMatchMultiplier
114+
}
115+
return weight
116+
}
117+
118+
// Fuzzy match if allowed
119+
if allowFuzzy {
120+
// Check each word in the field
121+
let fieldWords = field.split(separator: " ").map(String.init)
122+
for word in fieldWords {
123+
// Word starts with query
124+
if word.hasPrefix(query) {
125+
return weight * configuration.wordPrefixMatchMultiplier
126+
}
127+
128+
// Calculate similarity
129+
let similarity = calculateSimilarity(word, query)
130+
if similarity >= configuration.minSimilarityThreshold {
131+
return weight * similarity * configuration.fuzzyMatchMultiplier
132+
}
133+
}
134+
135+
// Try full field fuzzy match for short queries
136+
if query.count <= 10 {
137+
let similarity = calculateSimilarity(field, query)
138+
if similarity >= configuration.minSimilarityThreshold {
139+
return weight * similarity * configuration.fuzzyMatchMultiplier * 0.7
140+
}
141+
}
142+
}
143+
144+
return 0
145+
}
146+
147+
/// Calculate similarity between two strings using normalized edit distance
148+
private func calculateSimilarity(_ str1: String, _ str2: String) -> Double {
149+
let distance = levenshteinDistance(str1, str2)
150+
let maxLength = max(str1.count, str2.count)
151+
152+
// Don't allow too many edits relative to string length
153+
if distance > min(configuration.maxEditDistance, maxLength / 3) {
154+
return 0
155+
}
156+
157+
return 1.0 - (Double(distance) / Double(maxLength))
158+
}
159+
160+
/// Calculate Levenshtein edit distance between two strings
161+
private func levenshteinDistance(_ str1: String, _ str2: String) -> Int {
162+
let str1Array = Array(str1)
163+
let str2Array = Array(str2)
164+
165+
// Create matrix
166+
var matrix = Array(repeating: Array(repeating: 0, count: str2Array.count + 1), count: str1Array.count + 1)
167+
168+
// Initialize first row and column
169+
for i in 0...str1Array.count {
170+
matrix[i][0] = i
171+
}
172+
for j in 0...str2Array.count {
173+
matrix[0][j] = j
174+
}
175+
176+
// Fill matrix
177+
for i in 1...str1Array.count {
178+
for j in 1...str2Array.count {
179+
let cost = str1Array[i-1] == str2Array[j-1] ? 0 : 1
180+
matrix[i][j] = min(
181+
matrix[i-1][j] + 1, // deletion
182+
matrix[i][j-1] + 1, // insertion
183+
matrix[i-1][j-1] + cost // substitution
184+
)
185+
}
186+
}
187+
188+
return matrix[str1Array.count][str2Array.count]
189+
}
190+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import SwiftUI
2+
3+
struct CardModifier: ViewModifier {
4+
func body(content: Content) -> some View {
5+
content
6+
.background(Color(.systemBackground))
7+
.overlay(
8+
RoundedRectangle(cornerRadius: 26)
9+
.stroke(Color(.opaqueSeparator), lineWidth: 0.5)
10+
)
11+
.clipShape(RoundedRectangle(cornerRadius: 26))
12+
}
13+
}
14+
15+
extension View {
16+
func cardStyle() -> some View {
17+
modifier(CardModifier())
18+
}
19+
}

0 commit comments

Comments
 (0)