preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
729 lines (594 loc) • 20.6 kB
text/typescript
/*
* HSLayoutSplitter
* @version: 3.0.1
* @author: Preline Labs Ltd.
* @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html)
* Copyright 2024 Preline Labs Ltd.
*/
import { isJson, classToClassList, htmlToElement, dispatch } from '../../utils';
import {
ILayoutSplitterOptions,
ILayoutSplitter,
ISingleLayoutSplitter,
IControlLayoutSplitter,
} from './interfaces';
import HSBasePlugin from '../base-plugin';
import { ICollectionItem } from '../../interfaces';
class HSLayoutSplitter
extends HSBasePlugin<ILayoutSplitterOptions>
implements ILayoutSplitter {
static isListenersInitialized = false;
private readonly horizontalSplitterClasses: string | null;
private readonly horizontalSplitterTemplate: string;
private readonly verticalSplitterClasses: string | null;
private readonly verticalSplitterTemplate: string;
private readonly isSplittersAddedManually: boolean;
private horizontalSplitters: ISingleLayoutSplitter[];
private horizontalControls: IControlLayoutSplitter[];
private verticalSplitters: ISingleLayoutSplitter[];
private verticalControls: IControlLayoutSplitter[];
isDragging: boolean;
activeSplitter: IControlLayoutSplitter | null;
private onControlPointerDownListener:
| {
el: HTMLElement;
fn: () => void;
}[]
| null;
constructor(el: HTMLElement, options?: ILayoutSplitterOptions) {
super(el, options);
const data = el.getAttribute('data-hs-layout-splitter');
const dataOptions: ILayoutSplitterOptions = data ? JSON.parse(data) : {};
const concatOptions = {
...dataOptions,
...options,
};
this.horizontalSplitterClasses =
concatOptions?.horizontalSplitterClasses || null;
this.horizontalSplitterTemplate =
concatOptions?.horizontalSplitterTemplate || '<div></div>';
this.verticalSplitterClasses =
concatOptions?.verticalSplitterClasses || null;
this.verticalSplitterTemplate =
concatOptions?.verticalSplitterTemplate || '<div></div>';
this.isSplittersAddedManually = concatOptions?.isSplittersAddedManually ?? false;
this.horizontalSplitters = [];
this.horizontalControls = [];
this.verticalSplitters = [];
this.verticalControls = [];
this.isDragging = false;
this.activeSplitter = null;
this.onControlPointerDownListener = [];
this.init();
}
private controlPointerDown(item: IControlLayoutSplitter) {
this.isDragging = true;
this.activeSplitter = item;
this.onPointerDownHandler(item);
}
private controlPointerUp() {
this.isDragging = false;
this.activeSplitter = null;
this.onPointerUpHandler();
}
private static onDocumentPointerMove = (evt: PointerEvent) => {
const draggingElement = document.querySelector(
'.hs-layout-splitter-control.dragging',
);
if (!draggingElement) return;
const draggingInstance = HSLayoutSplitter.getInstance(
draggingElement.closest('[data-hs-layout-splitter]') as HTMLElement,
true,
) as ICollectionItem<HSLayoutSplitter>;
if (!draggingInstance || !draggingInstance.element.isDragging) return;
const activeSplitter = draggingInstance.element.activeSplitter;
if (!activeSplitter) return;
if (activeSplitter.direction === 'vertical')
draggingInstance.element.onPointerMoveHandler(
evt,
activeSplitter,
'vertical'
);
else
draggingInstance.element.onPointerMoveHandler(
evt,
activeSplitter,
'horizontal'
);
};
private static onDocumentPointerUp = () => {
const draggingElement = document.querySelector(
'.hs-layout-splitter-control.dragging',
);
if (!draggingElement) return;
const draggingInstance = HSLayoutSplitter.getInstance(
draggingElement.closest('[data-hs-layout-splitter]') as HTMLElement,
true,
) as ICollectionItem<HSLayoutSplitter>;
if (draggingInstance) draggingInstance.element.controlPointerUp();
};
private init() {
this.createCollection(window.$hsLayoutSplitterCollection, this);
this.buildSplitters();
if (!HSLayoutSplitter.isListenersInitialized) {
document.addEventListener(
'pointermove',
HSLayoutSplitter.onDocumentPointerMove,
);
document.addEventListener(
'pointerup',
HSLayoutSplitter.onDocumentPointerUp,
);
HSLayoutSplitter.isListenersInitialized = true;
}
}
private buildSplitters() {
this.buildHorizontalSplitters();
this.buildVerticalSplitters();
}
private buildHorizontalSplitters() {
const groups = this.el.querySelectorAll(
'[data-hs-layout-splitter-horizontal-group]',
);
if (groups.length) {
groups.forEach((el: HTMLElement) => {
this.horizontalSplitters.push({
el,
items: Array.from(
el.querySelectorAll(':scope > [data-hs-layout-splitter-item]'),
),
});
});
this.updateHorizontalSplitter();
}
}
private buildVerticalSplitters() {
const groups = this.el.querySelectorAll(
'[data-hs-layout-splitter-vertical-group]',
);
if (groups.length) {
groups.forEach((el: HTMLElement) => {
this.verticalSplitters.push({
el,
items: Array.from(
el.querySelectorAll(':scope > [data-hs-layout-splitter-item]'),
),
});
});
this.updateVerticalSplitter();
}
}
private buildControl(
prev: HTMLElement | null,
next: HTMLElement | null,
direction: 'horizontal' | 'vertical' = 'horizontal',
) {
let el;
if (this.isSplittersAddedManually) {
el = next?.previousElementSibling as HTMLElement;
if (!el) return false;
el.style.display = '';
} else {
el = htmlToElement(direction === 'horizontal' ? this.horizontalSplitterTemplate : this.verticalSplitterTemplate) as HTMLElement;
classToClassList(direction === 'horizontal' ? this.horizontalSplitterClasses : this.verticalSplitterClasses, el);
el.classList.add('hs-layout-splitter-control');
}
const item = { el, direction, prev, next };
if (direction === 'horizontal') this.horizontalControls.push(item);
else this.verticalControls.push(item);
this.bindListeners(item);
if (next && !this.isSplittersAddedManually) prev.insertAdjacentElement('afterend', el);
}
private getSplitterItemParsedParam(item: HTMLElement) {
const param = item.getAttribute('data-hs-layout-splitter-item');
return isJson(param) ? JSON.parse(param) : param;
}
private getContainerSize(container: Element, isHorizontal: boolean): number {
return isHorizontal ? container.getBoundingClientRect().width : container.getBoundingClientRect().height;
}
private getMaxFlexSize(element: HTMLElement, param: string, totalWidth: number): number {
const paramValue = this.getSplitterItemSingleParam(element, param);
return typeof paramValue === 'number' ? (paramValue / 100) * totalWidth : 0;
}
private updateHorizontalSplitter() {
this.horizontalSplitters.forEach(({ items }) => {
items.forEach((el: HTMLElement) => {
this.updateSingleSplitter(el);
});
items.forEach((el: HTMLElement, index: number) => {
if (index >= items.length - 1) this.buildControl(el, null);
else this.buildControl(el, items[index + 1]);
});
});
}
private updateSingleSplitter(el: HTMLElement) {
const param = el.getAttribute('data-hs-layout-splitter-item');
const parsedParam = isJson(param) ? JSON.parse(param) : param;
const width = isJson(param) ? parsedParam.dynamicSize : param;
el.style.flex = `${width} 1 0`;
}
private updateVerticalSplitter() {
this.verticalSplitters.forEach(({ items }) => {
items.forEach((el: HTMLElement) => {
this.updateSingleSplitter(el);
});
items.forEach((el: HTMLElement, index: number) => {
if (index >= items.length - 1) this.buildControl(el, null, 'vertical');
else this.buildControl(el, items[index + 1], 'vertical');
});
});
}
private updateSplitterItemParam(item: HTMLElement, newSize: number) {
const param = this.getSplitterItemParsedParam(item);
const newSizeFixed = newSize.toFixed(1);
const newParam = typeof param === 'object' ? JSON.stringify({
...param,
dynamicSize: +newSizeFixed,
}) : newSizeFixed;
item.setAttribute(
'data-hs-layout-splitter-item',
newParam,
);
}
private onPointerDownHandler(item: IControlLayoutSplitter) {
const { el, prev, next } = item;
el.classList.add('dragging');
prev.classList.add('dragging');
next.classList.add('dragging');
document.body.style.userSelect = 'none';
}
private onPointerUpHandler() {
document.body.style.userSelect = '';
}
private onPointerMoveHandler(
evt: PointerEvent,
item: IControlLayoutSplitter,
direction: 'horizontal' | 'vertical'
) {
const { prev, next } = item;
const container = item.el.closest(
direction === 'horizontal'
? '[data-hs-layout-splitter-horizontal-group]'
: '[data-hs-layout-splitter-vertical-group]'
);
const isHorizontal = direction === 'horizontal';
const totalSize = this.getContainerSize(container, isHorizontal);
const availableSize = this.calculateAvailableSize(container, prev, next, isHorizontal, totalSize);
const sizes = this.calculateResizedSizes(evt, prev, availableSize, isHorizontal);
const adjustedSizes = this.enforceLimits(sizes, prev, next, totalSize, availableSize);
this.applySizes(prev, next, adjustedSizes, totalSize);
}
private bindListeners(item: IControlLayoutSplitter) {
const { el } = item;
this.onControlPointerDownListener.push({
el,
fn: () => this.controlPointerDown(item),
});
el.addEventListener(
'pointerdown',
this.onControlPointerDownListener.find((control) => control.el === el).fn,
);
}
private calculateAvailableSize(
container: Element,
prev: HTMLElement,
next: HTMLElement,
isHorizontal: boolean,
totalSize: number
): number {
const items = container.querySelectorAll(':scope > [data-hs-layout-splitter-item]');
const otherSize = Array.from(items).reduce((sum, item) => {
if (item === prev || item === next) return sum;
const rect = item.getBoundingClientRect();
// TODO:: Test
const computedStyle = window.getComputedStyle(item);
return sum + (computedStyle.position === 'fixed' ? 0 : (isHorizontal ? rect.width : rect.height));
}, 0);
return totalSize - otherSize;
}
private calculateResizedSizes(
evt: PointerEvent,
prev: HTMLElement,
availableSize: number,
isHorizontal: boolean
): {
previousSize: number;
nextSize: number
} {
const prevStart = isHorizontal ? prev.getBoundingClientRect().left : prev.getBoundingClientRect().top;
let previousSize = Math.max(0, Math.min((isHorizontal ? evt.clientX : evt.clientY) - prevStart, availableSize));
let nextSize = availableSize - previousSize;
return { previousSize, nextSize };
}
private enforceLimits(
sizes: {
previousSize: number;
nextSize: number
},
prev: HTMLElement,
next: HTMLElement,
totalSize: number,
availableSize: number
): {
previousSize: number;
nextSize: number;
} {
const prevMinSize = this.getMaxFlexSize(prev, 'minSize', totalSize);
const nextMinSize = this.getMaxFlexSize(next, 'minSize', totalSize);
const prevPreLimitSize = this.getMaxFlexSize(prev, 'preLimitSize', totalSize);
const nextPreLimitSize = this.getMaxFlexSize(next, 'preLimitSize', totalSize);
let { previousSize, nextSize } = sizes;
if (nextSize < nextMinSize) {
nextSize = nextMinSize;
previousSize = availableSize - nextSize;
} else if (previousSize < prevMinSize) {
previousSize = prevMinSize;
nextSize = availableSize - previousSize;
}
const payload = {
prev,
next,
previousSize: previousSize.toFixed(),
previousFlexSize: (previousSize / totalSize) * 100,
previousPreLimitSize: prevPreLimitSize,
previousPreLimitFlexSize: (prevPreLimitSize / totalSize) * 100,
previousMinSize: prevMinSize,
previousMinFlexSize: (prevMinSize / totalSize) * 100,
nextSize: nextSize.toFixed(),
nextFlexSize: (nextSize / totalSize) * 100,
nextPreLimitSize: nextPreLimitSize,
nextPreLimitFlexSize: (nextPreLimitSize / totalSize) * 100,
nextMinSize: nextMinSize,
nextMinFlexSize: (nextMinSize / totalSize) * 100,
static: {
prev: {
minSize: this.getSplitterItemSingleParam(prev, 'minSize'),
preLimitSize: this.getSplitterItemSingleParam(prev, 'preLimitSize')
},
next: {
minSize: this.getSplitterItemSingleParam(next, 'minSize'),
preLimitSize: this.getSplitterItemSingleParam(next, 'preLimitSize')
}
}
};
if (nextSize < nextMinSize) {
this.fireEvent('onNextLimit', payload);
dispatch('onNextLimit.hs.layoutSplitter', this.el, payload);
} else if (previousSize < prevMinSize) {
this.fireEvent('onPrevLimit', payload);
dispatch('onPrevLimit.hs.layoutSplitter', this.el, payload);
}
if (previousSize <= prevPreLimitSize) {
this.fireEvent('onPrevPreLimit', payload);
dispatch('onPrevPreLimit.hs.layoutSplitter', this.el, payload);
}
if (nextSize <= nextPreLimitSize) {
this.fireEvent('onNextPreLimit', payload);
dispatch('onNextPreLimit.hs.layoutSplitter', this.el, payload);
}
this.fireEvent('drag', payload);
dispatch('drag.hs.layoutSplitter', this.el, payload);
return { previousSize, nextSize };
}
private applySizes(
prev: HTMLElement,
next: HTMLElement,
sizes: {
previousSize: number;
nextSize: number
},
totalSize: number
) {
const { previousSize, nextSize } = sizes;
const prevPercent = (previousSize / totalSize) * 100;
this.updateSplitterItemParam(prev, prevPercent);
prev.style.flex = `${prevPercent.toFixed(1)} 1 0`;
const nextPercent = (nextSize / totalSize) * 100;
this.updateSplitterItemParam(next, nextPercent);
next.style.flex = `${nextPercent.toFixed(1)} 1 0`;
}
// Public methods
public getSplitterItemSingleParam(item: HTMLElement, name: string) {
try {
const param = this.getSplitterItemParsedParam(item);
return param[name];
} catch {
console.log('There is no parameter with this name in the object.');
return false;
}
}
public getData(el: HTMLElement): any {
const container = el.closest('[data-hs-layout-splitter-horizontal-group], [data-hs-layout-splitter-vertical-group]');
if (!container) {
throw new Error('Element is not inside a valid layout splitter container.');
}
const isHorizontal = container.matches('[data-hs-layout-splitter-horizontal-group]');
const totalSize = this.getContainerSize(container, isHorizontal);
const dynamicFlexSize = this.getSplitterItemSingleParam(el, 'dynamicSize') || 0;
const minSize = this.getMaxFlexSize(el, 'minSize', totalSize);
const preLimitSize = this.getMaxFlexSize(el, 'preLimitSize', totalSize);
const dynamicSize = (dynamicFlexSize / 100) * totalSize;
const minFlexSize = (minSize / totalSize) * 100;
const preLimitFlexSize = (preLimitSize / totalSize) * 100;
return {
el,
dynamicSize: +dynamicSize.toFixed(),
dynamicFlexSize,
minSize: +minSize.toFixed(),
minFlexSize,
preLimitSize: +preLimitSize.toFixed(),
preLimitFlexSize,
static: {
minSize: this.getSplitterItemSingleParam(el, 'minSize') ?? null,
preLimitSize: this.getSplitterItemSingleParam(el, 'preLimitSize') ?? null
}
};
}
public setSplitterItemSize(el: HTMLElement, size: number) {
this.updateSplitterItemParam(el, size);
el.style.flex = `${size.toFixed(1)} 1 0`;
}
public updateFlexValues(data: Array<{
id: string;
breakpoints: Record<number, number>;
}>): void {
let totalFlex = 0;
const currentWidth = window.innerWidth;
const getBreakpointValue = (breakpoints: Record<number, number>): number => {
const sortedBreakpoints = Object.keys(breakpoints)
.map(Number)
.sort((a, b) => a - b);
for (let i = sortedBreakpoints.length - 1; i >= 0; i--) {
if (currentWidth >= sortedBreakpoints[i]) {
return breakpoints[sortedBreakpoints[i]];
}
}
return 0;
};
data.forEach(({ id, breakpoints }) => {
const item = document.getElementById(id);
if (item) {
const flexValue = getBreakpointValue(breakpoints);
this.updateSplitterItemParam(item, flexValue);
item.style.flex = `${flexValue.toFixed(1)} 1 0`;
totalFlex += flexValue;
}
});
if (totalFlex !== 100) {
const scaleFactor = 100 / totalFlex;
data.forEach(({ id }) => {
const item = document.getElementById(id);
if (item) {
const currentFlex = parseFloat(item.style.flex.split(" ")[0]);
const adjustedFlex = currentFlex * scaleFactor;
this.updateSplitterItemParam(item, adjustedFlex);
item.style.flex = `${adjustedFlex.toFixed(1)} 1 0`;
}
});
}
}
public destroy() {
if (this.onControlPointerDownListener) {
this.onControlPointerDownListener.forEach(({ el, fn }) => {
el.removeEventListener('pointerdown', fn);
});
this.onControlPointerDownListener = null;
}
this.horizontalSplitters.forEach(({ items }) => {
items.forEach((el: HTMLElement) => {
el.style.flex = '';
});
});
this.verticalSplitters.forEach(({ items }) => {
items.forEach((el: HTMLElement) => {
el.style.flex = '';
});
});
this.horizontalControls.forEach(({ el }) => {
if (this.isSplittersAddedManually) el.style.display = 'none';
else el.remove();
});
this.verticalControls.forEach(({ el }) => {
if (this.isSplittersAddedManually) el.style.display = 'none';
else el.remove();
});
this.horizontalControls = [];
this.verticalControls = [];
window.$hsLayoutSplitterCollection =
window.$hsLayoutSplitterCollection.filter(
({ element }) => element.el !== this.el,
);
if (
window.$hsLayoutSplitterCollection.length === 0 &&
HSLayoutSplitter.isListenersInitialized
) {
document.removeEventListener(
'pointermove',
HSLayoutSplitter.onDocumentPointerMove,
);
document.removeEventListener(
'pointerup',
HSLayoutSplitter.onDocumentPointerUp,
);
HSLayoutSplitter.isListenersInitialized = false;
}
}
// Static method
private static findInCollection(target: HSLayoutSplitter | HTMLElement | string): ICollectionItem<HSLayoutSplitter> | null {
return window.$hsLayoutSplitterCollection.find((el) => {
if (target instanceof HSLayoutSplitter) return el.element.el === target.el;
else if (typeof target === 'string') return el.element.el === document.querySelector(target);
else return el.element.el === target;
}) || null;
}
static autoInit() {
if (!window.$hsLayoutSplitterCollection) {
window.$hsLayoutSplitterCollection = [];
window.addEventListener('pointerup', () => {
if (!window.$hsLayoutSplitterCollection) return false;
const draggingElement = document.querySelector(
'.hs-layout-splitter-control.dragging',
);
const draggingSections = document.querySelectorAll(
'[data-hs-layout-splitter-item].dragging',
);
if (!draggingElement) return false;
const draggingInstance = HSLayoutSplitter.getInstance(
draggingElement.closest('[data-hs-layout-splitter]') as HTMLElement,
true,
) as ICollectionItem<HSLayoutSplitter>;
draggingElement.classList.remove('dragging');
draggingSections.forEach((el) => el.classList.remove('dragging'));
draggingInstance.element.isDragging = false;
});
}
if (window.$hsLayoutSplitterCollection)
window.$hsLayoutSplitterCollection =
window.$hsLayoutSplitterCollection.filter(({ element }) =>
document.contains(element.el),
);
document
.querySelectorAll(
'[data-hs-layout-splitter]:not(.--prevent-on-load-init)',
)
.forEach((el: HTMLElement) => {
if (
!window.$hsLayoutSplitterCollection.find(
(elC) => (elC?.element?.el as HTMLElement) === el,
)
)
new HSLayoutSplitter(el);
});
}
static getInstance(target: HTMLElement | string, isInstance?: boolean) {
const elInCollection = window.$hsLayoutSplitterCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string' ? document.querySelector(target) : target),
);
return elInCollection
? isInstance
? elInCollection
: elInCollection.element.el
: null;
}
static on(evt: string, target: HSLayoutSplitter | HTMLElement | string, cb: Function) {
const instance = HSLayoutSplitter.findInCollection(target);
if (instance) instance.element.events[evt] = cb;
}
}
declare global {
interface Window {
HSLayoutSplitter: Function;
$hsLayoutSplitterCollection: ICollectionItem<HSLayoutSplitter>[];
}
}
window.addEventListener('load', () => {
HSLayoutSplitter.autoInit();
// Uncomment for debug
// console.log('Layout splitter collection:', window.$hsLayoutSplitterCollection);
});
if (typeof window !== 'undefined') {
window.HSLayoutSplitter = HSLayoutSplitter;
}
export default HSLayoutSplitter;