@@ -2,15 +2,279 @@ import { allNodeData, archiveProgramIds, formatMetrics, renderMetricBar, getHigh
22import { scrollAndSelectNodeById } from './graph.js' ;
33
44const sidebar = document . getElementById ( 'sidebar' ) ;
5+ // Add a draggable resizer to let users change the sidebar width.
6+ // Creates a slim handle at the left edge of the sidebar and uses pointer events
7+ // to resize. The chosen width is persisted to localStorage under `sidebarWidth`.
8+ ( function enableSidebarResizer ( ) {
9+ if ( ! sidebar ) return ;
10+ try {
11+ const STORAGE_KEY = 'sidebarWidth' ;
12+ const DEFAULT_WIDTH_PX = 360 ;
13+ const MIN_WIDTH_PX = 200 ;
14+ const MAX_WIDTH_PX = Math . max ( window . innerWidth - 100 , 400 ) ;
15+
16+ // Restore saved width (if any)
17+ const saved = localStorage . getItem ( STORAGE_KEY ) ;
18+ if ( saved ) {
19+ sidebar . style . width = saved ;
20+ } else if ( ! sidebar . style . width ) {
21+ sidebar . style . width = DEFAULT_WIDTH_PX + 'px' ;
22+ }
23+
24+ // Do not override sidebar positioning from CSS; assume #sidebar styles control placement
25+
26+ // Create resizer element (left edge)
27+ const resizer = document . createElement ( 'div' ) ;
28+ resizer . id = 'sidebar-resizer' ;
29+ resizer . setAttribute ( 'role' , 'separator' ) ;
30+ resizer . setAttribute ( 'aria-orientation' , 'vertical' ) ;
31+ resizer . setAttribute ( 'tabindex' , '0' ) ;
32+ // Make the hit area a bit larger and use flex to center an inner visible handle
33+ Object . assign ( resizer . style , {
34+ position : 'fixed' ,
35+ left : '0px' , // will be calculated
36+ top : '0px' ,
37+ width : '14px' ,
38+ cursor : 'col-resize' ,
39+ zIndex : '9999' ,
40+ display : 'flex' ,
41+ alignItems : 'center' ,
42+ justifyContent : 'center' ,
43+ background : 'transparent' ,
44+ transition : 'background 120ms' ,
45+ // disable pointerEvents by default so expanding sidebar doesn't immediately capture the mouse
46+ pointerEvents : 'none' ,
47+ } ) ;
48+
49+ // Visible inner handle
50+ const handle = document . createElement ( 'div' ) ;
51+ handle . id = 'sidebar-resizer-handle' ;
52+ handle . setAttribute ( 'aria-hidden' , 'true' ) ;
53+ Object . assign ( handle . style , {
54+ width : '6px' ,
55+ height : '40px' ,
56+ borderRadius : '6px' ,
57+ // Use a subtle two-tone gradient and light border so it stands out in dark and light themes
58+ background : 'linear-gradient(180deg, rgba(255,255,255,0.9), rgba(200,200,200,0.6))' ,
59+ border : '1px solid rgba(0,0,0,0.12)' ,
60+ boxShadow : '0 1px 4px rgba(0,0,0,0.15)' ,
61+ transition : 'background 120ms, transform 120ms, box-shadow 120ms' ,
62+ } ) ;
63+ resizer . appendChild ( handle ) ;
64+ resizer . title = 'Drag to resize sidebar' ;
65+
66+ // Hover/focus effects to make it obvious
67+ function _resizerHoverOn ( ) { resizer . style . background = 'rgba(0,0,0,0.04)' ; handle . style . transform = 'scale(1.06)' ; handle . style . boxShadow = '0 2px 6px rgba(0,0,0,0.2)' ; }
68+ function _resizerHoverOff ( ) { resizer . style . background = 'transparent' ; handle . style . transform = 'scale(1)' ; handle . style . boxShadow = '0 1px 4px rgba(0,0,0,0.15)' ; }
69+ resizer . addEventListener ( 'pointerenter' , _resizerHoverOn ) ;
70+ resizer . addEventListener ( 'pointerleave' , _resizerHoverOff ) ;
71+ resizer . addEventListener ( 'focus' , _resizerHoverOn ) ;
72+ resizer . addEventListener ( 'blur' , _resizerHoverOff ) ;
73+
74+ // Insert the resizer as first child so it sits on the left edge
75+ // if (sidebar.firstChild) sidebar.insertBefore(resizer, sidebar.firstChild);
76+ // else sidebar.appendChild(resizer);
77+ // Append to body so it's not clipped by sidebar scrolling/overflow
78+ document . body . appendChild ( resizer ) ;
79+
80+ // Position update function to align the fixed resizer with the sidebar left edge
81+ function updateResizerPosition ( ) {
82+ const rect = sidebar . getBoundingClientRect ( ) ;
83+ if ( ! rect || ! isFinite ( rect . left ) || rect . width === 0 ) return ;
84+ // Consider sidebar hidden if its left edge is at or past the right viewport edge
85+ const viewportRight = window . innerWidth || document . documentElement . clientWidth ;
86+ const isOffscreen = rect . left >= ( viewportRight - 8 ) ;
87+ if ( isOffscreen || getComputedStyle ( sidebar ) . display === 'none' ) {
88+ resizer . style . display = 'none' ;
89+ return ;
90+ }
91+ // Ensure resizer is shown and aligned with the left edge of the sidebar
92+ resizer . style . display = 'flex' ;
93+ const left = Math . round ( rect . left - 7 ) ;
94+ resizer . style . left = left + 'px' ;
95+ resizer . style . top = Math . round ( rect . top ) + 'px' ;
96+ resizer . style . height = Math . max ( 40 , Math . round ( rect . height ) ) + 'px' ;
97+ }
98+ // Initial position
99+ updateResizerPosition ( ) ;
100+ // Keep in sync on resize and mutation of sidebar attributes
101+ window . addEventListener ( 'resize' , updateResizerPosition ) ;
102+ const mo = new MutationObserver ( updateResizerPosition ) ;
103+ mo . observe ( sidebar , { attributes : true , attributeFilter : [ 'style' , 'class' ] } ) ;
104+
105+ // Continuous updating while sidebar transitions or when mouse moves near the edge
106+ let rafId = null ;
107+ function rafLoop ( ) {
108+ updateResizerPosition ( ) ;
109+ rafId = requestAnimationFrame ( rafLoop ) ;
110+ }
111+ function startContinuousUpdate ( ) {
112+ if ( ! rafId ) rafLoop ( ) ;
113+ }
114+ function stopContinuousUpdate ( ) {
115+ if ( rafId ) {
116+ cancelAnimationFrame ( rafId ) ;
117+ rafId = null ;
118+ }
119+ }
120+
121+ // If the sidebar has a CSS transition on transform, run rAF during it to keep alignment
122+ sidebar . addEventListener ( 'transitionstart' , startContinuousUpdate ) ;
123+ sidebar . addEventListener ( 'transitionend' , function ( ) { updateResizerPosition ( ) ; stopContinuousUpdate ( ) ; } ) ;
124+ sidebar . addEventListener ( 'transitioncancel' , function ( ) { updateResizerPosition ( ) ; stopContinuousUpdate ( ) ; } ) ;
125+
126+ // Track last mouse position and proximity-based enabling of pointer events
127+ // to avoid the sidebar itself stealing the pointer when it expands under the cursor.
128+ const PROXIMITY_PX = 28 ;
129+ let mousePending = false ;
130+ let lastMouseX = null ;
131+ let lastMouseY = null ;
132+ function checkPointerProximity ( clientX , clientY ) {
133+ if ( ! resizer || resizer . style . display === 'none' ) return ;
134+ const r = resizer . getBoundingClientRect ( ) ;
135+ if ( ! r || r . width === 0 ) return ;
136+ // Compute distance to the resizer vertical centerline
137+ const dx = Math . max ( r . left - clientX , clientX - ( r . left + r . width ) ) ;
138+ const dy = Math . max ( r . top - clientY , clientY - ( r . top + r . height ) ) ;
139+ const within = ( dx <= PROXIMITY_PX && dy <= PROXIMITY_PX ) || ( clientX >= r . left && clientX <= r . left + r . width && clientY >= r . top && clientY <= r . top + r . height ) ;
140+ if ( within ) {
141+ if ( resizer . style . pointerEvents !== 'auto' ) {
142+ resizer . style . pointerEvents = 'auto' ;
143+ resizer . classList . add ( 'resizer-proximate' ) ;
144+ }
145+ } else {
146+ if ( resizer . style . pointerEvents !== 'none' && ! isResizing ) {
147+ resizer . style . pointerEvents = 'none' ;
148+ resizer . classList . remove ( 'resizer-proximate' ) ;
149+ }
150+ }
151+ // Also control the sidebar's pointer events: only enable when within proximity, when resizing, or when sidebar is sticky
152+ try {
153+ const srect = sidebar . getBoundingClientRect ( ) ;
154+ const viewportRight = window . innerWidth || document . documentElement . clientWidth ;
155+ const isOffscreen = srect . left >= ( viewportRight - 8 ) ;
156+ if ( isOffscreen || getComputedStyle ( sidebar ) . display === 'none' ) {
157+ sidebar . style . pointerEvents = 'none' ;
158+ } else if ( within || isResizing || sidebarSticky ) {
159+ sidebar . style . pointerEvents = 'auto' ;
160+ } else {
161+ // keep the sidebar unclickable unless cursor is near it
162+ sidebar . style . pointerEvents = 'none' ;
163+ }
164+ } catch ( err ) {
165+ // ignore
166+ }
167+ }
168+ document . addEventListener ( 'mousemove' , function ( e ) {
169+ lastMouseX = e . clientX ; lastMouseY = e . clientY ;
170+ if ( ! mousePending ) {
171+ mousePending = true ;
172+ requestAnimationFrame ( function ( ) {
173+ updateResizerPosition ( ) ;
174+ checkPointerProximity ( lastMouseX , lastMouseY ) ;
175+ mousePending = false ;
176+ } ) ;
177+ }
178+ } ) ;
179+
180+ let isResizing = false ;
181+ let startX = 0 ;
182+ let startWidth = 0 ;
183+
184+ function clampWidth ( w ) {
185+ const max = Math . min ( MAX_WIDTH_PX , Math . floor ( window . innerWidth - 100 ) ) ;
186+ return Math . max ( MIN_WIDTH_PX , Math . min ( w , max ) ) ;
187+ }
188+
189+ function onPointerMove ( e ) {
190+ if ( ! isResizing ) return ;
191+ const dx = e . clientX - startX ; // positive when moving right
192+ // Since the resizer is on the left edge of a right-aligned sidebar,
193+ // moving pointer to the right should make the sidebar narrower.
194+ // Compute new width as startWidth - dx.
195+ let newWidth = Math . round ( startWidth - dx ) ;
196+ newWidth = clampWidth ( newWidth ) ;
197+ sidebar . style . width = newWidth + 'px' ;
198+ }
199+
200+ function onPointerUp ( e ) {
201+ if ( ! isResizing ) return ;
202+ isResizing = false ;
203+ document . body . style . cursor = '' ;
204+ document . body . style . userSelect = '' ;
205+ try { localStorage . setItem ( STORAGE_KEY , sidebar . style . width ) ; } catch ( err ) { /* ignore */ }
206+ // Remove global listeners
207+ document . removeEventListener ( 'pointermove' , onPointerMove ) ;
208+ document . removeEventListener ( 'pointerup' , onPointerUp ) ;
209+ }
210+
211+ resizer . addEventListener ( 'pointerdown' , ( e ) => {
212+ e . preventDefault ( ) ;
213+ isResizing = true ;
214+ startX = e . clientX ;
215+ startWidth = parseInt ( window . getComputedStyle ( sidebar ) . width , 10 ) || DEFAULT_WIDTH_PX ;
216+ document . body . style . cursor = 'col-resize' ;
217+ document . body . style . userSelect = 'none' ;
218+ document . addEventListener ( 'pointermove' , onPointerMove ) ;
219+ document . addEventListener ( 'pointerup' , onPointerUp ) ;
220+ // attempt to capture pointer so touch works well
221+ try { e . target . setPointerCapture && e . target . setPointerCapture ( e . pointerId ) ; } catch ( err ) { }
222+ // Update resizer position during drag (useful when width changes)
223+ updateResizerPosition ( ) ;
224+ } ) ;
225+
226+ // Keyboard accessibility: left/right arrows adjust width
227+ resizer . addEventListener ( 'keydown' , ( e ) => {
228+ const step = 20 ;
229+ let cur = parseInt ( window . getComputedStyle ( sidebar ) . width , 10 ) || DEFAULT_WIDTH_PX ;
230+ if ( e . key === 'ArrowLeft' ) {
231+ cur = clampWidth ( cur - step ) ;
232+ sidebar . style . width = cur + 'px' ;
233+ try { localStorage . setItem ( STORAGE_KEY , sidebar . style . width ) ; } catch ( err ) { }
234+ e . preventDefault ( ) ;
235+ } else if ( e . key === 'ArrowRight' ) {
236+ cur = clampWidth ( cur + step ) ;
237+ sidebar . style . width = cur + 'px' ;
238+ try { localStorage . setItem ( STORAGE_KEY , sidebar . style . width ) ; } catch ( err ) { }
239+ e . preventDefault ( ) ;
240+ } else if ( e . key === 'Home' ) {
241+ sidebar . style . width = DEFAULT_WIDTH_PX + 'px' ;
242+ try { localStorage . setItem ( STORAGE_KEY , sidebar . style . width ) ; } catch ( err ) { }
243+ e . preventDefault ( ) ;
244+ }
245+ } ) ;
246+
247+ // Make sure the stored max width updates on window resize
248+ window . addEventListener ( 'resize' , ( ) => {
249+ const cur = parseInt ( window . getComputedStyle ( sidebar ) . width , 10 ) || DEFAULT_WIDTH_PX ;
250+ const clamped = clampWidth ( cur ) ;
251+ if ( clamped !== cur ) {
252+ sidebar . style . width = clamped + 'px' ;
253+ try { localStorage . setItem ( STORAGE_KEY , sidebar . style . width ) ; } catch ( err ) { }
254+ }
255+ updateResizerPosition ( ) ;
256+ } ) ;
257+ // When sidebar is shown/hidden via showSidebar/hideSidebar functions, keep resizer sync
258+ const showHideObserver = new MutationObserver ( updateResizerPosition ) ;
259+ showHideObserver . observe ( sidebar , { attributes : true , attributeFilter : [ 'style' , 'class' ] } ) ;
260+ } catch ( err ) {
261+ // don't crash the rest of the sidebar code if resizing support fails
262+ console . warn ( 'sidebar resizer init failed' , err ) ;
263+ }
264+ } ) ( ) ;
265+
5266export let sidebarSticky = false ;
6267let lastSidebarTab = null ;
7268
8269export function showSidebar ( ) {
9270 sidebar . style . transform = 'translateX(0)' ;
271+ // When explicitly shown, enable pointer events so controls are interactive
272+ try { sidebar . style . pointerEvents = 'auto' ; } catch ( e ) { }
10273}
11274export function hideSidebar ( ) {
12275 sidebar . style . transform = 'translateX(100%)' ;
13276 sidebarSticky = false ;
277+ try { sidebar . style . pointerEvents = 'none' ; } catch ( e ) { }
14278}
15279
16280export function showSidebarContent ( d , fromHover = false ) {
@@ -328,4 +592,7 @@ export function openInNewTab(event, d) {
328592
329593export function setSidebarSticky ( val ) {
330594 sidebarSticky = val ;
595+ try {
596+ sidebar . style . pointerEvents = val ? 'auto' : 'none' ;
597+ } catch ( e ) { }
331598}
0 commit comments