返回 presentation-ai
placeholder-card.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / placeholder-card.tsx
1 "use client";
2
3 import { ImageIcon, LayoutTemplate, Sparkles } from "lucide-react";
4 import { m as motion } from "motion/react";
5 import { nanoid } from "nanoid";
6 import { NodeApi } from "platejs";
7 import { useEditorSelector, type PlateElementProps } from "platejs/react";
8 import { useState } from "react";
9
10 import { cn } from "@/lib/utils";
11 import { usePresentationState } from "@/states/presentation-state";
12 import { type PlateSlide } from "../../utils/parser";
13 import { useSlideGeneration } from "../context/SlideGenerationContext";
14 import { GenerateSlideUI } from "./GenerateSlideUI";
15 import { SlideTemplateModal } from "./SlideTemplateModal";
16
17 const templates: Record<string, Omit<PlateSlide, "id">> = {
18 leftImage: {
19 layoutType: "left",
20 rootImage: {
21 query: "abstract background",
22 },
23 content: [
24 {
25 type: "h2",
26 id: nanoid(),
27 children: [{ text: "Your Title Here" }],
28 },
29 {
30 type: "p",
31 id: nanoid(),
32 children: [{ text: "Add your content description here..." }],
33 },
34 ],
35 },
36
37 rightImage: {
38 layoutType: "right",
39 rootImage: {
40 query: "abstract background",
41 },
42 content: [
43 {
44 type: "h2",
45 id: nanoid(),
46 children: [{ text: "Your Title Here" }],
47 },
48 {
49 type: "p",
50 id: nanoid(),
51 children: [{ text: "Add your content description here..." }],
52 },
53 ],
54 },
55
56 threeColumns: {
57 content: [
58 {
59 type: "bullets",
60 id: nanoid(),
61 columnSize: "md",
62 bulletType: "basic",
63 children: [
64 {
65 type: "bullet",
66 id: nanoid(),
67 children: [
68 {
69 type: "h3",
70 id: nanoid(),
71 children: [{ text: "Point 1" }],
72 },
73 {
74 type: "p",
75 id: nanoid(),
76 children: [{ text: "Description for the first point" }],
77 },
78 ],
79 },
80 {
81 type: "bullet",
82 id: nanoid(),
83 children: [
84 {
85 type: "h3",
86 id: nanoid(),
87 children: [{ text: "Point 2" }],
88 },
89 {
90 type: "p",
91 id: nanoid(),
92 children: [{ text: "Description for the second point" }],
93 },
94 ],
95 },
96 {
97 type: "bullet",
98 id: nanoid(),
99 children: [
100 {
101 type: "h3",
102 id: nanoid(),
103 children: [{ text: "Point 3" }],
104 },
105 {
106 type: "p",
107 id: nanoid(),
108 children: [{ text: "Description for the third point" }],
109 },
110 ],
111 },
112 ],
113 },
114 ],
115 },
116 bullets: {
117 content: [
118 {
119 type: "bullets",
120 id: nanoid(),
121 columnSize: "lg",
122 bulletType: "numbered",
123 children: [
124 {
125 type: "bullet",
126 id: nanoid(),
127 children: [
128 {
129 type: "p",
130 id: nanoid(),
131 children: [{ text: "First key point to discuss" }],
132 },
133 ],
134 },
135 {
136 type: "bullet",
137 id: nanoid(),
138 children: [
139 {
140 type: "p",
141 id: nanoid(),
142 children: [{ text: "Second key point to discuss" }],
143 },
144 ],
145 },
146 {
147 type: "bullet",
148 id: nanoid(),
149 children: [
150 {
151 type: "p",
152 id: nanoid(),
153 children: [{ text: "Third key point to discuss" }],
154 },
155 ],
156 },
157 {
158 type: "bullet",
159 id: nanoid(),
160 children: [
161 {
162 type: "p",
163 id: nanoid(),
164 children: [{ text: "Fourth key point to discuss" }],
165 },
166 ],
167 },
168 ],
169 },
170 ],
171 },
172 } as const;
173
174 // Template button component
175 function TemplateButton({
176 icon,
177 label,
178 onClick,
179 preview,
180 }: {
181 icon?: React.ReactNode;
182 label?: string;
183 onClick: () => void;
184 preview?: React.ReactNode;
185 }) {
186 return (
187 <motion.button
188 onClick={onClick}
189 className="group relative flex h-24 w-28 flex-col items-center justify-center gap-2 rounded-xl border border-border bg-card/50 backdrop-blur-sm transition-all hover:border-sidebar-accent-foreground/20 hover:bg-sidebar-accent"
190 whileHover={{ scale: 1.02, y: -2 }}
191 whileTap={{ scale: 0.98 }}
192 transition={{ type: "spring", stiffness: 400, damping: 25 }}
193 >
194 {preview ? (
195 <div className="flex h-12 w-20 items-center justify-center">
196 {preview}
197 </div>
198 ) : (
199 <div className="flex size-8 items-center justify-center text-muted-foreground group-hover:text-foreground">
200 {icon}
201 </div>
202 )}
203 {label && (
204 <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
205 {label}
206 </span>
207 )}
208 </motion.button>
209 );
210 }
211
212 // Preview components for template buttons
213 function ImageLeftPreview() {
214 return (
215 <div className="flex h-10 w-16 gap-1 rounded border border-border bg-muted/20 p-1">
216 <div className="flex h-full w-1/2 items-center justify-center rounded bg-muted/40">
217 <ImageIcon className="size-3 text-muted-foreground/60" />
218 </div>
219 <div className="flex h-full w-1/2 flex-col gap-0.5 py-0.5">
220 <div className="h-1 w-full rounded-sm bg-muted-foreground/30" />
221 <div className="h-0.5 w-3/4 rounded-sm bg-muted-foreground/20" />
222 <div className="h-0.5 w-1/2 rounded-sm bg-muted-foreground/20" />
223 </div>
224 </div>
225 );
226 }
227
228 function ImageRightPreview() {
229 return (
230 <div className="flex h-10 w-16 gap-1 rounded border border-border bg-muted/20 p-1">
231 <div className="flex h-full w-1/2 flex-col gap-0.5 py-0.5">
232 <div className="h-1 w-full rounded-sm bg-muted-foreground/30" />
233 <div className="h-0.5 w-3/4 rounded-sm bg-muted-foreground/20" />
234 <div className="h-0.5 w-1/2 rounded-sm bg-muted-foreground/20" />
235 </div>
236 <div className="flex h-full w-1/2 items-center justify-center rounded bg-muted/40">
237 <ImageIcon className="size-3 text-muted-foreground/60" />
238 </div>
239 </div>
240 );
241 }
242
243 function ThreeColumnPreview() {
244 return (
245 <div className="flex h-10 w-16 gap-0.5 rounded border border-border bg-muted/20 p-1">
246 {[1, 2, 3].map((i) => (
247 <div
248 key={i}
249 className="flex h-full flex-1 flex-col items-center gap-0.5 rounded bg-primary/10 p-0.5"
250 >
251 <div className="size-2 rounded-sm bg-primary/60" />
252 <div className="h-0.5 w-full rounded-sm bg-muted-foreground/20" />
253 <div className="h-0.5 w-3/4 rounded-sm bg-muted-foreground/15" />
254 </div>
255 ))}
256 </div>
257 );
258 }
259
260 function ContentListPreview() {
261 return (
262 <div className="flex h-10 w-16 flex-col gap-0.5 rounded border border-border bg-muted/20 p-1">
263 {[1, 2, 3, 4].map((i) => (
264 <div key={i} className="flex items-center gap-1">
265 <div className="size-1 rounded-full bg-primary/60" />
266 <div
267 className="h-0.5 rounded-sm bg-muted-foreground/30"
268 style={{ width: `${70 - i * 10}%` }}
269 />
270 </div>
271 ))}
272 </div>
273 );
274 }
275
276 export default function PlaceHolderCard(props: PlateElementProps) {
277 const { editor, element } = props;
278 // Use useEditorSelector to properly subscribe to content changes
279 // This ensures the component re-renders when content changes
280 const isEditorEmpty = useEditorSelector((editor) => editor.api.isEmpty(), []);
281 const setSlides = usePresentationState((s) => s.setSlides);
282 const { isGenerating, generatingSlideId } = useSlideGeneration();
283 const [showTemplateModal, setShowTemplateModal] = useState(false);
284 const [showGenerateUI, setShowGenerateUI] = useState(false);
285 const isCurrentElementEmpty = NodeApi.string(element).trim().length === 0;
286 const isFirstTopLevelElement = props.path.length === 1 && props.path[0] === 0;
287
288 // Check if this slide is currently being generated
289 const isGeneratingThisSlide = isGenerating && generatingSlideId === editor.id;
290
291 // Show generate UI if explicitly opened OR if this slide is being generated
292 const shouldShowGenerateUI = showGenerateUI || isGeneratingThisSlide;
293
294 // Only show template options when the entire editor is empty and not generating
295 const showPlaceholder = isEditorEmpty && isCurrentElementEmpty;
296 const showTemplateOptions =
297 showPlaceholder && isFirstTopLevelElement && !shouldShowGenerateUI;
298
299 const handleOpenTemplates = () => {
300 setShowTemplateModal(true);
301 };
302
303 const handleGenerate = () => {
304 setShowGenerateUI(true);
305 };
306
307 const handleCloseGenerateUI = () => {
308 setShowGenerateUI(false);
309 };
310
311 const applyTemplate = (template: keyof typeof templates) => {
312 const slides = usePresentationState.getState().slides;
313
314 console.log(templates[template]);
315 const newSlides = slides.map((slide) => {
316 if (slide.id !== editor.id) {
317 return slide;
318 }
319 return {
320 ...templates[template]!,
321 id: editor.id,
322 };
323 });
324
325 setSlides(newSlides);
326 };
327
328 // When generating, replace children with GenerateSlideUI
329 if (shouldShowGenerateUI) {
330 return (
331 <GenerateSlideUI slideId={editor.id} onClose={handleCloseGenerateUI} />
332 );
333 }
334
335 return (
336 <div className="relative">
337 {showPlaceholder && (
338 <div
339 contentEditable={false}
340 className={cn(
341 "pointer-events-none absolute inset-x-4 opacity-50 select-none md:inset-x-8",
342 // Paragraph styles
343 element.type === "p" &&
344 "px-0 py-1 [font-family:var(--presentation-body-font)] [font-size:var(--presentation-p-size)] leading-[1.6] text-(--presentation-text)",
345 // Heading styles
346 (element.type as string).startsWith("h") &&
347 "[font-family:var(--presentation-heading-font)] font-bold text-(--presentation-heading)",
348 element.type === "h1" &&
349 "pb-1 [font-size:var(--presentation-h1-size)]",
350 element.type === "h2" &&
351 "pb-px [font-size:var(--presentation-h2-size)]",
352 element.type === "h3" &&
353 "pb-px [font-size:var(--presentation-h3-size)]",
354 element.type === "h4" && "[font-size:var(--presentation-h4-size)]",
355 element.type === "h5" && "[font-size:var(--presentation-h5-size)]",
356 element.type === "h6" && "[font-size:var(--presentation-h6-size)]",
357 )}
358 >
359 {element.type === "p" ? "Type Something..." : "Untitled Card"}
360 </div>
361 )}
362
363 {props.children}
364 <SlideTemplateModal
365 isOpen={showTemplateModal}
366 onClose={() => setShowTemplateModal(false)}
367 slideId={editor.id}
368 />
369 {/* Template options overlay - only shown when editor is empty */}
370 {showTemplateOptions && (
371 <motion.div
372 contentEditable={false}
373 initial={{ opacity: 0, y: 10 }}
374 animate={{ opacity: 1, y: 0 }}
375 transition={{ duration: 0.3, ease: "easeOut" }}
376 className="pointer-events-auto mt-6 px-4 md:px-8"
377 >
378 {/* Label */}
379 <motion.p
380 contentEditable={false}
381 initial={{ opacity: 0 }}
382 animate={{ opacity: 1 }}
383 transition={{ delay: 0.1 }}
384 className="mb-4 text-sm font-medium text-muted-foreground"
385 >
386 Or start with a template
387 </motion.p>
388
389 {/* Template buttons grid */}
390 <motion.div
391 initial={{ opacity: 0 }}
392 animate={{ opacity: 1 }}
393 transition={{ delay: 0.15 }}
394 className="flex flex-wrap gap-3"
395 >
396 <TemplateButton
397 onClick={() => applyTemplate("leftImage")}
398 preview={<ImageLeftPreview />}
399 />
400 <TemplateButton
401 onClick={() => applyTemplate("rightImage")}
402 preview={<ImageRightPreview />}
403 />
404 <TemplateButton
405 onClick={() => applyTemplate("threeColumns")}
406 preview={<ThreeColumnPreview />}
407 />
408 <TemplateButton
409 onClick={() => applyTemplate("bullets")}
410 preview={<ContentListPreview />}
411 />
412 <TemplateButton
413 icon={<LayoutTemplate className="size-5" />}
414 label="Templates"
415 onClick={handleOpenTemplates}
416 />
417 <TemplateButton
418 icon={<Sparkles className="size-5" />}
419 label="Generate"
420 onClick={handleGenerate}
421 />
422 </motion.div>
423 </motion.div>
424 )}
425 </div>
426 );
427 }
428
428 lines Plain Text