Skip to content

Commit 24d30fa

Browse files
[ENG-1224 & ENG-343] Replaced fuzzy with minisearch in Discourse Summoning Menu (#694)
* replaced fuzzy with minisearch * some sm changes * limit items * avoid duplicates in infex * Update apps/roam/src/components/DiscourseNodeSearchMenu.tsx bound search result when search term is empty Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * fix lint --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 57c708b commit 24d30fa

3 files changed

Lines changed: 252 additions & 89 deletions

File tree

apps/roam/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"file-saver": "2.0.2",
6464
"fuzzy": "^0.1.3",
6565
"lodash.isequal": "^4.5.0",
66+
"minisearch": "^7.2.0",
6667
"nanoid": "2.0.4",
6768
"posthog-js": "catalog:",
6869
"react-charts": "^3.0.0-beta.48",
@@ -79,4 +80,4 @@
7980
"react": "catalog:roam",
8081
"react-dom": "catalog:roam"
8182
}
82-
}
83+
}

apps/roam/src/components/DiscourseNodeSearchMenu.tsx

Lines changed: 173 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes";
2525
import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression";
2626
import { Result } from "~/utils/types";
2727
import { getSetting } from "~/utils/extensionSettings";
28-
import fuzzy from "fuzzy";
28+
import MiniSearch from "minisearch";
2929

