Skip to content

Commit 5e3a35c

Browse files
authored
Merge pull request #19 from tessel-la/feat-resizable_panels
feat: Add vertically resizable panels with draggable handle Implemented a vertically split layout system allowing users to dynamically resize the camera/3D visualization area and control pad area by dragging a handle between them. Key Features: - Draggable handle between top (camera/3D) and bottom (control pad) sections - Smooth, real-time resizing with visual feedback on hover/drag - Configurable minimum heights (20% each section) to prevent collapse - Persistent panel sizes saved to localStorage - Default 60/40 split ratio (camera:pad) Mobile Optimizations: - Full touch event support (touchstart, touchmove, touchend) - Larger touch targets on mobile (20px vs 16px desktop) - Fixed viewport height using 100dvh for proper mobile browser handling - Prevented page scrolling and layout overflow issues - Fixed bottom panel cutoff on mobile devices Technical Implementation: - Custom React hook `useResizablePanels` for reusable resize logic - Fixed positioning to prevent unwanted scrolling - Proper overflow handling throughout layout hierarchy - Percentage-based height calculations with resize handle offset Both sections always fill 100% of available vertical space, providing an intuitive split-pane experience similar to docked windows.
2 parents 38a6667 + dbdb461 commit 5e3a35c

7 files changed

Lines changed: 255 additions & 22 deletions

File tree

dev-dist/sw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
8282
"revision": "3ca0b8505b4bec776b69afdba2768812"
8383
}, {
8484
"url": "index.html",
85-
"revision": "0.srr6p8sni98"
85+
"revision": "0.6t0ml03e15"
8686
}], {});
8787
workbox.cleanupOutdatedCaches();
8888
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

src/App.css

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
.App {
22
/* text-align: center; */ /* Remove if using flex for centering */
3-
min-height: 100vh; /* Ensure App fills viewport height */
3+
height: 100vh; /* Fixed height for viewport */
4+
height: 100dvh; /* Use dynamic viewport height for mobile browsers */
45
display: flex;
56
flex-direction: column; /* Stack children vertically */
7+
overflow: hidden; /* Prevent scrolling at App level */
8+
position: fixed;
9+
top: 0;
10+
left: 0;
11+
right: 0;
12+
bottom: 0;
613
}
714

815
.App main {
9-
flex-grow: 1; /* Allow main content to take up space */
16+
flex: 1; /* Allow main content to take up space */
17+
overflow: hidden; /* Prevent scrolling in main */
18+
position: relative;
1019
/* display: flex; /* REMOVED */
1120
/* justify-content: center; /* REMOVED */
1221
/* align-items: stretch; /* REMOVED */

src/components/ControlPanelTabs.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
flex-shrink: 0; /* Prevent shrinking */
66
padding-bottom: 5px; /* Space between tabs and panel content */
77
border-bottom: 1px solid var(--border-color);
8-
margin-bottom: 10px;
8+
margin-bottom: 8px; /* Reduced from 10px */
99
overflow-x: auto; /* Allow horizontal scrolling if many tabs */
10+
overflow-y: hidden; /* Prevent vertical overflow */
1011
position: relative; /* Establish stacking context */
1112
z-index: 1; /* Ensure it's above default content */
1213

src/components/MainControlView.css

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
display: flex;
33
flex-direction: column;
44
height: 100vh; /* Ensure it tries to fill the viewport height */
5+
height: 100dvh; /* Use dynamic viewport height for mobile browsers */
56
/* Remove padding-top, handled by content area now */
67
padding: 0;
78
gap: 0; /* Remove gap, handled by content area padding */
8-
position: relative;
9+
position: fixed; /* Fixed positioning to prevent scrolling */
10+
top: 0;
11+
left: 0;
12+
right: 0;
13+
bottom: 0;
914
box-sizing: border-box;
1015
overflow: hidden; /* Prevent scrolling on the main view */
1116
}
@@ -33,14 +38,17 @@
3338
/* Main Content Area Styles */
3439
.main-content-area {
3540
padding: 10px; /* Reduced from 15px */
36-
padding-top: calc(40px + 10px); /* Reduced from 15px */
41+
padding-top: calc(40px + 10px); /* Top bar height + padding */
42+
padding-bottom: 10px;
3743
display: flex;
3844
flex-direction: column;
3945
align-items: stretch;
40-
gap: 10px; /* Reduced from 15px */
4146
overflow: hidden;
42-
height: calc(100vh - 10px);
47+
height: 100vh;
48+
height: 100dvh; /* Use dynamic viewport height */
49+
width: 100%;
4350
box-sizing: border-box;
51+
position: relative;
4452
}
4553

