返回 presentation-ai
GradientMaker.tsx
根目录 / src / components / presentation / edit-panel / sections / GradientMaker.tsx
1 "use client";
2
3 import {
4 Check,
5 Layers,
6 MoveDown,
7 MoveUp,
8 Plus,
9 RotateCcw,
10 Trash2,
11 } from "lucide-react";
12 import { useCallback, useState } from "react";
13
14 import { Badge } from "@/components/ui/badge";
15 import { Button } from "@/components/ui/button";
16 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
17 import ColorPicker from "@/components/ui/color-picker";
18 import { Input } from "@/components/ui/input";
19 import { Label } from "@/components/ui/label";
20 import {
21 Select,
22 SelectContent,
23 SelectItem,
24 SelectTrigger,
25 SelectValue,
26 } from "@/components/ui/select";
27 import { Separator } from "@/components/ui/separator";
28 import { Slider } from "@/components/ui/slider";
29 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
30 import { useToast } from "@/components/ui/use-toast";
31
32 interface ColorStop {
33 id: string;
34 color: string;
35 position: number;
36 }
37
38 interface GradientLayer {
39 id: string;
40 type: "linear" | "radial";
41 direction: number; // linear (deg)
42 shape: "circle" | "ellipse"; // radial
43 position: string; // radial preset position
44 size:
45 | "closest-side"
46 | "closest-corner"
47 | "farthest-side"
48 | "farthest-corner"
49 | "custom";
50 customRadius: { x: number; y: number };
51 positionX: number;
52 positionY: number;
53 useCustomPosition: boolean;
54 colorStops: ColorStop[];
55 opacity: number; // 0-100
56 blendMode: string;
57 }
58
59 const defaultLayer: Omit<GradientLayer, "id"> = {
60 type: "linear",
61 direction: 90,
62 shape: "circle",
63 position: "center",
64 size: "farthest-corner",
65 customRadius: { x: 50, y: 50 },
66 positionX: 50,
67 positionY: 50,
68 useCustomPosition: false,
69 opacity: 100,
70 blendMode: "normal",
71 colorStops: [
72 { id: "1", color: "#ff0000", position: 0 },
73 { id: "2", color: "#0000ff", position: 100 },
74 ],
75 };
76
77 const radialPositions = [
78 "center",
79 "top",
80 "bottom",
81 "left",
82 "right",
83 "top left",
84 "top right",
85 "bottom left",
86 "bottom right",
87 ];
88
89 const radialSizes = [
90 { value: "closest-side", label: "Closest Side" },
91 { value: "closest-corner", label: "Closest Corner" },
92 { value: "farthest-side", label: "Farthest Side" },
93 { value: "farthest-corner", label: "Farthest Corner" },
94 { value: "custom", label: "Custom Size" },
95 ];
96
97 const blendModes = [
98 "normal",
99 "multiply",
100 "screen",
101 "overlay",
102 "darken",
103 "lighten",
104 "color-dodge",
105 "color-burn",
106 "hard-light",
107 "soft-light",
108 "difference",
109 "exclusion",
110 "hue",
111 "saturation",
112 "color",
113 "luminosity",
114 ];
115
116 export function GradientMaker({
117 onApply,
118 onClose,
119 }: {
120 onApply?: (cssBackground: string, blendModes?: string) => void;
121 onClose?: () => void;
122 }) {
123 const [layers, setLayers] = useState<GradientLayer[]>([
124 { ...defaultLayer, id: "layer-1" },
125 ]);
126 const [activeLayerId, setActiveLayerId] = useState<string>("layer-1");
127 const { toast } = useToast();
128
129 const activeLayer =
130 layers.find((layer) => layer.id === activeLayerId) || layers[0]!;
131
132 const generateLayerCSS = useCallback((layer: GradientLayer) => {
133 const {
134 type,
135 direction,
136 shape,
137 position,
138 size,
139 customRadius,
140 positionX,
141 positionY,
142 useCustomPosition,
143 colorStops,
144 } = layer;
145 const sortedStops = [...colorStops].sort((a, b) => a.position - b.position);
146 const colorString = sortedStops
147 .map((stop) => `${stop.color} ${stop.position}%`)
148 .join(", ");
149
150 if (type === "linear") {
151 return `linear-gradient(${direction}deg, ${colorString})`;
152 }
153 let sizeString = "";
154 if (size === "custom") {
155 sizeString =
156 shape === "circle"
157 ? `${customRadius.x}px`
158 : `${customRadius.x}px ${customRadius.y}px`;
159 } else {
160 sizeString = size;
161 }
162 const pos = useCustomPosition
163 ? `at ${positionX}% ${positionY}%`
164 : `at ${position}`;
165 return `radial-gradient(${shape} ${sizeString} ${pos}, ${colorString})`;
166 }, []);
167
168 const generateBackgroundImage = useCallback(() => {
169 return layers.map((layer) => generateLayerCSS(layer)).join(", ");
170 }, [layers, generateLayerCSS]);
171
172 const backgroundBlendModes = layers.map((l) => l.blendMode).join(", ");
173
174 const applyToBackground = () => {
175 if (onApply) onApply(generateBackgroundImage(), backgroundBlendModes);
176 toast({
177 title: "Applied",
178 description: "Background applied to presentation.",
179 });
180 onClose?.();
181 };
182
183 const addLayer = () => {
184 const newId = `layer-${Date.now()}`;
185 const newLayer: GradientLayer = {
186 ...defaultLayer,
187 id: newId,
188 colorStops: [
189 { id: `${newId}-1`, color: "#ffffff", position: 0 },
190 { id: `${newId}-2`, color: "#000000", position: 100 },
191 ],
192 };
193 setLayers((prev) => [...prev, newLayer]);
194 setActiveLayerId(newId);
195 };
196
197 const removeLayer = (layerId: string) => {
198 if (layers.length <= 1) {
199 toast({
200 title: "Cannot remove layer",
201 description: "At least one layer is required.",
202 variant: "destructive",
203 });
204 return;
205 }
206 setLayers((prev) => prev.filter((l) => l.id !== layerId));
207 if (activeLayerId === layerId)
208 setActiveLayerId(
209 layers.find((l) => l.id !== layerId)?.id || layers[0]!.id,
210 );
211 };
212
213 const moveLayer = (layerId: string, dir: "up" | "down") => {
214 setLayers((prev) => {
215 const idx = prev.findIndex((l) => l.id === layerId);
216 if (idx === -1) return prev;
217 const to = dir === "up" ? idx - 1 : idx + 1;
218 if (to < 0 || to >= prev.length) return prev;
219 const copy = [...prev];
220 const [moved] = copy.splice(idx, 1);
221 copy.splice(to, 0, moved!);
222 return copy;
223 });
224 };
225
226 const updateActiveLayer = (updates: Partial<GradientLayer>) => {
227 setLayers((prev) =>
228 prev.map((l) => (l.id === activeLayerId ? { ...l, ...updates } : l)),
229 );
230 };
231
232 const addColorStop = () => {
233 const newId = Date.now().toString();
234 const newPos =
235 activeLayer.colorStops.length > 0
236 ? Math.min(
237 Math.max(...activeLayer.colorStops.map((s) => s.position)) + 10,
238 100,
239 )
240 : 50;
241 updateActiveLayer({
242 colorStops: [
243 ...activeLayer.colorStops,
244 { id: newId, color: "#ffffff", position: newPos },
245 ],
246 });
247 };
248 const removeColorStop = (id: string) => {
249 if (activeLayer.colorStops.length <= 2) {
250 toast({
251 title: "Cannot remove color stop",
252 description: "A gradient requires at least two stops.",
253 variant: "destructive",
254 });
255 return;
256 }
257 updateActiveLayer({
258 colorStops: activeLayer.colorStops.filter((s) => s.id !== id),
259 });
260 };
261 const updateColorStop = (id: string, updates: Partial<ColorStop>) => {
262 updateActiveLayer({
263 colorStops: activeLayer.colorStops.map((s) =>
264 s.id === id ? { ...s, ...updates } : s,
265 ),
266 });
267 };
268
269 return (
270 <div className="grid grid-cols-1 gap-6 px-4 lg:grid-cols-2">
271 <Card>
272 <CardHeader>
273 <CardTitle className="flex items-center gap-2">
274 <Layers className="h-5 w-5" /> Background Preview
275 </CardTitle>
276 </CardHeader>
277 <CardContent className="space-y-4">
278 <div
279 className="h-64 w-full rounded-lg border"
280 style={{ background: generateBackgroundImage() }}
281 />
282 <div className="flex gap-2">
283 <Button
284 onClick={applyToBackground}
285 size="sm"
286 variant="default"
287 className="w-full"
288 >
289 <Check className="mr-2 h-4 w-4" /> Save Background
290 </Button>
291 </div>
292 </CardContent>
293 </Card>
294
295 <Card>
296 <CardHeader className="flex flex-row items-center justify-between">
297 <CardTitle>Layer Controls</CardTitle>
298 <Button
299 onClick={() => {
300 setLayers([{ ...defaultLayer, id: "layer-1" }]);
301 setActiveLayerId("layer-1");
302 }}
303 size="sm"
304 variant="outline"
305 >
306 <RotateCcw className="mr-2 h-4 w-4" /> Reset All
307 </Button>
308 </CardHeader>
309 <CardContent className="space-y-6">
310 <div className="space-y-4">
311 <div className="flex items-center justify-between">
312 <Label>Gradient Layers ({layers.length})</Label>
313 <Button onClick={addLayer} size="sm" variant="outline">
314 <Plus className="mr-2 h-4 w-4" /> Add Layer
315 </Button>
316 </div>
317 <div className="max-h-40 space-y-2 overflow-y-auto">
318 {layers.map((layer, index) => (
319 <div
320 key={layer.id}
321 className={`flex cursor-pointer items-center gap-2 rounded-lg border p-2 transition-colors ${activeLayerId === layer.id ? "border-primary bg-primary/10" : "hover:bg-muted/50"}`}
322 onClick={() => setActiveLayerId(layer.id)}
323 >
324 <div
325 className="h-6 w-6 rounded border"
326 style={{ background: generateLayerCSS(layer) }}
327 />
328 <div className="flex-1">
329 <div className="text-sm font-medium">
330 Layer {index + 1} ({layer.type})
331 </div>
332 <div className="text-xs text-muted-foreground">
333 {layer.opacity}% opacity • {layer.blendMode}
334 </div>
335 </div>
336 <div className="flex gap-1">
337 <Button
338 onClick={(e) => {
339 e.stopPropagation();
340 moveLayer(layer.id, "up");
341 }}
342 size="icon"
343 variant="ghost"
344 className="h-6 w-6"
345 disabled={index === 0}
346 >
347 <MoveUp className="h-3 w-3" />
348 </Button>
349 <Button
350 onClick={(e) => {
351 e.stopPropagation();
352 moveLayer(layer.id, "down");
353 }}
354 size="icon"
355 variant="ghost"
356 className="h-6 w-6"
357 disabled={index === layers.length - 1}
358 >
359 <MoveDown className="h-3 w-3" />
360 </Button>
361 <Button
362 onClick={(e) => {
363 e.stopPropagation();
364 removeLayer(layer.id);
365 }}
366 size="icon"
367 variant="ghost"
368 className="h-6 w-6 text-destructive hover:text-destructive"
369 >
370 <Trash2 className="h-3 w-3" />
371 </Button>
372 </div>
373 </div>
374 ))}
375 </div>
376 </div>
377
378 <Separator />
379
380 <div className="space-y-4">
381 <Label className="text-base font-semibold">
382 Editing: Layer{" "}
383 {layers.findIndex((l) => l.id === activeLayerId) + 1}
384 </Label>
385 <div className="grid grid-cols-2 gap-4">
386 <div className="space-y-2">
387 <Label>Opacity ({activeLayer.opacity}%)</Label>
388 <Slider
389 value={[activeLayer.opacity]}
390 onValueChange={([v]) => updateActiveLayer({ opacity: v })}
391 max={100}
392 min={0}
393 step={1}
394 className="w-full"
395 />
396 </div>
397 <div className="space-y-2">
398 <Label>Blend Mode</Label>
399 <Select
400 value={activeLayer.blendMode}
401 onValueChange={(v) => updateActiveLayer({ blendMode: v })}
402 >
403 <SelectTrigger>
404 <SelectValue />
405 </SelectTrigger>
406 <SelectContent>
407 {blendModes.map((mode) => (
408 <SelectItem key={mode} value={mode}>
409 {mode}
410 </SelectItem>
411 ))}
412 </SelectContent>
413 </Select>
414 </div>
415 </div>
416
417 <Tabs
418 value={activeLayer.type}
419 onValueChange={(v) =>
420 updateActiveLayer({ type: v as "linear" | "radial" })
421 }
422 >
423 <TabsList className="grid w-full grid-cols-2">
424 <TabsTrigger value="linear">Linear</TabsTrigger>
425 <TabsTrigger value="radial">Radial</TabsTrigger>
426 </TabsList>
427
428 <TabsContent value="linear" className="space-y-4">
429 <div className="space-y-2">
430 <Label>Direction ({activeLayer.direction}°)</Label>
431 <Slider
432 value={[activeLayer.direction]}
433 onValueChange={([v]) => updateActiveLayer({ direction: v })}
434 max={360}
435 min={0}
436 step={1}
437 className="w-full"
438 />
439 </div>
440 </TabsContent>
441
442 <TabsContent value="radial" className="space-y-4">
443 <div className="grid grid-cols-2 gap-4">
444 <div className="space-y-2">
445 <Label>Shape</Label>
446 <Select
447 value={activeLayer.shape}
448 onValueChange={(v) =>
449 updateActiveLayer({ shape: v as "circle" | "ellipse" })
450 }
451 >
452 <SelectTrigger>
453 <SelectValue />
454 </SelectTrigger>
455 <SelectContent>
456 <SelectItem value="circle">Circle</SelectItem>
457 <SelectItem value="ellipse">Ellipse</SelectItem>
458 </SelectContent>
459 </Select>
460 </div>
461 <div className="space-y-2">
462 <Label>Size</Label>
463 <Select
464 value={activeLayer.size}
465 onValueChange={(v) =>
466 updateActiveLayer({ size: v as GradientLayer["size"] })
467 }
468 >
469 <SelectTrigger>
470 <SelectValue />
471 </SelectTrigger>
472 <SelectContent>
473 {radialSizes.map((s) => (
474 <SelectItem key={s.value} value={s.value}>
475 {s.label}
476 </SelectItem>
477 ))}
478 </SelectContent>
479 </Select>
480 </div>
481 </div>
482
483 {activeLayer.size === "custom" && (
484 <div className="rounded-lg border bg-muted/50 p-4">
485 <Label className="text-sm font-medium">Custom Radius</Label>
486 <div className="grid grid-cols-1 gap-4">
487 <div className="space-y-2">
488 <Label className="text-xs">
489 {activeLayer.shape === "circle"
490 ? "Radius"
491 : "X Radius"}{" "}
492 ({activeLayer.customRadius.x}px)
493 </Label>
494 <Slider
495 value={[activeLayer.customRadius.x]}
496 onValueChange={([v]) =>
497 updateActiveLayer({
498 customRadius: {
499 ...activeLayer.customRadius,
500 x: v!,
501 },
502 })
503 }
504 max={500}
505 min={10}
506 step={5}
507 className="w-full"
508 />
509 </div>
510 {activeLayer.shape === "ellipse" && (
511 <div className="space-y-2">
512 <Label className="text-xs">
513 Y Radius ({activeLayer.customRadius.y}px)
514 </Label>
515 <Slider
516 value={[activeLayer.customRadius.y]}
517 onValueChange={([v]) =>
518 updateActiveLayer({
519 customRadius: {
520 ...activeLayer.customRadius,
521 y: v!,
522 },
523 })
524 }
525 max={500}
526 min={10}
527 step={5}
528 className="w-full"
529 />
530 </div>
531 )}
532 </div>
533 </div>
534 )}
535
536 <div className="space-y-4">
537 <div className="flex items-center justify-between">
538 <Label>Position</Label>
539 <Button
540 variant="outline"
541 size="sm"
542 onClick={() =>
543 updateActiveLayer({
544 useCustomPosition: !activeLayer.useCustomPosition,
545 })
546 }
547 >
548 {activeLayer.useCustomPosition
549 ? "Use Presets"
550 : "Custom Position"}
551 </Button>
552 </div>
553 {activeLayer.useCustomPosition ? (
554 <div className="rounded-lg border bg-muted/50 p-4">
555 <div className="space-y-2">
556 <Label className="text-xs">
557 X Position ({activeLayer.positionX}%)
558 </Label>
559 <Slider
560 value={[activeLayer.positionX]}
561 onValueChange={([v]) =>
562 updateActiveLayer({ positionX: v })
563 }
564 max={100}
565 min={0}
566 step={1}
567 className="w-full"
568 />
569 </div>
570 <div className="space-y-2">
571 <Label className="text-xs">
572 Y Position ({activeLayer.positionY}%)
573 </Label>
574 <Slider
575 value={[activeLayer.positionY]}
576 onValueChange={([v]) =>
577 updateActiveLayer({ positionY: v })
578 }
579 max={100}
580 min={0}
581 step={1}
582 className="w-full"
583 />
584 </div>
585 </div>
586 ) : (
587 <Select
588 value={activeLayer.position}
589 onValueChange={(v) => updateActiveLayer({ position: v })}
590 >
591 <SelectTrigger>
592 <SelectValue />
593 </SelectTrigger>
594 <SelectContent>
595 {radialPositions.map((p) => (
596 <SelectItem key={p} value={p}>
597 {p}
598 </SelectItem>
599 ))}
600 </SelectContent>
601 </Select>
602 )}
603 </div>
604 </TabsContent>
605 </Tabs>
606
607 <Separator />
608
609 <div className="space-y-4">
610 <div className="flex items-center justify-between">
611 <Label>Color Stops</Label>
612 <Button onClick={addColorStop} size="sm" variant="outline">
613 <Plus className="mr-2 h-4 w-4" /> Add Color
614 </Button>
615 </div>
616 <div className="space-y-3">
617 {activeLayer.colorStops
618 .sort((a, b) => a.position - b.position)
619 .map((stop) => (
620 <div
621 key={stop.id}
622 className="flex items-center gap-3 rounded-lg border p-3"
623 >
624 <div className="flex flex-1 items-center gap-2">
625 <ColorPicker
626 value={stop.color}
627 onChange={(c) =>
628 updateColorStop(stop.id, { color: c })
629 }
630 >
631 <Button
632 variant="outline"
633 className="h-8 w-8 p-0"
634 style={{ backgroundColor: stop.color }}
635 />
636 </ColorPicker>
637 <Input
638 value={stop.color}
639 onChange={(e) =>
640 updateColorStop(stop.id, { color: e.target.value })
641 }
642 className="flex-1 font-mono text-sm"
643 placeholder="#ffffff"
644 />
645 </div>
646 <div className="flex items-center gap-2">
647 <Badge variant="secondary" className="text-xs">
648 {stop.position}%
649 </Badge>
650 <div className="w-20">
651 <Slider
652 value={[stop.position]}
653 onValueChange={([v]) =>
654 updateColorStop(stop.id, { position: v })
655 }
656 max={100}
657 min={0}
658 step={1}
659 />
660 </div>
661 <Button
662 onClick={() => removeColorStop(stop.id)}
663 size="icon"
664 variant="ghost"
665 className="h-8 w-8 text-destructive hover:text-destructive"
666 >
667 <Trash2 className="h-4 w-4" />
668 </Button>
669 </div>
670 </div>
671 ))}
672 </div>
673 </div>
674 </div>
675 </CardContent>
676 </Card>
677 </div>
678 );
679 }
680
681 export type { GradientLayer };
682
682 lines Plain Text