返回 presentation-ai
MagicMenuDropdown.tsx
根目录 / src / components / presentation / slides / slide-editor / MagicMenuDropdown.tsx
1 "use client";
2
3 import {
4 ArrowDown,
5 ArrowRight,
6 Check,
7 Globe,
8 ImageIcon,
9 PenTool,
10 Plus,
11 Sparkles,
12 type LucideIcon,
13 } from "lucide-react";
14 import { useState } from "react";
15
16 import { PRESENTATION_PORTAL_CONTENT_CLASS } from "@/components/presentation/overlay-layers";
17 import { Button } from "@/components/ui/button";
18 import {
19 DropdownMenu,
20 DropdownMenuContent,
21 DropdownMenuSeparator,
22 DropdownMenuTrigger,
23 } from "@/components/ui/dropdown-menu";
24 import { cn } from "@/lib/utils";
25 import { usePresentationState } from "@/states/presentation-state";
26 import { useSlideEditorContext } from "./SlideEditorContext";
27
28 const makeVisualAction: MagicAction = {
29 icon: ImageIcon,
30 label: "Make visual",
31 prompt:
32 "Redesign this card to be more visual and presentation-ready. Convert dense text into a stronger visual hierarchy while preserving the meaning.",
33 };
34
35 interface MagicAction {
36 icon: LucideIcon;
37 label: string;
38 prompt: string;
39 }
40
41 export function MagicMenuDropdown() {
42 const [isOpen, setIsOpen] = useState(false);
43 const language = usePresentationState((s) => s.language);
44 const {
45 aiInput,
46 setAiInput,
47 handleAiSubmit,
48 handleAiKeyDown,
49 handleImageEdit,
50 handleMagicPrompt,
51 } = useSlideEditorContext();
52
53 const runMagicAction = (prompt: string) => {
54 handleMagicPrompt(prompt);
55 setIsOpen(false);
56 };
57
58 const writingActions: MagicAction[] = [
59 {
60 icon: PenTool,
61 label: "Improve writing",
62 prompt:
63 "Improve the writing on this card. Make it sharper, clearer, and more executive-ready while preserving the original meaning.",
64 },
65 {
66 icon: Check,
67 label: "Fix spelling",
68 prompt:
69 "Fix spelling, grammar, punctuation, and awkward phrasing on this card without changing the meaning or layout.",
70 },
71 {
72 icon: Globe,
73 label: "Translate",
74 prompt: `Translate this card into the presentation language (${language}). Preserve the slide structure and meaning.`,
75 },
76 {
77 icon: ArrowDown,
78 label: "Simplify",
79 prompt:
80 "Simplify this card. Use shorter sentences and clearer wording while keeping the same core points.",
81 },
82 ];
83
84 return (
85 <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
86 <DropdownMenuTrigger asChild>
87 <Button
88 variant="ghost"
89 size="icon"
90 className="size-8! gap-1 rounded-full border border-white/20 bg-background/50 shadow backdrop-blur-md transition-all hover:border-primary/40 hover:bg-background/90 hover:text-primary"
91 >
92 <Sparkles className="size-4 text-muted-foreground" />
93 </Button>
94 </DropdownMenuTrigger>
95 <DropdownMenuContent
96 align="start"
97 className={cn(
98 PRESENTATION_PORTAL_CONTENT_CLASS,
99 "w-86 overflow-hidden rounded-xl border-border/70 bg-neutral-950 p-0 text-neutral-50 shadow-2xl",
100 )}
101 >
102 <div className="border-b border-white/10 bg-linear-to-b from-white/10 to-transparent p-4">
103 <div className="mb-3 flex items-center gap-2">
104 <div className="flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground">
105 <Sparkles className="size-3.5" />
106 </div>
107 <h3 className="text-sm font-semibold">Edit this card</h3>
108 </div>
109
110 <div className="flex items-center gap-2 rounded-lg border border-white/15 bg-white/6 p-2 shadow-inner transition-colors focus-within:border-primary/70">
111 <input
112 aria-label="magic menu dropdown control"
113 type="text"
114 value={aiInput}
115 onChange={(e) => setAiInput(e.target.value)}
116 onKeyDown={handleAiKeyDown}
117 placeholder="How would you like to edit this card?"
118 className="min-w-0 flex-1 bg-transparent px-1 text-sm text-white outline-none placeholder:text-neutral-500"
119 />
120 <button
121 type="button"
122 onClick={() => {
123 handleAiSubmit();
124 if (aiInput.trim()) {
125 setIsOpen(false);
126 }
127 }}
128 disabled={!aiInput.trim()}
129 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-white text-neutral-950 transition hover:bg-primary hover:text-primary-foreground disabled:cursor-not-allowed disabled:bg-white/10 disabled:text-neutral-600"
130 >
131 <ArrowRight className="size-4" />
132 </button>
133 </div>
134 </div>
135
136 <div className="p-3">
137 <button
138 type="button"
139 onClick={() =>
140 runMagicAction(
141 "Try a new layout for this card. Keep the same message, but improve the visual balance, hierarchy, and slide structure.",
142 )
143 }
144 className="mb-3 flex w-full items-center justify-between rounded-lg border border-primary/25 bg-primary/10 px-3 py-2.5 text-left transition hover:border-primary/50 hover:bg-primary/20"
145 >
146 <span className="flex items-center gap-2 text-sm font-semibold">
147 <Sparkles className="size-4 text-primary" />
148 Try new layout
149 </span>
150 <ArrowRight className="size-4 text-neutral-500" />
151 </button>
152
153 <DropdownMenuSeparator className="bg-white/10" />
154
155 <div className="mt-3 mb-4">
156 <h4 className="mb-2 px-1 text-[0.68rem] font-semibold text-neutral-500 uppercase">
157 Writing
158 </h4>
159 <div className="mb-2 grid grid-cols-2 gap-2">
160 {writingActions.map((action) => (
161 <MagicActionButton
162 key={action.label}
163 action={action}
164 onClick={() => runMagicAction(action.prompt)}
165 />
166 ))}
167 </div>
168 </div>
169
170 <div>
171 <h4 className="mb-2 px-1 text-[0.68rem] font-semibold text-neutral-500 uppercase">
172 Image
173 </h4>
174 <div className="grid grid-cols-2 gap-2">
175 <MagicActionButton
176 action={makeVisualAction}
177 onClick={() => runMagicAction(makeVisualAction.prompt)}
178 />
179 <button
180 type="button"
181 className={cn(
182 "flex min-h-11 items-center gap-2 rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-left text-sm font-medium text-neutral-100 transition",
183 "hover:border-primary/50 hover:bg-white/12 hover:text-white focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:outline-none",
184 )}
185 onClick={() => {
186 handleImageEdit();
187 setIsOpen(false);
188 }}
189 >
190 <Plus className="size-4" />
191 Add image
192 </button>
193 </div>
194 </div>
195 </div>
196 </DropdownMenuContent>
197 </DropdownMenu>
198 );
199 }
200
201 function MagicActionButton({
202 action,
203 onClick,
204 }: {
205 action: MagicAction;
206 onClick: () => void;
207 }) {
208 const Icon = action.icon;
209
210 return (
211 <button
212 type="button"
213 onClick={onClick}
214 className={cn(
215 "flex min-h-11 items-center gap-2 rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-left text-sm font-medium text-neutral-100 transition",
216 "hover:border-primary/50 hover:bg-white/12 hover:text-white focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:outline-none",
217 )}
218 >
219 <Icon className="size-4 shrink-0 text-neutral-400" />
220 <span className="min-w-0 leading-tight">{action.label}</span>
221 </button>
222 );
223 }
224
224 lines Plain Text