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