返回 presentation-ai
useSlideContentScaling.ts
根目录 / src / hooks / presentation / useSlideContentScaling.ts
1 "use client";
2
3 import debounce from "lodash.debounce";
4 import { useCallback, useEffect, useRef, useState } from "react";
5
6 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
7 import { getSlideBaseWidth } from "@/config/slideFormats";
8 import { DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO } from "@/lib/presentation/aspect-ratio";
9 import { type SlideScalingConfig } from "./scaling";
10 import { getPresentModeViewportDimensions } from "./usePresentModeOrientation";
11
12 // Re-export for backward compatibility
13 /**
14 * useSlideContentScaling
15 *
16 * Computes responsive scaling for a slide based on the available content area,
17 * and exposes utilities to keep the parent layout height in sync when the slide
18 * is visually scaled via CSS transforms.
19 *
20 * Why this exists:
21 * - CSS `transform: scale(...)` changes how the slide looks but does not affect
22 * layout calculations (height) of parent elements. This can cause the
23 * parent container to have an incorrect height when the slide is scaled down.
24 * - This hook measures the unscaled content element and returns a calculated
25 * `scaledHeight` so parents can set an explicit height that matches the visual
26 * scale.
27 *
28 * Behavior by mode:
29 * - Edit mode (isPresenting = false):
30 * - Determines a scale factor based on the actual `.presentation-slides`
31 * container size (or a viewport fallback) and the configured base width.
32 * - Keeps font size at the base 16px for readability while editing.
33 * - Measures the unscaled content via `contentRef` and returns `scaledHeight`
34 * so the outer wrapper can adopt the correct layout height.
35 * - Present mode (isPresenting = true):
36 * - Scales the slide frame width to the viewport.
37 * - Keeps presentation-format text at the base font size because the editor
38 * surface applies region-aware transform scaling for present mode.
39 * - `scaledHeight` is undefined (full-screen layout is expected in present mode).
40 * - Only the active slide performs calculations to reduce memory usage.
41 */
42 /**
43 * Calculate slide scaling based on the actual content container width.
44 * Accounts for sidebars/UI by querying `.presentation-slides` instead of only
45 * using the viewport.
46 *
47 * @param slideWidthSize - Base logical slide width preset: "S" | "M" | "L".
48 * @param isPresenting - Whether the slide is in present mode.
49 * @param formatCategory - Format category for the slide.
50 * @param aspectRatio - Aspect ratio configuration.
51 * @param containerRefOverride - Optional container ref override.
52 * @param zoomLevel - Zoom multiplier (1 = 100%, 1.4 = 140%, etc.).
53 * @returns SlideScalingConfig with scale, dimensions, and refs.
54 */
55
56 /**
57 * Calculate slide scaling based on the actual content container width.
58 * Accounts for sidebars/UI by querying `.presentation-slides` instead of only
59 * using the viewport.
60 *
61 * @param slideWidthSize - Base logical slide width preset: "S" | "M" | "L".
62 * @param isPresenting - Whether the slide is in present mode.
63 * @param customBaseWidth - Custom base width override.
64 * @param customBaseWidth - Custom base width override.
65 * @returns An object containing:
66 * - `scale`: number applied to CSS `transform: scale(...)` on the content node
67 * - `slideWidth`: resolved base pixel width for the slide
68 * - `fontSize`: base font size (scaled in present mode, 16px in edit mode)
69 * - `scaledHeight?`: computed scaled height for parent layout in edit mode
70 * - `contentRef`: ref that must be attached to the transformed content element
71 *
72 * Usage:
73 * ```tsx
74 * const { scale, slideWidth, fontSize, scaledHeight, contentRef } =
75 * useSlideContentScaling("M", isPresenting);
76 *
77 * return (
78 * <div style={{ height: scaledHeight ? `${scaledHeight}px` : undefined }}>
79 * <div
80 * ref={contentRef}
81 * style={{ width: `${slideWidth}px`, transform: `scale(${scale})` }}
82 * />
83 * </div>
84 * );
85 * ```
86 */
87 export function useSlideContentScaling(
88 slideWidthSize: "S" | "M" | "L" = "M",
89 isPresenting: boolean = false,
90 formatCategory: PlateSlide["formatCategory"] = "presentation",
91 aspectRatio: PlateSlide["aspectRatio"] = DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO,
92 containerRefOverride?: React.RefObject<HTMLDivElement | null>,
93 zoomLevel: number = 1, // Zoom multiplier (1 = 100%, 1.4 = 140%, etc.)
94 ): SlideScalingConfig {
95 // Use centralized config to get slide width
96 const slideWidth = getSlideBaseWidth(
97 formatCategory,
98 slideWidthSize,
99 aspectRatio,
100 );
101
102 const contentRef = useRef<HTMLDivElement | null>(null);
103 const [measuredContentHeight, setMeasuredContentHeight] = useState<number>(0);
104 const measuredContentHeightRef = useRef(0);
105 const containerRef = useRef<HTMLElement | null>(null);
106 const resizeObserverRef = useRef<ResizeObserver | null>(null);
107 // Track if we've already locked the font scale in present mode (prevents feedback loop)
108 const hasSettledScaleRef = useRef<boolean>(false);
109 const scaleLockTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
110 null,
111 );
112 const [isScaleLocked, setIsScaleLocked] = useState(false);
113
114 const resetPresentingScaleLock = useCallback(() => {
115 hasSettledScaleRef.current = false;
116 setIsScaleLocked((prev) => (prev ? false : prev));
117 if (scaleLockTimeoutRef.current) {
118 clearTimeout(scaleLockTimeoutRef.current);
119 scaleLockTimeoutRef.current = null;
120 }
121 }, []);
122
123 // In present mode, only calculate for the active slide
124
125 const [scaling, setScaling] = useState<
126 Omit<SlideScalingConfig, "scaledHeight" | "contentRef" | "contentHeight">
127 >({
128 scale: 1,
129 slideWidth,
130 minHeight: undefined,
131 fontSize: 16,
132 presentFitScale: 1,
133 });
134 const scalingRef = useRef(scaling);
135
136 useEffect(() => {
137 scalingRef.current = scaling;
138 }, [scaling]);
139
140 // Memoize the calculation function to avoid recreating it
141 const calculateScaling = useCallback(() => {
142 // Skip calculation for inactive slides in present mode
143 // Skip if we've already locked the scale in present mode
144 if (isPresenting && hasSettledScaleRef.current) {
145 return;
146 }
147
148 let scale = 1;
149 let fontSize = 16;
150 const presentFitScale = 1;
151
152 // Get base width using centralized config
153 const slideWidth = getSlideBaseWidth(
154 formatCategory,
155 slideWidthSize,
156 aspectRatio,
157 );
158
159 if (isPresenting) {
160 const { width: viewportWidth } = getPresentModeViewportDimensions();
161 const availableWidth = Math.max(1, viewportWidth);
162 const widthScale = availableWidth / slideWidth;
163
164 // For social format: cap scale so slide fits within viewport but doesn't stretch
165 // beyond its natural size. This keeps the slide centered and proportional.
166 // For presentation/fluid: scale to fill viewport width as before.
167 if (formatCategory === "social") {
168 // Cap scale at 1 to prevent stretching beyond the base width
169 scale = Math.min(widthScale, 1);
170 fontSize = 16 * scale;
171 // Social format allows scroll for tall content, no presentFitScale adjustment needed
172 } else {
173 scale = widthScale;
174 fontSize = 16;
175 }
176 } else {
177 // Cache the container element to avoid repeated DOM queries
178 if (!containerRef.current && !containerRefOverride) {
179 containerRef.current = document.querySelector(".presentation-slides");
180 }
181
182 const presentationContainer =
183 containerRefOverride?.current ?? containerRef.current;
184
185 // Calculate the maximum scale that fits the available space
186 let maxScale = 1;
187
188 if (presentationContainer) {
189 const containerWidth = presentationContainer.clientWidth;
190
191 // Account for the max-w-[90%] wrapper and its padding/margin
192 const effectiveWidth = containerWidth * 0.9; // 90% of container
193 // Maximum scale is the ratio of available space to slide width
194 maxScale = effectiveWidth / slideWidth;
195 } else {
196 // Fallback: use viewport with padding to account for sidebar and panels
197 const viewportWidth = window.innerWidth;
198 const sidebarAndPadding = 350; // Conservative estimate for sidebar + right panel
199 const availableWidth = viewportWidth - sidebarAndPadding;
200 maxScale = availableWidth / slideWidth;
201 }
202
203 // Apply zoom level, but clamp it to maxScale to prevent overflow
204 // If zoomLevel would cause overflow, gradually reduce it
205 const desiredScale = zoomLevel;
206
207 if (desiredScale <= maxScale) {
208 // There's enough space for the desired zoom level
209 scale = desiredScale;
210 } else {
211 // Not enough space - clamp to maxScale
212 // This means on smaller screens, zoom is automatically reduced
213 scale = maxScale;
214 }
215
216 // Keep base font size in edit mode (don't scale text)
217 fontSize = 16;
218 }
219
220 const nextScaling = {
221 scale: Math.max(scale, 0.1),
222 slideWidth,
223 fontSize,
224 presentFitScale,
225 };
226 const currentScaling = scalingRef.current;
227
228 if (
229 currentScaling.scale !== nextScaling.scale ||
230 currentScaling.fontSize !== nextScaling.fontSize ||
231 currentScaling.slideWidth !== nextScaling.slideWidth ||
232 currentScaling.presentFitScale !== nextScaling.presentFitScale
233 ) {
234 scalingRef.current = nextScaling;
235 setScaling(nextScaling);
236 }
237 }, [
238 isPresenting,
239 formatCategory,
240 slideWidthSize,
241 aspectRatio,
242 containerRefOverride,
243 zoomLevel,
244 ]);
245
246 // Reset the settled flag when entering/exiting present mode
247 useEffect(() => {
248 resetPresentingScaleLock();
249 }, [isPresenting, resetPresentingScaleLock]);
250
251 // Setup resize listeners only for active slides
252 useEffect(() => {
253 calculateScaling();
254
255 // Use ResizeObserver on the presentation-slides container only for edit mode
256 if (!isPresenting) {
257 // Disconnect existing observer if it exists
258 if (resizeObserverRef.current) {
259 resizeObserverRef.current.disconnect();
260 resizeObserverRef.current = null;
261 }
262
263 // Use containerRefOverride if provided, otherwise fall back to querying DOM
264 const presentationContainer =
265 containerRefOverride?.current ??
266 (containerRef.current ||
267 document.querySelector(".presentation-slides"));
268
269 if (presentationContainer) {
270 resizeObserverRef.current = new ResizeObserver(calculateScaling);
271 resizeObserverRef.current.observe(presentationContainer);
272 }
273 }
274
275 const handleViewportResize = () => {
276 if (isPresenting) {
277 resetPresentingScaleLock();
278 }
279 calculateScaling();
280 };
281
282 // For present mode, react to viewport changes including mobile rotation.
283 window.addEventListener("resize", handleViewportResize, {
284 passive: true,
285 });
286 window.addEventListener("orientationchange", handleViewportResize);
287 window.visualViewport?.addEventListener("resize", handleViewportResize);
288
289 return () => {
290 window.removeEventListener("resize", handleViewportResize);
291 window.removeEventListener("orientationchange", handleViewportResize);
292 window.visualViewport?.removeEventListener(
293 "resize",
294 handleViewportResize,
295 );
296 if (resizeObserverRef.current) {
297 resizeObserverRef.current.disconnect();
298 resizeObserverRef.current = null;
299 }
300 };
301 }, [
302 isPresenting,
303 calculateScaling,
304 containerRefOverride,
305 resetPresentingScaleLock,
306 ]);
307
308 // Measure unscaled content height; in edit mode, compute scaledHeight for layout.
309 useEffect(() => {
310 const node = contentRef.current;
311 if (!node) return;
312
313 const updateHeight = () => {
314 // Use scrollHeight in present mode to get true content height (ignoring overflow clip)
315 const height = isPresenting
316 ? node.scrollHeight || 0
317 : node.offsetHeight || 0;
318 if (measuredContentHeightRef.current !== height) {
319 measuredContentHeightRef.current = height;
320 setMeasuredContentHeight(height);
321 }
322 };
323
324 updateHeight();
325
326 // Debounce height updates too
327 const debouncedUpdateHeight = debounce(updateHeight, 100);
328 const ro = new ResizeObserver(debouncedUpdateHeight);
329 ro.observe(node);
330 window.addEventListener("resize", debouncedUpdateHeight, { passive: true });
331
332 return () => {
333 ro.disconnect();
334 window.removeEventListener("resize", debouncedUpdateHeight);
335 };
336 }, [isPresenting]);
337
338 // Mark scale as locked for loader state once measurements and scaling settle in present mode.
339 useEffect(() => {
340 if (!isPresenting) return;
341 if (isScaleLocked) return;
342 if (measuredContentHeight <= 0) return;
343
344 if (scaleLockTimeoutRef.current) {
345 clearTimeout(scaleLockTimeoutRef.current);
346 }
347
348 scaleLockTimeoutRef.current = setTimeout(() => {
349 setIsScaleLocked((prev) => (prev ? prev : true));
350 }, 250);
351
352 return () => {
353 if (scaleLockTimeoutRef.current) {
354 clearTimeout(scaleLockTimeoutRef.current);
355 scaleLockTimeoutRef.current = null;
356 }
357 };
358 }, [
359 isPresenting,
360 measuredContentHeight,
361 scaling.scale,
362 scaling.fontSize,
363 scaling.presentFitScale,
364 isScaleLocked,
365 ]);
366
367 const scaledHeight = !isPresenting
368 ? Math.max(0, Math.ceil(measuredContentHeight * (scaling.scale || 1)))
369 : undefined;
370
371 // In present mode, if the content at full-width exceeds viewport height,
372 // compute a fit scale so the entire slide is visible without scrolling.
373 // This is applied as a CSS transform in SlideWrapper (not just fontSize).
374 let computedPresentFitScale = scaling.presentFitScale;
375 if (
376 isPresenting &&
377 measuredContentHeight > 0 &&
378 formatCategory !== "social"
379 ) {
380 const { height: vpHeight } = getPresentModeViewportDimensions();
381 if (vpHeight > 0 && measuredContentHeight > vpHeight) {
382 computedPresentFitScale = vpHeight / measuredContentHeight;
383 }
384 }
385
386 return {
387 ...scaling,
388 presentFitScale: computedPresentFitScale,
389 scaledHeight,
390 contentHeight: measuredContentHeight,
391 contentRef,
392 isScaleLocked,
393 };
394 }
395
395 lines TYPESCRIPT