Skip to content

Commit ea6e552

Browse files
feat(sidebar): refactor sidebar & improve blocks UX
1 parent be49940 commit ea6e552

8 files changed

Lines changed: 609 additions & 158 deletions

File tree

imagelab-frontend/src/components/LandingScreen.module.css

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,3 @@
1-
@keyframes float1 {
2-
0%,
3-
100% {
4-
transform: translate(0, 0) scale(1);
5-
}
6-
50% {
7-
transform: translate(30px, -40px) scale(1.05);
8-
}
9-
}
10-
@keyframes float2 {
11-
0%,
12-
100% {
13-
transform: translate(0, 0) scale(1);
14-
}
15-
50% {
16-
transform: translate(-20px, 30px) scale(0.95);
17-
}
18-
}
19-
@keyframes fadeUp {
20-
from {
21-
opacity: 0;
22-
transform: translateY(30px);
23-
}
24-
to {
25-
opacity: 1;
26-
transform: translateY(0);
27-
}
28-
}
29-
301
.startBtn:hover:not(:disabled) {
312
background: linear-gradient(135deg, #7c3aed, #059669) !important;
323
transform: translateY(-2px) !important;

imagelab-frontend/src/components/Layout.tsx

Lines changed: 311 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 { useDarkMode } from "../hooks/useDarkMode";
56
import Navbar from "./Navbar";
@@ -9,32 +10,333 @@ import PreviewPane from "./Preview/PreviewPane";
910
import InfoPane from "./InfoPane";
1011
import { 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+
/mac/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+
1262
export 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

Comments
 (0)