Skip to content

Commit 7a08427

Browse files
authored
Merge pull request #142 from AnthonyGress/dev
Fix scroll lock and flickering
2 parents 418f697 + bdcb05e commit 7a08427

28 files changed

+482
-336
lines changed

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "lab-dash-backend",
33
"main": "index.js",
44
"scripts": {
5-
"dev": "PORT=5000 nodemon",
5+
"dev": "NODE_ENV=development PORT=5000 nodemon",
66
"start": "node ./index.js",
77
"build": "NODE_ENV=production node esbuild.config.js",
88
"lint": "eslint .",

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"react-hook-form-mui": "^7.5.0",
3737
"react-icons": "^5.4.0",
3838
"react-markdown": "^10.1.0",
39+
"react-remove-scroll": "^2.7.1",
3940
"react-router-dom": "^7.2.0",
4041
"react-virtualized": "^9.22.6",
4142
"shortid": "^2.2.17",

frontend/src/App.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,20 @@ import { Box, Paper } from '@mui/material';
44
import { useEffect } from 'react';
55
import { Route, Routes, useNavigate } from 'react-router-dom';
66

7-
import { DashApi } from './api/dash-api';
87
import { SetupForm } from './components/forms/SetupForm';
8+
import { GlobalCustomScrollbar } from './components/GlobalCustomScrollbar';
99
import { WithNav } from './components/navbar/WithNav';
1010
import { ScrollToTop } from './components/ScrollToTop';
1111
import { BACKEND_URL } from './constants/constants';
12-
import { AppContextProvider } from './context/AppContextProvider';
1312
import { useAppContext } from './context/useAppContext';
13+
import { useMobilePointer } from './hooks/useMobilePointer';
1414
import { DashboardPage } from './pages/DashboardPage';
1515
import { LoginPage } from './pages/LoginPage';
1616
import { SettingsPage } from './pages/SettingsPage';
1717
import { styles } from './theme/styles';
18-
import { theme } from './theme/theme';
1918

2019
const SetupPage = () => {
21-
const { isFirstTimeSetup, setupComplete, setSetupComplete, checkLoginStatus } = useAppContext();
20+
const { isFirstTimeSetup, setSetupComplete } = useAppContext();
2221

2322
// Show loading state while checking
2423
if (isFirstTimeSetup === null) {
@@ -44,13 +43,11 @@ export const App = () => {
4443
isFirstTimeSetup,
4544
setupComplete,
4645
setSetupComplete,
47-
refreshDashboard,
48-
checkLoginStatus,
49-
isLoggedIn,
5046
pages
5147
} = useAppContext();
5248

5349
const navigate = useNavigate();
50+
const isMobilePointer = useMobilePointer();
5451

5552
// Check if setup is complete based on the config
5653
useEffect(() => {
@@ -171,6 +168,7 @@ export const App = () => {
171168
{globalStyles}
172169
<div id='background-container' />
173170
<ScrollToTop />
171+
{!isMobilePointer && <GlobalCustomScrollbar />}
174172
<Routes>
175173
<Route element={<WithNav />}>
176174
<Route path='/' element={isFirstTimeSetup && !setupComplete ? <SetupPage /> : <DashboardPage />}/>
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { Box, styled } from '@mui/material';
2+
import React, { useCallback, useEffect, useRef, useState } from 'react';
3+
4+
const ScrollbarTrack = styled(Box, {
5+
shouldForwardProp: (prop) => prop !== 'visible',
6+
})<{ visible: boolean }>(({ visible }) => ({
7+
position: 'fixed',
8+
top: 0,
9+
right: 0,
10+
width: '10px',
11+
height: '100vh',
12+
background: 'transparent',
13+
opacity: visible ? 1 : 0,
14+
transition: 'opacity 0.2s ease-in-out',
15+
pointerEvents: visible ? 'auto' : 'none',
16+
zIndex: 9999,
17+
}));
18+
19+
const ScrollbarThumb = styled(Box, {
20+
shouldForwardProp: (prop) =>
21+
prop !== 'height' && prop !== 'top' && prop !== 'isDragging',
22+
})<{
23+
height: number;
24+
top: number;
25+
isDragging: boolean;
26+
}>(({ height, top, isDragging }) => ({
27+
position: 'absolute',
28+
right: '2px',
29+
width: '8px',
30+
height: `${height}px`,
31+
top: `${top}px`,
32+
background: isDragging
33+
? 'rgba(255, 255, 255, 0.6)'
34+
: 'rgba(255, 255, 255, 0.3)',
35+
borderRadius: '6px',
36+
cursor: 'pointer',
37+
transition: isDragging ? 'none' : 'background-color 0.1s ease',
38+
'&:hover': {
39+
background: 'rgba(255, 255, 255, 0.5)',
40+
},
41+
}));
42+
43+
export const GlobalCustomScrollbar: React.FC = () => {
44+
const [isScrollbarVisible, setIsScrollbarVisible] = useState(false);
45+
const [thumbHeight, setThumbHeight] = useState(0);
46+
const [thumbTop, setThumbTop] = useState(0);
47+
const [isDragging, setIsDragging] = useState(false);
48+
const [dragStart, setDragStart] = useState({ y: 0, scrollTop: 0 });
49+
const [justFinishedDragging, setJustFinishedDragging] = useState(false);
50+
const [postDragAutoHideActive, setPostDragAutoHideActive] = useState(false);
51+
52+
const hideTimeoutRef = useRef<number>();
53+
const postDragTimeoutRef = useRef<number>();
54+
const isMouseOverScrollbarRef = useRef(false);
55+
56+
57+
const updateScrollbar = useCallback(() => {
58+
const scrollHeight = document.documentElement.scrollHeight;
59+
const clientHeight = window.innerHeight;
60+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
61+
62+
if (scrollHeight <= clientHeight) {
63+
setIsScrollbarVisible(false);
64+
return;
65+
}
66+
67+
const thumbHeightRatio = clientHeight / scrollHeight;
68+
const newThumbHeight = Math.max(30, clientHeight * thumbHeightRatio);
69+
70+
// Ensure thumb stays within visible bounds with some padding
71+
const trackPadding = 4; // Account for border radius and visual spacing
72+
const availableTrackHeight = clientHeight - (trackPadding * 2);
73+
const adjustedThumbHeight = Math.min(newThumbHeight, availableTrackHeight);
74+
75+
const maxThumbTop = availableTrackHeight - adjustedThumbHeight + trackPadding;
76+
const scrollPercentage = scrollTop / (scrollHeight - clientHeight);
77+
const newThumbTop = Math.max(trackPadding, Math.min(maxThumbTop, scrollPercentage * maxThumbTop));
78+
79+
setThumbHeight(adjustedThumbHeight);
80+
setThumbTop(newThumbTop);
81+
}, []);
82+
83+
const tryHideScrollbar = useCallback(() => {
84+
if (!isDragging && !isMouseOverScrollbarRef.current) {
85+
setIsScrollbarVisible(false);
86+
}
87+
}, [isDragging]);
88+
89+
const showScrollbar = useCallback(() => {
90+
// Don't interfere if we're in the post-drag auto-hide period
91+
if (postDragAutoHideActive) {
92+
return;
93+
}
94+
95+
setIsScrollbarVisible(true);
96+
if (hideTimeoutRef.current) {
97+
clearTimeout(hideTimeoutRef.current);
98+
}
99+
hideTimeoutRef.current = window.setTimeout(tryHideScrollbar, 2000);
100+
}, [tryHideScrollbar, postDragAutoHideActive]);
101+
102+
const handleScroll = useCallback(() => {
103+
updateScrollbar();
104+
105+
// If user scrolls after dragging, clear the flags and cancel post-drag auto-hide
106+
if (justFinishedDragging || postDragAutoHideActive) {
107+
setJustFinishedDragging(false);
108+
setPostDragAutoHideActive(false);
109+
if (postDragTimeoutRef.current) {
110+
clearTimeout(postDragTimeoutRef.current);
111+
}
112+
}
113+
114+
// Only show scrollbar if not currently dragging
115+
if (!isDragging) {
116+
showScrollbar();
117+
}
118+
}, [updateScrollbar, showScrollbar, isDragging, justFinishedDragging, postDragAutoHideActive]);
119+
120+
const handleMouseEnter = useCallback(() => {
121+
122+
123+
isMouseOverScrollbarRef.current = true;
124+
// Reset the auto-hide timer when mouse enters scrollbar
125+
if (hideTimeoutRef.current) {
126+
clearTimeout(hideTimeoutRef.current);
127+
}
128+
setIsScrollbarVisible(true);
129+
// Start a fresh 2-second timer
130+
hideTimeoutRef.current = window.setTimeout(tryHideScrollbar, 2000);
131+
}, [tryHideScrollbar]);
132+
133+
const handleMouseLeave = useCallback(() => {
134+
isMouseOverScrollbarRef.current = false;
135+
// Only set timer if not currently dragging
136+
// If dragging, handleMouseUp will handle the timer
137+
if (!isDragging) {
138+
if (hideTimeoutRef.current) {
139+
clearTimeout(hideTimeoutRef.current);
140+
}
141+
hideTimeoutRef.current = window.setTimeout(tryHideScrollbar, 1000);
142+
}
143+
}, [isDragging, tryHideScrollbar]);
144+
145+
const handleThumbMouseDown = useCallback((e: React.MouseEvent) => {
146+
e.preventDefault();
147+
148+
// Temporarily disable smooth scrolling during drag
149+
const originalScrollBehavior = document.documentElement.style.scrollBehavior;
150+
document.documentElement.style.scrollBehavior = 'auto';
151+
152+
setIsDragging(true);
153+
setDragStart({
154+
y: e.clientY,
155+
scrollTop: window.pageYOffset || document.documentElement.scrollTop,
156+
});
157+
158+
// Store the original scroll behavior to restore later
159+
(window as Window & { originalScrollBehavior?: string }).originalScrollBehavior = originalScrollBehavior;
160+
}, []);
161+
162+
const handleMouseMove = useCallback((e: MouseEvent) => {
163+
if (!isDragging) return;
164+
165+
const deltaY = e.clientY - dragStart.y;
166+
const scrollHeight = document.documentElement.scrollHeight;
167+
const clientHeight = window.innerHeight;
168+
169+
const scrollRange = scrollHeight - clientHeight;
170+
const thumbRange = clientHeight - thumbHeight;
171+
const scrollRatio = scrollRange / thumbRange;
172+
173+
const newScrollTop = Math.max(0, Math.min(scrollRange, dragStart.scrollTop + deltaY * scrollRatio));
174+
175+
// Direct scroll assignment for instant response - no animation frames or delays
176+
document.documentElement.scrollTop = newScrollTop;
177+
document.body.scrollTop = newScrollTop; // For Safari compatibility
178+
}, [isDragging, dragStart, thumbHeight]);
179+
180+
const handleMouseUp = useCallback(() => {
181+
const wasDragging = isDragging;
182+
setIsDragging(false);
183+
184+
// Restore original scroll behavior
185+
const originalBehavior = (window as Window & { originalScrollBehavior?: string }).originalScrollBehavior;
186+
if (originalBehavior !== undefined) {
187+
document.documentElement.style.scrollBehavior = originalBehavior;
188+
}
189+
190+
// Always start auto-hide timer after drag ends, regardless of mouse position
191+
if (wasDragging) {
192+
setJustFinishedDragging(true);
193+
setPostDragAutoHideActive(true); // Block showScrollbar from interfering
194+
195+
// Clear any existing timers
196+
if (hideTimeoutRef.current) {
197+
clearTimeout(hideTimeoutRef.current);
198+
}
199+
if (postDragTimeoutRef.current) {
200+
clearTimeout(postDragTimeoutRef.current);
201+
}
202+
203+
// Set post-drag auto-hide timer
204+
postDragTimeoutRef.current = window.setTimeout(() => {
205+
// Only hide if mouse is not over scrollbar
206+
if (!isMouseOverScrollbarRef.current) {
207+
setIsScrollbarVisible(false);
208+
} else {
209+
// Start normal auto-hide behavior since mouse is over scrollbar
210+
hideTimeoutRef.current = window.setTimeout(tryHideScrollbar, 2000);
211+
}
212+
setJustFinishedDragging(false);
213+
setPostDragAutoHideActive(false); // Re-enable normal behavior
214+
}, 1000);
215+
}
216+
}, [isDragging, tryHideScrollbar]);
217+
218+
const handleTrackClick = useCallback((e: React.MouseEvent) => {
219+
if (e.target === e.currentTarget) {
220+
const rect = e.currentTarget.getBoundingClientRect();
221+
const clickY = e.clientY - rect.top;
222+
const scrollHeight = document.documentElement.scrollHeight;
223+
const clientHeight = window.innerHeight;
224+
const scrollPercentage = clickY / clientHeight;
225+
const newScrollTop = scrollPercentage * (scrollHeight - clientHeight);
226+
227+
window.scrollTo({
228+
top: Math.max(0, Math.min(scrollHeight - clientHeight, newScrollTop)),
229+
behavior: 'smooth'
230+
});
231+
}
232+
}, []);
233+
234+
useEffect(() => {
235+
const handleGlobalMouseMove = (e: MouseEvent) => handleMouseMove(e);
236+
const handleGlobalMouseUp = () => handleMouseUp();
237+
238+
if (isDragging) {
239+
document.addEventListener('mousemove', handleGlobalMouseMove);
240+
document.addEventListener('mouseup', handleGlobalMouseUp);
241+
}
242+
243+
return () => {
244+
document.removeEventListener('mousemove', handleGlobalMouseMove);
245+
document.removeEventListener('mouseup', handleGlobalMouseUp);
246+
};
247+
}, [isDragging, handleMouseMove, handleMouseUp]);
248+
249+
useEffect(() => {
250+
updateScrollbar();
251+
252+
window.addEventListener('scroll', handleScroll);
253+
window.addEventListener('resize', updateScrollbar);
254+
255+
// Watch for DOM changes to detect when overlays open/close
256+
const observer = new MutationObserver(() => {
257+
updateScrollbar(); // Re-check if scrollbar should be visible
258+
});
259+
260+
// Observe changes to the document body and backdrop/modal elements
261+
observer.observe(document.body, {
262+
childList: true,
263+
subtree: true,
264+
attributes: true,
265+
attributeFilter: ['role', 'class', 'aria-hidden']
266+
});
267+
268+
return () => {
269+
window.removeEventListener('scroll', handleScroll);
270+
window.removeEventListener('resize', updateScrollbar);
271+
observer.disconnect();
272+
if (hideTimeoutRef.current) {
273+
clearTimeout(hideTimeoutRef.current);
274+
}
275+
};
276+
}, [updateScrollbar, handleScroll]);
277+
278+
// Don't render if there's no content to scroll
279+
if (thumbHeight === 0) {
280+
return null;
281+
}
282+
283+
return (
284+
<ScrollbarTrack
285+
visible={isScrollbarVisible}
286+
onMouseEnter={handleMouseEnter}
287+
onMouseLeave={handleMouseLeave}
288+
onClick={handleTrackClick}
289+
>
290+
<ScrollbarThumb
291+
height={thumbHeight}
292+
top={thumbTop}
293+
isDragging={isDragging}
294+
onMouseDown={handleThumbMouseDown}
295+
/>
296+
</ScrollbarTrack>
297+
);
298+
};

0 commit comments

Comments
 (0)