11'use client'
22
33import type { MouseEvent , ReactNode } from 'react'
4- import { useCallback , useEffect , useRef , useState } from 'react'
4+ import { useCallback , useEffect , useLayoutEffect , useRef , useState } from 'react'
55import { ZoomIn , ZoomOut } from 'lucide-react'
66import { Button } from '@/components/emcn'
77import { cn } from '@/lib/core/utils/cn'
@@ -10,6 +10,7 @@ const ZOOM_MIN = 0.25
1010const ZOOM_MAX = 4
1111const ZOOM_WHEEL_SENSITIVITY = 0.005
1212const ZOOM_BUTTON_FACTOR = 1.2
13+ const FIT_PADDING = 48
1314
1415const clampZoom = ( zoom : number ) => Math . min ( Math . max ( zoom , ZOOM_MIN ) , ZOOM_MAX )
1516
@@ -18,44 +19,104 @@ interface Offset {
1819 y : number
1920}
2021
22+ interface Size {
23+ width : number
24+ height : number
25+ }
26+
2127interface ZoomablePreviewProps {
2228 children : ReactNode
2329 className ?: string
2430 contentClassName ?: string
31+ initialScale ?: 'actual' | 'fit'
32+ resetKey ?: string | number
33+ }
34+
35+ function getElementSize ( element : HTMLElement | null ) : Size {
36+ if ( ! element ) return { width : 0 , height : 0 }
37+ return {
38+ width : element . offsetWidth ,
39+ height : element . offsetHeight ,
40+ }
2541}
2642
27- function clampOffset ( container : HTMLDivElement | null , offset : Offset , zoom : number ) : Offset {
28- if ( ! container ) return offset
43+ function getFitZoom ( container : Size , content : Size ) : number {
44+ if ( container . width <= 0 || container . height <= 0 || content . width <= 0 || content . height <= 0 ) {
45+ return 1
46+ }
2947
30- const maxX = Math . max ( 0 , ( container . clientWidth * zoom - container . clientWidth ) / 2 )
31- const maxY = Math . max ( 0 , ( container . clientHeight * zoom - container . clientHeight ) / 2 )
48+ const availableWidth = Math . max ( 1 , container . width - FIT_PADDING )
49+ const availableHeight = Math . max ( 1 , container . height - FIT_PADDING )
50+ return clampZoom ( Math . min ( 1 , availableWidth / content . width , availableHeight / content . height ) )
51+ }
52+
53+ function clampOffset ( container : Size , content : Size , offset : Offset , zoom : number ) : Offset {
54+ if ( container . width <= 0 || container . height <= 0 || content . width <= 0 || content . height <= 0 ) {
55+ return offset
56+ }
57+
58+ const scaledWidth = content . width * zoom
59+ const scaledHeight = content . height * zoom
60+ const maxX = Math . max ( 0 , ( scaledWidth - container . width ) / 2 )
61+ const maxY = Math . max ( 0 , ( scaledHeight - container . height ) / 2 )
3262
3363 return {
3464 x : Math . min ( Math . max ( offset . x , - maxX ) , maxX ) ,
3565 y : Math . min ( Math . max ( offset . y , - maxY ) , maxY ) ,
3666 }
3767}
3868
39- export function ZoomablePreview ( { children, className, contentClassName } : ZoomablePreviewProps ) {
69+ export function ZoomablePreview ( {
70+ children,
71+ className,
72+ contentClassName,
73+ initialScale = 'actual' ,
74+ resetKey,
75+ } : ZoomablePreviewProps ) {
4076 const [ zoom , setZoom ] = useState ( 1 )
4177 const [ offset , setOffset ] = useState ( { x : 0 , y : 0 } )
78+ const [ containerSize , setContainerSize ] = useState < Size > ( { width : 0 , height : 0 } )
79+ const [ contentSize , setContentSize ] = useState < Size > ( { width : 0 , height : 0 } )
4280 const containerRef = useRef < HTMLDivElement > ( null )
81+ const contentRef = useRef < HTMLDivElement > ( null )
4382 const isDragging = useRef ( false )
4483 const dragStart = useRef ( { x : 0 , y : 0 } )
4584 const offsetAtDragStart = useRef ( { x : 0 , y : 0 } )
85+ const hasInteractedRef = useRef ( false )
4686 const zoomRef = useRef ( zoom )
4787 const offsetRef = useRef ( offset )
88+ const containerSizeRef = useRef ( containerSize )
89+ const contentSizeRef = useRef ( contentSize )
4890 zoomRef . current = zoom
4991 offsetRef . current = offset
92+ containerSizeRef . current = containerSize
93+ contentSizeRef . current = contentSize
5094
5195 const applyZoom = useCallback ( ( nextZoom : number ) => {
5296 zoomRef . current = nextZoom
5397 setZoom ( nextZoom )
54- setOffset ( ( currentOffset ) => clampOffset ( containerRef . current , currentOffset , nextZoom ) )
98+ setOffset ( ( currentOffset ) =>
99+ clampOffset ( containerSizeRef . current , contentSizeRef . current , currentOffset , nextZoom )
100+ )
55101 } , [ ] )
56102
57- const zoomIn = ( ) => applyZoom ( clampZoom ( zoom * ZOOM_BUTTON_FACTOR ) )
58- const zoomOut = ( ) => applyZoom ( clampZoom ( zoom / ZOOM_BUTTON_FACTOR ) )
103+ const fitToView = useCallback ( ( ) => {
104+ hasInteractedRef . current = false
105+ const nextZoom =
106+ initialScale === 'fit' ? getFitZoom ( containerSizeRef . current , contentSizeRef . current ) : 1
107+ zoomRef . current = nextZoom
108+ setZoom ( nextZoom )
109+ setOffset ( { x : 0 , y : 0 } )
110+ } , [ initialScale ] )
111+
112+ const zoomIn = ( ) => {
113+ hasInteractedRef . current = true
114+ applyZoom ( clampZoom ( zoom * ZOOM_BUTTON_FACTOR ) )
115+ }
116+ const zoomOut = ( ) => {
117+ hasInteractedRef . current = true
118+ applyZoom ( clampZoom ( zoom / ZOOM_BUTTON_FACTOR ) )
119+ }
59120
60121 useEffect ( ( ) => {
61122 const el = containerRef . current
@@ -64,11 +125,14 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
64125 const onWheel = ( e : WheelEvent ) => {
65126 e . preventDefault ( )
66127 if ( e . ctrlKey || e . metaKey ) {
128+ hasInteractedRef . current = true
67129 applyZoom ( clampZoom ( zoomRef . current * Math . exp ( - e . deltaY * ZOOM_WHEEL_SENSITIVITY ) ) )
68130 } else {
131+ hasInteractedRef . current = true
69132 setOffset ( ( currentOffset ) =>
70133 clampOffset (
71- el ,
134+ containerSizeRef . current ,
135+ contentSizeRef . current ,
72136 {
73137 x : currentOffset . x - e . deltaX ,
74138 y : currentOffset . y - e . deltaY ,
@@ -83,19 +147,56 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
83147 return ( ) => el . removeEventListener ( 'wheel' , onWheel )
84148 } , [ applyZoom ] )
85149
86- useEffect ( ( ) => {
87- const el = containerRef . current
88- if ( ! el ) return
150+ useLayoutEffect ( ( ) => {
151+ const updateSizes = ( ) => {
152+ setContainerSize ( getElementSize ( containerRef . current ) )
153+ setContentSize ( getElementSize ( contentRef . current ) )
154+ }
155+ updateSizes ( )
156+
157+ const container = containerRef . current
158+ const content = contentRef . current
159+ if ( ! container || ! content ) return
89160
90161 const observer = new ResizeObserver ( ( ) => {
91- setOffset ( ( currentOffset ) => clampOffset ( el , currentOffset , zoomRef . current ) )
162+ updateSizes ( )
92163 } )
93- observer . observe ( el )
164+ observer . observe ( container )
165+ observer . observe ( content )
94166 return ( ) => observer . disconnect ( )
95167 } , [ ] )
96168
169+ useLayoutEffect ( ( ) => {
170+ if (
171+ containerSize . width <= 0 ||
172+ containerSize . height <= 0 ||
173+ contentSize . width <= 0 ||
174+ contentSize . height <= 0
175+ ) {
176+ return
177+ }
178+
179+ const nextZoom =
180+ initialScale === 'fit' && ! hasInteractedRef . current
181+ ? getFitZoom ( containerSize , contentSize )
182+ : zoomRef . current
183+ zoomRef . current = nextZoom
184+ setZoom ( nextZoom )
185+ setOffset ( ( currentOffset ) => clampOffset ( containerSize , contentSize , currentOffset , nextZoom ) )
186+ } , [ containerSize , contentSize , initialScale ] )
187+
188+ useLayoutEffect ( ( ) => {
189+ hasInteractedRef . current = false
190+ const nextZoom =
191+ initialScale === 'fit' ? getFitZoom ( containerSizeRef . current , contentSizeRef . current ) : 1
192+ zoomRef . current = nextZoom
193+ setZoom ( nextZoom )
194+ setOffset ( { x : 0 , y : 0 } )
195+ } , [ initialScale , resetKey ] )
196+
97197 const handleMouseDown = ( e : MouseEvent ) => {
98198 if ( e . button !== 0 ) return
199+ hasInteractedRef . current = true
99200 isDragging . current = true
100201 dragStart . current = { x : e . clientX , y : e . clientY }
101202 offsetAtDragStart . current = offsetRef . current
@@ -107,7 +208,8 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
107208 if ( ! isDragging . current ) return
108209 setOffset (
109210 clampOffset (
110- containerRef . current ,
211+ containerSizeRef . current ,
212+ contentSizeRef . current ,
111213 {
112214 x : offsetAtDragStart . current . x + ( e . clientX - dragStart . current . x ) ,
113215 y : offsetAtDragStart . current . y + ( e . clientY - dragStart . current . y ) ,
@@ -131,22 +233,31 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
131233 onMouseUp = { handleMouseUp }
132234 onMouseLeave = { handleMouseUp }
133235 >
134- < div
135- className = { cn (
136- 'pointer-events-none absolute inset-0 flex items-center justify-center' ,
137- contentClassName
138- ) }
139- style = { {
140- transform : `translate( ${ offset . x } px, ${ offset . y } px) scale( ${ zoom } )` ,
141- transformOrigin : 'center center' ,
142- } }
143- >
144- { children }
236+ < div className = 'pointer-events-none absolute inset-0 flex items-center justify-center' >
237+ < div
238+ ref = { contentRef }
239+ className = { cn ( 'flex items-center justify-center' , contentClassName ) }
240+ style = { {
241+ transform : `translate( ${ offset . x } px, ${ offset . y } px) scale( ${ zoom } )` ,
242+ transformOrigin : 'center center' ,
243+ } }
244+ >
245+ { children }
246+ </ div >
145247 </ div >
146248 < div
147249 className = 'absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-card'
148250 onMouseDown = { ( e ) => e . stopPropagation ( ) }
149251 >
252+ < Button
253+ variant = 'ghost'
254+ size = 'sm'
255+ onClick = { fitToView }
256+ className = 'h-6 px-2 text-[11px]'
257+ aria-label = { initialScale === 'fit' ? 'Fit to view' : 'Reset zoom' }
258+ >
259+ { initialScale === 'fit' ? 'Fit' : 'Reset' }
260+ </ Button >
150261 < Button
151262 variant = 'ghost'
152263 size = 'sm'
0 commit comments