panelsnap
Version:
A JavaScript plugin that provides snapping functionality to a set of panels within your interface.
256 lines (206 loc) • 8.27 kB
JavaScript
import Tweezer from 'tweezer.js';
import {
getScrollingElement,
getScrollEventContainer,
getTargetScrollOffset,
getElementsInContainerViewport,
elementFillsContainer,
passiveIsSupported,
} from './utilities';
let INSTANCE_COUNTER = 0;
const TWEEN_MAX_VALUE = 10000;
const defaultOptions = {
container: document.body,
panelSelector: '> section',
directionThreshold: 50,
delay: 0,
duration: 300,
easing: t => t,
};
export default class PanelSnap {
constructor(options) {
this.options = {
...defaultOptions,
...options,
};
if (this.options.container.dataset.panelsnapId) {
throw new Error('PanelSnap is already initialised on this container, aborting.');
}
this.container = this.options.container;
this.scrollContainer = getScrollingElement(this.container);
this.scrollEventContainer = getScrollEventContainer(this.container);
INSTANCE_COUNTER += 1;
this.instanceIndex = INSTANCE_COUNTER;
this.container.dataset.panelsnapId = this.instanceIndex;
const panelQuery = `[data-panelsnap-id="${this.instanceIndex}"] ${this.options.panelSelector}`;
this.panelList = Array.from(document.querySelectorAll(panelQuery));
this.events = [];
this.isEnabled = true;
this.isInteracting = false;
this.scrollTimeout = null;
this.resetAnimation();
this.onInteractStart = this.onInteractStart.bind(this);
this.onInteractStop = this.onInteractStop.bind(this);
this.onInteractStart = this.onInteractStart.bind(this);
this.onInteractStop = this.onInteractStop.bind(this);
this.onInteractStart = this.onInteractStart.bind(this);
this.onInteractStop = this.onInteractStop.bind(this);
this.onScroll = this.onScroll.bind(this);
this.onInteract = this.onInteract.bind(this);
this.scrollEventContainer.addEventListener('keydown', this.onInteractStart, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('keyup', this.onInteractStop, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('mousedown', this.onInteractStart, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('mouseup', this.onInteractStop, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('touchstart', this.onInteractStart, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('touchend', this.onInteractStop, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('scroll', this.onScroll, passiveIsSupported && { passive: true });
this.scrollEventContainer.addEventListener('wheel', this.onInteract, passiveIsSupported && { passive: true });
this.findSnapTarget();
}
destroy() {
// Stop current animations
this.stopAnimation();
// Prevent future activity
this.disable();
// Remove event lisiteners
this.scrollEventContainer.removeEventListener('keydown', this.onInteractStart, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('keyup', this.onInteractStop, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('mousedown', this.onInteractStart, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('mouseup', this.onInteractStop, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('touchstart', this.onInteractStart, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('touchend', this.onInteractStop, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('scroll', this.onScroll, passiveIsSupported && { passive: true });
this.scrollEventContainer.removeEventListener('wheel', this.onInteract, passiveIsSupported && { passive: true });
// Remove instance association
delete this.options.container.dataset.panelsnapId;
}
enable() {
this.isEnabled = true;
}
disable() {
this.isEnabled = false;
}
on(name, handler) {
const currentHandlers = this.events[name] || [];
this.events[name] = [...currentHandlers, handler];
if (name === 'activatePanel') {
handler.call(this, this.activePanel);
}
}
off(name, handler) {
const currentHandlers = this.events[name] || [];
this.events[name] = currentHandlers.filter(h => h !== handler);
}
emit(name, value) {
const currentHandlers = this.events[name] || [];
currentHandlers.forEach(h => h.call(this, value));
}
onInteractStart() {
this.stopAnimation();
this.isInteracting = true;
}
onInteractStop() {
this.isInteracting = false;
this.findSnapTarget();
}
onInteract() {
this.stopAnimation();
this.onScroll();
}
onScroll() {
clearTimeout(this.scrollTimeout);
if (this.isInteracting || this.animation) {
return;
}
this.scrollTimeout = setTimeout(this.findSnapTarget.bind(this), 50 + this.options.delay);
}
findSnapTarget() {
const deltaY = this.scrollContainer.scrollTop - this.currentScrollOffset.top;
const deltaX = this.scrollContainer.scrollLeft - this.currentScrollOffset.left;
this.currentScrollOffset = {
top: this.scrollContainer.scrollTop,
left: this.scrollContainer.scrollLeft,
};
const panelsInViewport = getElementsInContainerViewport(this.container, this.panelList);
if (panelsInViewport.length === 0) {
throw new Error('PanelSnap could not find a snappable panel, aborting.');
}
if (panelsInViewport.length > 1) {
if (
Math.abs(deltaY) < this.options.directionThreshold
&& Math.abs(deltaX) < this.options.directionThreshold
&& this.activePanel
) {
this.snapToPanel(this.activePanel, deltaY > 0, deltaX > 0);
return;
}
const targetIndex = deltaY > 0 || deltaX > 0 ? 1 : panelsInViewport.length - 2;
this.snapToPanel(panelsInViewport[targetIndex], deltaY < 0, deltaX < 0);
return;
}
const visiblePanel = panelsInViewport[0];
if (elementFillsContainer(this.container, visiblePanel)) {
this.activatePanel(visiblePanel);
return;
}
// TODO: Only one partial panel in viewport, add support for space between panels?
// eslint-disable-next-line no-console
console.error('PanelSnap does not support space between panels, snapping back.');
this.snapToPanel(visiblePanel, deltaY > 0, deltaX > 0);
}
snapToPanel(panel, toBottom = false, toRight = false) {
this.activatePanel(panel);
if (!this.isEnabled) {
return;
}
if (this.animation) {
this.animation.stop();
}
this.targetScrollOffset = getTargetScrollOffset(this.container, panel, toBottom, toRight);
this.animation = new Tweezer({
start: 0,
end: TWEEN_MAX_VALUE,
duration: this.options.duration,
});
this.animation.on('tick', this.animationTick.bind(this));
this.animation.on('done', () => {
this.emit('snapStop', panel);
this.resetAnimation();
});
this.emit('snapStart', panel);
this.animation.begin();
}
animationTick(value) {
const scrollTopDelta = this.targetScrollOffset.top - this.currentScrollOffset.top;
const scrollTop = this.currentScrollOffset.top + (scrollTopDelta * value / TWEEN_MAX_VALUE);
this.scrollContainer.scrollTop = scrollTop;
const scrollLeftDelta = this.targetScrollOffset.left - this.currentScrollOffset.left;
const scrollLeft = this.currentScrollOffset.left + (scrollLeftDelta * value / TWEEN_MAX_VALUE);
this.scrollContainer.scrollLeft = scrollLeft;
}
stopAnimation() {
if (!this.animation) {
return;
}
this.animation.stop();
this.resetAnimation();
}
resetAnimation() {
this.currentScrollOffset = {
top: this.scrollContainer.scrollTop,
left: this.scrollContainer.scrollLeft,
};
this.targetScrollOffset = {
top: 0,
left: 0,
};
this.animation = null;
}
activatePanel(panel) {
if (this.activePanel === panel) {
return;
}
this.emit('activatePanel', panel);
this.activePanel = panel;
}
}