返回 presentation-ai
BackgroundPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / BackgroundPanel.tsx
1 "use client";
2
3 import { Plus, RotateCcw } from "lucide-react";
4 import React, { useCallback, useMemo, useState } from "react";
5
6 import { Button } from "@/components/ui/button";
7 import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
8 import { ScrollArea } from "@/components/ui/scroll-area";
9 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
10 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
11 import { resolvePresentationThemeData } from "@/lib/presentation/theme-resolution";
12 import { themes, type ThemeProperties } from "@/lib/presentation/themes";
13 import { cn } from "@/lib/utils";
14 import { usePresentationState } from "@/states/presentation-state";
15 import { DEFAULT_COLORS } from "@/components/ui/color-picker";
16 import { BackgroundImageEditor } from "./BackgroundImageEditor";
17 import { GradientMaker } from "./GradientMaker";
18
19 type GradientType = "solid" | "linear" | "radial";
20 type GradientStop = { id: string; color: string; position: number };
21 type BackgroundGradient = {
22 type: GradientType;
23 angle?: number; // linear
24 shape?: "circle" | "ellipse"; // radial
25 at?: { x: number; y: number }; // radial position (percent)
26 stops: GradientStop[];
27 };
28
29 function isGradientString(input: string | undefined | null) {
30 if (!input) return false;
31 const v = input.toLowerCase().trim();
32 return v.startsWith("linear-gradient") || v.startsWith("radial-gradient");
33 }
34
35 function buildStopsCss(stops: GradientStop[]) {
36 return stops
37 .slice()
38 .sort((a, b) => a.position - b.position)
39 .map(
40 (s) =>
41 `${s.color} ${Math.round(Math.max(0, Math.min(100, s.position)))}%`,
42 )
43 .join(", ");
44 }
45
46 function toLinearCss(g: Pick<BackgroundGradient, "angle" | "stops">) {
47 const angle = Math.round(g.angle ?? 135);
48 return `linear-gradient(${angle}deg, ${buildStopsCss(g.stops ?? [])})`;
49 }
50
51 function toRadialCss(g: Pick<BackgroundGradient, "shape" | "at" | "stops">) {
52 const shape = g.shape ?? "circle";
53 const at = g.at ?? { x: 50, y: 50 };
54 return `radial-gradient(${shape} at ${Math.round(at.x)}% ${Math.round(at.y)}%, ${buildStopsCss(g.stops ?? [])})`;
55 }
56
57 export function BackgroundPanel() {
58 const { theme, customThemeData, pageBackground, setPageBackground } =
59 usePresentationState();
60 const { save } = useDebouncedSave({ delay: 800 });
61
62 const baseTheme: ThemeProperties = useMemo(() => {
63 return (
64 resolvePresentationThemeData({ customThemeData, theme }) ??
65 themes.mystique
66 );
67 }, [customThemeData, theme]);
68
69 const modeColors = baseTheme.colors;
70
71 const updateConfig = useCallback(
72 (patch: Record<string, unknown>) => {
73 const prev = (pageBackground ?? {}) as Record<string, unknown>;
74 setPageBackground({ ...prev, ...patch });
75 },
76 [pageBackground, setPageBackground],
77 );
78
79 const [isMakerOpen, setIsMakerOpen] = useState<boolean>(false);
80 // Selected solid color derived from current pageBackground
81 const selectedSolid = useMemo(() => {
82 const override = pageBackground?.backgroundOverride as string | undefined;
83 return isGradientString(override)
84 ? modeColors.background
85 : (override ?? modeColors.background);
86 }, [pageBackground?.backgroundOverride, modeColors.background]);
87
88 const onSolidChange = useCallback(
89 (value: string) => {
90 updateConfig({ backgroundType: "solid", backgroundOverride: value });
91 save({ includeMetadata: true });
92 },
93 [updateConfig, save],
94 );
95
96 const onReset = useCallback(() => {
97 const prev = (pageBackground ?? {}) as Record<string, unknown>;
98 const {
99 backgroundOverride: _o,
100 backgroundType: _t,
101 backgroundGradient: _g,
102 ...rest
103 } = prev;
104 setPageBackground(rest);
105 save({ includeMetadata: true });
106 }, [pageBackground, setPageBackground, save]);
107
108 return (
109 <div className="flex h-full flex-col">
110 <div className="space-y-4 border-b border-border bg-card px-4 pb-4">
111 <div className="flex gap-2">
112 <Button
113 onClick={() => setIsMakerOpen(true)}
114 className="flex-1"
115 size="sm"
116 >
117 <Plus className="mr-2 h-4 w-4" />
118 Create Custom
119 </Button>
120 <Button
121 variant="outline"
122 onClick={onReset}
123 size="sm"
124 className="flex-1 bg-transparent"
125 >
126 <RotateCcw className="mr-2 h-4 w-4" />
127 Reset
128 </Button>
129 </div>
130 </div>
131
132 <div className="h-full overflow-hidden">
133 <Tabs
134 defaultValue={
135 (pageBackground?.backgroundType as GradientType) || "solid"
136 }
137 className="flex h-full flex-col"
138 >
139 <TabsList className="w-full justify-start gap-0 rounded-none border-b border-border bg-transparent p-0">
140 <TabsTrigger
141 value="solid"
142 className="flex-1 rounded-none border-b-2 border-transparent px-4 py-3 data-[state=active]:border-primary data-[state=active]:bg-transparent"
143 >
144 Solid
145 </TabsTrigger>
146 <TabsTrigger
147 value="linear"
148 className="flex-1 rounded-none border-b-2 border-transparent px-4 py-3 data-[state=active]:border-primary data-[state=active]:bg-transparent"
149 >
150 Linear
151 </TabsTrigger>
152 <TabsTrigger
153 value="radial"
154 className="flex-1 rounded-none border-b-2 border-transparent px-4 py-3 data-[state=active]:border-primary data-[state=active]:bg-transparent"
155 >
156 Radial
157 </TabsTrigger>
158 <TabsTrigger
159 value="image"
160 className="flex-1 rounded-none border-b-2 border-transparent px-4 py-3 data-[state=active]:border-primary data-[state=active]:bg-transparent"
161 >
162 Image
163 </TabsTrigger>
164 </TabsList>
165
166 <ScrollArea className="flex-1">
167 <div className="p-6">
168 <TabsContent
169 value="solid"
170 className="m-0 flex justify-center space-y-4"
171 >
172 <SolidGrid
173 selected={selectedSolid}
174 onPick={(c) => onSolidChange(c)}
175 />
176 </TabsContent>
177 <TabsContent value="linear" className="m-0">
178 <div className="flex justify-center">
179 <LinearJsonGrid
180 onPick={(preset) => {
181 updateConfig({
182 backgroundType: "linear",
183 backgroundGradient: {
184 type: "linear",
185 ...preset.gradient,
186 },
187 backgroundOverride: preset.css,
188 });
189 save();
190 }}
191 count={300}
192 />
193 </div>
194 </TabsContent>
195 <TabsContent value="radial" className="m-0">
196 <div className="flex justify-center">
197 <RadialJsonGrid
198 onPick={(preset) => {
199 updateConfig({
200 backgroundType: "radial",
201 backgroundGradient: {
202 type: "radial",
203 ...preset.gradient,
204 },
205 backgroundOverride: preset.css,
206 });
207 save();
208 }}
209 count={300}
210 />
211 </div>
212 </TabsContent>
213 <TabsContent value="image" className="m-0">
214 <BackgroundImageEditor
215 config={pageBackground}
216 updateConfig={updateConfig}
217 onApply={() => save()}
218 />
219 </TabsContent>
220 </div>
221 </ScrollArea>
222 </Tabs>
223 </div>
224
225 <Dialog open={isMakerOpen} onOpenChange={setIsMakerOpen}>
226 <DialogContent className="max-w-6xl">
227 <DialogTitle>Create Custom Background</DialogTitle>
228 <div className="mt-4">
229 <GradientMaker
230 onApply={(css) => {
231 updateConfig({ backgroundOverride: css });
232 save();
233 }}
234 onClose={() => setIsMakerOpen(false)}
235 />
236 </div>
237 </DialogContent>
238 </Dialog>
239 </div>
240 );
241 }
242
243 function SolidGrid({
244 selected,
245 onPick,
246 paletteSize = DEFAULT_COLORS.length,
247 }: {
248 selected?: string;
249 onPick: (color: string) => void;
250 paletteSize?: number;
251 }) {
252 const palette = useMemo(
253 () => DEFAULT_COLORS.slice(0, paletteSize),
254 [paletteSize],
255 );
256 return (
257 <div className="grid h-full w-max grid-cols-7 place-items-center gap-3">
258 {/* Custom color picker as the first grid item */}
259 <div className="relative">
260 <input
261 type="color"
262 value={selected ?? "#ffffff"}
263 onChange={(e) => onPick(e.target.value)}
264 className="absolute size-10 cursor-pointer opacity-0"
265 aria-label="Custom color"
266 title="Custom color"
267 />
268 <button
269 type="button"
270 className="flex size-10 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted transition-all hover:scale-110 hover:border-primary hover:bg-primary/10"
271 >
272 <Plus className="h-4 w-4 text-muted-foreground" />
273 </button>
274 </div>
275
276 {/* Palette colors as grid items */}
277 {palette.map((c) => (
278 <button
279 key={c.value}
280 type="button"
281 onClick={() => onPick(c.value)}
282 className={cn(
283 "size-10 rounded-lg border-2 transition-all hover:scale-110 focus:ring-2 focus:ring-primary focus:ring-offset-2",
284 selected === c.value
285 ? "border-primary ring-2 ring-primary ring-offset-2"
286 : "border-transparent hover:border-primary/50",
287 )}
288 style={{ background: c.value }}
289 aria-label={c.name}
290 title={`${c.name} ${c.value}`}
291 />
292 ))}
293 </div>
294 );
295 }
296
297 type LinearPreset = {
298 css: string;
299 gradient: Pick<BackgroundGradient, "angle" | "stops">;
300 };
301 type RadialPreset = {
302 css: string;
303 gradient: Pick<BackgroundGradient, "shape" | "at" | "stops">;
304 };
305
306 type JsonGradient = { name: string; colors: string[] };
307
308 async function loadGradients(): Promise<JsonGradient[]> {
309 try {
310 const mod = await import("./gradient.json");
311 const data = (mod?.default ?? []) as unknown;
312 const list = Array.isArray(data) ? (data as JsonGradient[]) : [];
313 // sanitize: only keep entries with at least one valid color string
314 return list.filter(
315 (g) =>
316 g &&
317 Array.isArray(g.colors) &&
318 g.colors.some((c) => typeof c === "string" && c.trim().length > 0),
319 );
320 } catch {
321 return [];
322 }
323 }
324
325 // Assume JSON colors are valid and present (author-provided)
326 function normalizeColors(colors: unknown): string[] {
327 return colors as string[];
328 }
329
330 function toLinearFromJson(colors: string[], angle: number): LinearPreset {
331 const safe = normalizeColors(colors);
332 const stops: GradientStop[] = safe.map((c, idx) => ({
333 id: `j-${idx}`,
334 color: c,
335 position: Math.round((idx / (safe.length - 1)) * 100),
336 }));
337 const gradient = { angle, stops };
338 return { css: toLinearCss(gradient), gradient };
339 }
340
341 function toRadialFromJson(colors: string[]): RadialPreset {
342 const safe = normalizeColors(colors);
343 const stops: GradientStop[] = safe.map((c, idx) => ({
344 id: `j-${idx}`,
345 color: c,
346 position: Math.round((idx / (safe.length - 1)) * 100),
347 }));
348 const gradient = { shape: "circle" as const, at: { x: 50, y: 50 }, stops };
349 return { css: toRadialCss(gradient), gradient };
350 }
351
352 function LinearJsonGrid({
353 onPick,
354 count = 300,
355 }: {
356 onPick: (preset: LinearPreset) => void;
357 count?: number;
358 }) {
359 const [items, setItems] = React.useState<LinearPreset[] | null>(null);
360 React.useEffect(() => {
361 let cancelled = false;
362 (async () => {
363 const json = await loadGradients();
364 if (cancelled) return;
365 const presets = json
366 .slice(0, count)
367 .map((g, idx) =>
368 toLinearFromJson(g.colors ?? [], 135 + (idx % 4) * 15),
369 );
370 setItems(presets);
371 })();
372 return () => {
373 cancelled = true;
374 };
375 }, [count]);
376
377 if (!items) {
378 return (
379 <div className="grid w-max grid-cols-4 place-items-center gap-3">
380 {Array.from({ length: 24 }).map((_, i) => (
381 <div
382 key={i}
383 className="h-16 w-20 animate-pulse rounded-lg bg-muted"
384 />
385 ))}
386 </div>
387 );
388 }
389
390 return (
391 <div className="grid w-max grid-cols-4 place-items-center gap-3">
392 {items.map((p, i) => (
393 <button
394 key={i}
395 type="button"
396 onClick={() => onPick(p)}
397 className="h-16 w-20 rounded-lg border-2 border-border transition-all hover:scale-105 hover:border-primary hover:shadow-md"
398 style={{ background: p.css }}
399 aria-label={`Linear preset ${i + 1}`}
400 title={p.css}
401 />
402 ))}
403 </div>
404 );
405 }
406
407 function RadialJsonGrid({
408 onPick,
409 count = 300,
410 }: {
411 onPick: (preset: RadialPreset) => void;
412 count?: number;
413 }) {
414 const [items, setItems] = React.useState<RadialPreset[] | null>(null);
415 React.useEffect(() => {
416 let cancelled = false;
417 (async () => {
418 const json = await loadGradients();
419 if (cancelled) return;
420 const presets = json
421 .slice(0, count)
422 .map((g) => toRadialFromJson(g.colors ?? []));
423 setItems(presets);
424 })();
425 return () => {
426 cancelled = true;
427 };
428 }, [count]);
429
430 if (!items) {
431 return (
432 <div className="grid w-max grid-cols-4 place-items-center gap-3">
433 {Array.from({ length: 24 }).map((_, i) => (
434 <div
435 key={i}
436 className="h-16 w-20 animate-pulse rounded-lg bg-muted"
437 />
438 ))}
439 </div>
440 );
441 }
442
443 return (
444 <div className="grid w-max grid-cols-4 place-items-center gap-3">
445 {items.map((p, i) => (
446 <button
447 key={i}
448 type="button"
449 onClick={() => onPick(p)}
450 className="h-16 w-20 rounded-lg border-2 border-border transition-all hover:scale-105 hover:border-primary hover:shadow-md"
451 style={{ background: p.css }}
452 aria-label={`Radial preset ${i + 1}`}
453 title={p.css}
454 />
455 ))}
456 </div>
457 );
458 }
459
459 lines Plain Text