Skip to content

Commit 971ae9f

Browse files
authored
experimental: add command panel (#4367)
Ref #1696 Press cmd+k or ctrl+k and see yourself. Or just watch the video. Based on https://cmdk.paco.me used by shadcn. https://github.com/user-attachments/assets/46bb807a-c6af-487f-b57c-09fb8f6e5741
1 parent 8427428 commit 971ae9f

File tree

13 files changed

+612
-4
lines changed

13 files changed

+612
-4
lines changed

apps/builder/app/builder/builder.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { migrateWebstudioDataMutable } from "~/shared/webstudio-data-migrator";
5858
import { Loading, LoadingBackground } from "./shared/loading";
5959
import { mergeRefs } from "@react-aria/utils";
6060
import { initCopyPaste } from "~/shared/copy-paste";
61+
import { CommandPanel } from "./features/command-panel";
6162

6263
registerContainers();
6364

@@ -437,6 +438,7 @@ export const Builder = ({
437438
</ChromeWrapper>
438439
<Loading state={loadingState} />
439440
<BlockingAlerts />
441+
<CommandPanel />
440442
</div>
441443
</TooltipProvider>
442444
);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Meta, StoryFn } from "@storybook/react";
2+
import { useEffect } from "react";
3+
import { initialBreakpoints } from "@webstudio-is/sdk";
4+
import { coreMetas } from "@webstudio-is/react-sdk";
5+
import { createDefaultPages } from "@webstudio-is/project-build";
6+
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
7+
import * as remixComponentMetas from "@webstudio-is/sdk-components-react-remix/metas";
8+
import {
9+
$breakpoints,
10+
$pages,
11+
$registeredComponentMetas,
12+
} from "~/shared/nano-states";
13+
import { $awareness } from "~/shared/awareness";
14+
import { registerContainers } from "~/shared/sync";
15+
import {
16+
CommandPanel as CommandPanelComponent,
17+
openCommandPanel,
18+
} from "./command-panel";
19+
20+
const meta: Meta = {
21+
title: "CommandPanel",
22+
};
23+
export default meta;
24+
25+
registerContainers();
26+
27+
$registeredComponentMetas.set(
28+
new Map(
29+
Object.entries({
30+
...coreMetas,
31+
...baseComponentMetas,
32+
...remixComponentMetas,
33+
})
34+
)
35+
);
36+
37+
$breakpoints.set(
38+
new Map(
39+
initialBreakpoints.map((breakpoint, index) => [
40+
index.toString(),
41+
{ ...breakpoint, id: index.toString() },
42+
])
43+
)
44+
);
45+
46+
const pages = createDefaultPages({
47+
rootInstanceId: "",
48+
systemDataSourceId: "",
49+
});
50+
pages.pages.push({
51+
id: "page2",
52+
path: "",
53+
name: "Second Page",
54+
rootInstanceId: "",
55+
systemDataSourceId: "",
56+
title: "",
57+
meta: {},
58+
});
59+
pages.pages.push({
60+
id: "page3",
61+
path: "",
62+
name: "Thrid Page",
63+
rootInstanceId: "",
64+
systemDataSourceId: "",
65+
title: "",
66+
meta: {},
67+
});
68+
$pages.set(pages);
69+
$awareness.set({ pageId: pages.homePage.id });
70+
71+
export const CommandPanel: StoryFn = () => {
72+
useEffect(() => {
73+
const controller = new AbortController();
74+
addEventListener(
75+
"keydown",
76+
(event) => {
77+
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
78+
openCommandPanel();
79+
}
80+
},
81+
{ signal: controller.signal }
82+
);
83+
return () => {
84+
controller.abort();
85+
};
86+
}, []);
87+
return (
88+
<>
89+
<button onClick={openCommandPanel}>Open command panel</button>
90+
<br />
91+
<input
92+
defaultValue="Press cmd+k to open command panel"
93+
style={{ width: 300 }}
94+
/>
95+
<CommandPanelComponent />
96+
</>
97+
);
98+
};
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { atom, computed } from "nanostores";
2+
import { useStore } from "@nanostores/react";
3+
import {
4+
collectionComponent,
5+
componentCategories,
6+
WsComponentMeta,
7+
} from "@webstudio-is/react-sdk";
8+
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
9+
import {
10+
Kbd,
11+
Text,
12+
CommandDialog,
13+
CommandInput,
14+
CommandList,
15+
CommandGroup,
16+
CommandGroupHeading,
17+
CommandItem,
18+
CommandIcon,
19+
ScrollArea,
20+
Flex,
21+
} from "@webstudio-is/design-system";
22+
import { compareMedia } from "@webstudio-is/css-engine";
23+
import type { Breakpoint } from "@webstudio-is/sdk";
24+
import {
25+
$breakpoints,
26+
$pages,
27+
$registeredComponentMetas,
28+
$selectedBreakpoint,
29+
$selectedBreakpointId,
30+
} from "~/shared/nano-states";
31+
import { getInstanceLabel } from "~/shared/instance-utils";
32+
import { humanizeString } from "~/shared/string-utils";
33+
import { setCanvasWidth } from "~/builder/features/breakpoints";
34+
import { insert as insertComponent } from "~/builder/features/components/insert";
35+
import { $selectedPage, selectPage } from "~/shared/awareness";
36+
37+
const $commandPanel = atom<
38+
| undefined
39+
| {
40+
lastFocusedElement: null | HTMLElement;
41+
}
42+
>();
43+
44+
export const openCommandPanel = () => {
45+
if (isFeatureEnabled("command") === false) {
46+
return;
47+
}
48+
const activeElement =
49+
document.activeElement instanceof HTMLElement
50+
? document.activeElement
51+
: null;
52+
// store last focused element
53+
$commandPanel.set({
54+
lastFocusedElement: activeElement,
55+
});
56+
};
57+
58+
const closeCommandPanel = ({
59+
restoreFocus = false,
60+
}: { restoreFocus?: boolean } = {}) => {
61+
const commandPanel = $commandPanel.get();
62+
$commandPanel.set(undefined);
63+
// restore focus in the next frame
64+
if (restoreFocus && commandPanel?.lastFocusedElement) {
65+
requestAnimationFrame(() => {
66+
commandPanel.lastFocusedElement?.focus();
67+
});
68+
}
69+
};
70+
71+
const getMetaScore = (meta: WsComponentMeta) => {
72+
const categoryScore = componentCategories.indexOf(meta.category ?? "hidden");
73+
const componentScore = meta.order ?? Number.MAX_SAFE_INTEGER;
74+
// shift category
75+
return categoryScore * 1000 + componentScore;
76+
};
77+
78+
const $visibleMetas = computed(
79+
[$registeredComponentMetas, $selectedPage],
80+
(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;
94+
}
95+
// show everything except xml category in html documents
96+
return category !== "xml";
97+
});
98+
return new Map(entries);
99+
}
100+
);
101+
102+
const ComponentsGroup = () => {
103+
const metas = useStore($visibleMetas);
104+
return (
105+
<CommandGroup
106+
heading={<CommandGroupHeading>Components</CommandGroupHeading>}
107+
>
108+
{Array.from(metas).map(([component, meta]) => {
109+
return (
110+
<CommandItem
111+
key={component}
112+
keywords={["Components"]}
113+
onSelect={() => {
114+
closeCommandPanel();
115+
insertComponent(component);
116+
}}
117+
>
118+
<CommandIcon
119+
dangerouslySetInnerHTML={{ __html: meta.icon }}
120+
></CommandIcon>
121+
<Text variant="labelsTitleCase">
122+
{getInstanceLabel({ component }, meta)}{" "}
123+
<Text as="span" color="moreSubtle">
124+
({humanizeString(meta.category ?? "")})
125+
</Text>
126+
</Text>
127+
</CommandItem>
128+
);
129+
})}
130+
</CommandGroup>
131+
);
132+
};
133+
134+
const getBreakpointLabel = (breakpoint: Breakpoint) => {
135+
let label = "All Sizes";
136+
if (breakpoint.minWidth !== undefined) {
137+
label = `≥ ${breakpoint.minWidth} PX`;
138+
}
139+
if (breakpoint.maxWidth !== undefined) {
140+
label = `≤ ${breakpoint.maxWidth} PX`;
141+
}
142+
return `${breakpoint.label}: ${label}`;
143+
};
144+
145+
const BreakpointsGroup = () => {
146+
const breakpoints = useStore($breakpoints);
147+
const sortedBreakpoints = Array.from(breakpoints.values()).sort(compareMedia);
148+
const selectedBreakpoint = useStore($selectedBreakpoint);
149+
return (
150+
<CommandGroup
151+
heading={<CommandGroupHeading>Breakpoints</CommandGroupHeading>}
152+
>
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+
)}
173+
</CommandGroup>
174+
);
175+
};
176+
177+
const PagesGroup = () => {
178+
const pagesData = useStore($pages);
179+
const selectedPage = useStore($selectedPage);
180+
if (pagesData === undefined) {
181+
return;
182+
}
183+
const pages = [pagesData.homePage, ...pagesData.pages];
184+
return (
185+
<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+
)}
202+
</CommandGroup>
203+
);
204+
};
205+
206+
const CommandDialogContent = () => {
207+
return (
208+
<>
209+
<CommandInput />
210+
<Flex direction="column" css={{ maxHeight: 300 }}>
211+
<ScrollArea>
212+
<CommandList>
213+
<ComponentsGroup />
214+
<BreakpointsGroup />
215+
<PagesGroup />
216+
</CommandList>
217+
</ScrollArea>
218+
</Flex>
219+
</>
220+
);
221+
};
222+
223+
export const CommandPanel = () => {
224+
const isOpen = useStore($commandPanel) !== undefined;
225+
226+
if (isOpen === false) {
227+
return;
228+
}
229+
return (
230+
<CommandDialog
231+
open={isOpen}
232+
onOpenChange={() => closeCommandPanel({ restoreFocus: true })}
233+
>
234+
<CommandDialogContent />
235+
</CommandDialog>
236+
);
237+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./command-panel";

apps/builder/app/builder/features/components/components.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useStore } from "@nanostores/react";
33
import { CrossIcon } from "@webstudio-is/icons";
44
import {
55
type WsComponentMeta,
6+
collectionComponent,
67
componentCategories,
78
} from "@webstudio-is/react-sdk";
89
import {
@@ -111,7 +112,7 @@ const filterAndGroupComponents = ({
111112
}
112113

113114
if (documentType === "xml" && meta.category === "data") {
114-
return component === "ws:collection";
115+
return component === collectionComponent;
115116
}
116117

117118
if (component === "RemixForm" && isFeatureEnabled("filters") === false) {

apps/builder/app/builder/shared/commands.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "./nano-states";
2929
import { toast } from "@webstudio-is/design-system";
3030
import { selectInstance } from "~/shared/awareness";
31+
import { openCommandPanel } from "../features/command-panel";
3132

3233
const makeBreakpointCommand = <CommandName extends string>(
3334
name: CommandName,
@@ -293,5 +294,11 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
293294
serverSyncStore.redo();
294295
},
295296
},
297+
298+
{
299+
name: "search",
300+
defaultHotkeys: ["meta+k", "ctrl+k"],
301+
handler: openCommandPanel,
302+
},
296303
],
297304
});

0 commit comments

Comments
 (0)