1- import { useState } from "react" ;
2- import { useBlocklyWorkspace } from "../hooks/useBlocklyWorkspace" ;
1+ import { useState , useCallback , useRef , useEffect } from "react" ;
2+ import { ChevronRight , ChevronLeft } from "lucide-react" ;
3+ import { useBlocklyWorkspace , svgResizePreservingScroll } from "../hooks/useBlocklyWorkspace" ;
34import { usePipelineStore } from "../store/pipelineStore" ;
45import { useDarkMode } from "../hooks/useDarkMode" ;
56import Navbar from "./Navbar" ;
@@ -9,32 +10,333 @@ import PreviewPane from "./Preview/PreviewPane";
910import InfoPane from "./InfoPane" ;
1011import { ErrorBoundary } from "./ErrorBoundary" ;
1112
13+ const DEFAULT_SIDEBAR_WIDTH = 320 ;
14+ const MIN_SIDEBAR_WIDTH = 260 ;
15+ const MAX_SIDEBAR_WIDTH = 600 ;
16+
17+ const DEFAULT_PREVIEW_WIDTH = 320 ;
18+ const MIN_PREVIEW_WIDTH = 240 ;
19+ const MAX_PREVIEW_WIDTH = 600 ;
20+
21+ const STORAGE_KEYS = {
22+ sidebarWidth : "imagelab-sidebar-width" ,
23+ sidebarCollapsed : "imagelab-sidebar-collapsed" ,
24+ previewWidth : "imagelab-preview-width" ,
25+ previewCollapsed : "imagelab-preview-collapsed" ,
26+ } as const ;
27+
28+ function loadStoredNumber ( key : string , defaultVal : number , min : number , max : number ) : number {
29+ try {
30+ const v = localStorage . getItem ( key ) ;
31+ if ( v != null ) {
32+ const n = Number ( v ) ;
33+ if ( Number . isFinite ( n ) ) return Math . max ( min , Math . min ( max , n ) ) ;
34+ }
35+ } catch {
36+ /* ignore */
37+ }
38+ return defaultVal ;
39+ }
40+
41+ function loadStoredBool ( key : string ) : boolean {
42+ try {
43+ const v = localStorage . getItem ( key ) ;
44+ return v === "true" ;
45+ } catch {
46+ return false ;
47+ }
48+ }
49+
50+ // Detect macOS to show Cmd vs Ctrl in tooltips
51+ const isMac =
52+ typeof navigator !== "undefined" &&
53+ / m a c / i. test (
54+ (
55+ navigator as Navigator & {
56+ userAgentData ?: { platform ?: string } ;
57+ }
58+ ) . userAgentData ?. platform ?? navigator . userAgent ,
59+ ) ;
60+ const modShift = isMac ? "⌘⇧" : "Ctrl+Shift+" ;
61+
1262export default function Layout ( ) {
1363 const [ isDark , toggleDark ] = useDarkMode ( ) ;
1464 const { containerRef, workspace } = useBlocklyWorkspace ( { isDark } ) ;
1565 const { reset } = usePipelineStore ( ) ;
66+ const mainRowRef = useRef < HTMLDivElement > ( null ) ;
1667 const [ resetKey , setResetKey ] = useState ( 0 ) ;
68+ const [ sidebarWidth , setSidebarWidth ] = useState ( ( ) =>
69+ loadStoredNumber (
70+ STORAGE_KEYS . sidebarWidth ,
71+ DEFAULT_SIDEBAR_WIDTH ,
72+ MIN_SIDEBAR_WIDTH ,
73+ MAX_SIDEBAR_WIDTH ,
74+ ) ,
75+ ) ;
76+ const [ isSidebarCollapsed , setIsSidebarCollapsed ] = useState ( ( ) =>
77+ loadStoredBool ( STORAGE_KEYS . sidebarCollapsed ) ,
78+ ) ;
79+ const [ isResizing , setIsResizing ] = useState ( false ) ;
80+ const [ previewWidth , setPreviewWidth ] = useState ( ( ) =>
81+ loadStoredNumber (
82+ STORAGE_KEYS . previewWidth ,
83+ DEFAULT_PREVIEW_WIDTH ,
84+ MIN_PREVIEW_WIDTH ,
85+ MAX_PREVIEW_WIDTH ,
86+ ) ,
87+ ) ;
88+ const [ isPreviewCollapsed , setIsPreviewCollapsed ] = useState ( ( ) =>
89+ loadStoredBool ( STORAGE_KEYS . previewCollapsed ) ,
90+ ) ;
91+ const [ isPreviewResizing , setIsPreviewResizing ] = useState ( false ) ;
92+
93+ // RAF-throttled pending values to avoid layout thrashing during fast drag
94+ const pendingPreviewWidth = useRef < number | null > ( null ) ;
95+ const rafIdRef = useRef < number | null > ( null ) ;
96+
97+ const sidebarDragStartRef = useRef < { x : number ; width : number } | null > ( null ) ;
98+ const previewDragStartRef = useRef < { x : number ; width : number } | null > ( null ) ;
99+
100+ const flushPendingWidths = useCallback ( ( ) => {
101+ if ( pendingPreviewWidth . current !== null ) {
102+ setPreviewWidth ( pendingPreviewWidth . current ) ;
103+ pendingPreviewWidth . current = null ;
104+ }
105+ rafIdRef . current = null ;
106+ } , [ ] ) ;
107+
108+ const scheduleWidthUpdate = useCallback ( ( ) => {
109+ if ( rafIdRef . current === null ) {
110+ rafIdRef . current = requestAnimationFrame ( ( ) => {
111+ flushPendingWidths ( ) ;
112+ rafIdRef . current = null ;
113+ } ) ;
114+ }
115+ } , [ flushPendingWidths ] ) ;
116+
117+ useEffect ( ( ) => {
118+ return ( ) => {
119+ if ( rafIdRef . current !== null ) {
120+ cancelAnimationFrame ( rafIdRef . current ) ;
121+ rafIdRef . current = null ;
122+ }
123+ } ;
124+ } , [ ] ) ;
125+
126+ const startResizing = useCallback (
127+ ( e : React . MouseEvent ) => {
128+ sidebarDragStartRef . current = { x : e . clientX , width : sidebarWidth } ;
129+ setIsResizing ( true ) ;
130+ } ,
131+ [ sidebarWidth ] ,
132+ ) ;
133+
134+ const stopResizing = useCallback ( ( ) => {
135+ setIsResizing ( false ) ;
136+ sidebarDragStartRef . current = null ;
137+ } , [ ] ) ;
138+
139+ const resize = useCallback ( ( e : MouseEvent ) => {
140+ if ( ! sidebarDragStartRef . current ) return ;
141+ const deltaX = e . clientX - sidebarDragStartRef . current . x ;
142+ const w = Math . max (
143+ MIN_SIDEBAR_WIDTH ,
144+ Math . min ( MAX_SIDEBAR_WIDTH , sidebarDragStartRef . current . width + deltaX ) ,
145+ ) ;
146+ setSidebarWidth ( w ) ;
147+ } , [ ] ) ;
148+
149+ const startPreviewResizing = useCallback (
150+ ( e : React . MouseEvent ) => {
151+ previewDragStartRef . current = { x : e . clientX , width : previewWidth } ;
152+ setIsPreviewResizing ( true ) ;
153+ } ,
154+ [ previewWidth ] ,
155+ ) ;
156+
157+ const stopPreviewResizing = useCallback ( ( ) => {
158+ setIsPreviewResizing ( false ) ;
159+ previewDragStartRef . current = null ;
160+ flushPendingWidths ( ) ;
161+ } , [ flushPendingWidths ] ) ;
162+
163+ const resizePreview = useCallback (
164+ ( e : MouseEvent ) => {
165+ if ( ! previewDragStartRef . current ) return ;
166+ const deltaX = previewDragStartRef . current . x - e . clientX ;
167+ const w = Math . max (
168+ MIN_PREVIEW_WIDTH ,
169+ Math . min ( MAX_PREVIEW_WIDTH , previewDragStartRef . current . width + deltaX ) ,
170+ ) ;
171+ pendingPreviewWidth . current = w ;
172+ scheduleWidthUpdate ( ) ;
173+ } ,
174+ [ scheduleWidthUpdate ] ,
175+ ) ;
176+
177+ useEffect ( ( ) => {
178+ if ( isResizing ) {
179+ window . addEventListener ( "pointermove" , resize ) ;
180+ window . addEventListener ( "pointerup" , stopResizing ) ;
181+ }
182+ return ( ) => {
183+ window . removeEventListener ( "pointermove" , resize ) ;
184+ window . removeEventListener ( "pointerup" , stopResizing ) ;
185+ } ;
186+ } , [ isResizing , resize , stopResizing ] ) ;
187+
188+ useEffect ( ( ) => {
189+ if ( isPreviewResizing ) {
190+ window . addEventListener ( "pointermove" , resizePreview ) ;
191+ window . addEventListener ( "pointerup" , stopPreviewResizing ) ;
192+ }
193+ return ( ) => {
194+ window . removeEventListener ( "pointermove" , resizePreview ) ;
195+ window . removeEventListener ( "pointerup" , stopPreviewResizing ) ;
196+ } ;
197+ } , [ isPreviewResizing , resizePreview , stopPreviewResizing ] ) ;
198+
199+ useEffect ( ( ) => {
200+ if ( ! workspace ) return ;
201+ const raf = requestAnimationFrame ( ( ) => svgResizePreservingScroll ( workspace ) ) ;
202+ return ( ) => cancelAnimationFrame ( raf ) ;
203+ } , [ workspace , sidebarWidth , isSidebarCollapsed , previewWidth , isPreviewCollapsed ] ) ;
204+
205+ const toggleSidebar = useCallback ( ( ) => {
206+ setIsSidebarCollapsed ( ( prev ) => {
207+ const next = ! prev ;
208+ try {
209+ localStorage . setItem ( STORAGE_KEYS . sidebarCollapsed , String ( next ) ) ;
210+ } catch {
211+ /* ignore */
212+ }
213+ return next ;
214+ } ) ;
215+ } , [ ] ) ;
216+
217+ const togglePreview = useCallback ( ( ) => {
218+ setIsPreviewCollapsed ( ( prev ) => {
219+ const next = ! prev ;
220+ try {
221+ localStorage . setItem ( STORAGE_KEYS . previewCollapsed , String ( next ) ) ;
222+ } catch {
223+ /* ignore */
224+ }
225+ return next ;
226+ } ) ;
227+ } , [ ] ) ;
228+
229+ // Persist sidebar/preview widths when they change (debounced via RAF)
230+ const lastSavedWidths = useRef ( { sidebar : sidebarWidth , preview : previewWidth } ) ;
231+ useEffect ( ( ) => {
232+ if ( ! isSidebarCollapsed && Math . abs ( sidebarWidth - lastSavedWidths . current . sidebar ) >= 1 ) {
233+ lastSavedWidths . current . sidebar = sidebarWidth ;
234+ try {
235+ localStorage . setItem ( STORAGE_KEYS . sidebarWidth , String ( sidebarWidth ) ) ;
236+ } catch {
237+ /* ignore */
238+ }
239+ }
240+ if ( ! isPreviewCollapsed && Math . abs ( previewWidth - lastSavedWidths . current . preview ) >= 1 ) {
241+ lastSavedWidths . current . preview = previewWidth ;
242+ try {
243+ localStorage . setItem ( STORAGE_KEYS . previewWidth , String ( previewWidth ) ) ;
244+ } catch {
245+ /* ignore */
246+ }
247+ }
248+ } , [ sidebarWidth , isSidebarCollapsed , previewWidth , isPreviewCollapsed ] ) ;
17249
18250 const handleEditorReset = ( ) => {
19251 setResetKey ( ( prev ) => prev + 1 ) ;
20252 reset ( ) ;
21253 } ;
22254
255+ const isAnyResizing = isResizing || isPreviewResizing ;
256+
23257 return (
24- < div className = "h-screen flex flex-col bg-gray-50 dark:bg-gray-900" >
258+ < div
259+ className = { `h-screen flex flex-col bg-gray-50 dark:bg-gray-900 select-none overflow-hidden ${ isAnyResizing ? "imagelab-resizing" : "" } ` }
260+ >
25261 < Navbar isDark = { isDark } onToggleDark = { toggleDark } />
26- < Toolbar workspace = { workspace } />
27- < div className = "flex flex-1 min-h-0" >
28- < Sidebar workspace = { workspace } />
262+ < Toolbar
263+ workspace = { workspace }
264+ isSidebarCollapsed = { isSidebarCollapsed }
265+ isPreviewCollapsed = { isPreviewCollapsed }
266+ onToggleSidebar = { toggleSidebar }
267+ onTogglePreview = { togglePreview }
268+ />
269+ < div className = "flex flex-1 min-h-0 relative" >
270+ < div
271+ id = "sidebar-panel"
272+ role = "complementary"
273+ aria-label = "Blocks panel"
274+ className = "flex h-full"
275+ >
276+ < Sidebar
277+ workspace = { workspace }
278+ width = { isSidebarCollapsed ? 0 : sidebarWidth }
279+ isCollapsed = { isSidebarCollapsed }
280+ isResizing = { isResizing }
281+ />
282+ </ div >
283+ { ! isSidebarCollapsed && (
284+ < div
285+ className = { `w-1 cursor-col-resize hover:bg-blue-400 active:bg-blue-600 transition-colors z-30 flex-shrink-0 ${ isResizing ? "bg-blue-500" : "bg-transparent" } ` }
286+ onPointerDown = { ( e ) => {
287+ ( e . currentTarget as HTMLElement ) . setPointerCapture ( e . pointerId ) ;
288+ startResizing ( e ) ;
289+ } }
290+ />
291+ ) }
29292 < ErrorBoundary key = { resetKey } onReset = { handleEditorReset } >
30- < div className = "flex-1 flex min-w-0" >
293+ < div ref = { mainRowRef } className = "flex-1 flex min-w-0 bg-white relative " >
31294 < div className = "flex-1 flex flex-col min-w-0" >
32- < div ref = { containerRef } className = "flex-1" />
295+ < div ref = { containerRef } className = "flex-1 min-w-0 min-h-0 " />
33296 < InfoPane />
34297 </ div >
35- < PreviewPane />
298+ { ! isPreviewCollapsed && (
299+ < div
300+ className = { `w-1 cursor-col-resize hover:bg-blue-400 active:bg-blue-600 transition-colors z-30 flex-shrink-0 ${ isPreviewResizing ? "bg-blue-500" : "bg-transparent" } ` }
301+ onPointerDown = { ( e ) => {
302+ ( e . currentTarget as HTMLElement ) . setPointerCapture ( e . pointerId ) ;
303+ startPreviewResizing ( e ) ;
304+ } }
305+ />
306+ ) }
307+
308+ < PreviewPane
309+ width = { previewWidth }
310+ isCollapsed = { isPreviewCollapsed }
311+ isResizing = { isPreviewResizing }
312+ />
313+ { isPreviewCollapsed && (
314+ < button
315+ onClick = { togglePreview }
316+ aria-expanded = { ! isPreviewCollapsed }
317+ aria-controls = "preview-panel"
318+ aria-label = "Expand Preview"
319+ className = "absolute right-0 top-1/2 -translate-y-1/2 bg-white border border-gray-200 border-r-0 rounded-l-md p-1 shadow-sm hover:bg-gray-50 z-20 transition-shadow hover:shadow-md"
320+ title = { `Expand Preview (${ modShift } P)` }
321+ >
322+ < ChevronLeft size = { 16 } className = "text-gray-600" />
323+ </ button >
324+ ) }
36325 </ div >
37326 </ ErrorBoundary >
327+
328+ { isSidebarCollapsed && (
329+ < button
330+ onClick = { toggleSidebar }
331+ aria-expanded = { ! isSidebarCollapsed }
332+ aria-controls = "sidebar-panel"
333+ aria-label = "Show Sidebar"
334+ className = "absolute left-0 top-0 bottom-0 w-6 flex items-center justify-center bg-gray-50 hover:bg-gray-100 border-r border-gray-200 z-20 transition-colors"
335+ title = { `Show Sidebar (${ modShift } S)` }
336+ >
337+ < ChevronRight size = { 14 } className = "text-gray-500" />
338+ </ button >
339+ ) }
38340 </ div >
39341 </ div >
40342 ) ;
0 commit comments