Skip to content

Commit f6d1310

Browse files
feat(sidebar): refactor sidebar & improve blocks UX
1 parent 7baf8df commit f6d1310

7 files changed

Lines changed: 577 additions & 125 deletions

File tree

imagelab-frontend/src/components/Layout.tsx

Lines changed: 312 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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";
34
import { usePipelineStore } from "../store/pipelineStore";
45
import Navbar from "./Navbar";
56
import Toolbar from "./Toolbar";
@@ -8,31 +9,333 @@ import PreviewPane from "./Preview/PreviewPane";
89
import InfoPane from "./InfoPane";
910
import { 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+
/mac/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+
1161
export 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

Comments
 (0)