返回 presentation-ai
InfographicControls.tsx
根目录 / src / components / presentation / floating-toolbar / InfographicControls.tsx
1 "use client";
2
3 import { type InfographicOptions } from "@antv/infographic";
4 import {
5 AlignCenter,
6 AlignLeft,
7 AlignRight,
8 Minus,
9 Pencil,
10 Plus,
11 Split,
12 WandSparkles,
13 type LucideIcon,
14 } from "lucide-react";
15 import { useCallback, useMemo, useState } from "react";
16
17 import { InfographicDataEditorDialog } from "@/components/notebook/presentation/editor/custom-elements/infographic-data-editor-dialog";
18 import {
19 getInfographicThemeColors,
20 parseInfographicPalette,
21 parseInfographicStylize,
22 updateInfographicTheme,
23 } from "@/components/notebook/presentation/editor/utils/infographic-utils";
24 import { PALETTE_DROP_MUTABLE_KEY } from "@/components/notebook/presentation/editor/utils/paletteDrop";
25 import { type PlateNode } from "@/components/notebook/presentation/utils/parser";
26 import {
27 DropdownMenu,
28 DropdownMenuContent,
29 DropdownMenuItem,
30 DropdownMenuLabel,
31 DropdownMenuRadioGroup,
32 DropdownMenuRadioItem,
33 DropdownMenuTrigger,
34 } from "@/components/plate/ui/dropdown-menu";
35 import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar";
36 import { Button } from "@/components/ui/button";
37 import ColorPicker from "@/components/ui/color-picker";
38 import {
39 Popover,
40 PopoverContent,
41 PopoverTrigger,
42 } from "@/components/ui/popover";
43 import { Separator } from "@/components/ui/separator";
44 import { useAntvInfographicTheme } from "@/hooks/presentation/infographic/useAntvInfographicTheme";
45 import { cn } from "@/lib/utils";
46 import { usePresentationState } from "@/states/presentation-state";
47 import { EditWithAI } from "./EditWithAI";
48 import { FLOATING_TOOLBAR_IGNORE_CLASS } from "./toolbar-interaction";
49 import { useToolbarContext } from "./ToolbarContext";
50
51 type PaletteOption = {
52 id: string;
53 label: string;
54 value: string | string[] | null;
55 };
56
57 const PALETTE_OPTIONS: PaletteOption[] = [
58 { id: "default", label: "Default", value: null },
59 { id: "classic", label: "Prism", value: "antv" },
60 {
61 id: "bloom",
62 label: "Bloom",
63 value: ["#61DDAA", "#F6BD16", "#F08BB4"],
64 },
65 {
66 id: "ember",
67 label: "Ember",
68 value: ["#F08BB4", "#F6BD16", "#D588F0"],
69 },
70 {
71 id: "tide",
72 label: "Tide",
73 value: ["#5B8FF9", "#5AD8A6", "#5D7092"],
74 },
75 ];
76
77 function InfographicActionButton({
78 icon: Icon,
79 label,
80 tooltip,
81 pressed,
82 className,
83 action,
84 }: {
85 icon: LucideIcon;
86 label?: string;
87 tooltip: string;
88 pressed?: boolean;
89 className?: string;
90 action: () => void;
91 }) {
92 return (
93 <ToolbarButton
94 type="button"
95 tooltip={tooltip}
96 pressed={pressed}
97 size="sm"
98 className={className}
99 onClick={action}
100 >
101 <Icon
102 className={cn("size-4", pressed && label == null && "fill-current")}
103 />
104 {label ? <span className="text-xs">{label}</span> : null}
105 </ToolbarButton>
106 );
107 }
108
109 export function InfographicControls() {
110 const {
111 editor,
112 element,
113 handleNodePropertyUpdate,
114 isInfographicElement,
115 handleOpenInfographicEditor,
116 } = useToolbarContext();
117
118 const [openPaletteDropdown, setOpenPaletteDropdown] = useState(false);
119 const [openAIEditPopover, setOpenAIEditPopover] = useState(false);
120 const [openDataEditor, setOpenDataEditor] = useState(false);
121 const currentSlideId = usePresentationState((state) => state.currentSlideId);
122 const updateSlide = usePresentationState((state) => state.updateSlide);
123
124 const [customPalette, setCustomPalette] = useState<string[]>([
125 "#5B8FF9",
126 "#5AD8A6",
127 "#F6BD16",
128 ]);
129
130 const currentAlignment =
131 (element as { align?: "left" | "center" | "right" } | undefined)?.align ??
132 "center";
133
134 const handleAlignmentChange = useCallback(
135 (value: string) => {
136 handleNodePropertyUpdate("align", value);
137 },
138 [handleNodePropertyUpdate],
139 );
140
141 // Get current state from syntax
142 const currentSyntax =
143 (element as { syntax?: string } | undefined)?.syntax ?? "";
144 const { isDark, themeColors } = useAntvInfographicTheme(currentSyntax);
145
146 const currentStylize = useMemo(
147 () => parseInfographicStylize(currentSyntax),
148 [currentSyntax],
149 );
150
151 const currentPalette = useMemo(
152 () => parseInfographicPalette(currentSyntax),
153 [currentSyntax],
154 );
155
156 const currentThemeStyle =
157 currentStylize === "rough" ? "hand-drawn" : "default";
158
159 const currentPaletteId = useMemo(() => {
160 if (!currentPalette) return "default";
161 const paletteEntry = PALETTE_OPTIONS.find((option) => {
162 if (typeof option.value === "string")
163 return option.value === currentPalette;
164 if (Array.isArray(option.value) && Array.isArray(currentPalette)) {
165 return option.value.join(",") === currentPalette.join(",");
166 }
167 return false;
168 });
169 return paletteEntry?.id ?? "custom";
170 }, [currentPalette]);
171
172 const defaultPaletteColors = useMemo(
173 () => getInfographicThemeColors(isDark, themeColors).palette.slice(0, 3),
174 [isDark, themeColors],
175 );
176
177 const currentPaletteColors = useMemo(() => {
178 if (currentPaletteId === "default") return defaultPaletteColors;
179 if (currentPaletteId === "custom") return customPalette.slice(0, 3);
180 const option = PALETTE_OPTIONS.find((o) => o.id === currentPaletteId);
181 if (!option || !option.value) return ["currentColor"];
182 if (typeof option.value === "string")
183 return ["#5B8FF9", "#5AD8A6", "#5D7092"]; // Fallback for 'antv' named theme
184 return option.value.slice(0, 3);
185 }, [currentPaletteId, customPalette, defaultPaletteColors]);
186
187 const handleThemeStyleToggle = useCallback(() => {
188 if (!currentSyntax) return;
189 const newStylize = currentThemeStyle === "hand-drawn" ? null : "rough";
190 const newSyntax = updateInfographicTheme(currentSyntax, {
191 stylize: newStylize,
192 });
193 handleNodePropertyUpdate("syntax", newSyntax);
194 }, [currentSyntax, currentThemeStyle, handleNodePropertyUpdate]);
195
196 const handlePaletteChange = useCallback(
197 (value: string) => {
198 if (!currentSyntax) return;
199 if (value === "default") {
200 const newSyntax = updateInfographicTheme(currentSyntax, {
201 palette: null,
202 });
203 handleNodePropertyUpdate("syntax", newSyntax);
204 return;
205 }
206
207 if (value === "custom") {
208 const newSyntax = updateInfographicTheme(currentSyntax, {
209 palette: customPalette,
210 });
211 handleNodePropertyUpdate("syntax", newSyntax);
212 return;
213 }
214 const palette = PALETTE_OPTIONS.find(
215 (option) => option.id === value,
216 )?.value;
217 const newSyntax = updateInfographicTheme(currentSyntax, {
218 palette: palette ?? null,
219 });
220 handleNodePropertyUpdate("syntax", newSyntax);
221 },
222 [currentSyntax, customPalette, handleNodePropertyUpdate],
223 );
224
225 const handleCustomColorChange = useCallback(
226 (index: number, color: string) => {
227 setCustomPalette((prev) => {
228 const newPalette = [...prev];
229 newPalette[index] = color;
230 return newPalette;
231 });
232 },
233 [],
234 );
235
236 const addCustomColor = useCallback(() => {
237 setCustomPalette((prev) => [...prev, "#888888"]);
238 }, []);
239
240 const removeCustomColor = useCallback((index: number) => {
241 setCustomPalette((prev) => prev.filter((_, i) => i !== index));
242 }, []);
243
244 const applyCustomPalette = useCallback(() => {
245 if (!currentSyntax) return;
246 const newSyntax = updateInfographicTheme(currentSyntax, {
247 palette: customPalette,
248 });
249 handleNodePropertyUpdate("syntax", newSyntax);
250 setOpenPaletteDropdown(false);
251 }, [currentSyntax, customPalette, handleNodePropertyUpdate]);
252
253 const handleAISyntaxChange = useCallback(
254 (newSyntax: string) => {
255 handleNodePropertyUpdate("syntax", newSyntax);
256 },
257 [handleNodePropertyUpdate],
258 );
259
260 const handleInfographicDataChange = useCallback(
261 (update: { data: Partial<InfographicOptions>; syntax: string }) => {
262 if (!element) return;
263
264 editor.tf.setNodes(
265 {
266 data: update.data,
267 syntax: update.syntax,
268 [PALETTE_DROP_MUTABLE_KEY]: false,
269 },
270 {
271 at: [],
272 match: (node) => node.id === element.id,
273 },
274 );
275
276 if (currentSlideId) {
277 updateSlide(currentSlideId, {
278 content: editor.children as PlateNode[],
279 });
280 }
281 },
282 [currentSlideId, editor, element, updateSlide],
283 );
284
285 if (!isInfographicElement) return null;
286
287 return (
288 <ToolbarGroup className="gap-1.5 px-0.5">
289 {/* Edit Infographic Button - Opens sidebar for template conversion */}
290 <ToolbarGroup>
291 <InfographicActionButton
292 icon={Split}
293 label="Change"
294 tooltip="Edit Infographic"
295 className="gap-1"
296 action={handleOpenInfographicEditor}
297 />
298 <Separator orientation="vertical" className="mx-0.5 h-5 bg-border/60" />
299 <InfographicActionButton
300 icon={Pencil}
301 label="Edit"
302 tooltip="Edit Infographic"
303 className="gap-1"
304 action={() => setOpenDataEditor(true)}
305 />
306 <Separator orientation="vertical" className="mx-0.5 h-5 bg-border/60" />
307 </ToolbarGroup>
308 {/* Alignment Dropdown */}
309 <DropdownMenu modal={false}>
310 <DropdownMenuTrigger asChild>
311 <ToolbarButton tooltip="Alignment" size="sm">
312 {currentAlignment === "left" && <AlignLeft className="h-4 w-4" />}
313 {currentAlignment === "center" && (
314 <AlignCenter className="h-4 w-4" />
315 )}
316 {currentAlignment === "right" && <AlignRight className="h-4 w-4" />}
317 </ToolbarButton>
318 </DropdownMenuTrigger>
319 <DropdownMenuContent
320 align="start"
321 className={FLOATING_TOOLBAR_IGNORE_CLASS}
322 >
323 <DropdownMenuRadioGroup
324 value={currentAlignment}
325 onValueChange={handleAlignmentChange}
326 className={FLOATING_TOOLBAR_IGNORE_CLASS}
327 >
328 <DropdownMenuRadioItem value="left">
329 <AlignLeft className="mr-2 h-4 w-4" />
330 Left
331 </DropdownMenuRadioItem>
332 <DropdownMenuRadioItem value="center">
333 <AlignCenter className="mr-2 h-4 w-4" />
334 Center
335 </DropdownMenuRadioItem>
336 <DropdownMenuRadioItem value="right">
337 <AlignRight className="mr-2 h-4 w-4" />
338 Right
339 </DropdownMenuRadioItem>
340 </DropdownMenuRadioGroup>
341 </DropdownMenuContent>
342 </DropdownMenu>
343
344 <Separator orientation="vertical" className="mx-0.5 h-5 bg-border/60" />
345
346 {/* Style Toggle */}
347 <InfographicActionButton
348 icon={Pencil}
349 pressed={currentThemeStyle === "hand-drawn"}
350 action={handleThemeStyleToggle}
351 tooltip={
352 currentThemeStyle === "hand-drawn"
353 ? "Switch to standard"
354 : "Switch to hand-drawn"
355 }
356 className={cn(
357 "h-7 w-7 p-0 transition-all",
358 currentThemeStyle === "hand-drawn" &&
359 "bg-primary/10 text-primary hover:bg-primary/20",
360 )}
361 />
362
363 <Separator orientation="vertical" className="mx-0.5 h-5 bg-border/60" />
364
365 {/* Palette Picker */}
366 <DropdownMenu
367 open={openPaletteDropdown}
368 onOpenChange={setOpenPaletteDropdown}
369 modal={false}
370 >
371 <DropdownMenuTrigger asChild>
372 <ToolbarButton
373 isDropdown
374 tooltip="Adjust colors"
375 className="w-auto gap-1.5 px-2"
376 >
377 <div className="mr-0.5 flex items-center -space-x-1.5">
378 {currentPaletteColors.slice(0, 3).map((color, i) => (
379 <div
380 key={i}
381 className="h-3.5 w-3.5 rounded-full border border-background shadow ring ring-border/20"
382 style={{ backgroundColor: color }}
383 />
384 ))}
385 </div>
386 </ToolbarButton>
387 </DropdownMenuTrigger>
388 <DropdownMenuContent
389 className={`${FLOATING_TOOLBAR_IGNORE_CLASS} w-64 scroll-smooth rounded-xl border-border/50 bg-background/95 p-2 shadow-xl backdrop-blur-xl`}
390 align="start"
391 side="top"
392 >
393 <DropdownMenuLabel className="px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
394 Color Palette
395 </DropdownMenuLabel>
396 <div className="mb-2 grid grid-cols-1 gap-1">
397 {PALETTE_OPTIONS.map((option) => (
398 <DropdownMenuItem
399 key={option.id}
400 onSelect={() => handlePaletteChange(option.id)}
401 className={cn(
402 "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2",
403 currentPaletteId === option.id &&
404 "bg-accent text-accent-foreground",
405 )}
406 >
407 <span className="text-sm font-medium">{option.label}</span>
408 <div className="flex gap-1">
409 {(option.id === "default"
410 ? defaultPaletteColors
411 : Array.isArray(option.value)
412 ? option.value
413 : ["#5B8FF9", "#5AD8A6", "#5D7092"]
414 )
415 .slice(0, 5)
416 .map((color, i) => (
417 <div
418 key={i}
419 className="h-3 w-3 rounded-full"
420 style={{ backgroundColor: color }}
421 />
422 ))}
423 </div>
424 </DropdownMenuItem>
425 ))}
426 </div>
427
428 <Separator className="my-2 bg-border/50" />
429
430 <div className="px-2 pb-1">
431 <div className="mb-3 flex items-center justify-between">
432 <span className="text-xs font-medium text-muted-foreground">
433 Custom Colors
434 </span>
435 {currentPaletteId === "custom" && (
436 <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
437 Active
438 </span>
439 )}
440 </div>
441
442 <div className="space-y-3">
443 <div className="flex flex-wrap gap-2">
444 {customPalette.map((color, index) => (
445 <div key={index} className="group relative">
446 <ColorPicker
447 value={color}
448 onChange={(value) =>
449 handleCustomColorChange(index, value)
450 }
451 >
452 <button
453 type="button"
454 className="h-6 w-6 rounded-full border border-border shadow transition-transform hover:scale-110 focus:ring-2 focus:ring-primary/50 focus:outline-none"
455 style={{ backgroundColor: color }}
456 aria-label={`Color ${index + 1}`}
457 />
458 </ColorPicker>
459 {customPalette.length > 2 && (
460 <button
461 type="button"
462 onClick={() => removeCustomColor(index)}
463 className="absolute -top-1 -right-1 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow group-hover:flex"
464 >
465 <Minus className="h-2 w-2" />
466 </button>
467 )}
468 </div>
469 ))}
470
471 {customPalette.length < 10 && (
472 <button
473 type="button"
474 onClick={addCustomColor}
475 className="flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground transition-colors hover:border-primary hover:text-primary"
476 >
477 <Plus className="h-3 w-3" />
478 </button>
479 )}
480 </div>
481
482 <div className="flex w-full gap-2 pt-1">
483 <Button
484 size="sm"
485 variant="default"
486 className="h-7 w-full text-xs"
487 onClick={applyCustomPalette}
488 >
489 Apply Custom Palette
490 </Button>
491 </div>
492 </div>
493 </div>
494 </DropdownMenuContent>
495 </DropdownMenu>
496
497 <Separator orientation="vertical" className="mx-0.5 h-5 bg-border/60" />
498
499 {/* Edit with AI */}
500 <Popover open={openAIEditPopover} onOpenChange={setOpenAIEditPopover}>
501 <PopoverTrigger asChild>
502 <ToolbarButton
503 tooltip="Edit with AI"
504 className={cn(
505 "h-7 gap-1.5 px-2 text-xs font-medium",
506 openAIEditPopover && "bg-primary/10 text-primary",
507 )}
508 >
509 <WandSparkles className="h-3.5 w-3.5" />
510 </ToolbarButton>
511 </PopoverTrigger>
512 <PopoverContent
513 className={`${FLOATING_TOOLBAR_IGNORE_CLASS} w-auto rounded-xl border-border/50 bg-background/95 p-0 shadow-xl backdrop-blur-xl`}
514 align="start"
515 sideOffset={8}
516 >
517 <EditWithAI
518 currentSyntax={currentSyntax}
519 onSyntaxChange={handleAISyntaxChange}
520 onClose={() => setOpenAIEditPopover(false)}
521 />
522 </PopoverContent>
523 </Popover>
524
525 <InfographicDataEditorDialog
526 open={openDataEditor}
527 onOpenChange={setOpenDataEditor}
528 syntax={currentSyntax}
529 data={
530 (element as { data?: Partial<InfographicOptions> } | undefined)?.data
531 }
532 isDark={isDark}
533 themeColors={themeColors}
534 onApply={handleInfographicDataChange}
535 />
536 </ToolbarGroup>
537 );
538 }
539
539 lines Plain Text