From 64ec69a3679b0e108e6ccacb3ab85da24f24918c Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 20 Dec 2024 16:57:07 +0700 Subject: [PATCH 1/2] feat: allow finding tokens Ref https://github.com/webstudio-is/webstudio/issues/3901 https://github.com/webstudio-is/webstudio/issues/4608 Here added new command panel group: tokens. For only only supported selecting which opens a list of instances the token is defined on. --- .../features/command-panel/command-panel.tsx | 111 ++++----- .../features/command-panel/command-state.ts | 41 ++++ .../builder/features/command-panel/index.ts | 1 + .../features/command-panel/token-group.tsx | 224 ++++++++++++++++++ 4 files changed, 313 insertions(+), 64 deletions(-) create mode 100644 apps/builder/app/builder/features/command-panel/command-state.ts create mode 100644 apps/builder/app/builder/features/command-panel/token-group.tsx 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 374ae2ca30c0..4652b720b4f2 100644 --- a/apps/builder/app/builder/features/command-panel/command-panel.tsx +++ b/apps/builder/app/builder/features/command-panel/command-panel.tsx @@ -1,4 +1,4 @@ -import { atom, computed } from "nanostores"; +import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import { useState } from "react"; import { matchSorter } from "match-sorter"; @@ -7,7 +7,6 @@ import { componentCategories, WsComponentMeta, } from "@webstudio-is/react-sdk"; -import { isFeatureEnabled } from "@webstudio-is/feature-flags"; import { Command, CommandDialog, @@ -48,40 +47,12 @@ import { mapGroupBy } from "~/shared/shim"; import { setActiveSidebarPanel } from "~/builder/shared/nano-states"; import { $commandMetas } from "~/shared/commands-emitter"; import { emitCommand } from "~/builder/shared/commands"; - -const $commandPanel = atom< - | undefined - | { - lastFocusedElement: null | HTMLElement; - } ->(); - -export const openCommandPanel = () => { - if (isFeatureEnabled("command") === false) { - return; - } - const activeElement = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - // store last focused element - $commandPanel.set({ - lastFocusedElement: activeElement, - }); -}; - -const closeCommandPanel = ({ - restoreFocus = false, -}: { restoreFocus?: boolean } = {}) => { - const commandPanel = $commandPanel.get(); - $commandPanel.set(undefined); - // restore focus in the next frame - if (restoreFocus && commandPanel?.lastFocusedElement) { - requestAnimationFrame(() => { - commandPanel.lastFocusedElement?.focus(); - }); - } -}; +import { + $commandContent, + $isCommandPanelOpen, + closeCommandPanel, +} from "./command-state"; +import { $tokenOptions, TokenGroup, type TokenOption } from "./token-group"; const getMetaScore = (meta: WsComponentMeta) => { const categoryScore = componentCategories.indexOf(meta.category ?? "hidden"); @@ -91,7 +62,7 @@ const getMetaScore = (meta: WsComponentMeta) => { }; type ComponentOption = { - tokens: string[]; + terms: string[]; type: "component"; component: string; label: string; @@ -120,7 +91,7 @@ const $componentOptions = computed( } const label = getInstanceLabel({ component }, meta); componentOptions.push({ - tokens: ["components", label, category], + terms: ["components", label, category], type: "component", component, label, @@ -135,7 +106,7 @@ const $componentOptions = computed( } ); -const ComponentOptionsGroup = ({ options }: { options: ComponentOption[] }) => { +const ComponentGroup = ({ options }: { options: ComponentOption[] }) => { return ( { }; type BreakpointOption = { - tokens: string[]; + terms: string[]; type: "breakpoint"; breakpoint: Breakpoint; shortcut: string; @@ -199,7 +170,7 @@ const $breakpointOptions = computed( const width = (breakpoint.minWidth ?? breakpoint.maxWidth)?.toString() ?? ""; breakpointOptions.push({ - tokens: ["breakpoints", breakpoint.label, width], + terms: ["breakpoints", breakpoint.label, width], type: "breakpoint", breakpoint, shortcut: (index + 1).toString(), @@ -220,11 +191,7 @@ const getBreakpointLabel = (breakpoint: Breakpoint) => { return `${breakpoint.label}: ${label}`; }; -const BreakpointOptionsGroup = ({ - options, -}: { - options: BreakpointOption[]; -}) => { +const BreakpointGroup = ({ options }: { options: BreakpointOption[] }) => { return ( { +const PageGroup = ({ options }: { options: PageOption[] }) => { const action = useSelectedAction(); return ( { }; type ShortcutOption = { - tokens: string[]; + terms: string[]; type: "shortcut"; name: string; label: string; @@ -329,7 +296,7 @@ const $shortcutOptions = computed([$commandMetas], (commandMetas) => { ?.split("+") .map((key) => (key === "meta" ? "cmd" : key)); shortcutOptions.push({ - tokens: ["shortcuts", "commands", label], + terms: ["shortcuts", "commands", label], type: "shortcut", name, label, @@ -343,7 +310,7 @@ const $shortcutOptions = computed([$commandMetas], (commandMetas) => { return shortcutOptions; }); -const ShortcutOptionsGroup = ({ options }: { options: ShortcutOption[] }) => { +const ShortcutGroup = ({ options }: { options: ShortcutOption[] }) => { return ( { }; const $options = computed( - [$componentOptions, $breakpointOptions, $pageOptions, $shortcutOptions], - (componentOptions, breakpointOptions, pageOptions, commandOptions) => [ + [ + $componentOptions, + $breakpointOptions, + $pageOptions, + $shortcutOptions, + $tokenOptions, + ], + ( + componentOptions, + breakpointOptions, + pageOptions, + commandOptions, + tokenOptions + ) => [ ...componentOptions, ...breakpointOptions, ...pageOptions, ...commandOptions, + ...tokenOptions, ] ); @@ -387,7 +367,7 @@ const CommandDialogContent = () => { if (search.trim().length > 0) { for (const word of search.trim().split(/\s+/)) { matches = matchSorter(matches, word, { - keys: ["tokens"], + keys: ["terms"], }); } } @@ -401,7 +381,7 @@ const CommandDialogContent = () => { {Array.from(groups).map(([group, matches]) => { if (group === "component") { return ( - @@ -409,7 +389,7 @@ const CommandDialogContent = () => { } if (group === "breakpoint") { return ( - @@ -417,20 +397,22 @@ const CommandDialogContent = () => { } if (group === "page") { return ( - + ); } if (group === "shortcut") { return ( - ); } + if (group === "token") { + return ( + + ); + } group satisfies never; })} @@ -443,14 +425,15 @@ const CommandDialogContent = () => { }; export const CommandPanel = () => { - const isOpen = useStore($commandPanel) !== undefined; + const isOpen = useStore($isCommandPanelOpen); + const commandContent = useStore($commandContent); return ( closeCommandPanel({ restoreFocus: true })} > - + {commandContent ?? } ); diff --git a/apps/builder/app/builder/features/command-panel/command-state.ts b/apps/builder/app/builder/features/command-panel/command-state.ts new file mode 100644 index 000000000000..2a96e583da85 --- /dev/null +++ b/apps/builder/app/builder/features/command-panel/command-state.ts @@ -0,0 +1,41 @@ +import { atom, computed } from "nanostores"; +import type { ReactNode } from "react"; + +const $commandPanel = atom< + | undefined + | { + lastFocusedElement: null | HTMLElement; + } +>(); + +export const $isCommandPanelOpen = computed( + $commandPanel, + (commandPanel) => commandPanel !== undefined +); + +export const openCommandPanel = () => { + const activeElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + // store last focused element + $commandPanel.set({ + lastFocusedElement: activeElement, + }); +}; + +export const closeCommandPanel = ({ + restoreFocus = false, +}: { restoreFocus?: boolean } = {}) => { + const commandPanel = $commandPanel.get(); + $commandPanel.set(undefined); + $commandContent.set(undefined); + // restore focus in the next frame + if (restoreFocus && commandPanel?.lastFocusedElement) { + requestAnimationFrame(() => { + commandPanel.lastFocusedElement?.focus(); + }); + } +}; + +export const $commandContent = atom(); diff --git a/apps/builder/app/builder/features/command-panel/index.ts b/apps/builder/app/builder/features/command-panel/index.ts index 9338251328a2..feaf2671b81f 100644 --- a/apps/builder/app/builder/features/command-panel/index.ts +++ b/apps/builder/app/builder/features/command-panel/index.ts @@ -1 +1,2 @@ +export { openCommandPanel } from "./command-state"; export * from "./command-panel"; diff --git a/apps/builder/app/builder/features/command-panel/token-group.tsx b/apps/builder/app/builder/features/command-panel/token-group.tsx new file mode 100644 index 000000000000..fdd4ee09ddec --- /dev/null +++ b/apps/builder/app/builder/features/command-panel/token-group.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import { matchSorter } from "match-sorter"; +import { computed } from "nanostores"; +import { useStore } from "@nanostores/react"; +import { + CommandGroup, + CommandGroupHeading, + CommandInput, + CommandItem, + CommandList, + Flex, + ScrollArea, + Text, + toast, + useSelectedAction, +} from "@webstudio-is/design-system"; +import type { Instance, Instances, StyleSource } from "@webstudio-is/sdk"; +import { + $instances, + $pages, + $registeredComponentMetas, + $selectedStyleSources, + $styleSources, + $styleSourceSelections, +} from "~/shared/nano-states"; +import { getInstanceLabel } from "~/shared/instance-utils"; +import type { InstanceSelector } from "~/shared/tree-utils"; +import { $awareness } from "~/shared/awareness"; +import { $commandContent, closeCommandPanel } from "./command-state"; + +export type TokenOption = { + terms: string[]; + type: "token"; + token: Extract; + usages: number; +}; + +const $styleSourceUsages = computed( + $styleSourceSelections, + (styleSourceSelections) => { + const styleSourceUsages = new Map>(); + for (const { instanceId, values } of styleSourceSelections.values()) { + for (const styleSourceId of values) { + let usages = styleSourceUsages.get(styleSourceId); + if (usages === undefined) { + usages = new Set(); + styleSourceUsages.set(styleSourceId, usages); + } + usages.add(instanceId); + } + } + return styleSourceUsages; + } +); + +export const $tokenOptions = computed( + [$styleSources, $styleSourceUsages], + (styleSources, styleSourceUsages) => { + const tokenOptions: TokenOption[] = []; + for (const styleSource of styleSources.values()) { + if (styleSource.type !== "token") { + continue; + } + tokenOptions.push({ + terms: ["tokens", styleSource.name], + type: "token", + token: styleSource, + usages: styleSourceUsages.get(styleSource.id)?.size ?? 0, + }); + } + return tokenOptions; + } +); + +/** + * very loose selector finder + * will not work properly with collections + */ +const findInstanceById = ( + instances: Instances, + instanceSelector: InstanceSelector, + targetId: Instance["id"] +): undefined | InstanceSelector => { + const [instanceId] = instanceSelector; + if (instanceId === targetId) { + return instanceSelector; + } + const instance = instances.get(instanceId); + if (instance) { + for (const child of instance.children) { + if (child.type === "id") { + const matched = findInstanceById( + instances, + [child.value, ...instanceSelector], + targetId + ); + if (matched) { + return matched; + } + } + } + } +}; + +const selectToken = ( + instanceId: Instance["id"], + tokenId: StyleSource["id"] +) => { + const instances = $instances.get(); + const pagesData = $pages.get(); + if (pagesData === undefined) { + return; + } + const pages = [pagesData.homePage, ...pagesData.pages]; + for (const page of pages) { + const instanceSelector = findInstanceById( + instances, + [page.rootInstanceId], + instanceId + ); + if (instanceSelector) { + $awareness.set({ pageId: page.id, instanceSelector }); + const selectedStyleSources = new Map($selectedStyleSources.get()); + selectedStyleSources.set(instanceId, tokenId); + $selectedStyleSources.set(selectedStyleSources); + break; + } + } +}; + +type InstanceOption = { + label: string; + id: string; +}; + +const TokenInstances = ({ tokenId }: { tokenId: StyleSource["id"] }) => { + const usages = useStore($styleSourceUsages); + const usedInInstanceIds = usages.get(tokenId) ?? new Set(); + const instances = $instances.get(); + const metas = $registeredComponentMetas.get(); + const usedInInstances: InstanceOption[] = []; + for (const instanceId of usedInInstanceIds) { + const instance = instances.get(instanceId); + const meta = metas.get(instance?.component ?? ""); + if (instance && meta) { + usedInInstances.push({ + label: getInstanceLabel(instance, meta), + id: instance.id, + }); + } + } + const [search, setSearch] = useState(""); + let matches = usedInInstances; + // prevent searching when value is empty + // to preserve original items order + if (search.trim().length > 0) { + for (const word of search.trim().split(/\s+/)) { + matches = matchSorter(matches, word, { + keys: ["label"], + }); + } + } + return ( + <> + + + + + {matches.map(({ id, label }) => ( + { + selectToken(id, tokenId); + closeCommandPanel(); + }} + > + {label} + + ))} + + + + + ); +}; + +export const TokenGroup = ({ options }: { options: TokenOption[] }) => { + const action = useSelectedAction(); + return ( + Tokens} + actions={["find"]} + > + {options.map(({ token, usages }) => ( + { + if (action === "find") { + if (usages > 0) { + $commandContent.set(); + } else { + toast.error("Token should be added to instance"); + } + } + }} + > + + {token.name}{" "} + {usages > 0 && ( + + {usages} usages + + )} + + + ))} + + ); +}; From 681b2d87a5efee87daafa8f4ea44f54f712d8302 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 20 Dec 2024 17:13:17 +0700 Subject: [PATCH 2/2] Fix stories --- .../features/command-panel/command-panel.stories.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/builder/app/builder/features/command-panel/command-panel.stories.tsx b/apps/builder/app/builder/features/command-panel/command-panel.stories.tsx index b3ed6df1937a..97d801a7e51e 100644 --- a/apps/builder/app/builder/features/command-panel/command-panel.stories.tsx +++ b/apps/builder/app/builder/features/command-panel/command-panel.stories.tsx @@ -11,10 +11,8 @@ import { } from "~/shared/nano-states"; import { $awareness } from "~/shared/awareness"; import { registerContainers } from "~/shared/sync"; -import { - CommandPanel as CommandPanelComponent, - openCommandPanel, -} from "./command-panel"; +import { CommandPanel as CommandPanelComponent } from "./command-panel"; +import { openCommandPanel } from "./command-state"; const meta: Meta = { title: "CommandPanel",