返回 presentation-ai
SlideThumbnail.tsx
根目录 / src / components / presentation / sidebar / SlideThumbnail.tsx
1 import { useEffect, useRef, useState } from "react";
2
3 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
4 import { getSlideBaseWidth } from "@/config/slideFormats";
5 import { DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO } from "@/lib/presentation/aspect-ratio";
6 import { cn } from "@/lib/utils";
7
8 interface SlideThumbnailProps {
9 index: number;
10 isActive: boolean;
11 onClick: () => void;
12 children: React.ReactNode;
13 widthSize?: "S" | "M" | "L";
14 containerWidth?: number;
15 formatCategory?: PlateSlide["formatCategory"];
16 aspectRatio?: PlateSlide["aspectRatio"];
17 }
18
19 export function SlideThumbnail({
20 index,
21 isActive,
22 onClick,
23 children,
24 widthSize = "M",
25 containerWidth,
26 formatCategory = "presentation",
27 aspectRatio = DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO,
28 }: SlideThumbnailProps) {
29 const containerRef = useRef<HTMLDivElement | null>(null);
30 const contentRef = useRef<HTMLDivElement | null>(null);
31 const [scale, setScale] = useState(0.2);
32 const [height, setHeight] = useState<number | undefined>(undefined);
33 const unscaledHeightRef = useRef<number>(0);
34 const lastContainerWidthRef = useRef<number>(0);
35 const rafIdRef = useRef<number | null>(null);
36
37 useEffect(() => {
38 if (!containerRef.current || !contentRef.current) return;
39 const container = containerRef.current;
40 const content = contentRef.current;
41 const slideBaseWidth = getSlideBaseWidth(
42 formatCategory,
43 widthSize,
44 aspectRatio,
45 );
46
47 // Compute scale from container width to logical slide width
48 const commitScaleAndHeight = (containerWidth: number) => {
49 const newScale =
50 containerWidth > 0 ? containerWidth / slideBaseWidth : 0.2;
51 if (Math.abs(newScale - scale) > 0.005) {
52 setScale(newScale);
53 }
54 const nextHeight = Math.max(
55 0,
56 Math.ceil(unscaledHeightRef.current * newScale),
57 );
58 if (
59 (height ?? 0) === 0 ||
60 (Number.isFinite(nextHeight) &&
61 Math.abs((height ?? 0) - nextHeight) > 0)
62 ) {
63 setHeight(nextHeight > 0 ? nextHeight : undefined);
64 }
65 };
66
67 const scheduleScaleFromCurrentWidth = () => {
68 if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current);
69 rafIdRef.current = requestAnimationFrame(() => {
70 rafIdRef.current = null;
71 const effectiveWidth =
72 typeof containerWidth === "number" && containerWidth > 0
73 ? containerWidth
74 : container.clientWidth;
75 commitScaleAndHeight(effectiveWidth);
76 });
77 };
78
79 // Measure unscaled content height (transform does not affect layout metrics)
80 const updateUnscaledHeight = () => {
81 // offsetHeight is unscaled even if a CSS transform is applied on this node
82 const h = content.offsetHeight || 0;
83 if (h !== unscaledHeightRef.current) {
84 unscaledHeightRef.current = h;
85 // When content height changes, recompute scaled height (width unchanged)
86 scheduleScaleFromCurrentWidth();
87 }
88 };
89
90 // Drive width updates from prop (sidebar width) and window resize only to avoid RO ping-pong
91 lastContainerWidthRef.current =
92 typeof containerWidth === "number" && containerWidth > 0
93 ? containerWidth
94 : container.clientWidth;
95
96 const contentRO = new ResizeObserver(updateUnscaledHeight);
97 contentRO.observe(content);
98
99 // Initial measurements
100 updateUnscaledHeight();
101 scheduleScaleFromCurrentWidth();
102
103 const onWindowResize = () => scheduleScaleFromCurrentWidth();
104 window.addEventListener("resize", onWindowResize);
105
106 return () => {
107 contentRO.disconnect();
108 window.removeEventListener("resize", onWindowResize);
109 if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current);
110 };
111 }, [widthSize, containerWidth, formatCategory, aspectRatio]);
112
113 // Ensure recompute when only aspect changes (minHeight change without width change)
114 // This complements the RO by force-refreshing scale/height on aspectSignature updates.
115 useEffect(() => {
116 if (!containerRef.current || !contentRef.current) return;
117 const container = containerRef.current;
118 const content = contentRef.current;
119 const slideBaseWidth = getSlideBaseWidth(
120 formatCategory,
121 widthSize,
122 aspectRatio,
123 );
124
125 const effectiveWidth =
126 typeof containerWidth === "number" && containerWidth > 0
127 ? containerWidth
128 : container.clientWidth;
129
130 const newScale = effectiveWidth > 0 ? effectiveWidth / slideBaseWidth : 0.2;
131 setScale(newScale);
132 const h = content.offsetHeight || 0;
133 const nextHeight = Math.max(0, Math.ceil(h * newScale));
134 setHeight(nextHeight > 0 ? nextHeight : undefined);
135 }, [widthSize, containerWidth, formatCategory, aspectRatio]);
136
137 return (
138 <button
139 type="button"
140 className={cn(
141 "group relative cursor-pointer overflow-hidden rounded-md border transition-all hover:border-primary",
142 isActive ? "border-primary ring ring-primary" : "border-muted",
143 )}
144 onClick={onClick}
145 >
146 <div className="absolute top-1 left-2 z-10 rounded-sm bg-muted px-1 py-0.5 text-xs font-medium text-muted-foreground">
147 {index + 1}
148 </div>
149 <div
150 ref={containerRef}
151 className="pointer-events-none w-full overflow-hidden bg-card"
152 style={{
153 height: height ?? undefined,
154 // aspectRatio: height === undefined ? "16/9" : undefined,
155 }}
156 >
157 <div
158 ref={contentRef}
159 style={{
160 transform: `scale(${scale})`,
161 transformOrigin: "top left",
162 width: getSlideBaseWidth(formatCategory, widthSize, aspectRatio),
163 }}
164 >
165 {children}
166 </div>
167 </div>
168 </button>
169 );
170 }
171
171 lines Plain Text