gridstack
Version:
TypeScript/JS lib for dashboard layout and creation, responsive, mobile support, no external dependencies, with many wrappers (React, Angular, Vue, Ember, knockout...)
304 lines (273 loc) • 13.2 kB
text/typescript
/**
* gridstack.component.ts 12.0.0
* Copyright (c) 2022-2024 Alain Dumesny - see GridStack root license
*/
import { AfterContentInit, Component, ContentChildren, ElementRef, EventEmitter, Input,
OnDestroy, OnInit, Output, QueryList, Type, ViewChild, ViewContainerRef, reflectComponentType, ComponentRef } from '@angular/core';
import { NgIf } from '@angular/common';
import { Subscription } from 'rxjs';
import { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode, GridStackOptions, GridStackWidget } from 'gridstack';
import { NgGridStackNode, NgGridStackWidget } from './types';
import { BaseWidget } from './base-widget';
import { GridItemCompHTMLElement, GridstackItemComponent } from './gridstack-item.component';
/** events handlers emitters signature for different events */
export type eventCB = {event: Event};
export type elementCB = {event: Event, el: GridItemHTMLElement};
export type nodesCB = {event: Event, nodes: GridStackNode[]};
export type droppedCB = {event: Event, previousNode: GridStackNode, newNode: GridStackNode};
/** store element to Ng Class pointer back */
export interface GridCompHTMLElement extends GridHTMLElement {
_gridComp?: GridstackComponent;
}
/** selector string to runtime Type mapping */
export type SelectorToType = {[key: string]: Type<Object>};
/**
* HTML Component Wrapper for gridstack, in combination with GridstackItemComponent for the items
*/
export class GridstackComponent implements OnInit, AfterContentInit, OnDestroy {
/** track list of TEMPLATE (not recommended) grid items so we can sync between DOM and GS internals */
public gridstackItems?: QueryList<GridstackItemComponent>;
/** container to append items dynamically (recommended way) */
public container?: ViewContainerRef;
/** initial options for creation of the grid */
public set options(o: GridStackOptions) {
if (this._grid) {
this._grid.updateOptions(o);
} else {
this._options = o;
}
}
/** return the current running options */
public get options(): GridStackOptions { return this._grid?.opts || this._options || {}; }
/** true while ng-content with 'no-item-content' should be shown when last item is removed from a grid */
public isEmpty?: boolean;
/** individual list of GridStackEvent callbacks handlers as output
* otherwise use this.grid.on('name1 name2 name3', callback) to handle multiple at once
* see https://github.com/gridstack/gridstack.js/blob/master/demo/events.js#L4
*
* Note: camel casing and 'CB' added at the end to prevent @angular-eslint/no-output-native
* eg: 'change' would trigger the raw CustomEvent so use different name.
*/
public addedCB = new EventEmitter<nodesCB>();
public changeCB = new EventEmitter<nodesCB>();
public disableCB = new EventEmitter<eventCB>();
public dragCB = new EventEmitter<elementCB>();
public dragStartCB = new EventEmitter<elementCB>();
public dragStopCB = new EventEmitter<elementCB>();
public droppedCB = new EventEmitter<droppedCB>();
public enableCB = new EventEmitter<eventCB>();
public removedCB = new EventEmitter<nodesCB>();
public resizeCB = new EventEmitter<elementCB>();
public resizeStartCB = new EventEmitter<elementCB>();
public resizeStopCB = new EventEmitter<elementCB>();
/** return the native element that contains grid specific fields as well */
public get el(): GridCompHTMLElement { return this.elementRef.nativeElement; }
/** return the GridStack class */
public get grid(): GridStack | undefined { return this._grid; }
/** ComponentRef of ourself - used by dynamic object to correctly get removed */
public ref: ComponentRef<GridstackComponent> | undefined;
/**
* stores the selector -> Type mapping, so we can create items dynamically from a string.
* Unfortunately Ng doesn't provide public access to that mapping.
*/
public static selectorToType: SelectorToType = {};
/** add a list of ng Component to be mapped to selector */
public static addComponentToSelectorType(typeList: Array<Type<Object>>) {
typeList.forEach(type => GridstackComponent.selectorToType[ GridstackComponent.getSelector(type) ] = type);
}
/** return the ng Component selector */
public static getSelector(type: Type<Object>): string {
return reflectComponentType(type)!.selector;
}
protected _options?: GridStackOptions;
protected _grid?: GridStack;
protected _sub: Subscription | undefined;
protected loaded?: boolean;
constructor(protected readonly elementRef: ElementRef<GridCompHTMLElement>) {
// set globally our method to create the right widget type
if (!GridStack.addRemoveCB) {
GridStack.addRemoveCB = gsCreateNgComponents;
}
if (!GridStack.saveCB) {
GridStack.saveCB = gsSaveAdditionalNgInfo;
}
this.el._gridComp = this;
}
public ngOnInit(): void {
// init ourself before any template children are created since we track them below anyway - no need to double create+update widgets
this.loaded = !!this.options?.children?.length;
this._grid = GridStack.init(this._options, this.el);
delete this._options; // GS has it now
this.checkEmpty();
}
/** wait until after all DOM is ready to init gridstack children (after angular ngFor and sub-components run first) */
public ngAfterContentInit(): void {
// track whenever the children list changes and update the layout...
this._sub = this.gridstackItems?.changes.subscribe(() => this.updateAll());
// ...and do this once at least unless we loaded children already
if (!this.loaded) this.updateAll();
this.hookEvents(this.grid);
}
public ngOnDestroy(): void {
this.unhookEvents(this._grid);
this._sub?.unsubscribe();
this._grid?.destroy();
delete this._grid;
delete this.el._gridComp;
delete this.container;
delete this.ref;
}
/**
* called when the TEMPLATE (not recommended) list of items changes - get a list of nodes and
* update the layout accordingly (which will take care of adding/removing items changed by Angular)
*/
public updateAll() {
if (!this.grid) return;
const layout: GridStackWidget[] = [];
this.gridstackItems?.forEach(item => {
layout.push(item.options);
item.clearOptions();
});
this.grid.load(layout); // efficient that does diffs only
}
/** check if the grid is empty, if so show alternative content */
public checkEmpty() {
if (!this.grid) return;
this.isEmpty = !this.grid.engine.nodes.length;
}
/** get all known events as easy to use Outputs for convenience */
protected hookEvents(grid?: GridStack) {
if (!grid) return;
grid
.on('added', (event: Event, nodes: GridStackNode[]) => { this.checkEmpty(); this.addedCB.emit({event, nodes}); })
.on('change', (event: Event, nodes: GridStackNode[]) => this.changeCB.emit({event, nodes}))
.on('disable', (event: Event) => this.disableCB.emit({event}))
.on('drag', (event: Event, el: GridItemHTMLElement) => this.dragCB.emit({event, el}))
.on('dragstart', (event: Event, el: GridItemHTMLElement) => this.dragStartCB.emit({event, el}))
.on('dragstop', (event: Event, el: GridItemHTMLElement) => this.dragStopCB.emit({event, el}))
.on('dropped', (event: Event, previousNode: GridStackNode, newNode: GridStackNode) => this.droppedCB.emit({event, previousNode, newNode}))
.on('enable', (event: Event) => this.enableCB.emit({event}))
.on('removed', (event: Event, nodes: GridStackNode[]) => { this.checkEmpty(); this.removedCB.emit({event, nodes}); })
.on('resize', (event: Event, el: GridItemHTMLElement) => this.resizeCB.emit({event, el}))
.on('resizestart', (event: Event, el: GridItemHTMLElement) => this.resizeStartCB.emit({event, el}))
.on('resizestop', (event: Event, el: GridItemHTMLElement) => this.resizeStopCB.emit({event, el}))
}
protected unhookEvents(grid?: GridStack) {
if (!grid) return;
grid.off('added change disable drag dragstart dragstop dropped enable removed resize resizestart resizestop');
}
}
/**
* can be used when a new item needs to be created, which we do as a Angular component, or deleted (skip)
**/
export function gsCreateNgComponents(host: GridCompHTMLElement | HTMLElement, n: NgGridStackNode, add: boolean, isGrid: boolean): HTMLElement | undefined {
if (add) {
//
// create the component dynamically - see https://angular.io/docs/ts/latest/cookbook/dynamic-component-loader.html
//
if (!host) return;
if (isGrid) {
// TODO: figure out how to create ng component inside regular Div. need to access app injectors...
// if (!container) {
// const hostElement: Element = host;
// const environmentInjector: EnvironmentInjector;
// grid = createComponent(GridstackComponent, {environmentInjector, hostElement})?.instance;
// }
const gridItemComp = (host.parentElement as GridItemCompHTMLElement)?._gridItemComp;
if (!gridItemComp) return;
// check if gridItem has a child component with 'container' exposed to create under..
const container = (gridItemComp.childWidget as any)?.container || gridItemComp.container;
const gridRef = container?.createComponent(GridstackComponent);
const grid = gridRef?.instance;
if (!grid) return;
grid.ref = gridRef;
grid.options = n;
return grid.el;
} else {
const gridComp = (host as GridCompHTMLElement)._gridComp;
const gridItemRef = gridComp?.container?.createComponent(GridstackItemComponent);
const gridItem = gridItemRef?.instance;
if (!gridItem) return;
gridItem.ref = gridItemRef
// define what type of component to create as child, OR you can do it GridstackItemComponent template, but this is more generic
const selector = n.selector;
const type = selector ? GridstackComponent.selectorToType[selector] : undefined;
if (type) {
// shared code to create our selector component
const createComp = () => {
const childWidget = gridItem.container?.createComponent(type)?.instance as BaseWidget;
// if proper BaseWidget subclass, save it and load additional data
if (childWidget && typeof childWidget.serialize === 'function' && typeof childWidget.deserialize === 'function') {
gridItem.childWidget = childWidget;
childWidget.deserialize(n);
}
}
const lazyLoad = n.lazyLoad || n.grid?.opts?.lazyLoad && n.lazyLoad !== false;
if (lazyLoad) {
if (!n.visibleObservable) {
n.visibleObservable = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) {
n.visibleObservable?.disconnect();
delete n.visibleObservable;
createComp();
}});
window.setTimeout(() => n.visibleObservable?.observe(gridItem.el)); // wait until callee sets position attributes
}
} else createComp();
}
return gridItem.el;
}
} else {
//
// REMOVE - have to call ComponentRef:destroy() for dynamic objects to correctly remove themselves
// Note: this will destroy all children dynamic components as well: gridItem -> childWidget
//
if (isGrid) {
const grid = (n.el as GridCompHTMLElement)?._gridComp;
if (grid?.ref) grid.ref.destroy();
else grid?.ngOnDestroy();
} else {
const gridItem = (n.el as GridItemCompHTMLElement)?._gridItemComp;
if (gridItem?.ref) gridItem.ref.destroy();
else gridItem?.ngOnDestroy();
}
}
return;
}
/**
* called for each item in the grid - check if additional information needs to be saved.
* Note: since this is options minus gridstack protected members using Utils.removeInternalForSave(),
* this typically doesn't need to do anything. However your custom Component @Input() are now supported
* using BaseWidget.serialize()
*/
export function gsSaveAdditionalNgInfo(n: NgGridStackNode, w: NgGridStackWidget) {
const gridItem = (n.el as GridItemCompHTMLElement)?._gridItemComp;
if (gridItem) {
const input = gridItem.childWidget?.serialize();
if (input) {
w.input = input;
}
return;
}
// else check if Grid
const grid = (n.el as GridCompHTMLElement)?._gridComp;
if (grid) {
//.... save any custom data
}
}