Skip to content

Commit c54fbe4

Browse files
authored
experimental: fuzzy search tokens (#4396)
Ref #1696 Switch to match sorter for more control over results. Now it will try to match best token in the list of each option.
1 parent 50ca393 commit c54fbe4

File tree

5 files changed

+189
-90
lines changed

5 files changed

+189
-90
lines changed

apps/builder/app/builder/features/command-panel/command-panel.tsx

Lines changed: 179 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import {
77
} from "@webstudio-is/react-sdk";
88
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
99
import {
10-
Kbd,
11-
Text,
10+
Command,
1211
CommandDialog,
1312
CommandInput,
1413
CommandList,
@@ -18,9 +17,11 @@ import {
1817
CommandIcon,
1918
ScrollArea,
2019
Flex,
20+
Kbd,
21+
Text,
2122
} from "@webstudio-is/design-system";
2223
import { compareMedia } from "@webstudio-is/css-engine";
23-
import type { Breakpoint } from "@webstudio-is/sdk";
24+
import type { Breakpoint, Page } from "@webstudio-is/sdk";
2425
import {
2526
$breakpoints,
2627
$pages,
@@ -33,6 +34,9 @@ import { humanizeString } from "~/shared/string-utils";
3334
import { setCanvasWidth } from "~/builder/features/breakpoints";
3435
import { insert as insertComponent } from "~/builder/features/components/insert";
3536
import { $selectedPage, selectPage } from "~/shared/awareness";
37+
import { useState } from "react";
38+
import { matchSorter } from "match-sorter";
39+
import { mapGroupBy } from "~/shared/shim";
3640

3741
const $commandPanel = atom<
3842
| undefined
@@ -75,37 +79,57 @@ const getMetaScore = (meta: WsComponentMeta) => {
7579
return categoryScore * 1000 + componentScore;
7680
};
7781

78-
const $visibleMetas = computed(
82+
type ComponentOption = {
83+
tokens: string[];
84+
type: "component";
85+
component: string;
86+
label: string;
87+
meta: WsComponentMeta;
88+
};
89+
90+
const $componentOptions = computed(
7991
[$registeredComponentMetas, $selectedPage],
8092
(metas, selectedPage) => {
81-
const entries = Array.from(metas)
82-
.sort(
83-
([_leftComponent, leftMeta], [_rightComponent, rightMeta]) =>
84-
getMetaScore(leftMeta) - getMetaScore(rightMeta)
85-
)
86-
.filter(([component, meta]) => {
87-
const category = meta.category ?? "hidden";
88-
if (category === "hidden" || category === "internal") {
89-
return false;
90-
}
91-
// show only xml category and collection component in xml documents
92-
if (selectedPage?.meta.documentType === "xml") {
93-
return category === "xml" || component === collectionComponent;
93+
const componentOptions: ComponentOption[] = [];
94+
for (const [component, meta] of metas) {
95+
const category = meta.category ?? "hidden";
96+
if (category === "hidden" || category === "internal") {
97+
continue;
98+
}
99+
// show only xml category and collection component in xml documents
100+
if (selectedPage?.meta.documentType === "xml") {
101+
if (category !== "xml" && component !== collectionComponent) {
102+
continue;
94103
}
104+
} else {
95105
// show everything except xml category in html documents
96-
return category !== "xml";
106+
if (category === "xml") {
107+
continue;
108+
}
109+
}
110+
const label = getInstanceLabel({ component }, meta);
111+
componentOptions.push({
112+
tokens: ["components", label, category],
113+
type: "component",
114+
component,
115+
label,
116+
meta,
97117
});
98-
return new Map(entries);
118+
}
119+
componentOptions.sort(
120+
({ meta: leftMeta }, { meta: rightMeta }) =>
121+
getMetaScore(leftMeta) - getMetaScore(rightMeta)
122+
);
123+
return componentOptions;
99124
}
100125
);
101126

102-
const ComponentsGroup = () => {
103-
const metas = useStore($visibleMetas);
127+
const ComponentsGroup = ({ options }: { options: ComponentOption[] }) => {
104128
return (
105129
<CommandGroup
106130
heading={<CommandGroupHeading>Components</CommandGroupHeading>}
107131
>
108-
{Array.from(metas).map(([component, meta]) => {
132+
{options.map(({ component, label, meta }) => {
109133
return (
110134
<CommandItem
111135
key={component}
@@ -119,9 +143,9 @@ const ComponentsGroup = () => {
119143
dangerouslySetInnerHTML={{ __html: meta.icon }}
120144
></CommandIcon>
121145
<Text variant="labelsTitleCase">
122-
{getInstanceLabel({ component }, meta)}{" "}
146+
{label}{" "}
123147
<Text as="span" color="moreSubtle">
124-
({humanizeString(meta.category ?? "")})
148+
{humanizeString(meta.category ?? "")}
125149
</Text>
126150
</Text>
127151
</CommandItem>
@@ -131,6 +155,38 @@ const ComponentsGroup = () => {
131155
);
132156
};
133157

158+
type BreakpointOption = {
159+
tokens: string[];
160+
type: "breakpoint";
161+
breakpoint: Breakpoint;
162+
shortcut: string;
163+
};
164+
165+
const $breakpointOptions = computed(
166+
[$breakpoints, $selectedBreakpoint],
167+
(breakpoints, selectedBreakpoint) => {
168+
const sortedBreakpoints = Array.from(breakpoints.values()).sort(
169+
compareMedia
170+
);
171+
const breakpointOptions: BreakpointOption[] = [];
172+
for (let index = 0; index < sortedBreakpoints.length; index += 1) {
173+
const breakpoint = sortedBreakpoints[index];
174+
if (breakpoint.id === selectedBreakpoint?.id) {
175+
continue;
176+
}
177+
const width =
178+
(breakpoint.minWidth ?? breakpoint.maxWidth)?.toString() ?? "";
179+
breakpointOptions.push({
180+
tokens: ["breakpoints", breakpoint.label, width],
181+
type: "breakpoint",
182+
breakpoint,
183+
shortcut: (index + 1).toString(),
184+
});
185+
}
186+
return breakpointOptions;
187+
}
188+
);
189+
134190
const getBreakpointLabel = (breakpoint: Breakpoint) => {
135191
let label = "All Sizes";
136192
if (breakpoint.minWidth !== undefined) {
@@ -142,90 +198,133 @@ const getBreakpointLabel = (breakpoint: Breakpoint) => {
142198
return `${breakpoint.label}: ${label}`;
143199
};
144200

145-
const BreakpointsGroup = () => {
146-
const breakpoints = useStore($breakpoints);
147-
const sortedBreakpoints = Array.from(breakpoints.values()).sort(compareMedia);
148-
const selectedBreakpoint = useStore($selectedBreakpoint);
201+
const BreakpointsGroup = ({ options }: { options: BreakpointOption[] }) => {
149202
return (
150203
<CommandGroup
151204
heading={<CommandGroupHeading>Breakpoints</CommandGroupHeading>}
152205
>
153-
{sortedBreakpoints.map(
154-
(breakpoint, index) =>
155-
breakpoint.id !== selectedBreakpoint?.id && (
156-
<CommandItem
157-
key={breakpoint.id}
158-
keywords={["Breakpoints"]}
159-
onSelect={() => {
160-
closeCommandPanel({ restoreFocus: true });
161-
$selectedBreakpointId.set(breakpoint.id);
162-
setCanvasWidth(breakpoint.id);
163-
}}
164-
>
165-
<CommandIcon></CommandIcon>
166-
<Text variant="labelsTitleCase">
167-
{getBreakpointLabel(breakpoint)}
168-
</Text>
169-
<Kbd value={[(index + 1).toString()]} />
170-
</CommandItem>
171-
)
172-
)}
206+
{options.map(({ breakpoint, shortcut }) => (
207+
<CommandItem
208+
key={breakpoint.id}
209+
onSelect={() => {
210+
closeCommandPanel({ restoreFocus: true });
211+
$selectedBreakpointId.set(breakpoint.id);
212+
setCanvasWidth(breakpoint.id);
213+
}}
214+
>
215+
<CommandIcon></CommandIcon>
216+
<Text variant="labelsTitleCase">
217+
{getBreakpointLabel(breakpoint)}
218+
</Text>
219+
<Kbd value={[shortcut]} />
220+
</CommandItem>
221+
))}
173222
</CommandGroup>
174223
);
175224
};
176225

177-
const PagesGroup = () => {
178-
const pagesData = useStore($pages);
179-
const selectedPage = useStore($selectedPage);
180-
if (pagesData === undefined) {
181-
return;
226+
type PageOption = {
227+
tokens: string[];
228+
type: "page";
229+
page: Page;
230+
};
231+
232+
const $pageOptions = computed(
233+
[$pages, $selectedPage],
234+
(pages, selectedPage) => {
235+
const pageOptions: PageOption[] = [];
236+
if (pages) {
237+
for (const page of [pages.homePage, ...pages.pages]) {
238+
if (page.id === selectedPage?.id) {
239+
continue;
240+
}
241+
pageOptions.push({
242+
tokens: ["pages", page.name],
243+
type: "page",
244+
page,
245+
});
246+
}
247+
}
248+
return pageOptions;
182249
}
183-
const pages = [pagesData.homePage, ...pagesData.pages];
250+
);
251+
252+
const PagesGroup = ({ options }: { options: PageOption[] }) => {
184253
return (
185254
<CommandGroup heading={<CommandGroupHeading>Pages</CommandGroupHeading>}>
186-
{pages.map(
187-
(page) =>
188-
page.id !== selectedPage?.id && (
189-
<CommandItem
190-
key={page.id}
191-
keywords={["pages"]}
192-
onSelect={() => {
193-
closeCommandPanel();
194-
selectPage(page.id);
195-
}}
196-
>
197-
<CommandIcon></CommandIcon>
198-
<Text variant="labelsTitleCase">{page.name}</Text>
199-
</CommandItem>
200-
)
201-
)}
255+
{options.map(({ page }) => (
256+
<CommandItem
257+
key={page.id}
258+
onSelect={() => {
259+
closeCommandPanel();
260+
selectPage(page.id);
261+
}}
262+
>
263+
<CommandIcon></CommandIcon>
264+
<Text variant="labelsTitleCase">{page.name}</Text>
265+
</CommandItem>
266+
))}
202267
</CommandGroup>
203268
);
204269
};
205270

271+
const $options = computed(
272+
[$componentOptions, $breakpointOptions, $pageOptions],
273+
(componentOptions, breakpointOptions, pageOptions) => [
274+
...componentOptions,
275+
...breakpointOptions,
276+
...pageOptions,
277+
]
278+
);
279+
206280
const CommandDialogContent = () => {
281+
const [search, setSearch] = useState("");
282+
const options = useStore($options);
283+
let matches = options;
284+
for (const word of search.trim().split(/\s+/)) {
285+
matches = matchSorter(matches, word, {
286+
keys: ["tokens"],
287+
});
288+
}
289+
const groups = mapGroupBy(matches, (match) => match.type);
207290
return (
208-
<>
209-
<CommandInput />
291+
<Command shouldFilter={false}>
292+
<CommandInput value={search} onValueChange={setSearch} />
210293
<Flex direction="column" css={{ maxHeight: 300 }}>
211294
<ScrollArea>
212295
<CommandList>
213-
<ComponentsGroup />
214-
<BreakpointsGroup />
215-
<PagesGroup />
296+
{Array.from(groups).map(([group, matches]) => {
297+
if (group === "component") {
298+
return (
299+
<ComponentsGroup
300+
key={group}
301+
options={matches as ComponentOption[]}
302+
/>
303+
);
304+
}
305+
if (group === "breakpoint") {
306+
return (
307+
<BreakpointsGroup
308+
key={group}
309+
options={matches as BreakpointOption[]}
310+
/>
311+
);
312+
}
313+
if (group === "page") {
314+
return (
315+
<PagesGroup key={group} options={matches as PageOption[]} />
316+
);
317+
}
318+
})}
216319
</CommandList>
217320
</ScrollArea>
218321
</Flex>
219-
</>
322+
</Command>
220323
);
221324
};
222325

223326
export const CommandPanel = () => {
224327
const isOpen = useStore($commandPanel) !== undefined;
225-
226-
if (isOpen === false) {
227-
return;
228-
}
229328
return (
230329
<CommandDialog
231330
open={isOpen}

apps/builder/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"immerhin": "^0.9.0",
9090
"isbot": "^5.1.17",
9191
"lexical": "^0.16.0",
92-
"match-sorter": "^6.3.4",
92+
"match-sorter": "^8.0.0",
9393
"mdast-util-from-markdown": "^2.0.1",
9494
"mdast-util-gfm": "^3.0.0",
9595
"micromark-extension-gfm": "^3.0.0",

packages/design-system/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"change-case": "^5.4.4",
6767
"cmdk": "^1.0.4",
6868
"downshift": "^6.1.7",
69-
"match-sorter": "^6.3.4",
69+
"match-sorter": "^8.0.0",
7070
"react-hot-toast": "^2.4.1",
7171
"token-transformer": "^0.0.28",
7272
"use-debounce": "^9.0.4",

packages/design-system/src/components/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export const CommandDialog = ({
7070
<VisuallyHidden asChild>
7171
<DialogTitle>Command Panel</DialogTitle>
7272
</VisuallyHidden>
73-
<Command>{children}</Command>
73+
{children}
7474
</CommandDialogContent>
7575
</DialogPortal>
7676
</Dialog>

0 commit comments

Comments
 (0)