返回 presentation-ai
ElementTypeSelector.tsx
根目录 / src / components / presentation / floating-toolbar / ElementTypeSelector.tsx
1 "use client";
2
3 import { Search, X } from "lucide-react";
4 import * as React from "react";
5
6 import {
7 CATEGORY_ICONS,
8 ELEMENT_CATEGORIES,
9 ICON_LIST,
10 } from "@/components/notebook/presentation/editor/lib";
11 import {
12 DropdownMenu,
13 DropdownMenuContent,
14 DropdownMenuItem,
15 DropdownMenuSub,
16 DropdownMenuSubContent,
17 DropdownMenuSubTrigger,
18 DropdownMenuTrigger,
19 } from "@/components/plate/ui/dropdown-menu";
20 import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar";
21 import { FLOATING_TOOLBAR_IGNORE_CLASS } from "./toolbar-interaction";
22 import { useToolbarContext } from "./ToolbarContext";
23
24 type LayoutOption = ReturnType<
25 typeof useToolbarContext
26 >["availableOptionsGrouped"][string][number];
27
28 function getOptionProperty(
29 option: LayoutOption,
30 element: Record<string, unknown> | undefined,
31 ) {
32 return option.key ? element?.[option.key] : undefined;
33 }
34
35 function optionMatchesElement(
36 option: LayoutOption,
37 elementType: string,
38 element: Record<string, unknown> | undefined,
39 ) {
40 if (option.type !== elementType) return false;
41 if (!option.variant) return true;
42
43 if (option.key === "isFunnel") {
44 const currentVariant = element?.isFunnel ? "funnel" : "pyramid";
45 return currentVariant === option.variant;
46 }
47
48 const elementValue = getOptionProperty(option, element);
49 return (
50 elementValue === option.variant ||
51 (!elementValue && option.type === ICON_LIST && option.variant === "icon") ||
52 (!elementValue && option.variant === "default")
53 );
54 }
55
56 function getOptionLayoutChangeData(option: LayoutOption) {
57 const data: Record<string, unknown> = {};
58
59 if (option.supportsOrientation) {
60 data.orientation = "vertical";
61 }
62
63 if (option.variant && option.key) {
64 data[option.key] = option.variant;
65 }
66
67 return Object.keys(data).length > 0 ? data : undefined;
68 }
69
70 export function ElementTypeSelector() {
71 const [open, setOpen] = React.useState(false);
72 const [searchQuery, setSearchQuery] = React.useState("");
73 const searchInputRef = React.useRef<HTMLInputElement>(null);
74 const {
75 element,
76 elementType,
77 availableOptionsGrouped,
78 selectedOption,
79 isCurrentElementChart,
80 handleLayoutChange,
81 } = useToolbarContext();
82
83 const applyOption = React.useCallback(
84 (option: LayoutOption) => {
85 handleLayoutChange(option.type, getOptionLayoutChangeData(option));
86 setOpen(false);
87 },
88 [handleLayoutChange],
89 );
90
91 const filteredOptionsGrouped = React.useMemo(() => {
92 const normalizedQuery = searchQuery.trim().toLowerCase();
93
94 if (!normalizedQuery) {
95 return availableOptionsGrouped;
96 }
97
98 const nextOptionsGrouped: Record<string, readonly LayoutOption[]> = {};
99
100 for (const [category, options] of Object.entries(availableOptionsGrouped)) {
101 const categoryMatches = category.toLowerCase().includes(normalizedQuery);
102 const filteredOptions = categoryMatches
103 ? options
104 : options.filter((option) =>
105 [option.name, option.type, option.variant, option.key].some(
106 (value) =>
107 String(value ?? "")
108 .toLowerCase()
109 .includes(normalizedQuery),
110 ),
111 );
112
113 if (filteredOptions.length > 0) {
114 nextOptionsGrouped[category] = filteredOptions;
115 }
116 }
117
118 return nextOptionsGrouped;
119 }, [availableOptionsGrouped, searchQuery]);
120
121 const handleOpenChange = React.useCallback((nextOpen: boolean) => {
122 setOpen(nextOpen);
123
124 if (!nextOpen) {
125 setSearchQuery("");
126 }
127 }, []);
128
129 React.useEffect(() => {
130 if (!open) return;
131
132 const animationFrameId = window.requestAnimationFrame(() => {
133 window.setTimeout(() => searchInputRef.current?.focus(), 0);
134 });
135
136 return () => window.cancelAnimationFrame(animationFrameId);
137 }, [open]);
138
139 const keepSearchFocusInsideMenu = React.useCallback(
140 (event: React.SyntheticEvent) => {
141 event.stopPropagation();
142 },
143 [],
144 );
145
146 const filteredChartOptions =
147 filteredOptionsGrouped[ELEMENT_CATEGORIES.CHARTS] ?? [];
148 const hasFilteredOptions = isCurrentElementChart
149 ? filteredChartOptions.length > 0
150 : Object.keys(filteredOptionsGrouped).length > 0;
151
152 return (
153 <ToolbarGroup>
154 <DropdownMenu open={open} onOpenChange={handleOpenChange} modal={false}>
155 <DropdownMenuTrigger asChild>
156 <ToolbarButton
157 className="min-w-31.25"
158 pressed={open}
159 tooltip="Change Element Type"
160 isDropdown
161 >
162 {selectedOption}
163 </ToolbarButton>
164 </DropdownMenuTrigger>
165
166 <DropdownMenuContent
167 className={`${FLOATING_TOOLBAR_IGNORE_CLASS} w-80 min-w-0`}
168 align="start"
169 >
170 <div className="border-b border-border p-3">
171 <div className="relative">
172 <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
173 <input
174 ref={searchInputRef}
175 aria-label="Search element type"
176 className="h-9 w-full rounded-md border border-input bg-background pr-9 pl-9 text-sm outline-hidden transition-[border-color,box-shadow] placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50"
177 placeholder="Search element type"
178 value={searchQuery}
179 onChange={(event) => setSearchQuery(event.target.value)}
180 onClick={keepSearchFocusInsideMenu}
181 onKeyDown={(event) => {
182 if (event.key !== "Escape") {
183 event.stopPropagation();
184 }
185 }}
186 onMouseDown={keepSearchFocusInsideMenu}
187 onPointerDown={keepSearchFocusInsideMenu}
188 />
189 {searchQuery ? (
190 <button
191 type="button"
192 aria-label="Clear search"
193 className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
194 onClick={(event) => {
195 event.stopPropagation();
196 setSearchQuery("");
197 searchInputRef.current?.focus();
198 }}
199 onMouseDown={keepSearchFocusInsideMenu}
200 onPointerDown={keepSearchFocusInsideMenu}
201 >
202 <X className="size-4" />
203 </button>
204 ) : null}
205 </div>
206 </div>
207
208 <div className="scrollbar-thin max-h-96 overflow-y-auto p-2 scrollbar-thumb-primary scrollbar-track-primary/20">
209 <div className="space-y-1">
210 {/* For charts: show compatible charts directly without submenu */}
211 {!hasFilteredOptions ? (
212 <div className="px-3 py-8 text-center text-sm text-muted-foreground">
213 No layouts found
214 </div>
215 ) : isCurrentElementChart ? (
216 <div className="grid grid-cols-2 gap-1">
217 {filteredChartOptions.map((block) => {
218 const isSelected = block.type === elementType;
219 const IconComponent = block.icon;
220 return (
221 <DropdownMenuItem
222 key={block.type}
223 onClick={() => applyOption(block)}
224 className={`flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
225 isSelected
226 ? "bg-primary text-primary-foreground"
227 : "hover:bg-accent hover:text-accent-foreground"
228 }`}
229 >
230 {IconComponent}
231 <span className="flex-1 text-left">{block.name}</span>
232 {isSelected && (
233 <span className="text-xs font-bold">✓</span>
234 )}
235 </DropdownMenuItem>
236 );
237 })}
238 </div>
239 ) : (
240 /* For non-charts: use categories with submenus */
241 Object.entries(filteredOptionsGrouped).map(
242 ([category, elements]) => {
243 const isCategoryActive = elements.some((block) =>
244 optionMatchesElement(block, elementType, element),
245 );
246
247 return (
248 <DropdownMenuSub key={category}>
249 <DropdownMenuSubTrigger
250 className={`flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition-colors ${
251 isCategoryActive
252 ? "bg-primary text-primary-foreground data-[state=open]:bg-primary data-[state=open]:text-primary-foreground focus:bg-primary focus:text-primary-foreground"
253 : "hover:bg-accent hover:text-accent-foreground"
254 }`}
255 >
256 {CATEGORY_ICONS[category]}
257 <span className="flex-1 text-left">{category}</span>
258 </DropdownMenuSubTrigger>
259 <DropdownMenuSubContent
260 className={`${FLOATING_TOOLBAR_IGNORE_CLASS} w-56`}
261 >
262 <div className="space-y-1 p-1">
263 {elements.map((block) => {
264 const isSelected = optionMatchesElement(
265 block,
266 elementType,
267 element,
268 );
269 const IconComponent = block.icon;
270
271 return (
272 <DropdownMenuItem
273 key={`${block.type}-${block.key ?? "type"}-${block.variant || "default"}`}
274 onClick={() => applyOption(block)}
275 className={`flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
276 isSelected
277 ? "bg-primary text-primary-foreground"
278 : "hover:bg-accent hover:text-accent-foreground"
279 }`}
280 >
281 {IconComponent}
282 <span className="flex-1 text-left">
283 {block.name}
284 </span>
285 {isSelected && (
286 <span className="text-xs font-bold">✓</span>
287 )}
288 </DropdownMenuItem>
289 );
290 })}
291 </div>
292 </DropdownMenuSubContent>
293 </DropdownMenuSub>
294 );
295 },
296 )
297 )}
298 </div>
299 </div>
300 </DropdownMenuContent>
301 </DropdownMenu>
302 </ToolbarGroup>
303 );
304 }
305
305 lines Plain Text