UNPKG

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
/* * 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;