4654
/* Remove styles for the old .view-header */
@@ -148,36 +156,90 @@
148156

149157
/* Adjust containers to fit within the new main-content-area */
150158
.view-panel-container {
151-
/* flex: 3 1 auto; */
152-
flex: 1 1 auto; /* Let it grow and shrink to fill remaining space */
153159
display: flex;
154160
flex-direction: column;
155161
min-height: 0;
156162
width: 100%;
157163
box-sizing: border-box;
158164
overflow: hidden; /* Hide overflow for the view panel */
165+
flex-shrink: 0; /* Prevent flex shrinking */
166+
/* Height set via inline style calculated by hook */
159167
}
160168

161169
.control-panel-container {
162-
/* flex: 1 1 auto; /* Removed flex sizing */
163-
height: 35vh; /* Assign fixed viewport height (adjust as needed) */
164-
flex-shrink: 0; /* Prevent shrinking below this height */
165170
display: flex;
166171
flex-direction: column;
167-
/* min-height: 0; /* Not needed with fixed height */
172+
min-height: 0;
168173
width: 100%;
169174
box-sizing: border-box;
170-
margin-bottom: 10px;
175+
flex-shrink: 0; /* Prevent flex shrinking */
176+
/* Height set via inline style calculated by hook */
177+
padding-bottom: 0; /* Remove any extra padding */
178+
}
179+
180+
/* Resize Handle Styles */
181+
.resize-handle {
182+
height: 16px; /* Increased from 10px for better touch target */
183+
min-height: 16px;
184+
max-height: 16px;
185+
width: 100%;
186+
display: flex;
187+
align-items: center;
188+
justify-content: center;
189+
cursor: row-resize;
190+
position: relative;
191+
z-index: 10;
192+
user-select: none;
193+
flex-shrink: 0;
194+
touch-action: none; /* Prevent default touch behaviors */
195+
-webkit-user-select: none;
196+
-webkit-touch-callout: none;
197+
margin: 0; /* Remove any margins */
198+
padding: 0; /* Remove padding that adds to height */
199+
}
200+
201+
.resize-handle:hover .resize-handle-bar,
202+
.resize-handle.dragging .resize-handle-bar {
203+
background-color: var(--primary-color, #4285f4);
204+
height: 4px;
205+
}
206+
207+
.resize-handle-bar {
208+
width: 60px;
209+
height: 3px;
210+
background-color: var(--border-color, #ccc);
211+
border-radius: 2px;
212+
transition: all 0.2s ease;
213+
}
214+
215+
.resize-handle.dragging {
216+
background-color: rgba(66, 133, 244, 0.1);
217+
}
218+
219+
/* Mobile optimizations */
220+
@media (max-width: 768px) {
221+
.resize-handle {
222+
height: 20px; /* Even larger touch target on mobile */
223+
min-height: 20px;
224+
max-height: 20px;
225+
}
226+
227+
.resize-handle-bar {
228+
width: 80px; /* Wider bar on mobile */
229+
height: 4px; /* Thicker bar on mobile */
230+
}
171231
}
172232

173233
/* Ensure panels fill their containers */
174234
.view-panel,
175235
.control-panel {
176-
flex-grow: 1;
236+
flex: 1 1 auto;
177237
display: flex;
178238
flex-direction: column;
179239
overflow-y: auto;
240+
overflow-x: hidden;
180241
width: 100%;
242+
min-height: 0; /* Important for flex children with overflow */
181243
box-sizing: border-box;
182244
}
183245

@@ -192,6 +254,7 @@
192254

193255
.control-panel {
194256
padding: 2px; /* Keep padding for control panel */
257+
margin: 0; /* Remove any margin */
195258
}
196259

197260
/* Center placeholder message */
@@ -243,7 +306,7 @@ body.no-scroll {
243306
.main-content-area {
244307
padding: 5px; /* Reduced from 10px */
245308
padding-top: calc(40px + 5px); /* Reduced from 10px */
246-
gap: 5px; /* Reduced from 10px */
309+
padding-bottom: 5px; /* Ensure bottom padding is also reduced */
247310
}
248311

249312
/* Adjust top bar height/padding on mobile if needed */
@@ -262,6 +325,11 @@ body.no-scroll {
262325
/* Adjust icon sizes for mobile if needed */
263326
/* width: 1.1em; height: 1.1em; */
264327
}
328+
329+
/* Adjust control panel to ensure it fits */
330+
.control-panel {
331+
padding: 0px; /* Reduce padding on mobile */
332+
}
265333
}
266334

267335
/* View Panel Animation Styles */

src/components/MainControlView.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useEffect, useRef, useMemo } from 'react';
22
import { ConnectionParams } from '../App'; // Import types
33
import { useRos } from '../hooks/useRos'; // Import the hook
4+
import { useResizablePanels } from '../hooks/useResizablePanels'; // Import the resizable panels hook
45
import './MainControlView.css';
56
// Import placeholder components (we'll create these next)
67
import CameraView from './CameraView'; // Import the new CameraView
@@ -116,6 +117,14 @@ const MainControlView: React.FC<MainControlViewProps> = ({ connectionParams, onD
116117
const viewPanelRef = useRef<HTMLDivElement>(null);
117118
const [isTransitioning, setIsTransitioning] = useState(false);
118119

120+
// Resizable panels hook
121+
const { topHeight, bottomHeight, handleMouseDown, handleTouchStart, containerRef, isDragging } = useResizablePanels({
122+
initialTopHeight: 60,
123+
minTopHeight: 20,
124+
minBottomHeight: 20,
125+
storageKey: 'robo-boy-panel-split',
126+
});
127+
119128
// Fetch topics when connected
120129
useEffect(() => {
121130
if (isConnected && ros) {
@@ -386,8 +395,8 @@ const MainControlView: React.FC<MainControlViewProps> = ({ connectionParams, onD
386395
</div>
387396

388397
{/* Main Content Area - ensure it starts below the top bar */}
389-
<div className="main-content-area">
390-
<div className="view-panel-container">
398+
<div className="main-content-area" ref={containerRef}>
399+
<div className="view-panel-container" style={{ height: `calc(${topHeight}% - 8px)` }}>
391400
<div className="view-panel card" ref={viewPanelRef}>
392401
{viewMode === 'camera' ? (
393402
isConnected && ros && selectedCameraTopic ? (
@@ -412,7 +421,16 @@ const MainControlView: React.FC<MainControlViewProps> = ({ connectionParams, onD
412421
</div>
413422
</div>
414423

415-
<div className="control-panel-container">
424+
{/* Resizable Handle */}
425+
<div
426+
className={`resize-handle ${isDragging ? 'dragging' : ''}`}
427+
onMouseDown={handleMouseDown}
428+
onTouchStart={handleTouchStart}
429+
>
430+
<div className="resize-handle-bar" />
431+
</div>
432+
433+
<div className="control-panel-container" style={{ height: `calc(${bottomHeight}% - 8px)` }}>
416434
<ControlPanelTabs
417435
panels={activePanels}
418436
selectedPanelId={selectedPanelId}

src/hooks/useResizablePanels.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useState, useCallback, useRef, useEffect } from 'react';
2+
3+
interface UseResizablePanelsOptions {
4+
initialTopHeight?: number; // in percentage (0-100)
5+
minTopHeight?: number; // in percentage
6+
minBottomHeight?: number; // in percentage
7+
storageKey?: string; // localStorage key to persist sizes
8+
}
9+
10+
export const useResizablePanels = ({
11+
initialTopHeight = 60,
12+
minTopHeight = 20,
13+
minBottomHeight = 20,
14+
storageKey,
15+
}: UseResizablePanelsOptions = {}) => {
16+
// Load initial height from localStorage if storageKey is provided
17+
const [topHeight, setTopHeight] = useState<number>(() => {
18+
if (storageKey) {
19+
const stored = localStorage.getItem(storageKey);
20+
if (stored) {
21+
const parsed = parseFloat(stored);
22+
if (!isNaN(parsed) && parsed >= minTopHeight && parsed <= (100 - minBottomHeight)) {
23+
return parsed;
24+
}
25+
}
26+
}
27+
return initialTopHeight;
28+
});
29+
30+
const isDragging = useRef(false);
31+
const containerRef = useRef<HTMLDivElement>(null);
32+
33+
// Save to localStorage when topHeight changes
34+
useEffect(() => {
35+
if (storageKey) {
36+
localStorage.setItem(storageKey, topHeight.toString());
37+
}
38+
}, [topHeight, storageKey]);
39+
40+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
41+
e.preventDefault();
42+
isDragging.current = true;
43+
document.body.style.cursor = 'row-resize';
44+
document.body.style.userSelect = 'none';
45+
}, []);
46+
47+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
48+
e.preventDefault();
49+
isDragging.current = true;
50+
document.body.style.userSelect = 'none';
51+
}, []);
52+
53+
const handleMove = useCallback(
54+
(clientY: number) => {
55+
if (!isDragging.current || !containerRef.current) return;
56+
57+
const container = containerRef.current;
58+
const rect = container.getBoundingClientRect();
59+
const offsetY = clientY - rect.top;
60+
const newTopHeight = (offsetY / rect.height) * 100;
61+
62+
// Apply constraints
63+
const constrainedHeight = Math.max(
64+
minTopHeight,
65+
Math.min(100 - minBottomHeight, newTopHeight)
66+
);
67+
68+
setTopHeight(constrainedHeight);
69+
},
70+
[minTopHeight, minBottomHeight]
71+
);
72+
73+
const handleMouseMove = useCallback(
74+
(e: MouseEvent) => {
75+
handleMove(e.clientY);
76+
},
77+
[handleMove]
78+
);
79+
80+
const handleTouchMove = useCallback(
81+
(e: TouchEvent) => {
82+
if (e.touches.length > 0) {
83+
handleMove(e.touches[0].clientY);
84+
}
85+
},
86+
[handleMove]
87+
);
88+
89+
const handleEnd = useCallback(() => {
90+
if (isDragging.current) {
91+
isDragging.current = false;
92+
document.body.style.cursor = '';
93+
document.body.style.userSelect = '';
94+
}
95+
}, []);
96+
97+
// Add event listeners
98+
useEffect(() => {
99+
document.addEventListener('mousemove', handleMouseMove);
100+
document.addEventListener('mouseup', handleEnd);
101+
document.addEventListener('touchmove', handleTouchMove, { passive: false });
102+
document.addEventListener('touchend', handleEnd);
103+
document.addEventListener('touchcancel', handleEnd);
104+
105+
return () => {
106+
document.removeEventListener('mousemove', handleMouseMove);
107+
document.removeEventListener('mouseup', handleEnd);
108+
document.removeEventListener('touchmove', handleTouchMove);
109+
document.removeEventListener('touchend', handleEnd);
110+
document.removeEventListener('touchcancel', handleEnd);
111+
};
112+
}, [handleMouseMove, handleTouchMove, handleEnd]);
113+
114+
const bottomHeight = 100 - topHeight;
115+
116+
return {
117+
topHeight,
118+
bottomHeight,
119+
handleMouseDown,
120+
handleTouchStart,
121+
containerRef,
122+
isDragging: isDragging.current,
123+
};
124+
};
125+

0 commit comments

Comments
 (0)