3030
type Props = {
3131
textarea: HTMLTextAreaElement;
@@ -34,6 +34,13 @@ type Props = {
3434
triggerText: string;
3535
};
3636

37+
type MinisearchResult = Result & {
38+
type: string;
39+
};
40+
41+
const MIN_SEARCH_SCORE = 0.1;
42+
const MAX_ITEMS_PER_TYPE = 10;
43+
3744
const waitForBlock = ({
3845
uid,
3946
text,
@@ -76,7 +83,6 @@ const NodeSearchMenu = ({
7683
const [discourseTypes, setDiscourseTypes] = useState<DiscourseNode[]>([]);
7784
const [checkedTypes, setCheckedTypes] = useState<Record<string, boolean>>({});
7885
const [isLoading, setIsLoading] = useState(true);
79-
const [allNodes, setAllNodes] = useState<Record<string, Result[]>>({});
8086
const [searchResults, setSearchResults] = useState<Record<string, Result[]>>(
8187
{},
8288
);
@@ -90,7 +96,8 @@ const NodeSearchMenu = ({
9096
[typeIds, checkedTypes],
9197
);
9298
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
93-
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
99+
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
100+
const miniSearchRef = useRef<MiniSearch<MinisearchResult> | null>(null);
94101
const POPOVER_TOP_OFFSET = 30;
95102

96103
const debouncedSearchTerm = useCallback((term: string) => {
@@ -135,16 +142,79 @@ const NodeSearchMenu = ({
135142
}
136143
};
137144

138-
const filterNodesLocally = useCallback(
139-
(nodes: Result[], searchTerm: string): Result[] => {
140-
if (!searchTerm.trim()) return nodes;
145+
const searchWithMiniSearch = useCallback(
146+
(searchTerm: string, typeFilter?: string[]): Record<string, Result[]> => {
147+
if (!miniSearchRef.current) {
148+
return {};
149+
}
141150

142-
return fuzzy
143-
.filter(searchTerm, nodes, {
144-
extract: (node) => node.text,
145-
})
146-
.map((result) => result.original)
147-
.filter((node): node is Result => !!node);
151+
const search = miniSearchRef.current;
152+
153+
if (!searchTerm.trim()) {
154+
if (!typeFilter) {
155+
return {};
156+
}
157+
158+
const allResults: Record<string, Result[]> = {};
159+
typeFilter.forEach((type) => {
160+
const results = (
161+
search.search(MiniSearch.wildcard, {
162+
filter: (result) =>
163+
(result as unknown as MinisearchResult).type === type,
164+
}) as unknown as MinisearchResult[]
165+
)
166+
.slice(0, MAX_ITEMS_PER_TYPE)
167+
.map((r) => ({
168+
text: r.text,
169+
uid: r.uid,
170+
}));
171+
allResults[type] = results;
172+
});
173+
174+
return allResults;
175+
}
176+
177+
const rawSearchResults = search.search(searchTerm, {
178+
fields: ["text"],
179+
fuzzy: 0.2,
180+
prefix: true,
181+
combineWith: "AND",
182+
filter: typeFilter
183+
? (result) =>
184+
typeFilter.includes((result as unknown as MinisearchResult).type)
185+
: undefined,
186+
});
187+
188+
const filteredResults = rawSearchResults.filter(
189+
(r) => r.score > MIN_SEARCH_SCORE,
190+
);
191+
192+
const searchResults = (
193+
filteredResults as unknown as MinisearchResult[]
194+
).map((r) => ({
195+
text: r.text,
196+
uid: r.uid,
197+
type: r.type,
198+
}));
199+
200+
const results = searchResults.reduce(
201+
(acc, result) => {
202+
if (!acc[result.type]) {
203+
acc[result.type] = [];
204+
}
205+
if (acc[result.type].length < MAX_ITEMS_PER_TYPE) {
206+
acc[result.type].push({
207+
id: result.uid,
208+
text: result.text,
209+
uid: result.uid,
210+
});
211+
}
212+
return acc;
213+
},
214+
{} as Record<string, Result[]>,
215+
);
216+
217+
return results;
148218
},
149219
[],
150220
);
@@ -169,7 +239,30 @@ const NodeSearchMenu = ({
169239
allNodeTypes.forEach((type) => {
170240
allNodesCache[type.type] = searchNodesForType(type);
171241
});
172-
setAllNodes(allNodesCache);
242+
243+
const miniSearch = new MiniSearch<MinisearchResult>({
244+
fields: ["text"],
245+
storeFields: ["text", "uid", "type"],
246+
idField: "uid",
247+
});
248+
249+
const documentsToIndex: MinisearchResult[] = [];
250+
const seenUids = new Set<string>();
251+
252+
allNodeTypes.forEach((type) => {
253+
const nodes = allNodesCache[type.type] || [];
254+
nodes.forEach((node) => {
255+
if (seenUids.has(node.uid)) return;
256+
seenUids.add(node.uid);
257+
documentsToIndex.push({
258+
...node,
259+
type: type.type,
260+
});
261+
});
262+
});
263+
264+
miniSearch.addAll(documentsToIndex);
265+
miniSearchRef.current = miniSearch;
173266

174267
const initialSearchResults = Object.fromEntries(
175268
allNodeTypes.map((type) => [type.type, []]),
@@ -183,17 +276,21 @@ const NodeSearchMenu = ({
183276
}, []);
184277

185278
useEffect(() => {
186-
if (isLoading || Object.keys(allNodes).length === 0) return;
279+
if (isLoading || !miniSearchRef.current) return;
187280

188-
const newResults: Record<string, Result[]> = {};
189-
190-
discourseTypes.forEach((type) => {
191-
const cachedNodes = allNodes[type.type] || [];
192-
newResults[type.type] = filterNodesLocally(cachedNodes, searchTerm);
193-
});
281+
const selectedTypes = discourseTypes
282+
.filter((type) => checkedTypes[type.type])
283+
.map((type) => type.type);
194284

285+
const newResults = searchWithMiniSearch(searchTerm, selectedTypes);
195286
setSearchResults(newResults);
196-
}, [searchTerm, isLoading, allNodes, discourseTypes, filterNodesLocally]);
287+
}, [
288+
searchTerm,
289+
isLoading,
290+
discourseTypes,
291+
checkedTypes,
292+
searchWithMiniSearch,
293+
]);
197294

198295
const menuRef = useRef<HTMLUListElement>(null);
199296
const { ["block-uid"]: blockUid, ["window-id"]: windowId } = useMemo(
@@ -232,61 +329,61 @@ const NodeSearchMenu = ({
232329

233330
const onSelect = useCallback(
234331
(item: Result) => {
235-
if (!blockUid) {
236-
onClose();
237-
return;
238-
}
239-
void waitForBlock({ uid: blockUid, text: textarea.value })
240-
.then(() => {
241-
onClose();
242-
243-
setTimeout(() => {
244-
const originalText = getTextByBlockUid(blockUid);
245-
246-
const prefix = originalText.substring(0, triggerPosition);
247-
const suffix = originalText.substring(textarea.selectionStart);
248-
const pageRef = `[[${item.text}]]`;
249-
250-
const newText = `${prefix}${pageRef}${suffix}`;
251-
void updateBlock({ uid: blockUid, text: newText }).then(() => {
252-
const newCursorPosition = triggerPosition + pageRef.length;
253-
254-
if (window.roamAlphaAPI.ui.setBlockFocusAndSelection) {
255-
void window.roamAlphaAPI.ui.setBlockFocusAndSelection({
256-
location: {
257-
// eslint-disable-next-line @typescript-eslint/naming-convention
258-
"block-uid": blockUid,
259-
// eslint-disable-next-line @typescript-eslint/naming-convention
260-
"window-id": windowId,
261-
},
262-
selection: { start: newCursorPosition },
263-
});
264-
} else {
265-
setTimeout(() => {
266-
const textareaElements =
267-
document.querySelectorAll("textarea");
268-
for (const el of textareaElements) {
269-
if (getUids(el).blockUid === blockUid) {
270-
el.focus();
271-
el.setSelectionRange(
272-
newCursorPosition,
273-
newCursorPosition,
274-
);
275-
break;
276-
}
277-
}
278-
}, 50);
279-
}
280-
});
281-
posthog.capture("Discourse Node: Selected from Search Menu", {
282-
id: item.id,
283-
text: item.text,
284-
});
285-
}, 10);
286-
})
287-
.catch((error) => {
288-
console.error("Error waiting for block:", error);
289-
});
332+
if (!blockUid) {
333+
onClose();
334+
return;
335+
}
336+
void waitForBlock({ uid: blockUid, text: textarea.value })
337+
.then(() => {
338+
onClose();
339+
340+
setTimeout(() => {
341+
const originalText = getTextByBlockUid(blockUid);
342+
343+
const prefix = originalText.substring(0, triggerPosition);
344+
const suffix = originalText.substring(textarea.selectionStart);
345+
const pageRef = `[[${item.text}]]`;
346+
347+
const newText = `${prefix}${pageRef}${suffix}`;
348+
void updateBlock({ uid: blockUid, text: newText }).then(() => {
349+
const newCursorPosition = triggerPosition + pageRef.length;
350+
351+
if (window.roamAlphaAPI.ui.setBlockFocusAndSelection) {
352+
void window.roamAlphaAPI.ui.setBlockFocusAndSelection({
353+
location: {
354+
// eslint-disable-next-line @typescript-eslint/naming-convention
355+
"block-uid": blockUid,
356+
// eslint-disable-next-line @typescript-eslint/naming-convention
357+
"window-id": windowId,
358+
},
359+
selection: { start: newCursorPosition },
360+
});
361+
} else {
362+
setTimeout(() => {
363+
const textareaElements =
364+
document.querySelectorAll("textarea");
365+
for (const el of textareaElements) {
366+
if (getUids(el).blockUid === blockUid) {
367+
el.focus();
368+
el.setSelectionRange(
369+
newCursorPosition,
370+
newCursorPosition,
371+
);
372+
break;
373+
}
374+
}
375+
}, 50);
376+
}
377+
});
378+
posthog.capture("Discourse Node: Selected from Search Menu", {
379+
id: item.id,
380+
text: item.text,
381+
});
382+
}, 10);
383+
})
384+
.catch((error) => {
385+
console.error("Error waiting for block:", error);
386+
});
290387
},
291388
[blockUid, onClose, textarea, triggerPosition, windowId],
292389
);

0 commit comments

Comments
 (0)