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