返回 presentation-ai
RightPanelRenderer.tsx
根目录 / src / components / presentation / edit-panel / RightPanelRenderer.tsx
1 "use client";
2
3 import {
4 Blocks,
5 CaseSensitive,
6 ChartNoAxesCombined,
7 ChartPie,
8 Edit3,
9 ImageIcon,
10 Link as LinkIcon,
11 Sparkles,
12 X,
13 } from "lucide-react";
14 import { AnimatePresence, motion } from "motion/react";
15 import { useCallback, useEffect, useRef, useState } from "react";
16
17 import { Button } from "@/components/ui/button";
18 import { IconPickerPanel } from "@/components/ui/icon-picker";
19 import { ScrollArea } from "@/components/ui/scroll-area";
20 import {
21 usePresentationState,
22 type RightPanelType,
23 } from "@/states/presentation-state";
24 import { PresentationAgentPanel } from "../agent/PresentationAgentPanel";
25 import { GlobalSettings } from "../controls/global-settings/GlobalSettings";
26 import { LayoutEditorPanel } from "../floating-toolbar/LayoutPreviewSheet";
27 import { FLOATING_TOOLBAR_IGNORE_CLASS } from "../floating-toolbar/toolbar-interaction";
28 import { BackgroundPanel } from "./sections/BackgroundPanel";
29 import { BasicBlocksPanel } from "./sections/BasicBlocksPanel";
30 import { ChartEditorPanel } from "./sections/ChartEditorPanel";
31 import { ChartPanel } from "./sections/ChartPanel";
32 import { DiagramPanel } from "./sections/DiagramPanel";
33 import { ElementsPanel } from "./sections/ElementsPanel";
34 import { ImageEditorPanel } from "./sections/ImageEditorPanel";
35 import { InfographicEditorPanel } from "./sections/InfographicEditorPanel";
36 import { InfographicGenerationPanel } from "./sections/InfographicGenerationPanel";
37 import { MediaEmbedPanel } from "./sections/MediaEmbedPanel";
38 import { PresentationImageEditorPanel } from "./sections/PresentationImageEditorPanel";
39 import { ThemePanel } from "./sections/ThemePanel";
40
41 const PANEL_ARROW_TARGET_SELECTOR = "[data-panel-arrow-target='true']";
42 const ARROW_NAVIGATION_KEYS = new Set([
43 "ArrowLeft",
44 "ArrowRight",
45 "ArrowUp",
46 "ArrowDown",
47 "Home",
48 "End",
49 ]);
50 const RIGHT_PANEL_WIDTH = "26rem";
51 const RIGHT_PANEL_ANIMATION_DURATION_MS = 300;
52 const PANEL_CONTENT_LOAD_DELAY_MS = RIGHT_PANEL_ANIMATION_DURATION_MS + 50;
53 const IMMEDIATE_LOAD_PANELS: RightPanelType[] = ["basicBlocks", "elements"];
54
55 // Panel titles and icons for panels that need our generic header
56 const PANEL_INFO = {
57 basicBlocks: {
58 title: "Text",
59 icon: <CaseSensitive className="size-4 text-primary" />,
60 },
61 elements: {
62 title: "Add elements",
63 icon: <Blocks className="size-4 text-primary" />,
64 },
65 charts: {
66 title: "Add Charts",
67 icon: <ChartPie className="size-4 text-primary" />,
68 },
69 diagrams: {
70 title: "Add Diagrams",
71 icon: <ChartNoAxesCombined className="size-4 text-primary" />,
72 },
73 embed: {
74 title: "Media Embeds",
75 icon: <LinkIcon className="size-4 text-primary" />,
76 },
77 background: {
78 title: "Background",
79 icon: <ImageIcon className="size-4 text-primary" />,
80 },
81 layoutEditor: {
82 title: "Edit layout",
83 icon: <Edit3 className="size-4 text-primary" />,
84 },
85 iconPicker: {
86 title: "Choose icon",
87 icon: <Sparkles className="size-4 text-primary" />,
88 },
89 } as const;
90
91 // Panels that have their own complete structure (header, styling, etc.)
92 const SELF_CONTAINED_PANELS: RightPanelType[] = [
93 "agent",
94 "globalSettings",
95 "theme",
96 "imageEditor",
97 "chartEditor",
98 "infographicEditor",
99 "infographicGenerationEditor",
100 "presentationImageEditor",
101 ];
102
103 function PanelHeader({
104 panel,
105 onClose,
106 }: {
107 panel: RightPanelType;
108 onClose: () => void;
109 }) {
110 const info = PANEL_INFO[panel as keyof typeof PANEL_INFO];
111 if (!info) return null;
112
113 return (
114 <div className="flex items-center justify-between border-b px-4 py-2">
115 <div className="flex items-center gap-2">
116 {info.icon}
117 <h2 className="text-sm font-semibold tracking-wide">{info.title}</h2>
118 </div>
119 <Button
120 variant="ghost"
121 size="icon"
122 onClick={onClose}
123 className="size-8 rounded-full p-0 hover:bg-muted"
124 >
125 <X className="size-5" />
126 </Button>
127 </div>
128 );
129 }
130
131 function PanelContent({
132 isLoaded,
133 panel,
134 }: {
135 isLoaded: boolean;
136 panel: RightPanelType;
137 }) {
138 switch (panel) {
139 case "basicBlocks":
140 return <BasicBlocksPanel isLoaded={isLoaded} />;
141 case "elements":
142 return <ElementsPanel isLoaded />;
143 case "charts":
144 return <ChartPanel isLoaded />;
145 case "diagrams":
146 return <DiagramPanel isLoaded />;
147 case "embed":
148 return <MediaEmbedPanel />;
149 case "background":
150 return (
151 <ScrollArea className="max-h-full flex-1 p-4">
152 <BackgroundPanel />
153 </ScrollArea>
154 );
155 case "theme":
156 return <ThemePanel />;
157 case "agent":
158 return <PresentationAgentPanel />;
159 case "globalSettings":
160 return <GlobalSettings />;
161 case "imageEditor":
162 return <ImageEditorPanel />;
163 case "chartEditor":
164 return <ChartEditorPanel />;
165 case "infographicEditor":
166 return <InfographicEditorPanel />;
167 case "infographicGenerationEditor":
168 return <InfographicGenerationPanel />;
169 case "presentationImageEditor":
170 return <PresentationImageEditorPanel />;
171 case "layoutEditor":
172 return <LayoutEditorPanel isLoaded={isLoaded} />;
173 case "iconPicker":
174 return <IconPickerPanel />;
175 default:
176 return null;
177 }
178 }
179
180 export function RightPanelRenderer() {
181 const activeRightPanel = usePresentationState((s) => s.activeRightPanel);
182 const setActiveRightPanel = usePresentationState(
183 (s) => s.setActiveRightPanel,
184 );
185 const closeIconPicker = usePresentationState((s) => s.closeIconPicker);
186 const panelRef = useRef<HTMLDivElement | null>(null);
187 const [loadedPanel, setLoadedPanel] = useState<RightPanelType>(null);
188
189 const handleClose = () => {
190 if (activeRightPanel === "iconPicker") {
191 closeIconPicker();
192 return;
193 }
194
195 setActiveRightPanel(null);
196 };
197
198 const isSelfContained =
199 activeRightPanel && SELF_CONTAINED_PANELS.includes(activeRightPanel);
200 const shouldLoadImmediately =
201 activeRightPanel && IMMEDIATE_LOAD_PANELS.includes(activeRightPanel);
202 const isPanelLoaded =
203 Boolean(shouldLoadImmediately) || loadedPanel === activeRightPanel;
204
205 useEffect(() => {
206 if (!activeRightPanel) {
207 setLoadedPanel(null);
208 return;
209 }
210
211 if (IMMEDIATE_LOAD_PANELS.includes(activeRightPanel)) {
212 setLoadedPanel(activeRightPanel);
213 return;
214 }
215
216 const timeoutId = window.setTimeout(() => {
217 setLoadedPanel(activeRightPanel);
218 }, PANEL_CONTENT_LOAD_DELAY_MS);
219
220 return () => {
221 window.clearTimeout(timeoutId);
222 };
223 }, [activeRightPanel]);
224
225 const focusPanelArrowTarget = useCallback(() => {
226 const panel = panelRef.current;
227 if (!panel) return null;
228
229 const activeTarget = panel.querySelector<HTMLElement>(
230 `${PANEL_ARROW_TARGET_SELECTOR}[tabindex='0']`,
231 );
232 const fallbackTarget = panel.querySelector<HTMLElement>(
233 PANEL_ARROW_TARGET_SELECTOR,
234 );
235 const target = activeTarget ?? fallbackTarget;
236
237 target?.focus();
238 return target;
239 }, []);
240
241 useEffect(() => {
242 if (!activeRightPanel) return;
243
244 const animationFrameId = window.requestAnimationFrame(() => {
245 focusPanelArrowTarget();
246 });
247
248 return () => window.cancelAnimationFrame(animationFrameId);
249 }, [activeRightPanel, focusPanelArrowTarget]);
250
251 useEffect(() => {
252 if (!activeRightPanel) return;
253
254 const handleKeyDown = (event: KeyboardEvent) => {
255 if (!ARROW_NAVIGATION_KEYS.has(event.key)) return;
256
257 const panel = panelRef.current;
258 const targetNode = event.target;
259
260 if (!panel || !(targetNode instanceof Node)) return;
261 if (panel.contains(targetNode)) return;
262
263 event.preventDefault();
264 event.stopPropagation();
265 event.stopImmediatePropagation();
266
267 const panelTarget = focusPanelArrowTarget();
268 if (!panelTarget) return;
269
270 panelTarget.dispatchEvent(
271 new KeyboardEvent("keydown", {
272 key: event.key,
273 code: event.code,
274 bubbles: true,
275 cancelable: true,
276 altKey: event.altKey,
277 ctrlKey: event.ctrlKey,
278 metaKey: event.metaKey,
279 shiftKey: event.shiftKey,
280 }),
281 );
282 };
283
284 window.addEventListener("keydown", handleKeyDown, true);
285 return () => window.removeEventListener("keydown", handleKeyDown, true);
286 }, [activeRightPanel, focusPanelArrowTarget]);
287
288 return (
289 <AnimatePresence>
290 {activeRightPanel && (
291 <motion.div
292 key="right-panel"
293 initial={{ width: 0, opacity: 0.5 }}
294 animate={{ width: RIGHT_PANEL_WIDTH, opacity: 1 }}
295 exit={{ width: 0, opacity: 0.5 }}
296 transition={{
297 duration: RIGHT_PANEL_ANIMATION_DURATION_MS / 1000,
298 ease: "easeInOut",
299 }}
300 className="h-full shrink-0 overflow-hidden"
301 >
302 <div
303 ref={panelRef}
304 className={`${FLOATING_TOOLBAR_IGNORE_CLASS} flex h-full w-104 shrink-0 flex-col border-l bg-background`}
305 >
306 {!isSelfContained && (
307 <PanelHeader panel={activeRightPanel} onClose={handleClose} />
308 )}
309 <div className="flex-1 overflow-hidden">
310 <PanelContent isLoaded={isPanelLoaded} panel={activeRightPanel} />
311 </div>
312 </div>
313 </motion.div>
314 )}
315 </AnimatePresence>
316 );
317 }
318
318 lines Plain Text