返回 presentation-ai
usePresentModeEditorScale.ts
根目录 / src / components / notebook / presentation / editor / hooks / usePresentModeEditorScale.ts
1 "use client";
2
3 import { useEffect, useMemo, useState, type RefObject } from "react";
4
5 import { getSlideBaseWidth } from "@/config/slideFormats";
6 import { DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO } from "@/lib/presentation/aspect-ratio";
7 import { type PlateSlide } from "../../utils/parser";
8
9 const MAX_FIT_OVERFLOW_RATIO = 4;
10 const SCROLL_EXIT_OVERFLOW_RATIO = 3.9;
11 const SCROLL_MODE_TARGET_OVERFLOW_RATIO = 2;
12 const MIN_PRESENT_SCALE = 0.1;
13 const MEASUREMENT_TOLERANCE_PX = 2;
14
15 interface PresentModeEditorScaleOptions {
16 isPresenting: boolean;
17 initialContent?: PlateSlide;
18 editorRef: RefObject<HTMLDivElement | null>;
19 regionRef: RefObject<HTMLDivElement | null>;
20 }
21
22 interface PresentModeEditorScale {
23 contentScale: number;
24 logicalWidth?: number;
25 scaledContentHeight: number;
26 shouldScroll: boolean;
27 topOffset: number;
28 leftOffset: number;
29 }
30
31 interface PresentModeMeasurements {
32 regionWidth: number;
33 regionHeight: number;
34 rootWidth: number;
35 contentHeight: number;
36 }
37
38 function getViewportHeight() {
39 return Math.max(
40 1,
41 Math.round(window.visualViewport?.height ?? window.innerHeight),
42 );
43 }
44
45 function getRegionHeightBudget(
46 regionElement: HTMLElement,
47 rootElement: HTMLElement | null,
48 ) {
49 const slideRoot =
50 rootElement ??
51 regionElement.closest<HTMLElement>('[data-slide-content="true"]');
52
53 const rootHeight =
54 slideRoot && slideRoot.clientHeight > 0
55 ? slideRoot.clientHeight
56 : getViewportHeight();
57
58 if (!slideRoot) return rootHeight;
59
60 const rootImage = slideRoot.querySelector<HTMLElement>("[data-root-image]");
61 if (!rootImage) return rootHeight;
62
63 const rootImageRect = rootImage.getBoundingClientRect();
64 const isVerticalLayout = rootImageRect.width >= slideRoot.clientWidth * 0.8;
65
66 if (!isVerticalLayout) {
67 return rootHeight;
68 }
69
70 return Math.max(1, rootHeight - rootImageRect.height);
71 }
72
73 function hasMeaningfulMeasurementChange(
74 previous: PresentModeMeasurements,
75 next: PresentModeMeasurements,
76 ) {
77 return (
78 Math.abs(previous.regionWidth - next.regionWidth) >
79 MEASUREMENT_TOLERANCE_PX ||
80 Math.abs(previous.regionHeight - next.regionHeight) >
81 MEASUREMENT_TOLERANCE_PX ||
82 Math.abs(previous.rootWidth - next.rootWidth) > MEASUREMENT_TOLERANCE_PX ||
83 Math.abs(previous.contentHeight - next.contentHeight) >
84 MEASUREMENT_TOLERANCE_PX
85 );
86 }
87
88 function getAlignmentOffset(
89 alignment: PlateSlide["alignment"],
90 availableSpace: number,
91 ) {
92 if (availableSpace <= 0) return 0;
93 if (alignment === "start") return 0;
94 if (alignment === "end") return availableSpace;
95 return availableSpace / 2;
96 }
97
98 function getLogicalSlideWidth(initialContent: PlateSlide | undefined) {
99 return getSlideBaseWidth(
100 initialContent?.formatCategory ?? "presentation",
101 (initialContent?.width ?? "M") as "S" | "M" | "L",
102 initialContent?.aspectRatio ?? DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO,
103 );
104 }
105
106 export function usePresentModeEditorScale({
107 isPresenting,
108 initialContent,
109 editorRef,
110 regionRef,
111 }: PresentModeEditorScaleOptions): PresentModeEditorScale {
112 const [measurements, setMeasurements] = useState<PresentModeMeasurements>({
113 regionWidth: 0,
114 regionHeight: 0,
115 rootWidth: 0,
116 contentHeight: 0,
117 });
118 const [isScrollMode, setIsScrollMode] = useState(false);
119
120 const logicalSlideWidth = useMemo(
121 () => getLogicalSlideWidth(initialContent),
122 [initialContent],
123 );
124
125 useEffect(() => {
126 if (!isPresenting) {
127 setMeasurements({
128 regionWidth: 0,
129 regionHeight: 0,
130 rootWidth: 0,
131 contentHeight: 0,
132 });
133 setIsScrollMode(false);
134 return;
135 }
136
137 const regionElement = regionRef.current;
138 const editorElement = editorRef.current;
139 if (!regionElement || !editorElement) return;
140
141 let rafId = 0;
142
143 const updateMeasurements = () => {
144 const rootElement = regionElement.closest<HTMLElement>(
145 '[data-slide-content="true"]',
146 );
147 const nextMeasurements: PresentModeMeasurements = {
148 regionWidth: Math.round(regionElement.clientWidth),
149 regionHeight: Math.round(
150 getRegionHeightBudget(regionElement, rootElement),
151 ),
152 rootWidth: Math.round(rootElement?.clientWidth ?? window.innerWidth),
153 contentHeight: Math.round(editorElement.scrollHeight),
154 };
155
156 setMeasurements((previous) => {
157 if (!hasMeaningfulMeasurementChange(previous, nextMeasurements)) {
158 return previous;
159 }
160 return nextMeasurements;
161 });
162 };
163
164 const scheduleMeasurement = () => {
165 cancelAnimationFrame(rafId);
166 rafId = requestAnimationFrame(updateMeasurements);
167 };
168
169 updateMeasurements();
170
171 const resizeObserver = new ResizeObserver(scheduleMeasurement);
172 resizeObserver.observe(regionElement);
173 resizeObserver.observe(editorElement);
174
175 const rootElement = regionElement.closest<HTMLElement>(
176 '[data-slide-content="true"]',
177 );
178 if (rootElement) {
179 resizeObserver.observe(rootElement);
180 const rootImage =
181 rootElement.querySelector<HTMLElement>("[data-root-image]");
182 if (rootImage) {
183 resizeObserver.observe(rootImage);
184 }
185 }
186
187 window.addEventListener("resize", scheduleMeasurement, { passive: true });
188 window.visualViewport?.addEventListener("resize", scheduleMeasurement);
189
190 return () => {
191 cancelAnimationFrame(rafId);
192 resizeObserver.disconnect();
193 window.removeEventListener("resize", scheduleMeasurement);
194 window.visualViewport?.removeEventListener("resize", scheduleMeasurement);
195 };
196 }, [editorRef, isPresenting, regionRef]);
197
198 const overflowRatio = useMemo(() => {
199 const { regionHeight, rootWidth, contentHeight } = measurements;
200
201 if (
202 !isPresenting ||
203 regionHeight <= 0 ||
204 rootWidth <= 0 ||
205 logicalSlideWidth <= 0
206 ) {
207 return 1;
208 }
209
210 const baseScale = Math.max(
211 rootWidth / logicalSlideWidth,
212 MIN_PRESENT_SCALE,
213 );
214 const visualHeightAtBaseScale = contentHeight * baseScale;
215 return visualHeightAtBaseScale / regionHeight;
216 }, [isPresenting, logicalSlideWidth, measurements]);
217
218 useEffect(() => {
219 if (!isPresenting) {
220 setIsScrollMode(false);
221 return;
222 }
223
224 setIsScrollMode((previous) => {
225 if (previous) {
226 return overflowRatio > SCROLL_EXIT_OVERFLOW_RATIO;
227 }
228 return overflowRatio > MAX_FIT_OVERFLOW_RATIO;
229 });
230 }, [isPresenting, overflowRatio]);
231
232 return useMemo(() => {
233 const { regionWidth, regionHeight, rootWidth, contentHeight } =
234 measurements;
235
236 if (
237 !isPresenting ||
238 regionWidth <= 0 ||
239 regionHeight <= 0 ||
240 rootWidth <= 0 ||
241 logicalSlideWidth <= 0
242 ) {
243 return {
244 contentScale: 1,
245 logicalWidth: undefined,
246 scaledContentHeight: 0,
247 shouldScroll: false,
248 topOffset: 0,
249 leftOffset: 0,
250 };
251 }
252
253 const baseScale = Math.max(
254 rootWidth / logicalSlideWidth,
255 MIN_PRESENT_SCALE,
256 );
257 const logicalWidth = regionWidth / baseScale;
258 const visualHeightAtBaseScale = contentHeight * baseScale;
259 const shouldFitOverflow = overflowRatio > 1 && !isScrollMode;
260 const shouldScroll = isScrollMode;
261 const fitScale = shouldFitOverflow
262 ? regionHeight / Math.max(visualHeightAtBaseScale, 1)
263 : shouldScroll
264 ? Math.min(1, SCROLL_MODE_TARGET_OVERFLOW_RATIO / overflowRatio)
265 : 1;
266 const contentScale = baseScale * fitScale;
267 const scaledContentHeight = contentHeight * contentScale;
268 const visualContentWidth = logicalWidth * contentScale;
269 const topOffset = shouldScroll
270 ? 0
271 : getAlignmentOffset(
272 initialContent?.alignment,
273 regionHeight - scaledContentHeight,
274 );
275 const leftOffset = Math.max((regionWidth - visualContentWidth) / 2, 0);
276
277 return {
278 contentScale,
279 logicalWidth,
280 scaledContentHeight,
281 shouldScroll,
282 topOffset,
283 leftOffset,
284 };
285 }, [
286 initialContent?.alignment,
287 isPresenting,
288 isScrollMode,
289 logicalSlideWidth,
290 measurements,
291 overflowRatio,
292 ]);
293 }
294
294 lines TYPESCRIPT