-
Notifications
You must be signed in to change notification settings - Fork 195
feat(layouts): add resizable side panels with localStorage persistence #1040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
895bfa1
53e9296
8dfa71e
a7040ab
4bcba70
3c4bb21
3671459
e945697
2e8475c
59647a8
ed5d256
c260d36
5fd3cc8
617019f
c2eb6d2
8ca58ae
2a31d58
da33a19
875a062
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| /** | ||
| * Resizable Panels Feature | ||
| * Allows users to adjust the width of side panels (left sidebar and right TOC) | ||
| * Preferences are saved to localStorage and restored on page load | ||
| * Includes reset functionality to restore default widths | ||
| */ | ||
|
|
||
| (function() { | ||
| 'use strict'; | ||
|
|
||
| const STORAGE_KEY = 'layer5-docs-panel-widths'; | ||
| const DEFAULT_WIDTHS = { | ||
| sidebar: 2, // col-xl-2 = ~16.66% | ||
| toc: 2, // col-xl-2 = ~16.66% | ||
| main: 8 // col-xl-8 = ~66.66% | ||
| }; | ||
|
|
||
| // CSS class shortcuts for Bootstrap grid columns | ||
| const COL_CLASSES = { | ||
| 'col-1': 8.33, | ||
| 'col-2': 16.66, | ||
| 'col-3': 25, | ||
| 'col-4': 33.33, | ||
| 'col-5': 41.66, | ||
| 'col-6': 50, | ||
| 'col-7': 58.33, | ||
| 'col-8': 66.66, | ||
| 'col-9': 75, | ||
| 'col-10': 83.33, | ||
| 'col-11': 91.66, | ||
| 'col-12': 100 | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| class ResizablePanels { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The usage of classes is an overkill. The classes are discarded just as quickly as they got created, adding a lot of boilerplate in the process which makes the code harded to read. |
||
| constructor() { | ||
| this.sidebar = document.querySelector('.td-sidebar'); | ||
| this.toc = document.querySelector('.td-sidebar-toc'); | ||
| this.main = document.querySelector('main[role="main"]'); | ||
| this.row = document.querySelector('.row.flex-xl-nowrap'); | ||
|
|
||
| if (!this.row || !this.sidebar || !this.main) { | ||
| console.warn('Resizable panels: Required elements not found'); | ||
| return; | ||
| } | ||
|
|
||
| this.isResizing = false; | ||
| this.currentResizeTarget = null; | ||
| this.startX = 0; | ||
| this.startWidth = 0; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| this.init(); | ||
| } | ||
|
|
||
| init() { | ||
| // Load saved widths from localStorage | ||
| this.loadSavedWidths(); | ||
|
|
||
| // Create resize handles | ||
| this.createResizeHandles(); | ||
|
|
||
| // Add event listeners | ||
| this.addEventListeners(); | ||
|
|
||
| // Add reset button | ||
| this.addResetButton(); | ||
| } | ||
|
Comment on lines
+90
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting a hardcoded ID function createResetButton() {
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.className = 'resizable-panel-reset';
resetButton.innerHTML =
'<i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Reset layout</span>';
resetButton.title = 'Reset panel widths to default';
resetButton.addEventListener('click', reset);
sidebar.appendChild(resetButton);
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the indicator for a "resizable-panel" is it having a ".row.flex-xl-nowrap" then the id comment is correct. But this means that there's multiple items that have different sidebars which are being managed individually. Is the difference in design that big for there to be different size managers for the side panels?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After going through the code, there's only one instance per baseof.html. Also, the feedback given above is still pending for a response. |
||
|
|
||
| loadSavedWidths() { | ||
| try { | ||
| const saved = localStorage.getItem(STORAGE_KEY); | ||
| if (saved) { | ||
| const widths = JSON.parse(saved); | ||
| this.applyWidths(widths); | ||
| } | ||
| } catch (error) { | ||
| console.error('Error loading saved panel widths:', error); | ||
| } | ||
| } | ||
|
Comment on lines
+102
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dynamically attach the global pointer move and up/cancel event listeners here when the user actually starts dragging the handle. function startResize(event, handle) {
if (!mediaQuery.matches) {
return;
}
event.preventDefault();
activeHandle = handle;
startX = event.clientX;
startWidths = { ...widths };
handle.classList.add('resizable-panel-handle--active');
handle.setPointerCapture(event.pointerId);
document.body.classList.add('resizable-panels-dragging');
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', stopResize);
document.addEventListener('pointercancel', stopResize);
} |
||
|
|
||
| createResizeHandles() { | ||
| // Create resize handle between sidebar and main content | ||
| const sidebarHandle = document.createElement('div'); | ||
| sidebarHandle.className = 'resizable-panel-handle resizable-panel-handle--right'; | ||
| sidebarHandle.setAttribute('data-resize-target', 'sidebar'); | ||
| sidebarHandle.setAttribute('title', 'Drag to resize sidebar'); | ||
| this.sidebar.appendChild(sidebarHandle); | ||
|
|
||
| // Create resize handle for TOC (if it exists) | ||
| if (this.toc) { | ||
| const tocHandle = document.createElement('div'); | ||
| tocHandle.className = 'resizable-panel-handle resizable-panel-handle--left'; | ||
| tocHandle.setAttribute('data-resize-target', 'toc'); | ||
| tocHandle.setAttribute('title', 'Drag to resize table of contents'); | ||
| this.toc.appendChild(tocHandle); | ||
| } | ||
| } | ||
|
|
||
| addEventListeners() { | ||
| document.addEventListener('mousedown', (e) => this.onMouseDown(e)); | ||
| document.addEventListener('mousemove', (e) => this.onMouseMove(e)); | ||
| document.addEventListener('mouseup', (e) => this.onMouseUp(e)); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| onMouseDown(e) { | ||
| if (!e.target.classList.contains('resizable-panel-handle')) { | ||
| return; | ||
| } | ||
|
|
||
| this.isResizing = true; | ||
| this.currentResizeTarget = e.target.getAttribute('data-resize-target'); | ||
| this.startX = e.clientX; | ||
|
|
||
| // Store current widths for delta calculation | ||
| this.startWidths = this.getCurrentWidths(); | ||
|
|
||
| // Add active state | ||
| e.target.classList.add('resizable-panel-handle--active'); | ||
| document.body.style.userSelect = 'none'; | ||
| document.body.style.cursor = 'col-resize'; | ||
| } | ||
|
Comment on lines
+128
to
+152
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Throttle the pointer move updates using function onPointerMove(event) {
if (!activeHandle || !startWidths) {
return;
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
const clientX = event.clientX;
animationFrameId = requestAnimationFrame(() => {
const rowWidth = row.getBoundingClientRect().width;
if (!rowWidth) {
return;
}
const target = activeHandle.dataset.resizeTarget;
const delta = ((clientX - startX) / rowWidth) * 100;
const nextWidths = { ...startWidths };
if (target === 'sidebar') {
nextWidths.sidebar = startWidths.sidebar + delta;
}
if (target === 'toc') {
nextWidths.toc = startWidths.toc - delta;
}
widths = normalizeWidths(nextWidths);
applyWidths(widths);
});
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thoughts on this?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Response is still pending. |
||
|
|
||
| onMouseMove(e) { | ||
| if (!this.isResizing) return; | ||
|
|
||
| const delta = e.clientX - this.startX; | ||
| const adjustment = delta / window.innerWidth; // Convert pixels to percentage-like ratio | ||
|
|
||
| let newWidths = { ...this.startWidths }; | ||
|
|
||
| if (this.currentResizeTarget === 'sidebar') { | ||
| // Resizing left sidebar | ||
| const sidebarPercent = (this.startWidths.sidebar * 100) / 12; // Convert col units to percentage | ||
| const mainPercent = (this.startWidths.main * 100) / 12; | ||
|
|
||
| const newSidebarPercent = sidebarPercent + (adjustment * 100); | ||
| const newMainPercent = mainPercent - (adjustment * 100); | ||
|
|
||
| // Constrain widths: min 1 col, max 5 cols for sidebar; min 4 cols for main | ||
| if (newSidebarPercent >= 8.33 && newSidebarPercent <= 41.66 && newMainPercent >= 33.33) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| newWidths.sidebar = Math.round((newSidebarPercent / 100) * 12); | ||
| newWidths.main = Math.round((newMainPercent / 100) * 12); | ||
| } | ||
| } else if (this.currentResizeTarget === 'toc') { | ||
| // Resizing right TOC panel | ||
| const tocPercent = (this.startWidths.toc * 100) / 12; | ||
| const mainPercent = (this.startWidths.main * 100) / 12; | ||
|
|
||
| const newTocPercent = tocPercent - (adjustment * 100); | ||
| const newMainPercent = mainPercent + (adjustment * 100); | ||
|
|
||
| // Constrain widths: min 1 col, max 5 cols for toc; min 4 cols for main | ||
| if (newTocPercent >= 8.33 && newTocPercent <= 41.66 && newMainPercent >= 33.33) { | ||
| newWidths.toc = Math.round((newTocPercent / 100) * 12); | ||
| newWidths.main = Math.round((newMainPercent / 100) * 12); | ||
| } | ||
| } | ||
|
|
||
| this.applyWidths(newWidths); | ||
| } | ||
|
|
||
| onMouseUp(e) { | ||
| if (!this.isResizing) return; | ||
|
|
||
| this.isResizing = false; | ||
| const handle = document.querySelector('.resizable-panel-handle--active'); | ||
| if (handle) { | ||
| handle.classList.remove('resizable-panel-handle--active'); | ||
| } | ||
|
|
||
| document.body.style.userSelect = ''; | ||
| document.body.style.cursor = ''; | ||
|
|
||
| // Save widths to localStorage | ||
| this.savePanelWidths(); | ||
| } | ||
|
|
||
| applyWidths(widths) { | ||
| const { sidebar, toc, main } = widths; | ||
|
|
||
| // Update sidebar | ||
| this.removeBootstrapColClasses(this.sidebar); | ||
| this.sidebar.classList.add(`col-xl-${sidebar}`); | ||
|
|
||
| // Update main | ||
| this.removeBootstrapColClasses(this.main); | ||
| this.main.classList.add(`col-xl-${main}`); | ||
|
|
||
| // Update TOC if it exists | ||
| if (this.toc) { | ||
| this.removeBootstrapColClasses(this.toc); | ||
| this.toc.classList.add(`col-xl-${toc}`); | ||
| } | ||
| } | ||
|
|
||
| getCurrentWidths() { | ||
| const getColNumber = (element) => { | ||
| const classes = element.className.split(' '); | ||
| const colClass = classes.find(c => c.match(/col-xl-\d+/)); | ||
| return colClass ? parseInt(colClass.split('-')[2]) : null; | ||
| }; | ||
|
|
||
| return { | ||
| sidebar: getColNumber(this.sidebar) || DEFAULT_WIDTHS.sidebar, | ||
| toc: this.toc ? (getColNumber(this.toc) || DEFAULT_WIDTHS.toc) : DEFAULT_WIDTHS.toc, | ||
| main: getColNumber(this.main) || DEFAULT_WIDTHS.main | ||
| }; | ||
| } | ||
|
|
||
| removeBootstrapColClasses(element) { | ||
| const classes = element.className.split(' ').filter(c => !c.match(/col-xl-\d+/)); | ||
| element.className = classes.join(' ').trim(); | ||
| } | ||
|
|
||
| savePanelWidths() { | ||
| try { | ||
| const widths = this.getCurrentWidths(); | ||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(widths)); | ||
| } catch (error) { | ||
| console.error('Error saving panel widths:', error); | ||
| } | ||
| } | ||
|
|
||
| addResetButton() { | ||
| // Find the feature-info-container or page-header to add reset button | ||
| const pageHeader = document.querySelector('.page-header'); | ||
| if (!pageHeader) return; | ||
|
|
||
| const resetButton = document.createElement('button'); | ||
| resetButton.id = 'reset-panel-widths'; | ||
| resetButton.className = 'btn btn-sm btn-outline-secondary ms-2'; | ||
| resetButton.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reset Layout'; | ||
| resetButton.setAttribute('title', 'Reset panel widths to default'); | ||
|
|
||
| resetButton.addEventListener('click', () => this.resetPanelWidths()); | ||
|
|
||
| // Find a good place to insert the button | ||
| const featureContainer = pageHeader.querySelector('.feature-info-container'); | ||
| if (featureContainer) { | ||
| featureContainer.insertAdjacentElement('beforeend', resetButton); | ||
| } else { | ||
| pageHeader.insertAdjacentElement('beforeend', resetButton); | ||
| } | ||
| } | ||
|
|
||
| resetPanelWidths() { | ||
| this.applyWidths(DEFAULT_WIDTHS); | ||
| this.savePanelWidths(); | ||
| } | ||
| } | ||
|
|
||
| // Initialize when DOM is ready | ||
| if (document.readyState === 'loading') { | ||
| document.addEventListener('DOMContentLoaded', () => { | ||
| new ResizablePanels(); | ||
| }); | ||
| } else { | ||
| new ResizablePanels(); | ||
| } | ||
| })(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The feedback regarding the dynamic use of the event listening for the dragging of the panels hasn't been implemented. As of right now, there's always an event listening of the pointer movement despite there being a window in which a listening for those particulars event isn't needed.