// licensed under the MIT license - http://opensource.org/licenses/MIT // copyright (C) 2017-2018 Andrew Prasetya // version: 2017-05-10 export interface StackUpProperties { boundaryElement?: HTMLElement | Window columnWidth?: number containerSelector: string gutter?: number isFluid?: boolean itemsSelector: string layout?: 'ordinal' | 'optimized' moveItem?: Function numberOfColumns?: number resizeDebounceDelay?: number scaleContainer?: Function } export interface StackUpItem { element: HTMLElement height: number left: number top: number } export interface StackUpLayout { loop: Function plot: Function setup: Function } export class StackUp { public boundaryHeight: number = 0 public boundaryWidth: number = 0 public containerElement: HTMLElement public containerHeight: number = 0 public containerWidth: number = 0 public itemElements: NodeListOf public items: StackUpItem[] public numberOfColumns: number = 0 public config: StackUpProperties // public layoutStack: public setDefaultConfig(): StackUp { this.config = { boundaryElement: window, columnWidth: 320, containerSelector: undefined, gutter: 18, isFluid: false, itemsSelector: undefined, layout: 'ordinal', // ordinal, optimized numberOfColumns: 3, resizeDebounceDelay: 350, moveItem: (item, left, top, callback) => { item.style.left = left + 'px' item.style.top = top + 'px' callback() }, scaleContainer: (container, width, height, callback) => { container.style.height = height + 'px' container.style.width = width + 'px' callback() }, } return this } constructor(properties: StackUpProperties) { this.setDefaultConfig() this.setConfig(properties) } public setConfig(config: StackUpProperties): StackUp { for (let name in config) { this.config[name] = config[name] } return this } public initialize(): StackUp { window.addEventListener('resize', this.resizeHandler.bind(this)) this.boundaryUpdate() // Update grid selectors - reset this.getElements() .populateItems() // Update grid selectors - stacking this.updateNumberOfColumns() .applyLayout() .draw() return this } public boundaryUpdate(): StackUp { if (this.config.boundaryElement !== window) { let style = window.getComputedStyle(this.config.boundaryElement) let horizontalPaddings: number = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) let verticalPaddings: number = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) this.boundaryHeight = (this.config.boundaryElement).offsetHeight - verticalPaddings this.boundaryWidth = (this.config.boundaryElement).offsetWidth - horizontalPaddings } else { this.boundaryHeight = window.innerHeight this.boundaryWidth = window.innerWidth } return this } public resizeDebounceTimeout: number public resizeDebounce(fn: Function, delay: number): void { clearTimeout(this.resizeDebounceTimeout) this.resizeDebounceTimeout = window.setTimeout(fn, delay) } public resizeComplete(): void { if ( this.calculateNumberOfColumns() !== this.numberOfColumns && this.config.isFluid === true ) { this.restack() } } public resizeHandler(): void { this.boundaryUpdate() this.resizeDebounce(this.resizeComplete.bind(this), this.config.resizeDebounceDelay) } // Update grid selectors. (1) - reset // required stack-up.initialize to be called first. public getElements() { this.containerElement = document.querySelector(this.config.containerSelector) this.itemElements = >document.querySelectorAll( `#{this.config.containerSelector} > #{this.config.itemsSelector}` ) return this } // this only updates @items, it does not update the selectors public appendItem(item: HTMLElement): StackUp { item.style.width = "#{@config.columnWidth}px" this.items.push({ element: item, height: item.offsetHeight, left: 0, top: 0 }) return this } // populate grid items (2) - reset public populateItems() { // Clear items before populating. this.items = new Array let i: number = 0 while (i < this.itemElements.length) { this.appendItem(this.itemElements[i]) i++ } return this } public calculateNumberOfColumns(): number { let numberOfColumns: number = 0 if (this.config.isFluid) { let numberOfColumns = Math.floor( (this.boundaryWidth - this.config.gutter) / (this.config.columnWidth + this.config.gutter) ) } else { numberOfColumns = this.config.numberOfColumns } if (numberOfColumns > this.items.length) { numberOfColumns = this.items.length } if (this.items.length && numberOfColumns <= 0) { numberOfColumns = 1 } return numberOfColumns } // Update numberOfColumns (3) - stack public updateNumberOfColumns() { this.numberOfColumns = this.calculateNumberOfColumns() return this } // Scale container and move items (5) - stack public draw(): StackUp { this.containerWidth = (this.config.columnWidth + this.config.gutter) * this.numberOfColumns let height: number = this.containerHeight + this.config.gutter let width: number = this.containerWidth + this.config.gutter this.config.scaleContainer(this.containerElement, width, height, () => { let callback: Function = () => { } let i: number = 0 while (i < this.items.length) { this.config.moveItem( this.items[i].element, this.items[i].left, this.items[i].top, callback ) i++ } }) return this } public layoutStack: { [layout: string]: any[] } public layoutColumnPointer: number = 0 // stack (4) // layout updates the containerHeight and updates items public layout: any = { ordinal: { setup: () => { let i: number = 0 this.layoutStack['ordinal'] = new Array while (i < this.numberOfColumns) { this.layoutStack['ordinal'][i] = 0 } }, plot: (itemIndex: number) => { this.items[itemIndex][2] = this.config.gutter + (this.config.columnWidth + this.config.gutter) * this.layoutColumnPointer this.items[itemIndex][3] = this.config.gutter + this.layoutStack['ordinal'][this.layoutColumnPointer] this.layoutStack['ordinal'][this.layout.columnPointer] += this.items[itemIndex][1] + this.config.gutter if (this.layoutStack['ordinal'][this.layout.columnPointer] > this.containerHeight) { this.containerHeight = this.layoutStack['ordinal'][this.layoutColumnPointer] } this.layoutColumnPointer++ if (this.layoutColumnPointer >= this.numberOfColumns) { this.layoutColumnPointer = 0 } }, loop: () => { let i: number = 0 while (i < this.items.length) { this.layout['ordinal'].plot(i).bind(this) i++ } } }, optimized: { setup: () => { let i: number = 0 while (i < this.numberOfColumns) { this.layoutStack['optimized'][i] = [i, 0] i++ } }, plot: (itemIndex: number) => { this.items[itemIndex][2] = this.config.gutter + (this.config.columnWidth + this.config.gutter) * this.layoutStack['optimized'][0][0] this.items[itemIndex][3] = this.config.gutter + this.layoutStack['optimized'][0][1] this.layoutStack['optimized'][0][1] += this.items[itemIndex][1] + this.config.gutter if (this.layoutStack[0][1] > this.containerHeight) { this.containerHeight = this.layoutStack['optimized'][0][1] } this.layoutStack['optimized'].sort((a, b) => { return a[1] - b[1] }) this.layoutColumnPointer++ if (this.layoutColumnPointer >= this.numberOfColumns) { this.layoutColumnPointer = 0 } }, loop: () => { let i: number = 0 while (i < this.items.length) { this.layout['optimized'].plot(i).bind(this) i++ } } } } public applyLayout(): StackUp { this.layout[this.config.layout].setup().bind(this) if (this.items.length) { this.layout[this.config.layout].loop().bind(this) } return this } public resetLayout(): StackUp { this.containerHeight = 0 this.layoutColumnPointer = 0 return this } // This should be called when any of the item(s) are being modified, added, or removed public reset(): StackUp { this.containerWidth = 0 this.containerHeight = 0 this.items = new Array this.getElements() .populateItems() .resetLayout() .restack() return this } public append(item, callback: Function): StackUp { let itemIndex: number = this.items.length this.appendItem(item) if (this.calculateNumberOfColumns() === this.numberOfColumns) { this.layout[this.config.layout].plot(itemIndex).bind(this) this.draw() } else { this.restack() } return this } public restack(): StackUp { this.updateNumberOfColumns() .resetLayout() .applyLayout() .draw() return this } } this.StackUp = StackUp