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