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