1+ import React , { useState , useEffect , useRef , useCallback } from 'react' ;
2+
3+ const ExcalidrawIcon = ( props : React . SVGProps < SVGSVGElement > ) => (
4+ < svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" { ...props } >
5+ < path d = "M12 19l7-7 3 3-7 7-3-3z" > </ path >
6+ < path d = "M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" > </ path >
7+ < path d = "M2 2l7.586 7.586" > </ path >
8+ < circle cx = "11" cy = "11" r = "2" > </ circle >
9+ </ svg >
10+ ) ;
11+
12+ interface ExcalidrawSlidesProps {
13+ snapshotUrl : string ;
14+ height ?: string | number ;
15+ width ?: string | number ;
16+ title ?: string ;
17+ subtitle ?: string ;
18+ }
19+
20+ export function ExcalidrawSlides ( { snapshotUrl, title, subtitle, height = 500 , width = "100%" } : ExcalidrawSlidesProps ) {
21+ const containerRef = useRef < HTMLDivElement > ( null ) ;
22+ const svgContainerRef = useRef < HTMLDivElement > ( null ) ;
23+ const svgRef = useRef < SVGSVGElement | null > ( null ) ;
24+
25+ const [ data , setData ] = useState < any > ( null ) ;
26+ const [ frames , setFrames ] = useState < any [ ] > ( [ ] ) ;
27+ const [ currentSlide , setCurrentSlide ] = useState ( 0 ) ;
28+ const [ loading , setLoading ] = useState ( true ) ;
29+ const [ isClient , setIsClient ] = useState ( false ) ;
30+ const [ viewMode , setViewMode ] = useState < 'overview' | 'slide' > ( 'overview' ) ;
31+
32+ // The raw viewBox of the entire exported SVG
33+ const [ rawViewBox , setRawViewBox ] = useState < number [ ] | null > ( null ) ;
34+ const currentViewBoxRef = useRef < number [ ] > ( [ 0 , 0 , 100 , 100 ] ) ;
35+
36+ const requestRef = useRef < number > ( ) ;
37+ const transitionRef = useRef < { start : number [ ] , end : number [ ] , startTime : number , duration : number } | null > ( null ) ;
38+
39+ // Drag State
40+ const [ isDragging , setIsDragging ] = useState ( false ) ;
41+ const dragStartRef = useRef < { x : number , y : number , vb : number [ ] } | null > ( null ) ;
42+
43+ useEffect ( ( ) => { setIsClient ( true ) ; } , [ ] ) ;
44+
45+ const [ coordinateOffset , setCoordinateOffset ] = useState ( { x : 0 , y : 0 } ) ;
46+
47+ // 1. Fetch & Sort
48+ useEffect ( ( ) => {
49+ if ( ! isClient || ! snapshotUrl ) return ;
50+ async function loadData ( ) {
51+ try {
52+ const res = await fetch ( `${ snapshotUrl } ?t=${ Date . now ( ) } ` ) ;
53+ if ( ! res . ok ) throw new Error ( `Failed to load: ${ res . status } ` ) ;
54+ const json = await res . json ( ) ;
55+ const elements = json . elements || [ ] ;
56+
57+ const frameElements = elements . filter ( ( el : any ) => el . type === "frame" )
58+ . sort ( ( a : any , b : any ) => {
59+ const nameA = a . name || "" ;
60+ const nameB = b . name || "" ;
61+ const numA = parseInt ( nameA . match ( / ^ \d + / ) ?. [ 0 ] || "0" ) ;
62+ const numB = parseInt ( nameB . match ( / ^ \d + / ) ?. [ 0 ] || "0" ) ;
63+
64+ if ( numA !== 0 && numB !== 0 && numA !== numB ) return numA - numB ;
65+ if ( nameA !== nameB ) return nameA . localeCompare ( nameB ) ;
66+ if ( Math . abs ( a . y - b . y ) > 50 ) return a . y - b . y ;
67+ return a . x - b . x ;
68+ } ) ;
69+
70+ setData ( json ) ;
71+ setFrames ( frameElements ) ;
72+ } catch ( e ) { console . error ( e ) ; setLoading ( false ) ; }
73+ }
74+ loadData ( ) ;
75+ } , [ isClient , snapshotUrl ] ) ;
76+
77+ const setSvgViewBox = useCallback ( ( vb : number [ ] ) => {
78+ if ( svgRef . current ) {
79+ svgRef . current . setAttribute ( 'viewBox' , vb . join ( ' ' ) ) ;
80+ currentViewBoxRef . current = vb ;
81+ }
82+ } , [ ] ) ;
83+
84+ // 2. Render SVG
85+ useEffect ( ( ) => {
86+ if ( ! data || ! svgContainerRef . current ) return ;
87+ async function renderSvg ( ) {
88+ try {
89+ const { exportToSvg } = await import ( "@excalidraw/excalidraw" ) ;
90+
91+ const renderElements = data . elements . map ( ( el : any ) => {
92+ if ( el . type === 'frame' ) {
93+ return { ...el , strokeColor : '#00000000' , backgroundColor : 'transparent' , name : '' } ;
94+ }
95+ return el ;
96+ } ) ;
97+
98+ // Calculate JSON Min Bounds
99+ let minX = Infinity , minY = Infinity ;
100+ renderElements . forEach ( ( el : any ) => {
101+ if ( el . x < minX ) minX = el . x ;
102+ if ( el . y < minY ) minY = el . y ;
103+ } ) ;
104+ if ( minX === Infinity ) { minX = 0 ; minY = 0 ; }
105+
106+ const svg = await exportToSvg ( {
107+ elements : renderElements ,
108+ appState : { ...data . appState , exportBackground : true , viewBackgroundColor : "#ffffff" } ,
109+ files : data . files || { } ,
110+ exportPadding : 10 , // Restored padding to look nice, we will account for offset
111+ } ) ;
112+
113+ svg . removeAttribute ( 'width' ) ;
114+ svg . removeAttribute ( 'height' ) ;
115+ svg . style . width = "100%" ;
116+ svg . style . height = "100%" ;
117+ svg . style . display = "block" ;
118+ svg . setAttribute ( 'preserveAspectRatio' , 'xMidYMid meet' ) ;
119+
120+ const vb = svg . getAttribute ( 'viewBox' ) ?. split ( ' ' ) . map ( parseFloat ) ;
121+ if ( vb && vb . length === 4 ) {
122+ setRawViewBox ( vb ) ;
123+ currentViewBoxRef . current = vb ;
124+
125+ // Calculate Offset: SVG ViewBox Origin - JSON Element Origin
126+ // This accounts for padding or any normalization Excalidraw did.
127+ setCoordinateOffset ( {
128+ x : vb [ 0 ] - minX ,
129+ y : vb [ 1 ] - minY
130+ } ) ;
131+ }
132+
133+ svgContainerRef . current ! . innerHTML = '' ;
134+ svgContainerRef . current ! . appendChild ( svg ) ;
135+ svgRef . current = svg ;
136+ setLoading ( false ) ;
137+ } catch ( e ) { console . error ( e ) ; }
138+ }
139+ renderSvg ( ) ;
140+ } , [ data ] ) ;
141+
142+ const animate = useCallback ( ( time : number ) => {
143+ if ( ! transitionRef . current ) return ;
144+ const { start, end, startTime, duration } = transitionRef . current ;
145+ const progress = Math . min ( ( time - startTime ) / duration , 1 ) ;
146+ const ease = 1 - Math . pow ( 1 - progress , 3 ) ;
147+
148+ const newVB = start . map ( ( val , i ) => val + ( end [ i ] - val ) * ease ) ;
149+ setSvgViewBox ( newVB ) ;
150+
151+ if ( progress < 1 ) requestRef . current = requestAnimationFrame ( animate ) ;
152+ else transitionRef . current = null ;
153+ } , [ setSvgViewBox ] ) ;
154+
155+ const updateCamera = useCallback ( ( targetVB : number [ ] , duration = 600 ) => {
156+ transitionRef . current = { start : currentViewBoxRef . current , end : targetVB , startTime : performance . now ( ) , duration } ;
157+ if ( requestRef . current ) cancelAnimationFrame ( requestRef . current ) ;
158+ requestRef . current = requestAnimationFrame ( animate ) ;
159+ } , [ animate ] ) ;
160+
161+ // Sync View Logic
162+ const syncView = useCallback ( ( ) => {
163+ if ( ! rawViewBox ) return ;
164+
165+ let target : number [ ] ;
166+ if ( viewMode === 'overview' ) {
167+ target = rawViewBox ;
168+ } else {
169+ const frame = frames [ currentSlide ] ;
170+ if ( frame ) {
171+ const p = Math . max ( frame . width , frame . height ) * 0.10 ;
172+ // Apply coordinate offset to align frame coordinates with SVG coordinates
173+ target = [
174+ frame . x + coordinateOffset . x - p ,
175+ frame . y + coordinateOffset . y - p ,
176+ frame . width + p * 2 ,
177+ frame . height + p * 2
178+ ] ;
179+ } else {
180+ target = rawViewBox ;
181+ }
182+ }
183+ updateCamera ( target ) ;
184+ } , [ viewMode , currentSlide , rawViewBox , frames , updateCamera , coordinateOffset ] ) ;
185+
186+ // Trigger Sync on Logic Changes
187+ useEffect ( ( ) => {
188+ if ( ! loading && ! isDragging ) syncView ( ) ;
189+ // eslint-disable-next-line react-hooks/exhaustive-deps
190+ } , [ currentSlide , viewMode , loading ] ) ;
191+
192+ const goToSlide = ( index : number ) => {
193+ if ( index >= 0 && index < frames . length ) {
194+ setCurrentSlide ( index ) ;
195+ setViewMode ( 'slide' ) ;
196+ }
197+ } ;
198+
199+ // Drag Handlers
200+ const handleMouseDown = ( e : React . MouseEvent ) => {
201+ e . preventDefault ( ) ;
202+ setIsDragging ( true ) ;
203+ dragStartRef . current = {
204+ x : e . clientX ,
205+ y : e . clientY ,
206+ vb : [ ...currentViewBoxRef . current ]
207+ } ;
208+ transitionRef . current = null ;
209+ if ( requestRef . current ) cancelAnimationFrame ( requestRef . current ) ;
210+ } ;
211+
212+ const handleMouseMove = ( e : React . MouseEvent ) => {
213+ if ( ! isDragging || ! dragStartRef . current || ! containerRef . current ) return ;
214+
215+ const rect = containerRef . current . getBoundingClientRect ( ) ;
216+ if ( rect . width === 0 || rect . height === 0 ) return ;
217+
218+ const dx = e . clientX - dragStartRef . current . x ;
219+ const dy = e . clientY - dragStartRef . current . y ;
220+
221+ const [ vx , vy , vw , vh ] = dragStartRef . current . vb ;
222+
223+ // Calculate scale. Since we use 'meet', the SVG scale matches the constraining dimension.
224+ // We use the MAX dimension ratio to approximate the zoom level for panning speed.
225+ // This ensures 1px of drag ~= 1px of SVG movement regardless of aspect ratio letterboxing.
226+ const scale = Math . max ( vw / rect . width , vh / rect . height ) ;
227+
228+ const newVB = [
229+ vx - dx * scale ,
230+ vy - dy * scale ,
231+ vw ,
232+ vh
233+ ] ;
234+
235+ setSvgViewBox ( newVB ) ;
236+ } ;
237+
238+ const handleMouseUp = ( ) => {
239+ setIsDragging ( false ) ;
240+ dragStartRef . current = null ;
241+ } ;
242+
243+ // Zoom Handler
244+ useEffect ( ( ) => {
245+ const el = containerRef . current ;
246+ if ( ! el ) return ;
247+ const onWheelPassive = ( e : WheelEvent ) => {
248+ e . preventDefault ( ) ;
249+ transitionRef . current = null ;
250+ if ( requestRef . current ) cancelAnimationFrame ( requestRef . current ) ;
251+
252+ const zoomFactor = e . deltaY > 0 ? 1.05 : 0.95 ;
253+ const [ vx , vy , vw , vh ] = currentViewBoxRef . current ;
254+
255+ const newW = vw * zoomFactor ;
256+ const newH = vh * zoomFactor ;
257+ const newX = vx + ( vw - newW ) / 2 ;
258+ const newY = vy + ( vh - newH ) / 2 ;
259+
260+ setSvgViewBox ( [ newX , newY , newW , newH ] ) ;
261+ } ;
262+ el . addEventListener ( 'wheel' , onWheelPassive , { passive : false } ) ;
263+ return ( ) => el . removeEventListener ( 'wheel' , onWheelPassive ) ;
264+ } , [ loading , setSvgViewBox ] ) ;
265+
266+ const containerStyle = { height : typeof height === 'number' ? `${ height } px` : height , width : typeof width === 'number' ? `${ width } px` : width } ;
267+
268+ if ( ! isClient ) return < div style = { containerStyle } className = "bg-gray-50 flex items-center justify-center border-2 border-gray-200 rounded-xl" > Initializing...</ div > ;
269+
270+ return (
271+ < div className = "flex flex-col w-full my-6 bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm" >
272+ < div className = "bg-gray-50 border-b border-gray-100 px-6 py-2.5 flex items-center justify-between" >
273+ < div className = "flex flex-col" >
274+ < h3 className = "text-sm font-bold text-gray-800 m-0 leading-tight" > { title || "Excalidraw" } </ h3 >
275+ { subtitle && < span className = "text-[10px] text-gray-400 mt-0.5 font-medium" > { subtitle } </ span > }
276+ </ div >
277+ < div className = "opacity-60 hover:opacity-100 transition-opacity" >
278+ < ExcalidrawIcon className = "w-5 h-5 text-purple-600" />
279+ </ div >
280+ </ div >
281+
282+ < div
283+ ref = { containerRef }
284+ className = { `relative bg-white overflow-hidden select-none group ${ isDragging ? 'cursor-grabbing' : 'cursor-grab' } ` }
285+ style = { containerStyle }
286+ tabIndex = { 0 }
287+ onMouseDown = { handleMouseDown }
288+ onMouseMove = { handleMouseMove }
289+ onMouseUp = { handleMouseUp }
290+ onMouseLeave = { handleMouseUp }
291+ >
292+ < div ref = { svgContainerRef } className = "absolute inset-0 w-full h-full pointer-events-none" />
293+
294+ { loading && < div className = "absolute inset-0 z-20 flex items-center justify-center bg-white/80 backdrop-blur-sm" > < div className = "animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" > </ div > </ div > }
295+
296+ { /* Frame Label (Slide Mode) */ }
297+ { viewMode === 'slide' && (
298+ < div className = "absolute bottom-4 left-4 z-50 pointer-events-none" >
299+ < span className = "px-2 py-1 bg-white/90 backdrop-blur text-gray-600 text-[10px] font-bold uppercase tracking-wider rounded border border-gray-200 shadow-sm" >
300+ { frames [ currentSlide ] ?. name || `Slide ${ currentSlide + 1 } ` }
301+ </ span >
302+ </ div >
303+ ) }
304+ </ div >
305+
306+ < div className = "bg-gray-50 border-t border-gray-100 px-4 py-2 flex items-center justify-between h-10" >
307+ < div className = "w-24" >
308+ < button
309+ onClick = { ( ) => setViewMode ( prev => prev === 'overview' ? 'slide' : 'overview' ) }
310+ className = { `text-[10px] font-bold uppercase tracking-widest transition-colors px-2 py-1 rounded ${ viewMode === 'overview' ? 'text-blue-600 bg-blue-50' : 'text-gray-400 hover:text-gray-600' } ` }
311+ >
312+ { viewMode === 'overview' ? 'Slides' : 'Overview' }
313+ </ button >
314+ </ div >
315+
316+ < div className = "flex items-center bg-white border border-gray-200 rounded-lg px-1.5 py-0.5 shadow-sm gap-1" >
317+ < button onClick = { ( ) => goToSlide ( ( currentSlide - 1 + frames . length ) % frames . length ) } className = "p-1 hover:bg-gray-50 rounded text-gray-500 transition-colors" > < svg width = "14" height = "14" viewBox = "0 0 15 15" fill = "none" > < path d = "M8.84182 3.13514C9.04327 3.32401 9.05348 3.64042 8.86462 3.84188L5.43521 7.49991L8.86462 11.1579C9.05348 11.3594 9.04327 11.6758 8.84182 11.8647C8.64036 12.0535 8.32394 12.0433 8.13508 11.8419L4.38508 7.84188C4.20477 7.64955 4.20477 7.35027 4.38508 7.15794L8.13508 3.15794C8.32394 2.95648 8.64036 2.94628 8.84182 3.13514Z" fill = "currentColor" fillRule = "evenodd" > </ path > </ svg > </ button >
318+
319+ < div className = "flex items-center gap-1 px-1" >
320+ < input
321+ type = "text"
322+ value = { currentSlide + 1 }
323+ onChange = { ( e ) => {
324+ const val = parseInt ( e . target . value ) ;
325+ if ( ! isNaN ( val ) ) goToSlide ( val - 1 ) ;
326+ } }
327+ className = "w-6 bg-transparent text-[10px] font-black text-center text-gray-800 border-none outline-none focus:ring-0 p-0"
328+ />
329+ < span className = "text-[10px] font-black text-gray-300" > /</ span >
330+ < span className = "text-[10px] font-black text-gray-400 w-6 text-center" > { frames . length } </ span >
331+ </ div >
332+
333+ < button onClick = { ( ) => goToSlide ( ( currentSlide + 1 ) % frames . length ) } className = "p-1 hover:bg-gray-50 rounded text-gray-500 transition-colors" > < svg width = "14" height = "14" viewBox = "0 0 15 15" fill = "none" > < path d = "M6.1584 3.13523C5.95694 3.32411 5.94673 3.64053 6.1356 3.84199L9.565 7.50003L6.1356 11.1581C5.94673 11.3596 5.95694 11.676 6.1584 11.8648C6.35986 12.0537 6.67628 12.0435 6.86514 11.842L10.6151 7.84201C10.7954 7.64968 10.7954 7.35038 10.6151 7.15805L6.86514 3.15805C6.67628 2.95659 6.35986 2.94638 6.1584 3.13523Z" fill = "currentColor" fillRule = "evenodd" > </ path > </ svg > </ button >
334+ </ div >
335+
336+ < div className = "flex items-center gap-1 w-24 justify-end" >
337+ < button
338+ onClick = { ( ) => {
339+ syncView ( ) ;
340+ } }
341+ className = "w-7 h-7 flex items-center justify-center hover:bg-gray-200 rounded transition-colors text-xs"
342+ title = "Reset View"
343+ >
344+ 🔄
345+ </ button >
346+ < button onClick = { ( ) => setViewMode ( 'overview' ) } className = "w-7 h-7 flex items-center justify-center hover:bg-red-100 rounded transition-colors text-[10px]" title = "Overview" > ⏹️</ button >
347+ </ div >
348+ </ div >
349+ </ div >
350+ ) ;
351+ }
0 commit comments