PageSourceSearch

https://railsblocks.com/assets/controllers/autocomplete_fuzzy_sear…4df9faf8f3cbbae4773ba34efb39e5129b2768fe.js

js railsblocks.com collected 2026-09-25 20:22:43 UTC 4,976 bytes, 143 lines download raw bytes

1import { BaseAutocompleteExtension } from "controllers/autocomplete_controller";
2
3export default class extends BaseAutocompleteExtension {
4  static values = {
5    // This controller is specifically for fuzzy search functionality
6    // If you don't need fuzzy search, don't add this controller
7    fuzzyThreshold: { type: Number, default: 0.1 }, // Minimum score for fuzzy matches (0-1)
8  };
9
10  initialize() {
11    // Check if autocomplete controller is available before proceeding
12    if (!this.autocompleteController) {
13      return;
14    }
15
16    // Override the searchLocalData method for fuzzy search
17    this.overrideMethod("searchLocalData", this.searchLocalDataWithFuzzy);
18
19    // Override the highlightText method for fuzzy highlighting
20    this.overrideMethod("highlightText", this.highlightTextWithFuzzy);
21  }
22
23  searchLocalDataWithFuzzy(query) {
24    const currentData = this.autocompleteController.localDataValue;
25    const suggestions = this.fuzzyFilter(currentData, query);
26    this.autocompleteController.displaySuggestions(suggestions, query);
27  }
28
29  highlightTextWithFuzzy(text, query) {
30    // Fuzzy highlighting - highlight matching characters
31    return this.highlightFuzzyMatch(text, query);
32  }
33
34  // Fuzzy search implementation
35  fuzzyFilter(items, query) {
36    const scored = items
37      .map((item) => {
38        const text = typeof item === "string" ? item : item.title || item.name || item.label;
39        const score = this.fuzzyScore(text.toLowerCase(), query.toLowerCase());
40        return { item, score, text };
41      })
42      .filter((result) => result.score > this.fuzzyThresholdValue) // Filter out items with very low scores
43      .sort((a, b) => b.score - a.score)
44      .slice(0, this.autocompleteController.maxSuggestionsValue);
45
46    return scored.map((result) => result.item);
47  }
48
49  fuzzyScore(text, query) {
50    // Perfect match
51    if (text === query) return 1.0;
52
53    // Contains exact query
54    if (text.includes(query)) return 0.9;
55
56    // Normalize: remove spaces and convert to lowercase for better matching
57    const normalizedText = text.replace(/\s+/g, "").toLowerCase();
58    const normalizedQuery = query.replace(/\s+/g, "").toLowerCase();
59
60    // Check if normalized query is contained in normalized text
61    if (normalizedText.includes(normalizedQuery)) return 0.85;
62
63    const textLen = normalizedText.length;
64    const queryLen = normalizedQuery.length;
65
66    if (queryLen === 0) return 1.0;
67    if (queryLen > textLen) return 0.0;
68
69    // Check for subsequence match with improved scoring
70    let textIndex = 0;
71    let queryIndex = 0;
72    let matches = 0;
73    let consecutiveMatches = 0;
74    let maxConsecutive = 0;
75    let firstMatchIndex = -1;
76    let lastMatchIndex = -1;
77
78    while (textIndex < textLen && queryIndex < queryLen) {
79      if (normalizedText[textIndex] === normalizedQuery[queryIndex]) {
80        if (firstMatchIndex === -1) firstMatchIndex = textIndex;
81        lastMatchIndex = textIndex;
82        matches++;
83        consecutiveMatches++;
84        maxConsecutive = Math.max(maxConsecutive, consecutiveMatches);
85        queryIndex++;
86      } else {
87        consecutiveMatches = 0;
88      }
89      textIndex++;
90    }
91
92    // If not all characters were matched, return 0
93    if (queryIndex < queryLen) return 0.0;
94
95    // Calculate score with multiple factors
96    const matchRatio = matches / queryLen;
97    const consecutiveBonus = (maxConsecutive / queryLen) * 0.3;
98
99    // Bonus for matches at the beginning of the text
100    const startBonus = firstMatchIndex === 0 ? 0.2 : 0;
101
102    // Penalty for spread out matches (compactness bonus)
103    const matchSpread = lastMatchIndex - firstMatchIndex + 1;
104    const compactBonus = Math.max(0, (queryLen - matchSpread + queryLen) / (textLen * 2)) * 0.2;
105
106    // Small penalty for length difference
107    const lengthPenalty = Math.abs(queryLen - textLen) / (textLen * 10);
108
109    return Math.max(0, Math.min(1.0, matchRatio + consecutiveBonus + startBonus + compactBonus - lengthPenalty));
110  }
111
112  highlightFuzzyMatch(text, query) {
113    const lowerText = text.toLowerCase();
114    // Remove spaces from query for fuzzy matching
115    const normalizedQuery = query.replace(/\s+/g, "").toLowerCase();
116    const result = [];
117    let textIndex = 0;
118    let queryIndex = 0;
119
120    while (textIndex < text.length) {
121      const currentChar = lowerText[textIndex];
122
123      // Skip spaces in the original text when matching, but include them in output
124      if (currentChar === " ") {
125        result.push(text[textIndex]);
126        textIndex++;
127        continue;
128      }
129
130      if (queryIndex < normalizedQuery.length && currentChar === normalizedQuery[queryIndex]) {
131        // This character matches the query
132        result.push(`<mark class="bg-yellow-200 dark:bg-yellow-400 font-medium">${text[textIndex]}</mark>`);
133        queryIndex++;
134      } else {
135        // Regular character
136        result.push(text[textIndex]);
137      }
138      textIndex++;
139    }
140
141    return result.join("");
142  }
143};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.