返回 presentation-ai
BackgroundImageEditor.tsx
根目录 / src / components / presentation / edit-panel / sections / BackgroundImageEditor.tsx
1 "use client";
2
3 import { UploadCloud } from "lucide-react";
4 import React from "react";
5
6 import { SharedGenerateControls } from "@/components/presentation/shared/SharedGenerateControls";
7 import { SharedImageSearchControls } from "@/components/presentation/shared/SharedImageSearchControls";
8 import { Button } from "@/components/ui/button";
9 import { Input } from "@/components/ui/input";
10 import { Label } from "@/components/ui/label";
11 import {
12 Select,
13 SelectContent,
14 SelectItem,
15 SelectTrigger,
16 SelectValue,
17 } from "@/components/ui/select";
18 import { useUploadFile } from "@/hooks/canvas/useUploadFile";
19 import { cn } from "@/lib/utils";
20
21 export function BackgroundImageEditor({
22 config,
23 updateConfig,
24 onApply,
25 className,
26 }: {
27 config: Record<string, unknown>;
28 updateConfig: (patch: Record<string, unknown>) => void;
29 onApply: () => void;
30 className?: string;
31 }) {
32 const [imageMode, setImageMode] = React.useState<
33 "upload-url" | "ai" | "search"
34 >("upload-url");
35
36 const { uploadFile, uploadedFile, isUploading, progress } = useUploadFile();
37 const [previewUrl, setPreviewUrl] = React.useState<string>("");
38
39 React.useEffect(() => {
40 if (uploadedFile?.url) setPreviewUrl(uploadedFile.url);
41 }, [uploadedFile?.url]);
42
43 const applyUrl = React.useCallback(() => {
44 const url = previewUrl || (config?.backgroundImageUrl as string) || "";
45 if (!url) return;
46 const position = (config?.backgroundImagePosition as string) ?? "center";
47 const repeat = (config?.backgroundImageRepeat as string) ?? "no-repeat";
48 const background = `url(${url}) ${position}/cover ${repeat}`;
49 updateConfig({
50 backgroundType: "image",
51 backgroundOverride: background,
52 backgroundImageUrl: url,
53 });
54 onApply();
55 }, [config, updateConfig, onApply, previewUrl]);
56
57 return (
58 <div className={cn("flex h-full min-h-0 flex-col gap-5 p-1", className)}>
59 <div className="space-y-2">
60 <Label className="text-xs">Source</Label>
61 <Select
62 value={imageMode}
63 onValueChange={(v) => setImageMode(v as typeof imageMode)}
64 >
65 <SelectTrigger className="h-9">
66 <SelectValue />
67 </SelectTrigger>
68 <SelectContent>
69 <SelectItem value="upload-url">Image upload or URL</SelectItem>
70 <SelectItem value="ai">AI images</SelectItem>
71 <SelectItem value="search">Stock & web images</SelectItem>
72 </SelectContent>
73 </Select>
74 </div>
75
76 {imageMode === "upload-url" && (
77 <div className="space-y-4">
78 <div className="space-y-2">
79 <Label className="text-xs">URL</Label>
80 <div className="flex gap-2">
81 <Input
82 placeholder="Paste or enter image URL"
83 value={(config?.backgroundImageUrl as string) ?? ""}
84 onChange={(e) =>
85 updateConfig({ backgroundImageUrl: e.target.value })
86 }
87 />
88 </div>
89 </div>
90
91 <div className="space-y-2">
92 <Label className="text-xs">Upload</Label>
93 <PrettyUploadZone
94 isUploading={isUploading}
95 progress={progress}
96 onPick={(file) => void uploadFile(file)}
97 />
98 </div>
99
100 <Button className="w-full" onClick={applyUrl}>
101 Apply Image Background
102 </Button>
103 </div>
104 )}
105
106 {imageMode === "ai" && (
107 <SharedGenerateControls
108 onImageSelect={(url) => setPreviewUrl(url)}
109 className="min-h-0 flex-1"
110 />
111 )}
112
113 {imageMode === "search" && (
114 <SharedImageSearchControls
115 onImageSelect={(url) => setPreviewUrl(url)}
116 className="min-h-0 flex-1"
117 />
118 )}
119
120 {previewUrl && imageMode !== "upload-url" && (
121 <div className="sticky right-0 bottom-0 left-0 z-10 mt-auto border-t bg-background/80 p-2 backdrop-blur supports-backdrop-filter:bg-background/60">
122 <div className="flex gap-2">
123 <Button className="flex-1" onClick={applyUrl}>
124 Apply Selected
125 </Button>
126 <Button
127 variant="outline"
128 className="flex-1"
129 onClick={() => setPreviewUrl("")}
130 >
131 Clear
132 </Button>
133 </div>
134 </div>
135 )}
136 </div>
137 );
138 }
139
140 function PrettyUploadZone({
141 isUploading,
142 progress,
143 onPick,
144 }: {
145 isUploading: boolean;
146 progress: number;
147 onPick: (file: File) => void;
148 }) {
149 const inputRef = React.useRef<HTMLInputElement | null>(null);
150 const openPicker = () => inputRef.current?.click();
151
152 return (
153 <div className="relative w-full rounded-lg border-2 border-dashed p-6 text-center transition-colors hover:bg-accent/10">
154 aria-label="background image editor control"
155 <input
156 ref={inputRef}
157 type="file"
158 accept="image/*"
159 className="hidden"
160 onChange={(e) => {
161 const f = e.target.files?.[0];
162 if (f) onPick(f);
163 }}
164 />
165 <div className="flex flex-col items-center gap-2">
166 <div className="rounded-md border p-2 text-primary">
167 <UploadCloud className="size-5" />
168 </div>
169 <div className="text-sm">
170 Drag a file or
171 <button
172 type="button"
173 onClick={openPicker}
174 className="ml-1 text-primary underline underline-offset-2"
175 >
176 click to upload
177 </button>
178 </div>
179 <div className="text-xs text-muted-foreground">
180 Tip: You can also paste an image URL above
181 </div>
182 {isUploading && (
183 <div className="mt-1 text-xs text-muted-foreground">
184 Uploading… {progress}%
185 </div>
186 )}
187 </div>
188 </div>
189 );
190 }
191
191 lines Plain Text