Skip to content

Commit 37294ab

Browse files
feat(sidebar): refactor sidebar & improve blocks UX
1 parent a584e1f commit 37294ab

7 files changed

Lines changed: 446 additions & 25 deletions

File tree

imagelab-frontend/src/components/Layout.tsx

Lines changed: 293 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,316 @@
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 Navbar from "./Navbar";
45
import Toolbar from "./Toolbar";
56
import Sidebar from "./Sidebar/Sidebar";
67
import PreviewPane from "./Preview/PreviewPane";
78
import InfoPane from "./InfoPane";
89
import { ErrorBoundary } from "./ErrorBoundary";
910

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+
1060
export default function Layout() {
1161
const { containerRef, workspace } = useBlocklyWorkspace();
62+
const mainRowRef = useRef<HTMLDivElement>(null);
1263
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]);
13116

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;
17233

18234
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+
>
20238
<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+
24266
<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">
26268
<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" />
28270
<InfoPane />
29271
</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+
)}
31299
</div>
32300
</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+
)}
33314
</div>
34315
</div>
35316
);

imagelab-frontend/src/components/Preview/PreviewPane.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { ZoomIn, ZoomOut, Image, ImageDown, Trash2 } from "lucide-react";
33
import { usePipelineStore } from "../../store/pipelineStore";
44
import ImageDisplay from "./ImageDisplay";
55

6+
interface PreviewPaneProps {
7+
width?: number;
8+
isCollapsed?: boolean;
9+
isResizing?: boolean;
10+
}
11+
612
function ZoomControls({
713
disabled,
814
onZoomIn,
@@ -34,7 +40,11 @@ function ZoomControls({
3440
);
3541
}
3642

37-
export default function PreviewPane() {
43+
export default function PreviewPane({
44+
width = 320,
45+
isCollapsed = false,
46+
isResizing = false,
47+
}: PreviewPaneProps) {
3848
const { originalImage, imageFormat, processedImage, error, errorStep, clearImage } =
3949
usePipelineStore();
4050
const [originalZoom, setOriginalZoom] = useState<number | null>(null);
@@ -46,8 +56,17 @@ export default function PreviewPane() {
4656
setter((prev) => Math.max((prev ?? 300) - 100, 100));
4757

4858
return (
49-
<div className="w-80 h-full bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
50-
{/* Original image — top half */}
59+
<div
60+
id="preview-panel"
61+
role="complementary"
62+
aria-label="Image preview panel"
63+
className={`h-full bg-white border-l border-gray-200 flex flex-col flex-shrink-0 overflow-hidden min-w-0 ${
64+
isResizing ? "" : "transition-[width] duration-300 ease-in-out"
65+
}`}
66+
style={{ width: isCollapsed ? 0 : width }}
67+
>
68+
<div className="min-w-[200px] flex flex-col h-full">
69+
{/* Original image — top half */}
5170
<div className="flex-1 flex flex-col min-h-0 border-b border-gray-200">
5271
<div className="px-3 py-1.5 border-b border-gray-200 flex items-center gap-1.5">
5372
<Image size={14} className="text-gray-400" />
@@ -106,6 +125,7 @@ export default function PreviewPane() {
106125
onZoomIn={zoomIn(setProcessedZoom)}
107126
onZoomOut={zoomOut(setProcessedZoom)}
108127
/>
128+
</div>
109129
</div>
110130
</div>
111131
);

0 commit comments

Comments
 (0)