返回 presentation-ai
SlideSidebar.tsx
根目录 / src / components / presentation / sidebar / SlideSidebar.tsx
1 "use client";
2
3 import { GripVertical, PanelLeftOpen, PanelRightOpen } from "lucide-react";
4 import { AnimatePresence, motion } from "motion/react";
5 import React, { useCallback, useLayoutEffect, useState } from "react";
6
7 import { Resizable } from "@/components/ui/resizable";
8 import { usePresentationSlides } from "@/hooks/presentation/usePresentationSlides";
9 import { DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO } from "@/lib/presentation/aspect-ratio";
10 import { cn } from "@/lib/utils";
11 import { usePresentationState } from "@/states/presentation-state";
12 import StaticPresentationEditor from "../../notebook/presentation/editor/presentation-editor-static";
13 import { Button } from "../../ui/button";
14 import { Skeleton } from "../../ui/skeleton";
15 import { SlideThumbnail } from "./SlideThumbnail";
16
17 const SIDEBAR_WIDTH_STORAGE_KEY = "presentation-sidebar-width";
18 const DEFAULT_SIDEBAR_WIDTH = 150;
19
20 interface SlideSidebarProps {
21 onSlideClick?: (slideId: string) => void;
22 currentSlideId?: string;
23 showSidebar?: boolean;
24 variant?: "docked" | "sheet";
25 className?: string;
26 }
27
28 function SlideSidebarBase({
29 onSlideClick,
30 currentSlideId: currentSlideIdProp,
31 showSidebar = true,
32 variant = "docked",
33 className,
34 }: SlideSidebarProps) {
35 // Only subscribe to slide IDs to prevent re-render when content changes
36 // shallow ensures array comparison is shallow (same values = no re-render)
37 const slideIds = usePresentationState((s) =>
38 s.slides.map((slide) => slide.id),
39 );
40 const slidesCount = slideIds.length;
41 const stateCurrentSlideId = usePresentationState((s) => s.currentSlideId);
42 const setCurrentSlideId = usePresentationState((s) => s.setCurrentSlideId);
43 const isSidebarCollapsed = usePresentationState((s) => s.isSidebarCollapsed);
44 const setIsSidebarCollapsed = usePresentationState(
45 (s) => s.setIsSidebarCollapsed,
46 );
47 const isGeneratingPresentation = usePresentationState(
48 (s) => s.isGeneratingPresentation,
49 );
50 const effectiveCurrentSlideId =
51 typeof currentSlideIdProp === "string"
52 ? currentSlideIdProp
53 : stateCurrentSlideId;
54 const isSheetVariant = variant === "sheet";
55
56 const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH);
57 const { scrollToSlide } = usePresentationSlides();
58
59 // Load sidebar width from localStorage on mount
60 useLayoutEffect(() => {
61 if (isSheetVariant) {
62 return;
63 }
64
65 const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
66 if (stored) {
67 const parsed = parseInt(stored, 10);
68 if (!Number.isNaN(parsed) && parsed >= 100 && parsed <= 300) {
69 setSidebarWidth(parsed);
70 }
71 }
72 }, [isSheetVariant]);
73
74 const handleSlideClick = useCallback(
75 (slideId: string) => {
76 setCurrentSlideId(slideId);
77 scrollToSlide(slideId);
78 onSlideClick?.(slideId);
79 },
80 [onSlideClick, scrollToSlide, setCurrentSlideId],
81 );
82
83 const handleResize = useCallback(
84 (_e: unknown, _direction: unknown, _ref: unknown, d: { width: number }) => {
85 setSidebarWidth((prev) => {
86 const newWidth = prev + d.width;
87 localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, newWidth.toString());
88 return newWidth;
89 });
90 },
91 [],
92 );
93
94 const slideList = (
95 <div
96 className={
97 isSheetVariant
98 ? "scrollbar-thin h-full overflow-auto pr-1 scrollbar-thumb-muted scrollbar-track-transparent"
99 : "scrollbar-thin h-max max-h-[80dvh] overflow-auto scrollbar-thumb-muted scrollbar-track-transparent"
100 }
101 >
102 <div className="flex flex-col space-y-4 p-4">
103 {!isSheetVariant && (
104 <div className="mb-2 flex items-center justify-between">
105 <h2 className="text-sm font-semibold">
106 Slides
107 </h2>
108
109 <Button
110 onClick={() => setIsSidebarCollapsed(true)}
111 variant="ghost"
112 size="sm"
113 >
114 <PanelRightOpen className="size-3" />
115 </Button>
116 </div>
117 )}
118 <div className="flex flex-col space-y-4">
119 {isGeneratingPresentation && slidesCount === 0 && (
120 <div className="aspect-video w-full">
121 <Skeleton className="h-full w-full"></Skeleton>
122 </div>
123 )}
124 {slideIds.map((slideId, index) => (
125 <MemoPreviewItem
126 key={slideId}
127 index={index}
128 isActive={effectiveCurrentSlideId === slideId}
129 onClick={handleSlideClick}
130 slideId={slideId}
131 containerWidth={isSheetVariant ? undefined : sidebarWidth - 32}
132 />
133 ))}
134 </div>
135 </div>
136 </div>
137 );
138
139 if (isSheetVariant) {
140 if (!showSidebar) {
141 return null;
142 }
143
144 return (
145 <div
146 className={cn(
147 "pointer-events-none fixed top-1/2 left-3 z-40 flex -translate-y-1/2 justify-start",
148 className,
149 )}
150 >
151 {isSidebarCollapsed ? (
152 <motion.div
153 initial={{ opacity: 0, x: -12 }}
154 animate={{ opacity: 1, x: 0 }}
155 exit={{ opacity: 0, x: -12 }}
156 transition={{ duration: 0.2 }}
157 className="pointer-events-auto"
158 >
159 <button
160 onClick={() => setIsSidebarCollapsed(false)}
161 className="rounded-2xl border border-border/70 bg-background/95 px-3 py-4 shadow-lg backdrop-blur"
162 aria-label="Open slides sidebar"
163 >
164 <PanelLeftOpen className="size-5 text-primary" />
165 </button>
166 </motion.div>
167 ) : (
168 <motion.div
169 initial={{ opacity: 0, x: -16 }}
170 animate={{ opacity: 1, x: 0 }}
171 exit={{ opacity: 0, x: -16 }}
172 transition={{ duration: 0.25 }}
173 className="pointer-events-auto flex h-[min(60dvh,32rem)] w-[min(11rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-xl backdrop-blur"
174 >
175 <div className="flex items-center justify-between border-b border-border/60 px-3 py-2.5">
176 <h2 className="text-sm font-semibold">
177 Slides
178 </h2>
179 <Button
180 onClick={() => setIsSidebarCollapsed(true)}
181 variant="ghost"
182 size="sm"
183 className="size-8"
184 >
185 <PanelRightOpen className="size-4" />
186 </Button>
187 </div>
188 <div className="min-h-0 flex-1">{slideList}</div>
189 </motion.div>
190 )}
191 </div>
192 );
193 }
194
195 return (
196 <div
197 className={
198 className
199 ? `flex h-full items-center ${className}`
200 : "flex h-full items-center"
201 }
202 >
203 <div className="flex h-full items-center">
204 <AnimatePresence>
205 {showSidebar && !isSidebarCollapsed && (
206 <motion.div
207 initial={{
208 scale: 1,
209 width: "auto",
210 opacity: 1,
211 x: "-100%",
212 originX: 0.5,
213 originY: 0.5,
214 }}
215 animate={{
216 x: 0,
217 }}
218 exit={{
219 scale: 0,
220 width: 0,
221 opacity: 0,
222 originX: 0.5,
223 originY: 0.5,
224 }}
225 transition={{
226 duration: 0.35,
227 opacity: { duration: 0.25 },
228 }}
229 className="overflow-hidden"
230 >
231 <Resizable
232 size={{ width: sidebarWidth }}
233 minWidth={100}
234 maxWidth={300}
235 enable={{ right: true }}
236 onResizeStop={handleResize}
237 handleComponent={{
238 right: (
239 <div className="group/resize relative flex h-full w-1 cursor-col-resize bg-border">
240 <GripVertical className="absolute top-1/2 left-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 text-muted-foreground opacity-0 group-hover/resize:opacity-100" />
241 </div>
242 ),
243 }}
244 >
245 {slideList}
246 </Resizable>
247 </motion.div>
248 )}
249 </AnimatePresence>
250
251 {showSidebar && isSidebarCollapsed && (
252 <motion.div
253 initial={{ opacity: 0 }}
254 animate={{ opacity: 1, x: "0.5rem" }}
255 exit={{ opacity: 0 }}
256 transition={{
257 duration: 0.4,
258 opacity: { duration: 0.4, delay: 0.1 },
259 }}
260 >
261 <button
262 onClick={() => setIsSidebarCollapsed(false)}
263 className="rounded-md border border-(--presentation-primary) px-1 py-2"
264 >
265 <PanelLeftOpen className="size-5 text-sm" />
266 </button>
267 </motion.div>
268 )}
269 </div>
270 </div>
271 );
272 }
273
274 // moved to hooks/presentation/previewSignature
275
276 const MemoPreviewItem = React.memo(
277 function PreviewItem({
278 index,
279 isActive,
280 onClick,
281 slideId,
282 containerWidth,
283 }: {
284 index: number;
285 isActive: boolean;
286 onClick: (slideId: string) => void;
287 slideId: string;
288 containerWidth?: number;
289 }) {
290 // Each item fetches its own slide - stable reference unless THIS slide changes
291 const slide = usePresentationState((s) =>
292 s.slides.find((slide) => slide.id === slideId),
293 );
294
295 const handleClick = useCallback(() => onClick(slideId), [onClick, slideId]);
296
297 if (!slide) return null;
298
299 const effectiveContainerWidth =
300 typeof containerWidth === "number"
301 ? containerWidth -
302 ((slide.formatCategory ?? "presentation") === "social" ? 8 : 0)
303 : undefined;
304
305 return (
306 <SlideThumbnail
307 index={index} // For showing the slide number
308 isActive={isActive}
309 onClick={handleClick}
310 widthSize={(slide.width ?? "M") as "S" | "M" | "L"}
311 formatCategory={slide.formatCategory ?? "presentation"}
312 aspectRatio={
313 slide.aspectRatio ?? DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO
314 }
315 containerWidth={effectiveContainerWidth}
316 >
317 <StaticPresentationEditor
318 initialContent={slide}
319 className="border"
320 id={`preview-${slideId}`}
321 />
322 </SlideThumbnail>
323 );
324 },
325 (prev, next) => {
326 if (prev.index !== next.index) return false;
327 if (prev.isActive !== next.isActive) return false;
328 if (prev.slideId !== next.slideId) return false;
329 if (prev.containerWidth !== next.containerWidth) return false;
330 // Note: We don't need to compare slide anymore since each item fetches its own
331 // and React will re-render when the selector returns a new reference
332 return true;
333 },
334 );
335
336 export const SlideSidebar = React.memo(SlideSidebarBase);
337
337 lines Plain Text