返回 presentation-ai
presentation-icon-utils.ts
根目录 / src / components / notebook / presentation / editor / custom-elements / presentation-icon-utils.ts
1 "use client";
2
3 import { type IconType } from "react-icons";
4
5 type IconModule = Record<string, IconType>;
6
7 type IconLibraryKey =
8 | "fa"
9 | "fi"
10 | "ai"
11 | "bs"
12 | "bi"
13 | "gi"
14 | "hi"
15 | "im"
16 | "io"
17 | "md"
18 | "ri"
19 | "si"
20 | "ti"
21 | "vsc"
22 | "wi";
23
24 type SearchableIcon = {
25 Component: IconType;
26 name: string;
27 normalizedKey: string;
28 normalizedLabel: string;
29 };
30
31 export type ResolvedPresentationIcon = {
32 Component: IconType;
33 name: string;
34 };
35
36 export const DEFAULT_PRESENTATION_ICON = "FaHome";
37
38 const ICON_LIBRARY_LOADERS: Record<IconLibraryKey, () => Promise<IconModule>> =
39 {
40 fa: async () => (await import("react-icons/fa")) as unknown as IconModule,
41 fi: async () => (await import("react-icons/fi")) as unknown as IconModule,
42 ai: async () => (await import("react-icons/ai")) as unknown as IconModule,
43 bs: async () => (await import("react-icons/bs")) as unknown as IconModule,
44 bi: async () => (await import("react-icons/bi")) as unknown as IconModule,
45 gi: async () => (await import("react-icons/gi")) as unknown as IconModule,
46 hi: async () => (await import("react-icons/hi")) as unknown as IconModule,
47 im: async () => (await import("react-icons/im")) as unknown as IconModule,
48 io: async () => (await import("react-icons/io")) as unknown as IconModule,
49 md: async () => (await import("react-icons/md")) as unknown as IconModule,
50 ri: async () => (await import("react-icons/ri")) as unknown as IconModule,
51 si: async () => (await import("react-icons/si")) as unknown as IconModule,
52 ti: async () => (await import("react-icons/ti")) as unknown as IconModule,
53 vsc: async () => (await import("react-icons/vsc")) as unknown as IconModule,
54 wi: async () => (await import("react-icons/wi")) as unknown as IconModule,
55 };
56
57 const ICON_LIBRARY_PREFIXES: ReadonlyArray<readonly [string, IconLibraryKey]> =
58 [
59 ["vsc", "vsc"],
60 ["fa", "fa"],
61 ["fi", "fi"],
62 ["ai", "ai"],
63 ["bs", "bs"],
64 ["bi", "bi"],
65 ["gi", "gi"],
66 ["hi", "hi"],
67 ["im", "im"],
68 ["io", "io"],
69 ["md", "md"],
70 ["ri", "ri"],
71 ["si", "si"],
72 ["ti", "ti"],
73 ["wi", "wi"],
74 ];
75
76 const ICON_SEARCH_ORDER: ReadonlyArray<IconLibraryKey> = [
77 "fa",
78 "fi",
79 "ai",
80 "bs",
81 "bi",
82 "md",
83 "ri",
84 "si",
85 "gi",
86 "hi",
87 "im",
88 "io",
89 "ti",
90 "vsc",
91 "wi",
92 ];
93
94 const POPULAR_ICON_NAMES: ReadonlyArray<string> = [
95 "FaHome",
96 "FaUser",
97 "FaCog",
98 "FaSearch",
99 "FaBell",
100 "FaCalendar",
101 "FaEnvelope",
102 "FaHeart",
103 "FaStar",
104 "FaBookmark",
105 "FaCheck",
106 "FaTimes",
107 "FaEdit",
108 "FaTrash",
109 "FaDownload",
110 "FaUpload",
111 "FaShare",
112 "FaLink",
113 "FaMapMarker",
114 "FaClock",
115 "FaCamera",
116 "FaVideo",
117 "FaMusic",
118 "FaFile",
119 "FaFolder",
120 "FaComments",
121 "FaThumbsUp",
122 "FaPhone",
123 "FaLock",
124 "FaUserPlus",
125 ];
126
127 const iconModuleCache = new Map<IconLibraryKey, Promise<IconModule>>();
128 const resolvedIconCache = new Map<
129 string,
130 Promise<ResolvedPresentationIcon | null>
131 >();
132 let iconSearchIndexPromise: Promise<SearchableIcon[]> | null = null;
133
134 function normalizeWhitespace(value: string) {
135 return value.trim().replace(/\s+/g, " ");
136 }
137
138 function stripLibraryPrefix(value: string) {
139 const normalized = value.trim();
140 const lowerValue = normalized.toLowerCase();
141
142 for (const [prefix] of ICON_LIBRARY_PREFIXES) {
143 if (lowerValue.startsWith(prefix)) {
144 return normalized.slice(prefix.length);
145 }
146 }
147
148 return normalized;
149 }
150
151 function normalizeForSearch(value: string) {
152 return stripLibraryPrefix(value)
153 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
154 .replace(/[_-]+/g, " ")
155 .replace(/[^a-zA-Z0-9 ]+/g, " ")
156 .toLowerCase()
157 .replace(/\s+/g, " ")
158 .trim();
159 }
160
161 function tokenizeSearchValue(value: string) {
162 return normalizeForSearch(value).split(" ").filter(Boolean);
163 }
164
165 function getIconLibraryHint(iconName: string) {
166 const normalizedName = iconName.trim().toLowerCase();
167
168 for (const [prefix, libraryKey] of ICON_LIBRARY_PREFIXES) {
169 if (normalizedName.startsWith(prefix)) {
170 return libraryKey;
171 }
172 }
173
174 return undefined;
175 }
176
177 async function loadIconModule(libraryKey: IconLibraryKey) {
178 const cachedModule = iconModuleCache.get(libraryKey);
179
180 if (cachedModule) {
181 return cachedModule;
182 }
183
184 const nextModulePromise = ICON_LIBRARY_LOADERS[libraryKey]();
185 iconModuleCache.set(libraryKey, nextModulePromise);
186 return nextModulePromise;
187 }
188
189 async function buildIconSearchIndex() {
190 const modules = await Promise.all(
191 ICON_SEARCH_ORDER.map(async (libraryKey) => ({
192 libraryKey,
193 module: await loadIconModule(libraryKey),
194 })),
195 );
196
197 return modules.flatMap(({ module }) =>
198 Object.entries(module).map(([name, Component]) => ({
199 Component,
200 name,
201 normalizedKey: name.toLowerCase(),
202 normalizedLabel: normalizeForSearch(name),
203 })),
204 );
205 }
206
207 async function getIconSearchIndex() {
208 iconSearchIndexPromise ??= buildIconSearchIndex();
209 return iconSearchIndexPromise;
210 }
211
212 function scoreIconMatch(query: string, icon: SearchableIcon) {
213 const normalizedQuery = normalizeForSearch(query);
214
215 if (!normalizedQuery) {
216 return 0;
217 }
218
219 if (icon.normalizedKey === query.toLowerCase()) {
220 return 1000;
221 }
222
223 if (icon.normalizedLabel === normalizedQuery) {
224 return 900;
225 }
226
227 if (icon.normalizedKey.startsWith(query.toLowerCase())) {
228 return 850;
229 }
230
231 if (icon.normalizedLabel.startsWith(normalizedQuery)) {
232 return 800;
233 }
234
235 const queryTokens = tokenizeSearchValue(query);
236 const labelTokens = tokenizeSearchValue(icon.name);
237
238 if (
239 queryTokens.length > 0 &&
240 queryTokens.every((token) =>
241 labelTokens.some((labelToken) => labelToken.includes(token)),
242 )
243 ) {
244 return 700 - Math.max(labelTokens.length - queryTokens.length, 0);
245 }
246
247 if (icon.normalizedKey.includes(query.toLowerCase())) {
248 return 500;
249 }
250
251 if (icon.normalizedLabel.includes(normalizedQuery)) {
252 return 450;
253 }
254
255 return 0;
256 }
257
258 async function findExactIconByName(iconName: string) {
259 const hintedLibrary = getIconLibraryHint(iconName);
260 const searchOrder = hintedLibrary
261 ? [
262 hintedLibrary,
263 ...ICON_SEARCH_ORDER.filter((key) => key !== hintedLibrary),
264 ]
265 : [...ICON_SEARCH_ORDER];
266
267 for (const libraryKey of searchOrder) {
268 const iconModule = await loadIconModule(libraryKey);
269 const exactIcon = iconModule[iconName];
270
271 if (exactIcon) {
272 return {
273 name: iconName,
274 Component: exactIcon,
275 } satisfies ResolvedPresentationIcon;
276 }
277
278 const normalizedName = iconName.toLowerCase();
279 const matchingName = Object.keys(iconModule).find(
280 (candidateName) => candidateName.toLowerCase() === normalizedName,
281 );
282
283 if (matchingName) {
284 return {
285 name: matchingName,
286 Component: iconModule[matchingName]!,
287 } satisfies ResolvedPresentationIcon;
288 }
289 }
290
291 return null;
292 }
293
294 export async function resolvePresentationIcon(iconName: string) {
295 const normalizedName = normalizeWhitespace(iconName);
296
297 if (!normalizedName) {
298 return null;
299 }
300
301 const cachedIcon = resolvedIconCache.get(normalizedName);
302 if (cachedIcon) return cachedIcon;
303
304 const iconPromise = (async () => {
305 const exactMatch = await findExactIconByName(normalizedName);
306
307 if (exactMatch) {
308 return exactMatch;
309 }
310
311 const fuzzyMatches = await searchPresentationIcons(normalizedName, 1);
312 return fuzzyMatches[0] ?? null;
313 })().catch((error: unknown) => {
314 resolvedIconCache.delete(normalizedName);
315 throw error;
316 });
317
318 resolvedIconCache.set(normalizedName, iconPromise);
319 return iconPromise;
320 }
321
322 export async function searchPresentationIcons(
323 query: string,
324 limit = 60,
325 ): Promise<ResolvedPresentationIcon[]> {
326 const normalizedQuery = normalizeWhitespace(query);
327
328 if (!normalizedQuery) {
329 return getPopularPresentationIcons(limit);
330 }
331
332 const iconSearchIndex = await getIconSearchIndex();
333 const dedupedMatches = new Map<
334 string,
335 { icon: ResolvedPresentationIcon; score: number }
336 >();
337
338 for (const icon of iconSearchIndex) {
339 const score = scoreIconMatch(normalizedQuery, icon);
340
341 if (score <= 0) {
342 continue;
343 }
344
345 const existingMatch = dedupedMatches.get(icon.name);
346
347 if (!existingMatch || score > existingMatch.score) {
348 dedupedMatches.set(icon.name, {
349 score,
350 icon: {
351 name: icon.name,
352 Component: icon.Component,
353 },
354 });
355 }
356 }
357
358 return [...dedupedMatches.values()]
359 .sort((left, right) => {
360 if (right.score !== left.score) {
361 return right.score - left.score;
362 }
363
364 return left.icon.name.localeCompare(right.icon.name);
365 })
366 .slice(0, limit)
367 .map(({ icon }) => icon);
368 }
369
370 export async function getPopularPresentationIcons(limit = 30) {
371 const popularIcons = await Promise.all(
372 POPULAR_ICON_NAMES.map((iconName) => resolvePresentationIcon(iconName)),
373 );
374
375 return popularIcons
376 .filter((icon): icon is ResolvedPresentationIcon => icon !== null)
377 .slice(0, limit);
378 }
379
379 lines TYPESCRIPT