Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions assets/js/resizable-panels.js

Copy link
Copy Markdown
Contributor

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.

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
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The COL_CLASSES object is defined but never used anywhere in the script. It should be removed to keep the code clean.


class ResizablePanels {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The property this.startWidth is initialized here but never used. The script uses this.startWidths (plural) for its logic instead. This line can be safely removed.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Setting a hardcoded ID reset-panel-widths on the reset button will result in duplicate IDs in the DOM if multiple resizable rows are initialized on the same page. Since the styling in _resizable-panels.scss only targets the class .resizable-panel-reset, we can safely remove the id attribute to maintain valid HTML standards.

    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);
    }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The mousemove and mouseup event listeners are attached to the document globally and remain active even when the user is not interacting with the resize handles. For better performance, consider attaching these listeners only within onMouseDown and removing them in onMouseUp.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Throttle the pointer move updates using requestAnimationFrame to ensure smooth 60fps+ resizing and prevent layout thrashing.

    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);
      });
    }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These magic numbers (8.33, 41.66, 33.33) represent specific Bootstrap column percentages (1/12, 5/12, 4/12). It would be more maintainable to define these as named constants at the top of the file to clarify their meaning and make them easier to adjust if the layout constraints change.

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();
}
})();
Loading
Loading