Skip to content

Commit c278050

Browse files
committed
enable resizing of the sidebar
1 parent dad6a09 commit c278050

File tree

1 file changed

+267
-0
lines changed

1 file changed

+267
-0
lines changed

scripts/static/js/sidebar.js

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,279 @@ import { allNodeData, archiveProgramIds, formatMetrics, renderMetricBar, getHigh
22
import { scrollAndSelectNodeById } from './graph.js';
33

44
const 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+
5266
export let sidebarSticky = false;
6267
let lastSidebarTab = null;
7268

8269
export 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
}
11274
export function hideSidebar() {
12275
sidebar.style.transform = 'translateX(100%)';
13276
sidebarSticky = false;
277+
try { sidebar.style.pointerEvents = 'none'; } catch (e) {}
14278
}
15279

16280
export function showSidebarContent(d, fromHover = false) {
@@ -328,4 +592,7 @@ export function openInNewTab(event, d) {
328592

329593
export function setSidebarSticky(val) {
330594
sidebarSticky = val;
595+
try {
596+
sidebar.style.pointerEvents = val ? 'auto' : 'none';
597+
} catch (e) {}
331598
}

0 commit comments

Comments
 (0)