返回 presentation-ai
ChartSettingsPopover.tsx
根目录 / src / components / presentation / floating-toolbar / ChartSettingsPopover.tsx
1 "use client";
2
3 import { cn } from "@/lib/utils";
4 import { Settings } from "lucide-react";
5
6 import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar";
7 import { DebouncedInput } from "@/components/ui/debounced-input";
8 import { Input } from "@/components/ui/input";
9 import { Label } from "@/components/ui/label";
10 import {
11 Popover,
12 PopoverContent,
13 PopoverTrigger,
14 } from "@/components/ui/popover";
15 import {
16 Select,
17 SelectContent,
18 SelectItem,
19 SelectTrigger,
20 SelectValue,
21 } from "@/components/ui/select";
22 import { Slider } from "@/components/ui/slider";
23 import { Switch } from "@/components/ui/switch";
24 import { useToolbarContext } from "./ToolbarContext";
25
26 // Type definition for axis configuration
27 interface AxisConfig {
28 title?: string | { text?: string; enabled?: boolean };
29 label?: { enabled?: boolean };
30 gridLine?: { enabled?: boolean };
31 }
32
33 export function ChartSettingsPopover() {
34 const {
35 element,
36 elementType,
37 handleNodePropertyUpdate,
38 isCurrentElementChart,
39 } = useToolbarContext();
40
41 if (!isCurrentElementChart) {
42 return null;
43 }
44
45 // Get current values from element
46 const title = (element as { title?: { text?: string } })?.title?.text ?? "";
47 const subtitle =
48 (element as { subtitle?: { text?: string } })?.subtitle?.text ?? "";
49
50 // Get per-axis configuration
51 const xAxisConfig = (element as { xAxis?: AxisConfig })?.xAxis ?? {};
52 const yAxisConfig = (element as { yAxis?: AxisConfig })?.yAxis ?? {};
53
54 const normalizeTitle = (title?: AxisConfig["title"]) =>
55 typeof title === "string" ? { text: title } : (title ?? {});
56
57 // Legacy fallbacks
58 const showGrid = (element as { showGrid?: boolean })?.showGrid ?? true;
59 const showAxisLabels =
60 (element as { showAxisLabels?: boolean })?.showAxisLabels ?? true;
61
62 // Per-axis values with fallback to legacy
63 const xAxisTitleConfig = normalizeTitle(xAxisConfig.title);
64 const xAxisTitleEnabled =
65 xAxisTitleConfig.enabled ?? Boolean(xAxisTitleConfig.text);
66 const xAxisTitle = xAxisTitleConfig.text ?? "";
67 const xAxisLabelEnabled = xAxisConfig.label?.enabled ?? showAxisLabels;
68 const xAxisGridEnabled = xAxisConfig.gridLine?.enabled ?? false;
69
70 const yAxisTitleConfig = normalizeTitle(yAxisConfig.title);
71 const yAxisTitleEnabled =
72 yAxisTitleConfig.enabled ?? Boolean(yAxisTitleConfig.text);
73 const yAxisTitle = yAxisTitleConfig.text ?? "";
74 const yAxisLabelEnabled = yAxisConfig.label?.enabled ?? showAxisLabels;
75 const yAxisGridEnabled = yAxisConfig.gridLine?.enabled ?? showGrid;
76
77 // Animation settings
78 const animationEnabled =
79 (element as { disableAnimation?: boolean })?.disableAnimation === false ||
80 (element as { animation?: { enabled?: boolean } })?.animation?.enabled ===
81 true;
82 const animationDuration =
83 (element as { animation?: { duration?: number } })?.animation?.duration ??
84 500;
85
86 // Legend settings
87 const legendEnabled =
88 (element as { showLegend?: boolean })?.showLegend ??
89 (element as { legend?: { enabled?: boolean } })?.legend?.enabled ??
90 true;
91 const legendPosition =
92 (element as { legend?: { position?: string } })?.legend?.position ??
93 "bottom";
94
95 // Background settings
96 const backgroundFill =
97 (element as { background?: { fill?: string } })?.background?.fill ?? "";
98 const backgroundVisible =
99 (element as { background?: { visible?: boolean } })?.background?.visible ??
100 false;
101
102 // Check if chart type supports axes (not applicable to pie/donut/gauge/etc)
103 const supportsAxes = ![
104 "chart-pie",
105 "chart-donut",
106 "chart-radar",
107 "chart-radial-bar",
108 "chart-nightingale",
109 "chart-radial-column",
110 "chart-sunburst",
111 "chart-sankey",
112 "chart-chord",
113 "chart-funnel",
114 "chart-cone-funnel",
115 "chart-pyramid",
116 "chart-radial-gauge",
117 "chart-linear-gauge",
118 "chart-treemap",
119 ].includes(elementType);
120
121 // Check if this is a donut chart (for inner labels)
122 const isDonutChart = elementType === "chart-donut";
123
124 // Donut inner labels configuration
125 type InnerLabelConfig = {
126 text: string;
127 fontWeight?: "normal" | "bold";
128 fontSize?: number;
129 color?: string;
130 spacing?: number;
131 };
132 const innerLabels =
133 (element as { innerLabels?: InnerLabelConfig[] })?.innerLabels ?? [];
134 const innerCircleFill =
135 (element as { innerCircle?: { fill?: string } })?.innerCircle?.fill ?? "";
136 const innerRadiusRatio =
137 (element as { innerRadiusRatio?: number })?.innerRadiusRatio ?? 0.7;
138
139 // Get first two inner labels for UI (title and value)
140 const innerLabelTitle = innerLabels[0]?.text ?? "";
141 const innerLabelValue = innerLabels[1]?.text ?? "";
142
143 // Handlers for nested object updates
144 const updateTitle = (text: string) => {
145 handleNodePropertyUpdate("title", { text });
146 };
147
148 const updateSubtitle = (text: string) => {
149 handleNodePropertyUpdate("subtitle", { text });
150 };
151
152 // Update X-axis settings
153 const updateXAxis = (updates: Partial<AxisConfig>) => {
154 const nextTitle = normalizeTitle(
155 updates.title ?? xAxisConfig.title ?? undefined,
156 );
157 handleNodePropertyUpdate("xAxis", {
158 ...xAxisConfig,
159 ...updates,
160 label: {
161 ...xAxisConfig.label,
162 ...updates.label,
163 },
164 gridLine: {
165 ...xAxisConfig.gridLine,
166 ...updates.gridLine,
167 },
168 title:
169 updates.title !== undefined
170 ? nextTitle
171 : (xAxisConfig.title ?? nextTitle),
172 });
173 };
174
175 // Update Y-axis settings
176 const updateYAxis = (updates: Partial<AxisConfig>) => {
177 const nextTitle = normalizeTitle(
178 updates.title ?? yAxisConfig.title ?? undefined,
179 );
180 handleNodePropertyUpdate("yAxis", {
181 ...yAxisConfig,
182 ...updates,
183 label: {
184 ...yAxisConfig.label,
185 ...updates.label,
186 },
187 gridLine: {
188 ...yAxisConfig.gridLine,
189 ...updates.gridLine,
190 },
191 title:
192 updates.title !== undefined
193 ? nextTitle
194 : (yAxisConfig.title ?? nextTitle),
195 });
196 };
197
198 const updateAnimation = (enabled: boolean, duration?: number) => {
199 // Update both disableAnimation (backward compat) and animation object
200 handleNodePropertyUpdate("disableAnimation", !enabled);
201 handleNodePropertyUpdate("animation", {
202 enabled,
203 duration: duration ?? animationDuration,
204 });
205 };
206
207 const updateLegend = (enabled: boolean, position?: string) => {
208 // Update both showLegend (backward compat) and legend object
209 handleNodePropertyUpdate("showLegend", enabled);
210 handleNodePropertyUpdate("legend", {
211 enabled,
212 position: position ?? legendPosition,
213 });
214 };
215
216 const updateBackground = (fill: string, visible?: boolean) => {
217 handleNodePropertyUpdate("background", {
218 fill,
219 visible: visible ?? backgroundVisible,
220 });
221 };
222
223 const updateInnerLabels = (titleText: string, valueText: string) => {
224 const newLabels: InnerLabelConfig[] = [];
225 if (titleText) {
226 newLabels.push({
227 text: titleText,
228 fontWeight: "bold",
229 });
230 }
231 if (valueText) {
232 newLabels.push({
233 text: valueText,
234 spacing: 4,
235 fontSize: 32,
236 });
237 }
238 handleNodePropertyUpdate("innerLabels", newLabels);
239 };
240
241 return (
242 <ToolbarGroup>
243 <Popover>
244 <PopoverTrigger asChild>
245 <ToolbarButton tooltip="Chart Settings" size="sm">
246 <Settings className="h-4 w-4" />
247 </ToolbarButton>
248 </PopoverTrigger>
249 <PopoverContent
250 className="ignore-click-outside/toolbar max-h-[70vh] w-80 overflow-y-auto"
251 align="start"
252 >
253 <div className="grid gap-4">
254 <div className="space-y-2">
255 <h4 className="leading-none font-medium">Chart Settings</h4>
256 <p className="text-sm text-muted-foreground">
257 Configure chart appearance
258 </p>
259 </div>
260
261 {/* Title Section */}
262 <div className="grid gap-2">
263 <Label htmlFor="chart-title">Title</Label>
264 <DebouncedInput
265 id="chart-title"
266 placeholder="Chart title"
267 value={title}
268 onChange={(value) => updateTitle(String(value))}
269 />
270 </div>
271
272 <div className="grid gap-2">
273 <Label htmlFor="chart-subtitle">Subtitle</Label>
274 <DebouncedInput
275 id="chart-subtitle"
276 placeholder="Chart subtitle"
277 value={subtitle}
278 onChange={(value) => updateSubtitle(String(value))}
279 />
280 </div>
281
282 {/* X-Axis Section - Only for applicable charts */}
283 {supportsAxes && (
284 <div className="space-y-3 rounded-lg border p-3">
285 <h5 className="text-sm font-medium">X-Axis</h5>
286
287 <div className="flex items-center justify-between">
288 <Label htmlFor="x-axis-labels" className="text-xs">
289 Show Labels
290 </Label>
291 <Switch
292 id="x-axis-labels"
293 checked={xAxisLabelEnabled}
294 onCheckedChange={(checked) =>
295 updateXAxis({ label: { enabled: checked } })
296 }
297 />
298 </div>
299
300 <div className="flex items-center justify-between">
301 <Label htmlFor="x-axis-grid" className="text-xs">
302 Show Grid Lines
303 </Label>
304 <Switch
305 id="x-axis-grid"
306 checked={xAxisGridEnabled}
307 onCheckedChange={(checked) =>
308 updateXAxis({ gridLine: { enabled: checked } })
309 }
310 />
311 </div>
312
313 <div className="flex items-center justify-between">
314 <Label htmlFor="x-axis-title-enabled" className="text-xs">
315 Show Title
316 </Label>
317 <Switch
318 id="x-axis-title-enabled"
319 checked={xAxisTitleEnabled}
320 onCheckedChange={(checked) =>
321 updateXAxis({
322 title: { ...xAxisTitleConfig, enabled: checked },
323 })
324 }
325 />
326 </div>
327
328 <div className="grid gap-1.5">
329 <Label htmlFor="x-axis-title" className="text-xs">
330 Title
331 </Label>
332 <DebouncedInput
333 id="x-axis-title"
334 placeholder="X-axis title"
335 value={xAxisTitle}
336 disabled={!xAxisTitleEnabled}
337 onChange={(value) =>
338 updateXAxis({
339 title: { ...xAxisTitleConfig, text: String(value) },
340 })
341 }
342 className="h-8 text-sm"
343 />
344 </div>
345 </div>
346 )}
347
348 {/* Y-Axis Section - Only for applicable charts */}
349 {supportsAxes && (
350 <div className="space-y-3 rounded-lg border p-3">
351 <h5 className="text-sm font-medium">Y-Axis</h5>
352
353 <div className="flex items-center justify-between">
354 <Label htmlFor="y-axis-labels" className="text-xs">
355 Show Labels
356 </Label>
357 <Switch
358 id="y-axis-labels"
359 checked={yAxisLabelEnabled}
360 onCheckedChange={(checked) =>
361 updateYAxis({ label: { enabled: checked } })
362 }
363 />
364 </div>
365
366 <div className="flex items-center justify-between">
367 <Label htmlFor="y-axis-grid" className="text-xs">
368 Show Grid Lines
369 </Label>
370 <Switch
371 id="y-axis-grid"
372 checked={yAxisGridEnabled}
373 onCheckedChange={(checked) =>
374 updateYAxis({ gridLine: { enabled: checked } })
375 }
376 />
377 </div>
378
379 <div className="flex items-center justify-between">
380 <Label htmlFor="y-axis-title-enabled" className="text-xs">
381 Show Title
382 </Label>
383 <Switch
384 id="y-axis-title-enabled"
385 checked={yAxisTitleEnabled}
386 onCheckedChange={(checked) =>
387 updateYAxis({
388 title: { ...yAxisTitleConfig, enabled: checked },
389 })
390 }
391 />
392 </div>
393
394 <div className="grid gap-1.5">
395 <Label htmlFor="y-axis-title" className="text-xs">
396 Title
397 </Label>
398 <DebouncedInput
399 id="y-axis-title"
400 placeholder="Y-axis title"
401 value={yAxisTitle}
402 disabled={!yAxisTitleEnabled}
403 onChange={(value) =>
404 updateYAxis({
405 title: { ...yAxisTitleConfig, text: String(value) },
406 })
407 }
408 className="h-8 text-sm"
409 />
410 </div>
411 </div>
412 )}
413
414 {/* Animation Section - Uses grid-rows for smooth transition */}
415 <div className="space-y-2">
416 <div className="flex items-center justify-between">
417 <Label htmlFor="animation-toggle">Animation</Label>
418 <Switch
419 id="animation-toggle"
420 checked={animationEnabled}
421 onCheckedChange={(checked) => updateAnimation(checked)}
422 />
423 </div>
424
425 <div
426 className={cn(
427 "rounded-lg border p-3 transition-opacity",
428 !animationEnabled && "opacity-50",
429 )}
430 aria-disabled={!animationEnabled}
431 >
432 <div className="flex items-center justify-between">
433 <Label htmlFor="animation-duration">Duration</Label>
434 <span className="text-sm text-muted-foreground">
435 {animationDuration}ms
436 </span>
437 </div>
438 <Slider
439 id="animation-duration"
440 min={100}
441 max={2000}
442 step={100}
443 value={[animationDuration]}
444 disabled={!animationEnabled}
445 onValueChange={([value]) =>
446 updateAnimation(animationEnabled, value)
447 }
448 className="mt-2"
449 />
450 </div>
451 </div>
452
453 {/* Legend Section */}
454 <div className="space-y-2">
455 <div className="flex items-center justify-between">
456 <Label htmlFor="legend-toggle">Legend</Label>
457 <Switch
458 id="legend-toggle"
459 checked={legendEnabled}
460 onCheckedChange={(checked) => updateLegend(checked)}
461 />
462 </div>
463
464 <div
465 className={cn(
466 "rounded-lg border p-3 transition-opacity",
467 !legendEnabled && "opacity-50",
468 )}
469 aria-disabled={!legendEnabled}
470 >
471 <Label htmlFor="legend-position">Legend Position</Label>
472 <Select
473 value={legendPosition}
474 disabled={!legendEnabled}
475 onValueChange={(value) => updateLegend(legendEnabled, value)}
476 >
477 <SelectTrigger id="legend-position" className="mt-1.5">
478 <SelectValue placeholder="Position" />
479 </SelectTrigger>
480 <SelectContent className="ignore-click-outside/toolbar">
481 <SelectItem value="top">Top</SelectItem>
482 <SelectItem value="right">Right</SelectItem>
483 <SelectItem value="bottom">Bottom</SelectItem>
484 <SelectItem value="left">Left</SelectItem>
485 </SelectContent>
486 </Select>
487 </div>
488 </div>
489
490 {/* Background Section */}
491 <div className="space-y-2">
492 <div className="flex items-center justify-between">
493 <Label htmlFor="background-toggle">Background</Label>
494 <Switch
495 id="background-toggle"
496 checked={backgroundVisible}
497 onCheckedChange={(checked) =>
498 updateBackground(backgroundFill, checked)
499 }
500 />
501 </div>
502
503 <div
504 className={cn(
505 "rounded-lg border p-3 transition-opacity",
506 !backgroundVisible && "opacity-50",
507 )}
508 aria-disabled={!backgroundVisible}
509 >
510 <Label htmlFor="background-fill">Background Color</Label>
511 <div className="mt-1.5 flex gap-2">
512 <Input
513 id="background-fill"
514 type="color"
515 value={backgroundFill || "#ffffff"}
516 disabled={!backgroundVisible}
517 onChange={(e) =>
518 updateBackground(e.target.value, backgroundVisible)
519 }
520 className="h-8 w-12 p-1"
521 />
522 <DebouncedInput
523 value={backgroundFill}
524 disabled={!backgroundVisible}
525 onChange={(value) =>
526 updateBackground(String(value), backgroundVisible)
527 }
528 placeholder="#ffffff"
529 className="flex-1"
530 />
531 </div>
532 </div>
533 </div>
534
535 {/* Inner Labels Section - Only for Donut Charts */}
536 {isDonutChart && (
537 <div className="space-y-3 rounded-lg border p-3">
538 <h5 className="text-sm font-medium">Inner Labels</h5>
539
540 <div className="grid gap-1.5">
541 <Label htmlFor="inner-label-title" className="text-xs">
542 Title
543 </Label>
544 <DebouncedInput
545 id="inner-label-title"
546 placeholder="e.g. Total"
547 value={innerLabelTitle}
548 onChange={(value) =>
549 updateInnerLabels(String(value), innerLabelValue)
550 }
551 className="h-8 text-sm"
552 />
553 </div>
554
555 <div className="grid gap-1.5">
556 <Label htmlFor="inner-label-value" className="text-xs">
557 Value
558 </Label>
559 <DebouncedInput
560 id="inner-label-value"
561 placeholder="e.g. $100,000"
562 value={innerLabelValue}
563 onChange={(value) =>
564 updateInnerLabels(innerLabelTitle, String(value))
565 }
566 className="h-8 text-sm"
567 />
568 </div>
569
570 <div className="grid gap-1.5">
571 <Label htmlFor="inner-circle-fill" className="text-xs">
572 Background Color
573 </Label>
574 <div className="flex gap-2">
575 <Input
576 id="inner-circle-fill"
577 type="color"
578 value={innerCircleFill || "#f0f0f0"}
579 onChange={(e) =>
580 handleNodePropertyUpdate("innerCircle", {
581 fill: e.target.value,
582 })
583 }
584 className="h-8 w-12 p-1"
585 />
586 <DebouncedInput
587 value={innerCircleFill}
588 onChange={(value) =>
589 handleNodePropertyUpdate("innerCircle", {
590 fill: String(value),
591 })
592 }
593 placeholder="#f0f0f0"
594 className="flex-1"
595 />
596 </div>
597 </div>
598
599 <div className="flex items-center justify-between">
600 <Label htmlFor="inner-radius-ratio" className="text-xs">
601 Inner Radius: {Math.round(innerRadiusRatio * 100)}%
602 </Label>
603 </div>
604 <Slider
605 id="inner-radius-ratio"
606 min={0}
607 max={100}
608 step={5}
609 value={[Math.round(innerRadiusRatio * 100)]}
610 onValueChange={(values) => {
611 const value = values[0];
612 if (value !== undefined) {
613 handleNodePropertyUpdate("innerRadiusRatio", value / 100);
614 }
615 }}
616 />
617 </div>
618 )}
619 </div>
620 </PopoverContent>
621 </Popover>
622 </ToolbarGroup>
623 );
624 }
625
625 lines Plain Text