| 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 |