yufan.me/src/assets/scripts/sticky-sidebar.js
2024-06-17 20:26:39 +08:00

430 lines
15 KiB
JavaScript

import resizeSensor from 'resize-sensor';
class stickySidebarEventHandler {
constructor() {
this.functionMap = {};
}
addEventListener(target, event, func) {
this.functionMap[event] = func;
target.addEventListener(event.split('.')[0], this.functionMap[event]);
}
removeEventListener(target, event) {
target.removeEventListener(event.split('.')[0], this.functionMap[event]);
delete this.functionMap[event];
}
}
const stickySidebarStickySidebar = (target, opts) => {
const defaults = {
containerSelector: '',
additionalMarginTop: 0,
additionalMarginBottom: 0,
updateSidebarHeight: true,
minWidth: 0,
disableOnResponsiveLayouts: true,
sidebarBehavior: 'modern',
defaultPosition: 'relative',
namespace: 'TSS',
};
const options = Object.assign(defaults, opts);
// Validate options
options.additionalMarginTop = Number.parseInt(options.additionalMarginTop) || 0;
options.additionalMarginBottom = Number.parseInt(options.additionalMarginBottom) || 0;
tryInitOrHookIntoEvents(options, target);
// Try doing init, otherwise hook into window.resize and document.scroll and try again then.
function tryInitOrHookIntoEvents(options, $that) {
const success = tryInit(options, $that);
if (!success) {
console.log('TSS: Body width smaller than options.minWidth. Init is delayed.');
stickySidebarEventHandler.addEventListener(
document,
`scroll.${options.namespace}`,
((options, $that) => () => {
const success = tryInit(options, $that);
if (success) {
stickySidebarEventHandler.removeEventListener(document, `scroll.${options.namespace}`);
}
})(options, $that),
);
stickySidebarEventHandler.addEventListener(
window,
`resize.${options.namespace}`,
((options, $that) => () => {
const success = tryInit(options, $that);
if (success) {
stickySidebarEventHandler.removeEventListener(window, `resize.${options.namespace}`);
}
})(options, $that),
);
}
}
// Try doing init if proper conditions are met.
function tryInit(options, $that) {
if (options.initialized === true) {
return true;
}
if (document.querySelector('body').getBoundingClientRect().width < options.minWidth) {
return false;
}
init(options, $that);
return true;
}
// Init the sticky sidebar(s).
function init(options, $that) {
options.initialized = true;
// Add CSS
const existingStylesheet = document.querySelectorAll(
`#stickySidebar-sticky-sidebar-stylesheet-${options.namespace}`,
);
if (existingStylesheet.length === 0) {
document
.querySelector('head')
.insertAdjacentHTML(
'beforeend',
`<style id="stickySidebar-sticky-sidebar-stylesheet-${options.namespace}">.stickySidebarStickySidebar:after {content: ""; display: table; clear: both;}</style>`,
);
}
for (const element of $that) {
const o = {};
o.sidebar = element;
// Save options
o.options = options || {};
// Get container
o.container = o.options.containerSelector === '' ? [] : document.querySelectorAll(o.options.containerSelector);
if (o.container.length === 0) {
o.container = o.sidebar.parentNode;
}
// Create sticky sidebar
let parentNode = o.sidebar.parentNode;
while (parentNode && parentNode !== document) {
parentNode.style.setProperty('-webkit-transform', 'none'); // Fix for WebKit bug - https://code.google.com/p/chromium/issues/detail?id=20574
parentNode = parentNode.parentNode;
}
for (const [key, value] of Object.entries({
position: o.options.defaultPosition,
overflow: 'visible',
// The "box-sizing" must be set to "content-box" because we set a fixed height to this element when the sticky sidebar has a fixed position.
'-webkit-box-sizing': 'border-box',
'-moz-box-sizing': 'border-box',
'box-sizing': 'border-box',
})) {
o.sidebar.style.setProperty(key, value);
}
// Get the sticky sidebar element. If none has been found, then create one.
o.stickySidebar = o.sidebar.querySelector('.stickySidebarStickySidebar');
if (o.stickySidebar === null) {
// Remove <script> tags, otherwise they will be run again when added to the stickySidebar.
const javaScriptMIMETypes = /(?:text|application)\/(?:x-)?(?:javascript|ecmascript)/i;
for (const script of Array.from(o.sidebar.querySelectorAll('script'))) {
if (script.type.length === 0 || script.type.match(javaScriptMIMETypes)) {
script.remove();
}
}
o.stickySidebar = document.createElement('div');
o.stickySidebar.classList.add('stickySidebarStickySidebar');
for (const child of Array.from(o.sidebar.children)) {
o.stickySidebar.appendChild(child);
}
o.sidebar.appendChild(o.stickySidebar);
}
// Get existing top and bottom margins and paddings
const computedStyle = window.getComputedStyle(o.sidebar);
o.marginBottom = Number.parseInt(computedStyle.getPropertyValue('margin-bottom'));
o.paddingTop = Number.parseInt(computedStyle.getPropertyValue('padding-top'));
o.paddingBottom = Number.parseInt(computedStyle.getPropertyValue('padding-bottom'));
// Add a temporary padding rule to check for collapsable margins.
let collapsedTopHeight = o.stickySidebar.getBoundingClientRect().top + window.scrollY;
let collapsedBottomHeight = o.stickySidebar.getBoundingClientRect().height;
o.stickySidebar.style.setProperty('padding-top', '1px');
o.stickySidebar.style.setProperty('padding-bottom', '1px');
collapsedTopHeight -= o.stickySidebar.getBoundingClientRect().top + window.scrollY;
collapsedBottomHeight =
o.stickySidebar.getBoundingClientRect().height - collapsedBottomHeight - collapsedTopHeight;
if (collapsedTopHeight === 0) {
o.stickySidebar.style.setProperty('padding-top', '0');
o.stickySidebarPaddingTop = 0;
} else {
o.stickySidebarPaddingTop = 1;
}
if (collapsedBottomHeight === 0) {
o.stickySidebar.style.setProperty('padding-bottom', '0');
o.stickySidebarPaddingBottom = 0;
} else {
o.stickySidebarPaddingBottom = 1;
}
// We use this to know whether the user is scrolling up or down.
o.previousScrollTop = null;
// Scroll top (value) when the sidebar has fixed position.
o.fixedScrollTop = 0;
// Set sidebar to default values.
resetSidebar();
o.onScroll = (o) => {
// Stop if the sidebar isn't visible.
if (getComputedStyle(o.stickySidebar).display === 'none') {
return;
}
// Stop if the window is too small.
if (document.querySelector('body').getBoundingClientRect().width < o.options.minWidth) {
resetSidebar();
return;
}
// Stop if the sidebar width is larger than the container width (e.g. the theme is responsive and the sidebar is now below the content)
if (o.options.disableOnResponsiveLayouts) {
const sidebarStyles = getComputedStyle(o.sidebar);
const margins =
sidebarStyles.float === 'none'
? Number.parseInt(sidebarStyles.marginLeft) + Number.parseInt(sidebarStyles.marginRight)
: 0;
const sidebarWidth = o.sidebar.getBoundingClientRect().width + margins;
if (sidebarWidth + 50 > o.container.getBoundingClientRect().width) {
resetSidebar();
return;
}
}
const scrollTop = window.scrollY;
let position = 'static';
let top = 0;
// Position of element relative to document = position of element relative to screen + window scrolling position
const sidebarOffset = o.sidebar.getBoundingClientRect().top + window.scrollY;
// If the user has scrolled down enough for the sidebar to be clipped at the top, then we can consider changing its position.
if (scrollTop >= sidebarOffset + (o.paddingTop - o.options.additionalMarginTop)) {
// The top and bottom offsets, used in various calculations.
const offsetTop = o.paddingTop + options.additionalMarginTop;
const offsetBottom = o.paddingBottom + o.marginBottom + options.additionalMarginBottom;
// All top and bottom positions are relative to the window, not to the parent elements.
const containerTop = o.sidebar.getBoundingClientRect().top + window.scrollY;
const containerBottom =
o.sidebar.getBoundingClientRect().top + window.scrollY + getClearedHeight(o.container);
// The top and bottom offsets relative to the window screen top (zero) and bottom (window height).
const windowOffsetTop = 0 + options.additionalMarginTop;
let windowOffsetBottom;
const sidebarSmallerThanWindow =
o.stickySidebar.getBoundingClientRect().height + offsetTop + offsetBottom < window.innerHeight;
if (sidebarSmallerThanWindow) {
windowOffsetBottom = windowOffsetTop + o.stickySidebar.getBoundingClientRect().height;
} else {
windowOffsetBottom = window.innerHeight - o.marginBottom - o.paddingBottom - options.additionalMarginBottom;
}
const staticLimitTop = containerTop - scrollTop + o.paddingTop;
const staticLimitBottom = containerBottom - scrollTop - o.paddingBottom - o.marginBottom;
top = o.stickySidebar.getBoundingClientRect().top + window.scrollY - scrollTop;
const scrollTopDiff = o.previousScrollTop - scrollTop;
// If the sidebar position is fixed, then it won't move up or down by itself. So, we manually adjust the top coordinate.
if (getComputedStyle(o.stickySidebar).position === 'fixed') {
if (o.options.sidebarBehavior === 'modern') {
top += scrollTopDiff;
}
}
if (o.options.sidebarBehavior === 'stick-to-top') {
top = options.additionalMarginTop;
}
if (o.options.sidebarBehavior === 'stick-to-bottom') {
top = windowOffsetBottom - o.stickySidebar.getBoundingClientRect().height;
}
if (scrollTopDiff > 0) {
// If the user is scrolling up.
top = Math.min(top, windowOffsetTop);
} else {
// If the user is scrolling down.
top = Math.max(top, windowOffsetBottom - o.stickySidebar.getBoundingClientRect().height);
}
top = Math.max(top, staticLimitTop);
top = Math.min(top, staticLimitBottom - o.stickySidebar.getBoundingClientRect().height);
// If the sidebar is the same height as the container, we won't use fixed positioning.
const sidebarSameHeightAsContainer =
o.container.getBoundingClientRect().height === o.stickySidebar.getBoundingClientRect().height;
if (!sidebarSameHeightAsContainer && top === windowOffsetTop) {
position = 'fixed';
} else if (
!sidebarSameHeightAsContainer &&
top === windowOffsetBottom - o.stickySidebar.getBoundingClientRect().height
) {
position = 'fixed';
} else if (
scrollTop + top - (o.sidebar.getBoundingClientRect().top + window.scrollY) - o.paddingTop <=
options.additionalMarginTop
) {
// Stuck to the top of the page. No special behavior.
position = 'static';
} else {
// Stuck to the bottom of the page.
position = 'absolute';
}
}
/*
* Performance notice: It's OK to set these CSS values at each resize/scroll, even if they don't change.
* It's way slower to first check if the values have changed.
*/
if (position === 'fixed') {
const scrollLeft = window.scrollX;
for (const [key, value] of Object.entries({
position: 'fixed',
width: `${getWidthForObject(o.stickySidebar)}px`,
transform: `translateY(${top}px)`,
left: `${o.sidebar.getBoundingClientRect().left + Number.parseInt(getComputedStyle(o.sidebar).paddingLeft) - scrollLeft}px`,
top: '0',
})) {
o.stickySidebar.style.setProperty(key, value);
}
} else if (position === 'absolute') {
const css = {};
if (getComputedStyle(o.stickySidebar).position !== 'absolute') {
css.position = 'absolute';
css.transform = `translateY(${scrollTop + top - (o.sidebar.getBoundingClientRect().top + window.scrollY) - o.stickySidebarPaddingTop - o.stickySidebarPaddingBottom}px)`;
css.top = '0px';
}
css.width = `${getWidthForObject(o.stickySidebar)}px`;
css.left = '';
for (const [key, value] of Object.entries(css)) {
o.stickySidebar.style.setProperty(key, value);
}
} else if (position === 'static') {
resetSidebar();
}
if (position !== 'static') {
if (o.options.updateSidebarHeight === true) {
`${o.sidebar.style.setProperty(
'min-height',
o.stickySidebar.outerHeight +
(o.stickySidebar.getBoundingClientRect().top + window.scrollY) -
(o.sidebar.getBoundingClientRect().top + window.scrollY) +
o.paddingBottom,
)}px`;
}
}
o.previousScrollTop = scrollTop;
};
// Initialize the sidebar's position.
o.onScroll(o);
// Recalculate the sidebar's position on every scroll and resize.
document.addEventListener(
'scroll',
((o) => () => {
o.onScroll(o);
})(o),
);
window.addEventListener(
'resize',
((o) => () => {
o.stickySidebar.style.setProperty('position', 'static');
o.onScroll(o);
})(o),
);
// Recalculate the sidebar's position every time the sidebar changes its size.
if (typeof resizeSensor !== 'undefined') {
new resizeSensor(
o.stickySidebar,
((o) => () => {
o.onScroll(o);
})(o),
);
}
// Reset the sidebar to its default state
function resetSidebar() {
o.fixedScrollTop = 0;
o.sidebar.style.setProperty('min-height', '1px');
for (const [key, value] of Object.entries({
position: 'static',
width: '',
transform: 'none',
})) {
o.stickySidebar.style.setProperty(key, value);
}
}
// Get the height of a div as if its floated children were cleared. Note that this function fails if the floats are more than one level deep.
function getClearedHeight(e) {
let height = e.getBoundingClientRect().height;
for (const child of Array.from(e.children)) {
height = Math.max(height, child.getBoundingClientRect().height);
}
return height;
}
}
}
function getWidthForObject(object) {
let width;
try {
width = object[0].getBoundingClientRect().width;
} catch (err) {}
if (typeof width === 'undefined') {
width = object.getBoundingClientRect().width;
}
return width;
}
return this;
};
export default stickySidebarStickySidebar;