mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
experimental: fuzzy search tokens (#4396)
Ref https://github.com/webstudio-is/webstudio/issues/1696 Switch to match sorter for more control over results. Now it will try to match best token in the list of each option.
This commit is contained in:
1 parent
50ca393e73
commit
c54fbe45c4
5 files changed
+189
-90
No files matched your search
@@ -7,8 +7,7 @@ import {
|
||||
} from "@webstudio-is/react-sdk";
|
||||
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
|
||||
import {
|
||||
Kbd,
|
||||
Text,
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
@@ -18,9 +17,11 @@ import {
|
||||
CommandIcon,
|
||||
ScrollArea,
|
||||
Flex,
|
||||
Kbd,
|
||||
Text,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { compareMedia } from "@webstudio-is/css-engine";
|
||||
import type { Breakpoint } from "@webstudio-is/sdk";
|
||||
import type { Breakpoint, Page } from "@webstudio-is/sdk";
|
||||
import {
|
||||
$breakpoints,
|
||||
$pages,
|
||||
@@ -33,6 +34,9 @@ import { humanizeString } from "~/shared/string-utils";
|
||||
import { setCanvasWidth } from "~/builder/features/breakpoints";
|
||||
import { insert as insertComponent } from "~/builder/features/components/insert";
|
||||
import { $selectedPage, selectPage } from "~/shared/awareness";
|
||||
import { useState } from "react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { mapGroupBy } from "~/shared/shim";
|
||||
|
||||
const $commandPanel = atom<
|
||||
| undefined
|
||||
@@ -75,37 +79,57 @@ const getMetaScore = (meta: WsComponentMeta) => {
|
||||
return categoryScore * 1000 + componentScore;
|
||||
};
|
||||
|
||||
const $visibleMetas = computed(
|
||||
type ComponentOption = {
|
||||
tokens: string[];
|
||||
type: "component";
|
||||
component: string;
|
||||
label: string;
|
||||
meta: WsComponentMeta;
|
||||
};
|
||||
|
||||
const $componentOptions = computed(
|
||||
[$registeredComponentMetas, $selectedPage],
|
||||
(metas, selectedPage) => {
|
||||
const entries = Array.from(metas)
|
||||
.sort(
|
||||
([_leftComponent, leftMeta], [_rightComponent, rightMeta]) =>
|
||||
getMetaScore(leftMeta) - getMetaScore(rightMeta)
|
||||
)
|
||||
.filter(([component, meta]) => {
|
||||
const category = meta.category ?? "hidden";
|
||||
if (category === "hidden" || category === "internal") {
|
||||
return false;
|
||||
}
|
||||
// show only xml category and collection component in xml documents
|
||||
if (selectedPage?.meta.documentType === "xml") {
|
||||
return category === "xml" || component === collectionComponent;
|
||||
const componentOptions: ComponentOption[] = [];
|
||||
for (const [component, meta] of metas) {
|
||||
const category = meta.category ?? "hidden";
|
||||
if (category === "hidden" || category === "internal") {
|
||||
continue;
|
||||
}
|
||||
// show only xml category and collection component in xml documents
|
||||
if (selectedPage?.meta.documentType === "xml") {
|
||||
if (category !== "xml" && component !== collectionComponent) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// show everything except xml category in html documents
|
||||
return category !== "xml";
|
||||
if (category === "xml") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const label = getInstanceLabel({ component }, meta);
|
||||
componentOptions.push({
|
||||
tokens: ["components", label, category],
|
||||
type: "component",
|
||||
component,
|
||||
label,
|
||||
meta,
|
||||
});
|
||||
return new Map(entries);
|
||||
}
|
||||
componentOptions.sort(
|
||||
({ meta: leftMeta }, { meta: rightMeta }) =>
|
||||
getMetaScore(leftMeta) - getMetaScore(rightMeta)
|
||||
);
|
||||
return componentOptions;
|
||||
}
|
||||
);
|
||||
|
||||
const ComponentsGroup = () => {
|
||||
const metas = useStore($visibleMetas);
|
||||
const ComponentsGroup = ({ options }: { options: ComponentOption[] }) => {
|
||||
return (
|
||||
<CommandGroup
|
||||
heading={<CommandGroupHeading>Components</CommandGroupHeading>}
|
||||
>
|
||||
{Array.from(metas).map(([component, meta]) => {
|
||||
{options.map(({ component, label, meta }) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={component}
|
||||
@@ -119,9 +143,9 @@ const ComponentsGroup = () => {
|
||||
dangerouslySetInnerHTML={{ __html: meta.icon }}
|
||||
></CommandIcon>
|
||||
<Text variant="labelsTitleCase">
|
||||
{getInstanceLabel({ component }, meta)}{" "}
|
||||
{label}{" "}
|
||||
<Text as="span" color="moreSubtle">
|
||||
({humanizeString(meta.category ?? "")})
|
||||
{humanizeString(meta.category ?? "")}
|
||||
</Text>
|
||||
</Text>
|
||||
</CommandItem>
|
||||
@@ -131,6 +155,38 @@ const ComponentsGroup = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type BreakpointOption = {
|
||||
tokens: string[];
|
||||
type: "breakpoint";
|
||||
breakpoint: Breakpoint;
|
||||
shortcut: string;
|
||||
};
|
||||
|
||||
const $breakpointOptions = computed(
|
||||
[$breakpoints, $selectedBreakpoint],
|
||||
(breakpoints, selectedBreakpoint) => {
|
||||
const sortedBreakpoints = Array.from(breakpoints.values()).sort(
|
||||
compareMedia
|
||||
);
|
||||
const breakpointOptions: BreakpointOption[] = [];
|
||||
for (let index = 0; index < sortedBreakpoints.length; index += 1) {
|
||||
const breakpoint = sortedBreakpoints[index];
|
||||
if (breakpoint.id === selectedBreakpoint?.id) {
|
||||
continue;
|
||||
}
|
||||
const width =
|
||||
(breakpoint.minWidth ?? breakpoint.maxWidth)?.toString() ?? "";
|
||||
breakpointOptions.push({
|
||||
tokens: ["breakpoints", breakpoint.label, width],
|
||||
type: "breakpoint",
|
||||
breakpoint,
|
||||
shortcut: (index + 1).toString(),
|
||||
});
|
||||
}
|
||||
return breakpointOptions;
|
||||
}
|
||||
);
|
||||
|
||||
const getBreakpointLabel = (breakpoint: Breakpoint) => {
|
||||
let label = "All Sizes";
|
||||
if (breakpoint.minWidth !== undefined) {
|
||||
@@ -142,90 +198,133 @@ const getBreakpointLabel = (breakpoint: Breakpoint) => {
|
||||
return `${breakpoint.label}: ${label}`;
|
||||
};
|
||||
|
||||
const BreakpointsGroup = () => {
|
||||
const breakpoints = useStore($breakpoints);
|
||||
const sortedBreakpoints = Array.from(breakpoints.values()).sort(compareMedia);
|
||||
const selectedBreakpoint = useStore($selectedBreakpoint);
|
||||
const BreakpointsGroup = ({ options }: { options: BreakpointOption[] }) => {
|
||||
return (
|
||||
<CommandGroup
|
||||
heading={<CommandGroupHeading>Breakpoints</CommandGroupHeading>}
|
||||
>
|
||||
{sortedBreakpoints.map(
|
||||
(breakpoint, index) =>
|
||||
breakpoint.id !== selectedBreakpoint?.id && (
|
||||
<CommandItem
|
||||
key={breakpoint.id}
|
||||
keywords={["Breakpoints"]}
|
||||
onSelect={() => {
|
||||
closeCommandPanel({ restoreFocus: true });
|
||||
$selectedBreakpointId.set(breakpoint.id);
|
||||
setCanvasWidth(breakpoint.id);
|
||||
}}
|
||||
>
|
||||
<CommandIcon></CommandIcon>
|
||||
<Text variant="labelsTitleCase">
|
||||
{getBreakpointLabel(breakpoint)}
|
||||
</Text>
|
||||
<Kbd value={[(index + 1).toString()]} />
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
{options.map(({ breakpoint, shortcut }) => (
|
||||
<CommandItem
|
||||
key={breakpoint.id}
|
||||
onSelect={() => {
|
||||
closeCommandPanel({ restoreFocus: true });
|
||||
$selectedBreakpointId.set(breakpoint.id);
|
||||
setCanvasWidth(breakpoint.id);
|
||||
}}
|
||||
>
|
||||
<CommandIcon></CommandIcon>
|
||||
<Text variant="labelsTitleCase">
|
||||
{getBreakpointLabel(breakpoint)}
|
||||
</Text>
|
||||
<Kbd value={[shortcut]} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const PagesGroup = () => {
|
||||
const pagesData = useStore($pages);
|
||||
const selectedPage = useStore($selectedPage);
|
||||
if (pagesData === undefined) {
|
||||
return;
|
||||
type PageOption = {
|
||||
tokens: string[];
|
||||
type: "page";
|
||||
page: Page;
|
||||
};
|
||||
|
||||
const $pageOptions = computed(
|
||||
[$pages, $selectedPage],
|
||||
(pages, selectedPage) => {
|
||||
const pageOptions: PageOption[] = [];
|
||||
if (pages) {
|
||||
for (const page of [pages.homePage, ...pages.pages]) {
|
||||
if (page.id === selectedPage?.id) {
|
||||
continue;
|
||||
}
|
||||
pageOptions.push({
|
||||
tokens: ["pages", page.name],
|
||||
type: "page",
|
||||
page,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pageOptions;
|
||||
}
|
||||
const pages = [pagesData.homePage, ...pagesData.pages];
|
||||
);
|
||||
|
||||
const PagesGroup = ({ options }: { options: PageOption[] }) => {
|
||||
return (
|
||||
<CommandGroup heading={<CommandGroupHeading>Pages</CommandGroupHeading>}>
|
||||
{pages.map(
|
||||
(page) =>
|
||||
page.id !== selectedPage?.id && (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
keywords={["pages"]}
|
||||
onSelect={() => {
|
||||
closeCommandPanel();
|
||||
selectPage(page.id);
|
||||
}}
|
||||
>
|
||||
<CommandIcon></CommandIcon>
|
||||
<Text variant="labelsTitleCase">{page.name}</Text>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
{options.map(({ page }) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
onSelect={() => {
|
||||
closeCommandPanel();
|
||||
selectPage(page.id);
|
||||
}}
|
||||
>
|
||||
<CommandIcon></CommandIcon>
|
||||
<Text variant="labelsTitleCase">{page.name}</Text>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const $options = computed(
|
||||
[$componentOptions, $breakpointOptions, $pageOptions],
|
||||
(componentOptions, breakpointOptions, pageOptions) => [
|
||||
...componentOptions,
|
||||
...breakpointOptions,
|
||||
...pageOptions,
|
||||
]
|
||||
);
|
||||
|
||||
const CommandDialogContent = () => {
|
||||
const [search, setSearch] = useState("");
|
||||
const options = useStore($options);
|
||||
let matches = options;
|
||||
for (const word of search.trim().split(/\s+/)) {
|
||||
matches = matchSorter(matches, word, {
|
||||
keys: ["tokens"],
|
||||
});
|
||||
}
|
||||
const groups = mapGroupBy(matches, (match) => match.type);
|
||||
return (
|
||||
<>
|
||||
<CommandInput />
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput value={search} onValueChange={setSearch} />
|
||||
<Flex direction="column" css={{ maxHeight: 300 }}>
|
||||
<ScrollArea>
|
||||
<CommandList>
|
||||
<ComponentsGroup />
|
||||
<BreakpointsGroup />
|
||||
<PagesGroup />
|
||||
{Array.from(groups).map(([group, matches]) => {
|
||||
if (group === "component") {
|
||||
return (
|
||||
<ComponentsGroup
|
||||
key={group}
|
||||
options={matches as ComponentOption[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (group === "breakpoint") {
|
||||
return (
|
||||
<BreakpointsGroup
|
||||
key={group}
|
||||
options={matches as BreakpointOption[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (group === "page") {
|
||||
return (
|
||||
<PagesGroup key={group} options={matches as PageOption[]} />
|
||||
);
|
||||
}
|
||||
})}
|
||||
</CommandList>
|
||||
</ScrollArea>
|
||||
</Flex>
|
||||
</>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandPanel = () => {
|
||||
const isOpen = useStore($commandPanel) !== undefined;
|
||||
|
||||
if (isOpen === false) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<CommandDialog
|
||||
open={isOpen}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"immerhin": "^0.9.0",
|
||||
"isbot": "^5.1.17",
|
||||
"lexical": "^0.16.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"match-sorter": "^8.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"change-case": "^5.4.4",
|
||||
"cmdk": "^1.0.4",
|
||||
"downshift": "^6.1.7",
|
||||
"match-sorter": "^6.3.4",
|
||||
"match-sorter": "^8.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"token-transformer": "^0.0.28",
|
||||
"use-debounce": "^9.0.4",
|
||||
|
||||
@@ -70,7 +70,7 @@ export const CommandDialog = ({
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>Command Panel</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<Command>{children}</Command>
|
||||
{children}
|
||||
</CommandDialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
|
||||
Generated
+7
-7
@@ -341,8 +341,8 @@ importers:
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.0
|
||||
match-sorter:
|
||||
specifier: ^6.3.4
|
||||
version: 6.3.4
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
mdast-util-from-markdown:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -1406,8 +1406,8 @@ importers:
|
||||
specifier: ^6.1.7
|
||||
version: 6.1.7(react@18.3.0-canary-14898b6a9-20240318)
|
||||
match-sorter:
|
||||
specifier: ^6.3.4
|
||||
version: 6.3.4
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
react-hot-toast:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1(csstype@3.1.1)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)
|
||||
@@ -7325,8 +7325,8 @@ packages:
|
||||
markdown-table@3.0.3:
|
||||
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
|
||||
|
||||
match-sorter@6.3.4:
|
||||
resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==}
|
||||
match-sorter@8.0.0:
|
||||
resolution: {integrity: sha512-bGJ6Zb+OhzXe+ptP5d80OLVx7AkqfRbtGEh30vNSfjNwllu+hHI+tcbMIT/fbkx/FKN1PmKuDb65+Oofg+XUxw==}
|
||||
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
@@ -15378,7 +15378,7 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.3: {}
|
||||
|
||||
match-sorter@6.3.4:
|
||||
match-sorter@8.0.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.0
|
||||
remove-accents: 0.5.0
|
||||
|
||||
Reference in new issue
Block a user