From c54fbe45c44a677b44beff40a4c64fc8cd6949ea Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 10 Nov 2024 02:12:45 +0400 Subject: [PATCH] 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. --- .../features/command-panel/command-panel.tsx | 259 ++++++++++++------ apps/builder/package.json | 2 +- packages/design-system/package.json | 2 +- .../design-system/src/components/command.tsx | 2 +- pnpm-lock.yaml | 14 +- 5 files changed, 189 insertions(+), 90 deletions(-) diff --git a/apps/builder/app/builder/features/command-panel/command-panel.tsx b/apps/builder/app/builder/features/command-panel/command-panel.tsx index 21aeea41c5..dbe915bb57 100644 --- a/apps/builder/app/builder/features/command-panel/command-panel.tsx +++ b/apps/builder/app/builder/features/command-panel/command-panel.tsx @@ -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 ( Components} > - {Array.from(metas).map(([component, meta]) => { + {options.map(({ component, label, meta }) => { return ( { dangerouslySetInnerHTML={{ __html: meta.icon }} > - {getInstanceLabel({ component }, meta)}{" "} + {label}{" "} - ({humanizeString(meta.category ?? "")}) + {humanizeString(meta.category ?? "")} @@ -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 ( Breakpoints} > - {sortedBreakpoints.map( - (breakpoint, index) => - breakpoint.id !== selectedBreakpoint?.id && ( - { - closeCommandPanel({ restoreFocus: true }); - $selectedBreakpointId.set(breakpoint.id); - setCanvasWidth(breakpoint.id); - }} - > - - - {getBreakpointLabel(breakpoint)} - - - - ) - )} + {options.map(({ breakpoint, shortcut }) => ( + { + closeCommandPanel({ restoreFocus: true }); + $selectedBreakpointId.set(breakpoint.id); + setCanvasWidth(breakpoint.id); + }} + > + + + {getBreakpointLabel(breakpoint)} + + + + ))} ); }; -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 ( Pages}> - {pages.map( - (page) => - page.id !== selectedPage?.id && ( - { - closeCommandPanel(); - selectPage(page.id); - }} - > - - {page.name} - - ) - )} + {options.map(({ page }) => ( + { + closeCommandPanel(); + selectPage(page.id); + }} + > + + {page.name} + + ))} ); }; +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 ( - <> - + + - - - + {Array.from(groups).map(([group, matches]) => { + if (group === "component") { + return ( + + ); + } + if (group === "breakpoint") { + return ( + + ); + } + if (group === "page") { + return ( + + ); + } + })} - + ); }; export const CommandPanel = () => { const isOpen = useStore($commandPanel) !== undefined; - - if (isOpen === false) { - return; - } return ( Command Panel - {children} + {children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d66437c1ba..3e0600ce13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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