diff --git a/README.md b/README.md index 2365bd7..6bdf964 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,101 @@ Here is listed the basic API of both KtdGridComponent and KtdGridItemComponent. startDragManually(startEvent: MouseEvent | TouchEvent); ``` +#### KtdGridComponent +```ts +/** Type of compaction that will be applied to the layout (vertical, horizontal or free). Defaults to 'vertical' */ +@Input() compactType: KtdGridCompactType = 'vertical'; + +/** + * Row height as number or as 'fit'. + * If rowHeight is a number value, it means that each row would have those css pixels in height. + * if rowHeight is 'fit', it means that rows will fit in the height available. If 'fit' value is set, a 'height' should be also provided. + */ +@Input() rowHeight: number | 'fit' = 100; + +/** Number of columns */ +@Input() cols: number = 6; + +/** Layout of the grid. Array of all the grid items with its 'id' and position on the grid. */ +@Input() layout: KtdGridLayout; + +/** Grid gap in css pixels */ +@Input() gap: number = 0; + +/** + * If height is a number, fixes the height of the grid to it, recommended when rowHeight = 'fit' is used. + * If height is null, height will be automatically set according to its inner grid items. + * Defaults to null. + * */ +@Input() height: number | null = null; + + +/** + * Parent element that contains the scroll. If an string is provided it would search that element by id on the dom. + * If no data provided or null autoscroll is not performed. + */ +@Input() scrollableParent: HTMLElement | Document | string | null = null; + +/** Number of CSS pixels that would be scrolled on each 'tick' when auto scroll is performed. */ +@Input() scrollSpeed: number = 2; + +/** Whether or not to update the internal layout when some dependent property change. */ +@Input() compactOnPropsChange = true; +/** If true, grid items won't change position when being dragged over. Handy when using no compaction */ +@Input() preventCollision = false; + +/** Emits when layout change */ +@Output() layoutUpdated: EventEmitter = new EventEmitter(); + +/** Emits when drag starts */ +@Output() dragStarted: EventEmitter = new EventEmitter(); + +/** Emits when resize starts */ +@Output() resizeStarted: EventEmitter = new EventEmitter(); + +/** Emits when drag ends */ +@Output() dragEnded: EventEmitter = new EventEmitter(); + +/** Emits when resize ends */ +@Output() resizeEnded: EventEmitter = new EventEmitter(); + +/** Emits when a grid item is being resized and its bounds have changed */ +@Output() gridItemResize: EventEmitter = new EventEmitter(); + +``` + +#### KtdDrag +```ts + +/** Id of the ktd drag item. This property is strictly compulsory. */ +@Input() id: string; + +/** Whether the item is disabled or not. Defaults to false. */ +@Input() disabled: boolean = false; + +/** Minimum amount of pixels that the user should move before it starts the drag sequence. */ +@Input() dragStartThreshold: number = 0; + +/** Whether the item is draggable or not. Defaults to true. Does not affect manual dragging using the startDragManually method. */ +@Input() draggable: boolean = true; + +/** Width of draggable item in number of cols. Defaults to the width of grid. */ +@Input() width: number; + +/** Height of draggable item in number of rows. Defaults to 1. */ +@Input() height: number = 1; + +/** Event emitted when the user starts dragging the item. */ +@Output() dragStart: Observable; + +/** Event emitted when the user is dragging the item. !!! Emitted for every pixel. !!! */ +@Output() dragMove: Observable; + +/** Event emitted when the user stops dragging the item. */ +@Output() dragEnd: Observable; + +``` ## TODO features - [x] Add delete feature to Playground page. diff --git a/projects/angular-grid-layout/src/lib/directives/ktd-drag.ts b/projects/angular-grid-layout/src/lib/directives/ktd-drag.ts new file mode 100644 index 0000000..37e2ee0 --- /dev/null +++ b/projects/angular-grid-layout/src/lib/directives/ktd-drag.ts @@ -0,0 +1,208 @@ +import { + AfterContentInit, ContentChild, ContentChildren, + Directive, ElementRef, + InjectionToken, Input, OnDestroy, Output, QueryList +} from '@angular/core'; +import {coerceBooleanProperty} from "../coercion/boolean-property"; +import {Observable, Observer, Subscription} from "rxjs"; +import {coerceNumberProperty} from "../coercion/number-property"; +import {KtdRegistryService} from "../ktd-registry.service"; +import {KTD_GRID_DRAG_HANDLE, KtdGridDragHandle} from "./drag-handle"; +import {DragRef} from "../utils/drag-ref"; +import {KTD_GRID_ITEM_PLACEHOLDER, KtdGridItemPlaceholder} from "./placeholder"; +import {KtdGridComponent, PointingDeviceEvent} from "../grid.component"; +import {KtdGridService} from "../grid.service"; + + +export const KTD_DRAG = new InjectionToken>('KtdDrag'); + +@Directive({ + selector: '[ktdDrag]', + host: { + '[class.ktd-draggable]': '_dragHandles.length === 0 && draggable', + '[class.ktd-dragging]': '_dragRef.isDragging', + '[class.ktd-drag-disabled]': 'disabled', + }, + providers: [{provide: KTD_DRAG, useExisting: KtdDrag}] +}) +export class KtdDrag implements AfterContentInit, OnDestroy { + /** Elements that can be used to drag the draggable item. */ + @ContentChildren(KTD_GRID_DRAG_HANDLE, {descendants: true}) _dragHandles: QueryList; + + /** Template ref for placeholder */ + @ContentChild(KTD_GRID_ITEM_PLACEHOLDER) placeholder: KtdGridItemPlaceholder; + + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this._dragRef.draggable = !this._disabled; + } + private _disabled: boolean = false; + + /** Minimum amount of pixels that the user should move before it starts the drag sequence. */ + @Input() + get dragStartThreshold(): number { + return this._dragRef.dragStartThreshold; + } + set dragStartThreshold(val: number) { + this._dragRef.dragStartThreshold = coerceNumberProperty(val); + } + + /** Number of CSS pixels that would be scrolled on each 'tick' when auto scroll is performed. */ + @Input() + get scrollSpeed(): number { return this._dragRef.scrollSpeed; } + set scrollSpeed(value: number) { + this._dragRef.scrollSpeed = coerceNumberProperty(value, 2); + } + + /** + * Parent element that contains the scroll. If an string is provided it would search that element by id on the dom. + * If no data provided or null autoscroll is not performed. + */ + @Input() + get scrollableParent(): HTMLElement | Document | string | null { return this._dragRef.scrollableParent; } + set scrollableParent(value: HTMLElement | Document | string | null) { + this._dragRef.scrollableParent = value; + } + + /** Whether the item is draggable or not. Defaults to true. Does not affect manual dragging using the startDragManually method. */ + @Input() + get draggable(): boolean { + return this._dragRef.draggable; + } + set draggable(val: boolean) { + this._dragRef.draggable = coerceBooleanProperty(val); + } + + /** + * List of ids of grids or grid components that the item is connected to. + */ + @Input() + get connectedTo(): KtdGridComponent[] { + return this._connectedTo; + } + set connectedTo(val: (string|KtdGridComponent|any)[]) { + this._connectedTo = val.map((item: string|KtdGridComponent|any) => { + if (typeof item === 'string') { + const grid = this.registryService._ktgGrids.find(grid => grid.id === item); + if (grid === undefined) { + throw new Error(`KtdDrag connectedTo: could not find grid with id ${item}`); + } + return grid; + } + if (item instanceof KtdGridComponent) { + return item; + } + throw new Error(`KtdDrag connectedTo: connectedTo must be an array of KtdGridComponent or string`); + }); + this.registryService.updateConnectedTo(this._dragRef, this._connectedTo); + } + private _connectedTo: KtdGridComponent[] = []; + + @Input() + get id(): string { + return this._dragRef.id; + } + set id(val: string) { + this._dragRef.id = val; + } + + /** + * Width of the draggable item, in cols. Minimum value is 1. Maximum value is how many cols the grid has. + */ + @Input() + get width(): number { + return this._dragRef.width; + } + set width(val: number) { + const width = coerceNumberProperty(val); + this._dragRef.width = width <= 0 ? 1 : width; + } + + /** + * Height of the draggable item, in cols. Minimum value is 1. Maximum value is how many rows the grid has. + */ + @Input() + get height(): number { + return this._dragRef.height; + } + set height(val: number) { + const height = coerceNumberProperty(val); + this._dragRef.height = height <= 0 ? 1 : height; + } + + @Input('ktdDragData') + set data(val: T) { + this._dragRef.data = val; + } + + @Output('dragStart') + readonly dragStart: Observable<{source: DragRef, event: PointingDeviceEvent}> = new Observable( + (observer: Observer<{source: DragRef, event: PointingDeviceEvent}>) => { + const subscription = this._dragRef.dragStart$ + .subscribe(observer); + + return () => { + subscription.unsubscribe(); + }; + }, + ); + + @Output('dragMove') + readonly dragMove: Observable<{source: DragRef, event: PointingDeviceEvent}> = new Observable( + (observer: Observer<{source: DragRef, event: PointingDeviceEvent}>) => { + const subscription = this._dragRef.dragMove$ + .subscribe(observer); + + return () => { + subscription.unsubscribe(); + }; + }, + ); + + @Output('dragEnd') + readonly dragEnd: Observable<{source: DragRef, event: PointingDeviceEvent}> = new Observable( + (observer: Observer<{source: DragRef, event: PointingDeviceEvent}>) => { + const subscription = this._dragRef.dragEnd$ + .subscribe(observer); + + return () => { + subscription.unsubscribe(); + }; + }, + ); + + public _dragRef: DragRef; + + private subscriptions: Subscription[] = []; + + constructor( + /** Element that the draggable is attached to. */ + public elementRef: ElementRef, + private gridService: KtdGridService, + private registryService: KtdRegistryService, + ) { + this._dragRef = this.registryService.createKtgDrag(this.elementRef, this.gridService, this); + } + + ngAfterContentInit(): void { + this.registryService.registerKtgDragItem(this); + this.subscriptions.push( + this._dragHandles.changes.subscribe(() => { + this._dragRef.dragHandles = this._dragHandles.toArray(); + }), + this.dragStart.subscribe(({event}) => { + this.gridService.startDrag(event, this._dragRef, 'drag'); + }), + ); + } + + ngOnDestroy(): void { + this.registryService.unregisterKtgDragItem(this); + this.registryService.destroyKtgDrag(this._dragRef); + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + } +} diff --git a/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.html b/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.html index 000ac74..7f03dcc 100644 --- a/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.html +++ b/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.html @@ -1,2 +1,2 @@ -
\ No newline at end of file +
diff --git a/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.scss b/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.scss index 6cc1fd4..6685614 100644 --- a/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.scss +++ b/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.scss @@ -4,6 +4,7 @@ position: absolute; z-index: 1; overflow: hidden; + touch-action: none; div { position: absolute; diff --git a/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.ts b/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.ts index 6b60929..94a4cb3 100644 --- a/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.ts +++ b/projects/angular-grid-layout/src/lib/grid-item/grid-item.component.ts @@ -1,18 +1,31 @@ import { - AfterContentInit, ChangeDetectionStrategy, Component, ContentChild, ContentChildren, ElementRef, Inject, Input, NgZone, OnDestroy, OnInit, - QueryList, Renderer2, ViewChild + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChild, + ContentChildren, + ElementRef, + Inject, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + Renderer2, + ViewChild } from '@angular/core'; -import { BehaviorSubject, iif, merge, NEVER, Observable, Subject, Subscription } from 'rxjs'; -import { exhaustMap, filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators'; -import { ktdMouseOrTouchDown, ktdMouseOrTouchEnd, ktdPointerClient } from '../utils/pointer.utils'; +import { BehaviorSubject, merge, NEVER, Observable, Observer, Subject, Subscription} from 'rxjs'; +import { startWith, switchMap} from 'rxjs/operators'; +import { ktdPointerDown} from '../utils/pointer.utils'; import { GRID_ITEM_GET_RENDER_DATA_TOKEN, KtdGridItemRenderDataTokenType } from '../grid.definitions'; import { KTD_GRID_DRAG_HANDLE, KtdGridDragHandle } from '../directives/drag-handle'; import { KTD_GRID_RESIZE_HANDLE, KtdGridResizeHandle } from '../directives/resize-handle'; -import { KtdGridService } from '../grid.service'; -import { ktdOutsideZone } from '../utils/operators'; import { BooleanInput, coerceBooleanProperty } from '../coercion/boolean-property'; import { coerceNumberProperty, NumberInput } from '../coercion/number-property'; import { KTD_GRID_ITEM_PLACEHOLDER, KtdGridItemPlaceholder } from '../directives/placeholder'; +import {DragRef} from "../utils/drag-ref"; +import {KtdRegistryService} from "../ktd-registry.service"; +import {KtdGridService} from "../grid.service"; @Component({ selector: 'ktd-grid-item', @@ -20,7 +33,7 @@ import { KTD_GRID_ITEM_PLACEHOLDER, KtdGridItemPlaceholder } from '../directives styleUrls: ['./grid-item.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class KtdGridItemComponent implements OnInit, OnDestroy, AfterContentInit { +export class KtdGridItemComponent implements OnInit, OnDestroy, AfterContentInit { /** Elements that can be used to drag the grid item. */ @ContentChildren(KTD_GRID_DRAG_HANDLE, {descendants: true}) _dragHandles: QueryList; @ContentChildren(KTD_GRID_RESIZE_HANDLE, {descendants: true}) _resizeHandles: QueryList; @@ -38,74 +51,98 @@ export class KtdGridItemComponent implements OnInit, OnDestroy, AfterContentInit /** CSS transition style. Note that for more performance is preferable only make transition on transform property. */ @Input() transition: string = 'transform 500ms ease, width 500ms ease, height 500ms ease'; - dragStart$: Observable; resizeStart$: Observable; /** Id of the grid item. This property is strictly compulsory. */ @Input() get id(): string { - return this._id; + return this._dragRef.id; } - set id(val: string) { - this._id = val; + this._dragRef.id = val; } - private _id: string; - /** Minimum amount of pixels that the user should move before it starts the drag sequence. */ @Input() - get dragStartThreshold(): number { return this._dragStartThreshold; } - + get dragStartThreshold(): number { return this._dragRef.dragStartThreshold; } set dragStartThreshold(val: number) { - this._dragStartThreshold = coerceNumberProperty(val); + this._dragRef.dragStartThreshold = coerceNumberProperty(val); } - private _dragStartThreshold: number = 0; - - /** Whether the item is draggable or not. Defaults to true. Does not affect manual dragging using the startDragManually method. */ @Input() get draggable(): boolean { - return this._draggable; + return this._dragRef.draggable; } - set draggable(val: boolean) { - this._draggable = coerceBooleanProperty(val); - this._draggable$.next(this._draggable); + this._dragRef.draggable = coerceBooleanProperty(val); } - private _draggable: boolean = true; - private _draggable$: BehaviorSubject = new BehaviorSubject(this._draggable); - - private _manualDragEvents$: Subject = new Subject(); - /** Whether the item is resizable or not. Defaults to true. */ @Input() get resizable(): boolean { return this._resizable; } - set resizable(val: boolean) { this._resizable = coerceBooleanProperty(val); this._resizable$.next(this._resizable); } - private _resizable: boolean = true; private _resizable$: BehaviorSubject = new BehaviorSubject(this._resizable); - private dragStartSubject: Subject = new Subject(); private resizeStartSubject: Subject = new Subject(); private subscriptions: Subscription[] = []; + get dragRef(): DragRef { + return this._dragRef; + } + private readonly _dragRef: DragRef; + + @Output('dragStart') + readonly dragStart: Observable<{source: DragRef, event: MouseEvent | TouchEvent}> = new Observable( + (observer: Observer<{source: DragRef, event: MouseEvent | TouchEvent}>) => { + const subscription = this._dragRef.dragStart$ + .subscribe(observer); + + return () => { + subscription.unsubscribe(); + }; + }, + ); + + @Output('dragMove') + readonly dragMove: Observable<{source: DragRef, event: MouseEvent | TouchEvent}> = new Observable( + (observer: Observer<{source: DragRef, event: MouseEvent | TouchEvent}>) => { + const subscription = this._dragRef.dragMove$ + .subscribe(observer); + + return () => { + subscription.unsubscribe(); + }; + }, + ); + + @Output('dragEnd') + readonly dragEnd: Observable<{source: DragRef, event: MouseEvent | TouchEvent}> = new Observable( + (observer: Observer<{source: DragRef, event: MouseEvent | TouchEvent}>) => { + const subscription = this._dragRef.dragEnd$ + .subscribe(observer); + + return () => { + subscription.unsubscribe(); + }; + }, + ); + constructor(public elementRef: ElementRef, private gridService: KtdGridService, + private registryService: KtdRegistryService, private renderer: Renderer2, - private ngZone: NgZone, @Inject(GRID_ITEM_GET_RENDER_DATA_TOKEN) private getItemRenderData: KtdGridItemRenderDataTokenType) { - this.dragStart$ = this.dragStartSubject.asObservable(); this.resizeStart$ = this.resizeStartSubject.asObservable(); + + this._dragRef = this.registryService.createKtgDrag(this.elementRef, this.gridService, this); } ngOnInit() { @@ -115,12 +152,18 @@ export class KtdGridItemComponent implements OnInit, OnDestroy, AfterContentInit ngAfterContentInit() { this.subscriptions.push( - this._dragStart$().subscribe(this.dragStartSubject), this._resizeStart$().subscribe(this.resizeStartSubject), + this._dragHandles.changes.subscribe(() => { + this._dragRef.dragHandles = this._dragHandles.toArray(); + }), + this._resizeHandles.changes.subscribe(() => { + this._dragRef.resizeHandles = this._resizeHandles.toArray(); + }), ); } ngOnDestroy() { + this.registryService.destroyKtgDrag(this._dragRef); this.subscriptions.forEach(sub => sub.unsubscribe()); } @@ -132,68 +175,19 @@ export class KtdGridItemComponent implements OnInit, OnDestroy, AfterContentInit * @param startEvent The pointer event that should initiate the drag. */ startDragManually(startEvent: MouseEvent | TouchEvent) { - this._manualDragEvents$.next(startEvent); + this._dragRef.startDragManual(startEvent); } - setStyles({top, left, width, height}: { top: string, left: string, width?: string, height?: string }) { + setStyles({top, left, width, height}: { top: number, left: number, width?: number, height?: number }) { + this._dragRef.transformX = left; + this._dragRef.transformY = top; + // transform is 6x times faster than top/left - this.renderer.setStyle(this.elementRef.nativeElement, 'transform', `translateX(${left}) translateY(${top})`); + this.renderer.setStyle(this.elementRef.nativeElement, 'transform', `translateX(${left}px) translateY(${top}px)`); this.renderer.setStyle(this.elementRef.nativeElement, 'display', `block`); this.renderer.setStyle(this.elementRef.nativeElement, 'transition', this.transition); - if (width != null) { this.renderer.setStyle(this.elementRef.nativeElement, 'width', width); } - if (height != null) {this.renderer.setStyle(this.elementRef.nativeElement, 'height', height); } - } - - private _dragStart$(): Observable { - return merge( - this._manualDragEvents$, - this._draggable$.pipe( - switchMap((draggable) => { - if (!draggable) { - return NEVER; - } - return this._dragHandles.changes.pipe( - startWith(this._dragHandles), - switchMap((dragHandles: QueryList) => { - return iif( - () => dragHandles.length > 0, - merge(...dragHandles.toArray().map(dragHandle => ktdMouseOrTouchDown(dragHandle.element.nativeElement, 1))), - ktdMouseOrTouchDown(this.elementRef.nativeElement, 1) - ) - }) - ); - }) - ) - ).pipe( - exhaustMap(startEvent => { - // If the event started from an element with the native HTML drag&drop, it'll interfere - // with our own dragging (e.g. `img` tags do it by default). Prevent the default action - // to stop it from happening. Note that preventing on `dragstart` also seems to work, but - // it's flaky and it fails if the user drags it away quickly. Also note that we only want - // to do this for `mousedown` since doing the same for `touchstart` will stop any `click` - // events from firing on touch devices. - if (startEvent.target && (startEvent.target as HTMLElement).draggable && startEvent.type === 'mousedown') { - startEvent.preventDefault(); - } - - const startPointer = ktdPointerClient(startEvent); - return this.gridService.mouseOrTouchMove$(document).pipe( - takeUntil(ktdMouseOrTouchEnd(document, 1)), - ktdOutsideZone(this.ngZone), - filter((moveEvent) => { - moveEvent.preventDefault(); - const movePointer = ktdPointerClient(moveEvent); - const distanceX = Math.abs(startPointer.clientX - movePointer.clientX); - const distanceY = Math.abs(startPointer.clientY - movePointer.clientY); - // When this conditions returns true mean that we are over threshold. - return distanceX + distanceY >= this.dragStartThreshold; - }), - take(1), - // Return the original start event - map(() => startEvent) - ); - }) - ); + if (width != null) { this.renderer.setStyle(this.elementRef.nativeElement, 'width', `${width}px`); } + if (height != null) {this.renderer.setStyle(this.elementRef.nativeElement, 'height', `${height}px`); } } private _resizeStart$(): Observable { @@ -210,10 +204,10 @@ export class KtdGridItemComponent implements OnInit, OnDestroy, AfterContentInit if (resizeHandles.length > 0) { // Side effect to hide the resizeElem if there are resize handles. this.renderer.setStyle(this.resizeElem.nativeElement, 'display', 'none'); - return merge(...resizeHandles.toArray().map(resizeHandle => ktdMouseOrTouchDown(resizeHandle.element.nativeElement, 1))); + return merge(...resizeHandles.toArray().map(resizeHandle => ktdPointerDown(resizeHandle.element.nativeElement))); } else { this.renderer.setStyle(this.resizeElem.nativeElement, 'display', 'block'); - return ktdMouseOrTouchDown(this.resizeElem.nativeElement, 1); + return ktdPointerDown(this.resizeElem.nativeElement); } }) ); diff --git a/projects/angular-grid-layout/src/lib/grid.component.html b/projects/angular-grid-layout/src/lib/grid.component.html index 95a0b70..6dbc743 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.html +++ b/projects/angular-grid-layout/src/lib/grid.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/projects/angular-grid-layout/src/lib/grid.component.scss b/projects/angular-grid-layout/src/lib/grid.component.scss index b93cd8e..e8e0efa 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.scss +++ b/projects/angular-grid-layout/src/lib/grid.component.scss @@ -1,3 +1,7 @@ +.ktd-draggable { + cursor: move; +} + ktd-grid { display: block; position: relative; diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index 3020b68..1b7090d 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -1,50 +1,111 @@ import { - AfterContentChecked, AfterContentInit, ChangeDetectionStrategy, Component, ContentChildren, ElementRef, EmbeddedViewRef, EventEmitter, - HostBinding, Input, - NgZone, OnChanges, OnDestroy, Output, QueryList, Renderer2, SimpleChanges, ViewContainerRef, ViewEncapsulation + AfterContentChecked, + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + ElementRef, + EmbeddedViewRef, + EventEmitter, + Input, + NgZone, + OnChanges, + OnDestroy, + Output, + QueryList, + Renderer2, + SimpleChanges, + ViewContainerRef, + ViewEncapsulation } from '@angular/core'; import { coerceNumberProperty, NumberInput } from './coercion/number-property'; -import { KtdGridItemComponent } from './grid-item/grid-item.component'; -import { combineLatest, empty, merge, NEVER, Observable, Observer, of, Subscription } from 'rxjs'; -import { exhaustMap, map, startWith, switchMap, takeUntil } from 'rxjs/operators'; -import { ktdGetGridItemRowHeight, ktdGridItemDragging, ktdGridItemLayoutItemAreEqual, ktdGridItemResizing } from './utils/grid.utils'; -import { compact } from './utils/react-grid-layout.utils'; +import { KtdGridItemComponent} from './grid-item/grid-item.component'; +import { combineLatest, merge, NEVER, Observable, of, Subscription} from 'rxjs'; +import {exhaustMap, map, startWith, switchMap, takeUntil} from 'rxjs/operators'; import { - GRID_ITEM_GET_RENDER_DATA_TOKEN, KtdGridBackgroundCfg, KtdGridCfg, KtdGridCompactType, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem + ktdGetGridItemRowHeight, + ktdGridItemDragging, ktdGridItemLayoutItemAreEqual, + ktdGridItemResizing +} from './utils/grid.utils'; +import {compact, Layout} from './utils/react-grid-layout.utils'; +import { + DragActionType, + GRID_ITEM_GET_RENDER_DATA_TOKEN, + KtdGridBackgroundCfg, + KtdGridCfg, + KtdGridCompactType, + KtdGridItemRenderData, + KtdGridLayout, + KtdGridLayoutItem, } from './grid.definitions'; -import { ktdMouseOrTouchEnd, ktdPointerClientX, ktdPointerClientY } from './utils/pointer.utils'; +import { ktdPointerClientX, ktdPointerClientY } from './utils/pointer.utils'; import { KtdDictionary } from '../types'; -import { KtdGridService } from './grid.service'; +import {KtdGridService, PointerEventInfo} from './grid.service'; import { getMutableClientRect, KtdClientRect } from './utils/client-rect'; import { ktdGetScrollTotalRelativeDifference$, ktdScrollIfNearElementClientRect$ } from './utils/scroll'; import { BooleanInput, coerceBooleanProperty } from './coercion/boolean-property'; -import { KtdGridItemPlaceholder } from './directives/placeholder'; +import { KtdGridItemPlaceholder} from './directives/placeholder'; import { getTransformTransitionDurationInMs } from './utils/transition-duration'; +import {KtdRegistryService} from "./ktd-registry.service"; +import {DragRef} from "./utils/drag-ref"; +import {KtdDrag} from "./directives/ktd-drag"; + +// region Types + +interface KtdGridDrag { + dragSubscription: Subscription | null; + scrollSubscription: Subscription | null; + startEvent: MouseEvent | TouchEvent; + newLayout: Layout | null; +} interface KtdDragResizeEvent { layout: KtdGridLayout; layoutItem: KtdGridLayoutItem; - gridItemRef: KtdGridItemComponent; + gridItemRef: KtdGridItemComponent | KtdDrag; // } export type KtdDragStart = KtdDragResizeEvent; -export type KtdResizeStart = KtdDragResizeEvent; +export type KtdDragEnter = KtdGridEnterLeaveEvent; +export type KtdDragLeave = KtdGridEnterLeaveEvent; export type KtdDragEnd = KtdDragResizeEvent; +export type KtdDropped = KtdDroppedEvent; + +export type KtdResizeStart = KtdDragResizeEvent; export type KtdResizeEnd = KtdDragResizeEvent; +interface KtdDroppedEvent { + event: PointingDeviceEvent; + currentLayout: KtdGridLayout; + currentLayoutItem: KtdGridLayoutItem; +} + export interface KtdGridItemResizeEvent { width: number; height: number; gridItemRef: KtdGridItemComponent; } -type DragActionType = 'drag' | 'resize'; +export type PointingDeviceEvent = MouseEvent | TouchEvent | PointerEvent; -function getDragResizeEventData(gridItem: KtdGridItemComponent, layout: KtdGridLayout): KtdDragResizeEvent { +export interface KtdGridEnterLeaveEvent { + grid: KtdGridComponent; + event: PointingDeviceEvent; + source: DragRef; + dragInfo: PointerEventInfo; +} + +export function getDragResizeEventData(dragRef: DragRef, layout: KtdGridLayout): KtdDragResizeEvent { return { layout, - layoutItem: layout.find((item) => item.id === gridItem.id)!, - gridItemRef: gridItem + layoutItem: dragRef.itemRef instanceof KtdGridItemComponent ? layout.find((item) => item.id === dragRef.id)! : { + id: dragRef.id, + x: 0, + y: 0, + w: 1, + h: 1, + }, + gridItemRef: dragRef.itemRef, }; } @@ -59,7 +120,12 @@ function getRowHeightInPixels(config: KtdGridCfg, height: number): number { return rowHeight === 'fit' ? ktdGetGridItemRowHeight(layout, height, gap) : rowHeight; } -function layoutToRenderItems(config: KtdGridCfg, width: number, height: number): KtdDictionary> { +function layoutToRenderItems( + config: KtdGridCfg, + width: number, + height: number, + draggingLayoutItem: KtdGridLayoutItem | null = null +): { dict: KtdDictionary>, draggingItem: KtdGridItemRenderData | null } { const {layout, gap} = config; const rowHeightInPixels = getRowHeightInPixels(config, height); const itemWidthPerColumn = getColumnWidth(config, width); @@ -73,28 +139,27 @@ function layoutToRenderItems(config: KtdGridCfg, width: number, height: number): height: item.h * rowHeightInPixels + gap * Math.max(item.h - 1, 0), }; } - return renderItems; -} - -function getGridHeight(layout: KtdGridLayout, rowHeight: number, gap: number): number { - return layout.reduce((acc, cur) => Math.max(acc, (cur.y + cur.h) * rowHeight + Math.max(cur.y + cur.h - 1, 0) * gap), 0); -} - -// eslint-disable-next-line @katoid/prefix-exported-code -export function parseRenderItemToPixels(renderItem: KtdGridItemRenderData): KtdGridItemRenderData { return { - id: renderItem.id, - top: `${renderItem.top}px`, - left: `${renderItem.left}px`, - width: `${renderItem.width}px`, - height: `${renderItem.height}px` + dict: renderItems, + draggingItem: draggingLayoutItem !== null ? { + id: draggingLayoutItem.id, + top: draggingLayoutItem.y * rowHeightInPixels + gap * draggingLayoutItem.y, + left: draggingLayoutItem.x * itemWidthPerColumn + gap * draggingLayoutItem.x, + width: draggingLayoutItem.w * itemWidthPerColumn + gap * Math.max(draggingLayoutItem.w - 1, 0), + height: draggingLayoutItem.h * rowHeightInPixels + gap * Math.max(draggingLayoutItem.h - 1, 0), + } : null, }; } +function getGridHeight(layout: KtdGridLayout, rowHeight: number, gap: number, draggedItem: KtdGridLayoutItem | null): number { + const newLayout = draggedItem !== null ? [...layout, draggedItem] : layout; + return newLayout.reduce((acc, cur) => Math.max(acc, (cur.y + cur.h) * rowHeight + Math.max(cur.y + cur.h - 1, 0) * gap), 0); +} + // eslint-disable-next-line @katoid/prefix-exported-code export function __gridItemGetRenderDataFactoryFunc(gridCmp: KtdGridComponent) { return function(id: string) { - return parseRenderItemToPixels(gridCmp.getItemRenderData(id)); + return gridCmp.getItemRenderData(id); }; } @@ -112,6 +177,8 @@ const defaultBackgroundConfig: Required> = { borderWidth: 1, }; +// endregion + @Component({ selector: 'ktd-grid', templateUrl: './grid.component.html', @@ -127,8 +194,11 @@ const defaultBackgroundConfig: Required> = { ] }) export class KtdGridComponent implements OnChanges, AfterContentInit, AfterContentChecked, OnDestroy { + // region Parameters + private static _nextUniqueId: number = 0; + /** Query list of grid items that are being rendered. */ - @ContentChildren(KtdGridItemComponent, {descendants: true}) _gridItems: QueryList; + @ContentChildren(KtdGridItemComponent, {descendants: true}) private _gridItems: QueryList; /** Emits when layout change */ @Output() layoutUpdated: EventEmitter = new EventEmitter(); @@ -136,12 +206,21 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte /** Emits when drag starts */ @Output() dragStarted: EventEmitter = new EventEmitter(); - /** Emits when resize starts */ - @Output() resizeStarted: EventEmitter = new EventEmitter(); + /** Emits when user moves the item into the grid. Being emitted from grid.service. */ + @Output() dragEntered: EventEmitter = new EventEmitter(); + + /** Emits when user moves the item out of the grid. Being emitted from grid.service. */ + @Output() dragExited: EventEmitter = new EventEmitter(); /** Emits when drag ends */ @Output() dragEnded: EventEmitter = new EventEmitter(); + /** Emits when user drops an item to this grid */ + @Output() dropped: EventEmitter = new EventEmitter(); + + /** Emits when resize starts */ + @Output() resizeStarted: EventEmitter = new EventEmitter(); + /** Emits when resize ends */ @Output() resizeEnded: EventEmitter = new EventEmitter(); @@ -154,46 +233,43 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte */ @Input() scrollableParent: HTMLElement | Document | string | null = null; + @Input() + get id(): string { return this._id; } + set id(val: string) { + this._id = val; + } + private _id: string = `ktd-grid-${KtdGridComponent._nextUniqueId++}`; + /** Whether or not to update the internal layout when some dependent property change. */ @Input() get compactOnPropsChange(): boolean { return this._compactOnPropsChange; } - set compactOnPropsChange(value: boolean) { this._compactOnPropsChange = coerceBooleanProperty(value); } - private _compactOnPropsChange: boolean = true; /** If true, grid items won't change position when being dragged over. Handy when using no compaction */ @Input() get preventCollision(): boolean { return this._preventCollision; } - set preventCollision(value: boolean) { this._preventCollision = coerceBooleanProperty(value); } - private _preventCollision: boolean = false; /** Number of CSS pixels that would be scrolled on each 'tick' when auto scroll is performed. */ @Input() get scrollSpeed(): number { return this._scrollSpeed; } - set scrollSpeed(value: number) { this._scrollSpeed = coerceNumberProperty(value, 2); } - private _scrollSpeed: number = 2; /** Type of compaction that will be applied to the layout (vertical, horizontal or free). Defaults to 'vertical' */ @Input() - get compactType(): KtdGridCompactType { - return this._compactType; - } - + get compactType(): KtdGridCompactType { return this._compactType; } set compactType(val: KtdGridCompactType) { this._compactType = val; } - private _compactType: KtdGridCompactType = 'vertical'; /** @@ -203,27 +279,22 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte */ @Input() get rowHeight(): number | 'fit' { return this._rowHeight; } - set rowHeight(val: number | 'fit') { this._rowHeight = val === 'fit' ? val : Math.max(1, Math.round(coerceNumberProperty(val))); } - private _rowHeight: number | 'fit' = 100; /** Number of columns */ @Input() get cols(): number { return this._cols; } - set cols(val: number) { this._cols = Math.max(1, Math.round(coerceNumberProperty(val))); } - private _cols: number = 6; /** Layout of the grid. Array of all the grid items with its 'id' and position on the grid. */ @Input() get layout(): KtdGridLayout { return this._layout; } - set layout(layout: KtdGridLayout) { /** * Enhancement: @@ -236,56 +307,41 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte */ this._layout = layout; } - private _layout: KtdGridLayout; /** Grid gap in css pixels */ @Input() - get gap(): number { - return this._gap; - } - + get gap(): number { return this._gap; } set gap(val: number) { this._gap = Math.max(coerceNumberProperty(val), 0); } - private _gap: number = 0; - /** * If height is a number, fixes the height of the grid to it, recommended when rowHeight = 'fit' is used. * If height is null, height will be automatically set according to its inner grid items. * Defaults to null. * */ @Input() - get height(): number | null { - return this._height; - } - + get height(): number | null { return this._height; } set height(val: number | null) { this._height = typeof val === 'number' ? Math.max(val, 0) : null; } - private _height: number | null = null; - @Input() - get backgroundConfig(): KtdGridBackgroundCfg | null { - return this._backgroundConfig; - } - + get backgroundConfig(): KtdGridBackgroundCfg | null { return this._backgroundConfig; } set backgroundConfig(val: KtdGridBackgroundCfg | null) { this._backgroundConfig = val; // If there is background configuration, add main grid background class. Grid background class comes with opacity 0. // It is done this way for adding opacity animation and to don't add any styles when grid background is null. - const classList = (this.elementRef.nativeElement as HTMLDivElement).classList; + const classList = this.gridElement.classList; this._backgroundConfig !== null ? classList.add('ktd-grid-background') : classList.remove('ktd-grid-background'); // Set background visibility this.setGridBackgroundVisible(this._backgroundConfig?.show === 'always'); } - private _backgroundConfig: KtdGridBackgroundCfg | null = null; private gridCurrentHeight: number; @@ -298,6 +354,7 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte layout: this.layout, preventCollision: this.preventCollision, gap: this.gap, + gridId: this.id, }; } @@ -310,16 +367,27 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte private _gridItemsRenderData: KtdDictionary>; private subscriptions: Subscription[]; + public get drag(): KtdGridDrag | null { + return this._drag; + } + private _drag: KtdGridDrag | null = null; + + private readonly gridElement: HTMLElement; + + // endregion + constructor(private gridService: KtdGridService, + private ktdRegistryService: KtdRegistryService, private elementRef: ElementRef, private viewContainerRef: ViewContainerRef, private renderer: Renderer2, - private ngZone: NgZone) { - + private ngZone: NgZone + ) { + this.gridElement = this.elementRef.nativeElement as HTMLElement; + this.ktdRegistryService.registerKtdGrid(this); } ngOnChanges(changes: SimpleChanges) { - if (this.rowHeight === 'fit' && this.height == null) { console.warn(`KtdGridComponent: The @Input() height should not be null when using rowHeight 'fit'`); } @@ -365,6 +433,7 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte ngOnDestroy() { this.subscriptions.forEach(sub => sub.unsubscribe()); + this.ktdRegistryService.unregisterKtdGrid(this); } compactLayout() { @@ -380,21 +449,22 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte } calculateRenderData() { - const clientRect = (this.elementRef.nativeElement as HTMLElement).getBoundingClientRect(); - this.gridCurrentHeight = this.height ?? (this.rowHeight === 'fit' ? clientRect.height : getGridHeight(this.layout, this.rowHeight, this.gap)); - this._gridItemsRenderData = layoutToRenderItems(this.config, clientRect.width, this.gridCurrentHeight); + const clientRect = this.gridElement.getBoundingClientRect(); + this.gridCurrentHeight = this.height ?? (this.rowHeight === 'fit' ? clientRect.height : getGridHeight(this.layout, this.rowHeight, this.gap, null)); + const {dict} = layoutToRenderItems(this.config, clientRect.width, this.gridCurrentHeight); + this._gridItemsRenderData = dict; // Set Background CSS variables this.setBackgroundCssVariables(getRowHeightInPixels(this.config, this.gridCurrentHeight)); } render() { - this.renderer.setStyle(this.elementRef.nativeElement, 'height', `${this.gridCurrentHeight}px`); + this.renderer.setStyle(this.gridElement, 'height', `${this.gridCurrentHeight}px`); this.updateGridItemsStyles(); } private setBackgroundCssVariables(rowHeight: number) { - const style = (this.elementRef.nativeElement as HTMLDivElement).style; + const style = (this.gridElement as HTMLDivElement).style; if (this._backgroundConfig) { // structure @@ -426,240 +496,297 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte if (gridItemRenderData == null) { console.error(`Couldn\'t find the specified grid item for the id: ${item.id}`); } else { - item.setStyles(parseRenderItemToPixels(gridItemRenderData)); + item.setStyles(gridItemRenderData); } }); } - - private setGridBackgroundVisible(visible: boolean) { - const classList = (this.elementRef.nativeElement as HTMLDivElement).classList; + public setGridBackgroundVisible(visible: boolean) { + const classList = this.gridElement.classList; visible ? classList.add('ktd-grid-background-visible') : classList.remove('ktd-grid-background-visible'); } private initSubscriptions() { + // const itemsConnectedToGrid$ = this.ktdRegistryService.getKtdDragItemsConnectedToGrid(this); this.subscriptions = [ this._gridItems.changes.pipe( startWith(this._gridItems), switchMap((gridItems: QueryList) => { return merge( - ...gridItems.map((gridItem) => gridItem.dragStart$.pipe(map((event) => ({event, gridItem, type: 'drag' as DragActionType})))), + ...gridItems.map((gridItem) => gridItem.dragStart.pipe(map(({event}) => ({ + event, + gridItem, + type: 'drag' as DragActionType + })))), ...gridItems.map((gridItem) => gridItem.resizeStart$.pipe(map((event) => ({ event, gridItem, - type: 'resize' as DragActionType + type: 'resize' as DragActionType, })))), - ).pipe(exhaustMap(({event, gridItem, type}) => { - // Emit drag or resize start events. Ensure that is start event is inside the zone. - this.ngZone.run(() => (type === 'drag' ? this.dragStarted : this.resizeStarted).emit(getDragResizeEventData(gridItem, this.layout))); + ).pipe(exhaustMap((data) => of(data))); + }) + ).subscribe(({event, gridItem, type}) => { + // Prevents the event from bubbling up the DOM tree, preventing any parent event handlers from being notified of the event. + // Specifically, it prevents the event from being sent to any parent event handlers that may be listening for it. + if (type === 'resize') { + event.stopPropagation(); + } - this.setGridBackgroundVisible(this._backgroundConfig?.show === 'whenDragging' || this._backgroundConfig?.show === 'always'); + // If the event started from an element with the native HTML drag&drop, it'll interfere + // with our positioning logic since it'll start dragging the native element. + if (event.target && ((event.target as HTMLElement).draggable || true) && event.type === 'pointerdown') { + event.preventDefault(); + } - // Perform drag sequence - return this.performDragSequence$(gridItem, event, type).pipe( - map((layout) => ({layout, gridItem, type}))); + const layoutItem = this.layout.find((item) => item.id === gridItem.id); + const renderData = this._gridItemsRenderData[gridItem.id]; - })); - }) - ).subscribe(({layout, gridItem, type}) => { - this.layout = layout; - // Calculate new rendering data given the new layout. - this.calculateRenderData(); - // Emit drag or resize end events. - (type === 'drag' ? this.dragEnded : this.resizeEnded).emit(getDragResizeEventData(gridItem, layout)); - // Notify that the layout has been updated. - this.layoutUpdated.emit(layout); - - this.setGridBackgroundVisible(this._backgroundConfig?.show === 'always'); - }) + if (layoutItem === undefined || renderData === undefined) { + console.error(`Couldn\'t find the specified grid item for the id: ${gridItem.id}`); + return; + } + return this.gridService.startDrag(event, gridItem.dragRef, type, this, {layoutItem, renderData}); + }), + this.dragEntered.subscribe(({event, dragInfo }) => { + this.startRestoreDragSequence(event, dragInfo); + }), + this.dragExited.subscribe(({dragInfo}) => { + this.pauseDragSequence(dragInfo); + }), ]; } /** - * Perform a general grid drag action, from start to end. A general grid drag action basically includes creating the placeholder element and adding - * some class animations. calcNewStateFunc needs to be provided in order to calculate the new state of the layout. - * @param gridItem that is been dragged - * @param pointerDownEvent event (mousedown or touchdown) where the user initiated the drag - * @param calcNewStateFunc function that return the new layout state and the drag element position + * Starts the drag sequence when a drag event is triggered. It will restore paused drag sequence if it's already started. + * @param event The event that triggered the drag sequence. + * @param dragInfo The drag info. */ - private performDragSequence$(gridItem: KtdGridItemComponent, pointerDownEvent: MouseEvent | TouchEvent, type: DragActionType): Observable { + private startRestoreDragSequence(event: PointingDeviceEvent, dragInfo: PointerEventInfo): void { + // Drag sequence can be paused, but the resize sequence can't be paused. + if (this.drag !== null && dragInfo.type === 'resize') { + return; + } + + // Prevents the resize from starting if the resize already started on another grid. + if (dragInfo.type === 'resize' && this.drag === null && dragInfo.fromGrid !== this) { + return; + } - return new Observable((observer: Observer) => { - // Retrieve grid (parent) and gridItem (draggedElem) client rects. - const gridElemClientRect: KtdClientRect = getMutableClientRect(this.elementRef.nativeElement as HTMLElement); - const dragElemClientRect: KtdClientRect = getMutableClientRect(gridItem.elementRef.nativeElement as HTMLElement); + const scrollableParent = typeof this.scrollableParent === 'string' ? document.getElementById(this.scrollableParent) : this.scrollableParent; + + // TODO (enhancement): consider move this 'side effect' observable inside the main drag loop. + // - Pros are that we would not repeat subscriptions and takeUntil would shut down observables at the same time. + // - Cons are that moving this functionality as a side effect inside the main drag loop would be confusing. + const scrollSubscription = this.ngZone.runOutsideAngular(() => + (!scrollableParent ? NEVER : this.gridService.pointerMove$.pipe( + map((event) => ({ + pointerX: ktdPointerClientX(event), + pointerY: ktdPointerClientY(event) + })), + ktdScrollIfNearElementClientRect$(scrollableParent, {scrollStep: this.scrollSpeed}) + )).pipe( + takeUntil(this.gridService.pointerEnd$), + ).subscribe()); + + this._drag = { + dragSubscription: this.createDragResizeLoop(scrollableParent, dragInfo), + scrollSubscription, + startEvent: event, + newLayout: null, + }; + } - const scrollableParent = typeof this.scrollableParent === 'string' ? document.getElementById(this.scrollableParent) : this.scrollableParent; + private pauseDragSequence(dragInfo: PointerEventInfo): void { + // If the drag is a resize, we don't need to pause the drag sequence. + if (dragInfo.type === 'resize') { + return; + } - this.renderer.addClass(gridItem.elementRef.nativeElement, 'no-transitions'); - this.renderer.addClass(gridItem.elementRef.nativeElement, 'ktd-grid-item-dragging'); + // Partially reset the drag sequence. + this.clearDragSequence(); + } - const placeholderClientRect: KtdClientRect = { - ...dragElemClientRect, - left: dragElemClientRect.left - gridElemClientRect.left, - top: dragElemClientRect.top - gridElemClientRect.top - } - this.createPlaceholderElement(placeholderClientRect, gridItem.placeholder); - - let newLayout: KtdGridLayoutItem[]; - - // TODO (enhancement): consider move this 'side effect' observable inside the main drag loop. - // - Pros are that we would not repeat subscriptions and takeUntil would shut down observables at the same time. - // - Cons are that moving this functionality as a side effect inside the main drag loop would be confusing. - const scrollSubscription = this.ngZone.runOutsideAngular(() => - (!scrollableParent ? NEVER : this.gridService.mouseOrTouchMove$(document).pipe( - map((event) => ({ - pointerX: ktdPointerClientX(event), - pointerY: ktdPointerClientY(event) - })), - ktdScrollIfNearElementClientRect$(scrollableParent, {scrollStep: this.scrollSpeed}) - )).pipe( - takeUntil(ktdMouseOrTouchEnd(document)) - ).subscribe()); - - /** - * Main subscription, it listens for 'pointer move' and 'scroll' events and recalculates the layout on each emission - */ - const subscription = this.ngZone.runOutsideAngular(() => - merge( - combineLatest([ - this.gridService.mouseOrTouchMove$(document), - ...(!scrollableParent ? [of({top: 0, left: 0})] : [ - ktdGetScrollTotalRelativeDifference$(scrollableParent).pipe( - startWith({top: 0, left: 0}) // Force first emission to allow CombineLatest to emit even no scroll event has occurred - ) - ]) + /** + * Creates the drag loop. It listens for 'pointer move' and 'scroll' events and recalculates the layout on each emission. + * @param scrollableParent The parent element that contains the scroll. + * @param dragInfo The drag info. + */ + private createDragResizeLoop(scrollableParent: HTMLElement | Document | null, dragInfo: PointerEventInfo): Subscription { + let renderData: KtdGridItemRenderData | null = null; + + // Retrieve grid (parent) and gridItem (draggedElem) client rects. + const gridElemClientRect: KtdClientRect = getMutableClientRect(this.gridElement); + const dragElemClientRect: KtdClientRect = getMutableClientRect(dragInfo.dragRef.elementRef.nativeElement as HTMLElement); + + this.renderer.addClass(dragInfo.dragRef.elementRef.nativeElement, 'no-transitions'); + this.renderer.addClass(dragInfo.dragRef.elementRef.nativeElement, 'ktd-grid-item-dragging'); + + const placeholderClientRect: KtdClientRect = { + ...dragElemClientRect, + left: dragElemClientRect.left - gridElemClientRect.left, + top: dragElemClientRect.top - gridElemClientRect.top + } + + this.createPlaceholderElement(placeholderClientRect, dragInfo.dragRef.placeholder); + + // Main subscription, it listens for 'pointer move' and 'scroll' events and recalculates the layout on each emission + return this.ngZone.runOutsideAngular(() => + merge( + combineLatest([ + this.gridService.pointerMove$, + ...(!scrollableParent ? [of({top: 0, left: 0})] : [ + ktdGetScrollTotalRelativeDifference$(scrollableParent).pipe( + startWith({top: 0, left: 0}) // Force first emission to allow CombineLatest to emit even no scroll event has occurred + ) ]) - ).pipe( - takeUntil(ktdMouseOrTouchEnd(document)), - ).subscribe(([pointerDragEvent, scrollDifference]: [MouseEvent | TouchEvent, { top: number, left: number }]) => { - pointerDragEvent.preventDefault(); - - /** - * Set the new layout to be the layout in which the calcNewStateFunc would be executed. - * NOTE: using the mutated layout is the way to go by 'react-grid-layout' utils. If we don't use the previous layout, - * some utilities from 'react-grid-layout' would not work as expected. - */ - const currentLayout: KtdGridLayout = newLayout || this.layout; - - // Get the correct newStateFunc depending on if we are dragging or resizing - const calcNewStateFunc = type === 'drag' ? ktdGridItemDragging : ktdGridItemResizing; - - const {layout, draggedItemPos} = calcNewStateFunc(gridItem, { - layout: currentLayout, - rowHeight: this.rowHeight, - height: this.height, - cols: this.cols, - preventCollision: this.preventCollision, - gap: this.gap, - }, this.compactType, { - pointerDownEvent, - pointerDragEvent, - gridElemClientRect, - dragElemClientRect, - scrollDifference - }); - newLayout = layout; - - this.gridCurrentHeight = this.height ?? (this.rowHeight === 'fit' ? gridElemClientRect.height : getGridHeight(newLayout, this.rowHeight, this.gap)) - - this._gridItemsRenderData = layoutToRenderItems({ - cols: this.cols, - rowHeight: this.rowHeight, - height: this.height, - layout: newLayout, - preventCollision: this.preventCollision, - gap: this.gap, - }, gridElemClientRect.width, gridElemClientRect.height); - - const newGridItemRenderData = {...this._gridItemsRenderData[gridItem.id]} - const placeholderStyles = parseRenderItemToPixels(newGridItemRenderData); - - // Put the real final position to the placeholder element - this.placeholder!.style.width = placeholderStyles.width; - this.placeholder!.style.height = placeholderStyles.height; - this.placeholder!.style.transform = `translateX(${placeholderStyles.left}) translateY(${placeholderStyles.top})`; - - // modify the position of the dragged item to be the once we want (for example the mouse position or whatever) - this._gridItemsRenderData[gridItem.id] = { - ...draggedItemPos, - id: this._gridItemsRenderData[gridItem.id].id - }; - - this.setBackgroundCssVariables(this.rowHeight === 'fit' ? ktdGetGridItemRowHeight(newLayout, gridElemClientRect.height, this.gap) : this.rowHeight); - - this.render(); - - // If we are performing a resize, and bounds have changed, emit event. - // NOTE: Only emit on resize for now. Use case for normal drag is not justified for now. Emitting on resize is, since we may want to re-render the grid item or the placeholder in order to fit the new bounds. - if (type === 'resize') { - const prevGridItem = currentLayout.find(item => item.id === gridItem.id)!; - const newGridItem = newLayout.find(item => item.id === gridItem.id)!; - // Check if item resized has changed, if so, emit resize change event - if (!ktdGridItemLayoutItemAreEqual(prevGridItem, newGridItem)) { - this.gridItemResize.emit({ - width: newGridItemRenderData.width, - height: newGridItemRenderData.height, - gridItemRef: getDragResizeEventData(gridItem, newLayout).gridItemRef - }); - } - } - }, - (error) => observer.error(error), - () => { - this.ngZone.run(() => { - // Remove drag classes - this.renderer.removeClass(gridItem.elementRef.nativeElement, 'no-transitions'); - this.renderer.removeClass(gridItem.elementRef.nativeElement, 'ktd-grid-item-dragging'); - - this.addGridItemAnimatingClass(gridItem).subscribe(); - // Consider destroying the placeholder after the animation has finished. - this.destroyPlaceholder(); - - if (newLayout) { - // TODO: newLayout should already be pruned. If not, it should have type Layout, not KtdGridLayout as it is now. - // Prune react-grid-layout compact extra properties. - observer.next(newLayout.map(item => ({ - id: item.id, - x: item.x, - y: item.y, - w: item.w, - h: item.h, - minW: item.minW, - minH: item.minH, - maxW: item.maxW, - maxH: item.maxH, - })) as KtdGridLayout); - } else { - // TODO: Need we really to emit if there is no layout change but drag started and ended? - observer.next(this.layout); - } - - observer.complete(); + ]) + ).pipe( + takeUntil(this.gridService.pointerEnd$), + ).subscribe(([pointerDragEvent, scrollDifference]: [MouseEvent | TouchEvent, { top: number, left: number }]) => { + pointerDragEvent.preventDefault(); + + // + // Set the new layout to be the layout in which the calcNewStateFunc would be executed. + // NOTE: using the mutated layout is the way to go by 'react-grid-layout' utils. If we don't use the previous layout, + // some utilities from 'react-grid-layout' would not work as expected. + // + const currentLayout: KtdGridLayout = this.drag!.newLayout || this.layout; + // const ktdLayoutItem = dragInfo.fromGrid !== this ? dragInfo.newLayoutItem : currentLayout.find(item => item.id === dragInfo.dragRef.id)!; + const ktdLayoutItem = dragInfo.newLayoutItem; + + // Get the correct newStateFunc depending on if we are dragging or resizing + const calcNewStateFunc = dragInfo.type === 'drag' ? ktdGridItemDragging : ktdGridItemResizing; + + const {layout, draggedItemPos, draggedLayoutItem} = calcNewStateFunc(ktdLayoutItem, { + layout: currentLayout, + rowHeight: this.rowHeight, + height: this.height, + cols: this.cols, + preventCollision: this.preventCollision, + gap: this.gap, + gridId: this.id, + }, this.compactType, { + pointerDownEvent: this.drag!.startEvent!, + pointerDragEvent, + gridElemClientRect, + dragElemClientRect, + scrollDifference, + draggingFromOutside: dragInfo.fromGrid !== this, + }); + this.drag!.newLayout = layout; + + this.gridCurrentHeight = this.height ?? (this.rowHeight === 'fit' ? gridElemClientRect.height : getGridHeight(this.drag!.newLayout, this.rowHeight, this.gap, draggedLayoutItem)); + + const {dict, draggingItem} = layoutToRenderItems({ + cols: this.cols, + rowHeight: this.rowHeight, + height: this.height, + layout: this.drag!.newLayout, + preventCollision: this.preventCollision, + gap: this.gap, + gridId: this.id, + }, gridElemClientRect.width, gridElemClientRect.height, draggedLayoutItem); + this._gridItemsRenderData = dict; + + if (dragInfo.fromGrid !== this || dragInfo.type === 'resize') { + dragInfo.newLayoutItem = draggedLayoutItem; + renderData = draggingItem!; + } + + const newGridItemRenderData = renderData !== null && dragInfo.fromGrid !== this ? renderData : this._gridItemsRenderData[dragInfo.dragRef.id]; + + // Put the real final position to the placeholder element + this.placeholder!.style.width = `${newGridItemRenderData.width}px`; + this.placeholder!.style.height = `${newGridItemRenderData.height}px`; + this.placeholder!.style.transform = `translateX(${newGridItemRenderData.left}px) translateY(${newGridItemRenderData.top}px)`; + + // modify the position of the dragged item to be the once we want (for example the mouse position or whatever) + if (renderData !== null && dragInfo.fromGrid !== this ) { + renderData = { + ...renderData, + ...draggedItemPos, + } + } else { + this._gridItemsRenderData[dragInfo.dragRef.id] = { + ...draggedItemPos, + id: this._gridItemsRenderData[dragInfo.dragRef.id].id + }; + } + + this.setBackgroundCssVariables(this.rowHeight === 'fit' ? ktdGetGridItemRowHeight(this.drag!.newLayout, gridElemClientRect.height, this.gap) : this.rowHeight); + + this.render(); + + // If we are performing a resize, and bounds have changed, emit event. + // NOTE: Only emit on resize for now. Use case for normal drag is not justified for now. Emitting on resize is, since we may want to re-render the grid item or the placeholder in order to fit the new bounds. + if (dragInfo.type === 'resize') { + const prevGridItem = currentLayout.find(item => item.id === dragInfo.dragRef.id)!; + const newGridItem = this.drag!.newLayout.find(item => item.id === dragInfo.dragRef.id)!; + // Check if item resized has changed, if so, emit resize change event + if (!ktdGridItemLayoutItemAreEqual(prevGridItem, newGridItem)) { + this.gridItemResize.emit({ + width: newGridItemRenderData.width, + height: newGridItemRenderData.height, + gridItemRef: dragInfo.dragRef.itemRef as KtdGridItemComponent, // We always have a grid item ref when resizing }); + } + } + }) + ); + } - })); + public stopDragSequence(dragInfo: PointerEventInfo): void { + if (this.drag === null) { + return; + } + // Remove drag classes + this.renderer.removeClass(dragInfo.dragRef.elementRef.nativeElement, 'no-transitions'); + this.renderer.removeClass(dragInfo.dragRef.elementRef.nativeElement, 'ktd-grid-item-dragging'); - return () => { - scrollSubscription.unsubscribe(); - subscription.unsubscribe(); - }; + this.ngZone.run(() => { + (dragInfo.type === 'drag' ? this.dragEnded : this.resizeEnded).emit(getDragResizeEventData(dragInfo.dragRef, this.drag!.newLayout!)); }); + + this.clearDragSequence(); + this._drag = null; + this.addGridItemAnimatingClass(dragInfo.dragRef).subscribe(); } + /** + * Clears the drag sequence. + * This is called from grid-service when drag/resize finishes. + */ + public clearDragSequence(): void { + if (this.drag !== null) { + this.destroyPlaceholder(); + this.drag?.dragSubscription?.unsubscribe(); + this.drag?.scrollSubscription?.unsubscribe(); + this.drag.dragSubscription = null; + this.drag.scrollSubscription = null; + } + } + + public isPointerInsideGridElement(event: MouseEvent | TouchEvent): boolean { + const gridElemClientRect: KtdClientRect = getMutableClientRect(this.gridElement); + const pointerX = ktdPointerClientX(event); + const pointerY = ktdPointerClientY(event); + return gridElemClientRect.left < pointerX && pointerX < gridElemClientRect.right && gridElemClientRect.top < pointerY && pointerY < gridElemClientRect.bottom; + } + + public getNextId(): string { + return this._gridItems.toArray().reduce((acc, cur) => acc > parseInt(cur.id) ? acc : parseInt(cur.id), 0) + 1 + ''; + } /** * It adds the `ktd-grid-item-animating` class and removes it when the animated transition is complete. * This function is meant to be executed when the drag has ended. - * @param gridItem that has been dragged + * @param dragRef that has been dragged */ - private addGridItemAnimatingClass(gridItem: KtdGridItemComponent): Observable { - + private addGridItemAnimatingClass(dragRef: DragRef): Observable { return new Observable(observer => { - - const duration = getTransformTransitionDurationInMs(gridItem.elementRef.nativeElement); + const duration = getTransformTransitionDurationInMs(dragRef.elementRef.nativeElement); if (duration === 0) { observer.next(); @@ -667,10 +794,10 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte return; } - this.renderer.addClass(gridItem.elementRef.nativeElement, 'ktd-grid-item-animating'); + this.renderer.addClass(dragRef.elementRef.nativeElement, 'ktd-grid-item-animating'); const handler = ((event: TransitionEvent) => { - if (!event || (event.target === gridItem.elementRef.nativeElement && event.propertyName === 'transform')) { - this.renderer.removeClass(gridItem.elementRef.nativeElement, 'ktd-grid-item-animating'); + if (!event || (event.target === dragRef.elementRef.nativeElement && event.propertyName === 'transform')) { + this.renderer.removeClass(dragRef.elementRef.nativeElement, 'ktd-grid-item-animating'); removeEventListener(); clearTimeout(timeout); observer.next(); @@ -682,7 +809,7 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte // Since we know how long it's supposed to take, add a timeout with a 50% buffer that'll // fire if the transition hasn't completed when it was supposed to. const timeout = setTimeout(handler, duration * 1.5); - const removeEventListener = this.renderer.listen(gridItem.elementRef.nativeElement, 'transitionend', handler); + const removeEventListener = this.renderer.listen(dragRef.elementRef.nativeElement, 'transitionend', handler); }) } @@ -693,7 +820,7 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte this.placeholder!.style.height = `${clientRect.height}px`; this.placeholder!.style.transform = `translateX(${clientRect.left}px) translateY(${clientRect.top}px)`; this.placeholder!.classList.add('ktd-grid-item-placeholder'); - this.renderer.appendChild(this.elementRef.nativeElement, this.placeholder); + this.renderer.appendChild(this.gridElement, this.placeholder); // Create and append custom placeholder if provided. // Important: Append it after creating & appending the container placeholder. This way we ensure parent bounds are set when creating the embeddedView. @@ -721,5 +848,5 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte static ngAcceptInputType_scrollSpeed: NumberInput; static ngAcceptInputType_compactOnPropsChange: BooleanInput; static ngAcceptInputType_preventCollision: BooleanInput; -} + } diff --git a/projects/angular-grid-layout/src/lib/grid.definitions.ts b/projects/angular-grid-layout/src/lib/grid.definitions.ts index 12e976b..2d5c1a8 100644 --- a/projects/angular-grid-layout/src/lib/grid.definitions.ts +++ b/projects/angular-grid-layout/src/lib/grid.definitions.ts @@ -2,7 +2,8 @@ import { InjectionToken } from '@angular/core'; import { CompactType } from './utils/react-grid-layout.utils'; import { KtdClientRect } from './utils/client-rect'; -export interface KtdGridLayoutItem { + +export interface KtdGridLayoutItem { id: string; x: number; y: number; @@ -12,10 +13,11 @@ export interface KtdGridLayoutItem { minH?: number; maxW?: number; maxH?: number; + data?: T; } export type KtdGridCompactType = CompactType; - +export type DragActionType = 'drag' | 'resize'; export interface KtdGridBackgroundCfg { show: 'never' | 'always' | 'whenDragging'; @@ -33,6 +35,7 @@ export interface KtdGridCfg { layout: KtdGridLayoutItem[]; preventCollision: boolean; gap: number; + gridId: string; } export type KtdGridLayout = KtdGridLayoutItem[]; @@ -57,7 +60,7 @@ export interface KtdGridItemRenderData { * We inject a token because of the 'circular dependency issue warning'. In case we don't had this issue with the circular dependency, we could just * import KtdGridComponent on KtdGridItem and execute the needed function to get the rendering data. */ -export type KtdGridItemRenderDataTokenType = (id: string) => KtdGridItemRenderData; +export type KtdGridItemRenderDataTokenType = (id: string) => KtdGridItemRenderData; export const GRID_ITEM_GET_RENDER_DATA_TOKEN: InjectionToken = new InjectionToken('GRID_ITEM_GET_RENDER_DATA_TOKEN'); export interface KtdDraggingData { @@ -66,4 +69,5 @@ export interface KtdDraggingData { gridElemClientRect: KtdClientRect; dragElemClientRect: KtdClientRect; scrollDifference: { top: number, left: number }; + draggingFromOutside: boolean; } diff --git a/projects/angular-grid-layout/src/lib/grid.module.ts b/projects/angular-grid-layout/src/lib/grid.module.ts index 56e36ca..fea8b15 100644 --- a/projects/angular-grid-layout/src/lib/grid.module.ts +++ b/projects/angular-grid-layout/src/lib/grid.module.ts @@ -6,6 +6,7 @@ import { KtdGridDragHandle } from './directives/drag-handle'; import { KtdGridResizeHandle } from './directives/resize-handle'; import { KtdGridService } from './grid.service'; import { KtdGridItemPlaceholder } from '../public-api'; +import { KtdDrag } from './directives/ktd-drag'; @NgModule({ declarations: [ @@ -13,14 +14,16 @@ import { KtdGridItemPlaceholder } from '../public-api'; KtdGridItemComponent, KtdGridDragHandle, KtdGridResizeHandle, - KtdGridItemPlaceholder + KtdGridItemPlaceholder, + KtdDrag ], exports: [ KtdGridComponent, KtdGridItemComponent, KtdGridDragHandle, KtdGridResizeHandle, - KtdGridItemPlaceholder + KtdGridItemPlaceholder, + KtdDrag ], providers: [ KtdGridService diff --git a/projects/angular-grid-layout/src/lib/grid.service.ts b/projects/angular-grid-layout/src/lib/grid.service.ts index 372ddab..63ad770 100644 --- a/projects/angular-grid-layout/src/lib/grid.service.ts +++ b/projects/angular-grid-layout/src/lib/grid.service.ts @@ -1,49 +1,244 @@ -import { Injectable, NgZone, OnDestroy } from '@angular/core'; -import { ktdNormalizePassiveListenerOptions } from './utils/passive-listeners'; -import { fromEvent, iif, Observable, Subject, Subscription } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import { ktdIsMobileOrTablet } from './utils/pointer.utils'; - -/** Event options that can be used to bind an active, capturing event. */ -const activeCapturingEventOptions = ktdNormalizePassiveListenerOptions({ - passive: false, - capture: true -}); +import {Injectable, NgZone} from '@angular/core'; +import {Observable, Subject, Subscription} from 'rxjs'; +import {DragActionType, KtdGridItemRenderData} from "./grid.definitions"; +import {DragRef} from "./utils/drag-ref"; +import {getDragResizeEventData, KtdGridComponent, PointingDeviceEvent} from "./grid.component"; +import {KtdDrag} from "./directives/ktd-drag"; +import {ktdOutsideZone} from "./utils/operators"; +import {takeUntil} from "rxjs/operators"; +import {ktdPointerMove, ktdPointerUp} from './utils/pointer.utils'; +import {KtdRegistryService} from "./ktd-registry.service"; +import {LayoutItem} from "./utils/react-grid-layout.utils"; + + +export interface PointerEventInfo { + dragRef: DragRef; + startEvent: PointingDeviceEvent; + moveEvent: PointingDeviceEvent; + type: DragActionType; + newLayoutItem: LayoutItem; + renderData: KtdGridItemRenderData | null; + fromGrid: KtdGridComponent | null; // The grid where the drag started, it can be null if the drag started outside a grid. For example, when dragging from a connected drag item + currentGrid: KtdGridComponent | null; + lastGrid: KtdGridComponent | null; // The last grid that we were in, is used so when we stop resizing outside a grid, we can notify the last grid of new layout +} @Injectable({providedIn: 'root'}) -export class KtdGridService implements OnDestroy { +export class KtdGridService { + pointerMove$: Observable; + private pointerMoveSubject: Subject = new Subject(); + private pointerMoveSubscription: Subscription; - touchMove$: Observable; - private touchMoveSubject: Subject = new Subject(); - private touchMoveSubscription: Subscription; + pointerEnd$: Observable; + private pointerEndSubject: Subject = new Subject(); + private pointerEndSubscription: Subscription; - constructor(private ngZone: NgZone) { - this.touchMove$ = this.touchMoveSubject.asObservable(); - this.registerTouchMoveSubscription(); - } + private drag: PointerEventInfo | null = null; - ngOnDestroy() { - this.touchMoveSubscription.unsubscribe(); + constructor( + private ngZone: NgZone, + private registryService: KtdRegistryService, + ) { + this.pointerMove$ = this.pointerMoveSubject.asObservable(); + this.pointerEnd$ = this.pointerEndSubject.asObservable(); + this.initSubscriptions(); } - mouseOrTouchMove$(element): Observable { - return iif( - () => ktdIsMobileOrTablet(), - this.touchMove$, - fromEvent(element, 'mousemove', activeCapturingEventOptions as AddEventListenerOptions) // TODO: Fix rxjs typings, boolean should be a good param too. + private initSubscriptions() { + this.pointerMoveSubscription = this.ngZone.runOutsideAngular(() => + ktdPointerMove(document) + .subscribe((mouseEvent: MouseEvent | TouchEvent) => this.pointerMoveSubject.next(mouseEvent)) ); - } - private registerTouchMoveSubscription() { - // The `touchmove` event gets bound once, ahead of time, because WebKit - // won't preventDefault on a dynamically-added `touchmove` listener. - // See https://bugs.webkit.org/show_bug.cgi?id=184250. - this.touchMoveSubscription = this.ngZone.runOutsideAngular(() => - // The event handler has to be explicitly active, - // because newer browsers make it passive by default. - fromEvent(document, 'touchmove', activeCapturingEventOptions as AddEventListenerOptions) // TODO: Fix rxjs typings, boolean should be a good param too. - .pipe(filter((touchEvent: TouchEvent) => touchEvent.touches.length === 1)) - .subscribe((touchEvent: TouchEvent) => this.touchMoveSubject.next(touchEvent)) + this.pointerEndSubscription = this.ngZone.runOutsideAngular(() => + ktdPointerUp(document) + .subscribe((mouseEvent: MouseEvent | TouchEvent) => { + this.pointerEndSubject.next(mouseEvent); + if (this.drag !== null) { + this.updateGrids(this.drag); + } + this.drag = null; + }) ); } + + /** + * Start a drag sequence. + * @param event The event that triggered the drag sequence. + * @param dragRef The dragRef that started the drag sequence. + * @param type The type of drag sequence. + * @param grid The grid where the drag sequence started. It can be null if the drag sequence started outside a grid. + * @param gridItem The grid item that is being dragged. It can be null if the drag sequence started from outside a grid. + */ + public startDrag(event: MouseEvent | TouchEvent | PointerEvent, dragRef: DragRef, type: DragActionType, grid: KtdGridComponent | null = null, gridItem: {layoutItem: LayoutItem, renderData: KtdGridItemRenderData} | null = null): void { + // Make sure, this function is only being called once + if (this.drag !== null) { + return; + } + + const isKtdDrag = dragRef.itemRef instanceof KtdDrag; + + if (!isKtdDrag && gridItem === null) { + throw new Error('layoutItem must be provided when dragging from a connected drag item'); + } + + this.drag = { + dragRef, + startEvent: event, + moveEvent: event, + type, + fromGrid: isKtdDrag ? null : grid, + currentGrid: null, + lastGrid: isKtdDrag ? null : grid, + newLayoutItem: isKtdDrag ? { + id: dragRef.id, + w: dragRef.width, + h: dragRef.height, + x: -1, + y: -1, + data: dragRef.data, + } : gridItem!.layoutItem!, + renderData: isKtdDrag ? null : gridItem!.renderData!, + }; + + if (grid !== null) { + grid.setGridBackgroundVisible(grid.backgroundConfig?.show === 'whenDragging' || grid.backgroundConfig?.show === 'always'); + this.ngZone.run(() => (type === 'drag' ? grid.dragStarted : grid.resizeStarted).emit(getDragResizeEventData(dragRef, grid.layout))); + } + + const connectedToGrids = isKtdDrag ? (dragRef.itemRef as KtdDrag).connectedTo : this.registryService._ktgGrids; + this.pointerMove$.pipe( + takeUntil(this.pointerEnd$), + ktdOutsideZone(this.ngZone), + ).subscribe((moveEvent: MouseEvent | TouchEvent) => { + this.drag!.moveEvent = moveEvent; + this.handleGridInteraction(moveEvent, connectedToGrids); + }); + } + + private handleGridInteraction(moveEvent: MouseEvent | TouchEvent, connectedToGrids: KtdGridComponent[]): void { + for (const grid of connectedToGrids) { + if (!grid.isPointerInsideGridElement(moveEvent)) { + continue; + } + + // When we are still in the same grid, we don't need to do anything + if (grid.id === this.drag!.currentGrid?.id) { + return; + } + + // We are in new grid, so we need to notify the previous grid that the item has left it + this.notifyGrids(grid, moveEvent); + return; + } + + // We are not in any grid, so we need to notify the previous grid that the item has left it + this.notifyGrids(null, moveEvent); + } + + /** + * Notify the previous grid that the item has left it and notify the new grid that the item has entered it. + */ + private notifyGrids(grid: KtdGridComponent | null, moveEvent: MouseEvent | TouchEvent): void { + if (this.drag!.currentGrid !== null) { + this.drag!.currentGrid.dragExited.next({ + source: this.drag!.dragRef, + event: moveEvent, + grid: this.drag!.currentGrid, + dragInfo: this.drag!, + }); + } + + if (grid !== null) { + grid.dragEntered.next({ + source: this.drag!.dragRef, + event: moveEvent, + grid: grid, + dragInfo: this.drag!, + }); + } + + this.drag!.currentGrid = grid; + this.drag!.lastGrid = grid === null ? this.drag!.lastGrid : grid; + } + + private updateGrids(drag: PointerEventInfo): void { + // If the drag ended outside a grid, we don't need to do anything + if (drag.currentGrid === null && drag.type === 'drag') { + /* + * This emit is not required, but when it is not here, it cases a bug where the grid-element, + * does not return to its original position when the drag ends outside the grid. + * The same thing happens when the resize ends outside the grid. + * */ + drag.fromGrid?.layoutUpdated.emit(drag.fromGrid!.layout); + drag.fromGrid?.stopDragSequence(drag); + return; + } + + if (drag.type === 'resize') { + if (drag.lastGrid === null) { + console.error('lastGrid is null'); + return; + } + + // Do not update the layout if the resize did not change the layout, + // this can happen when the user only clicks on the resize handle, but does not resize + if (drag.fromGrid !== null && drag.fromGrid.drag !== null && drag.fromGrid.drag.newLayout !== null) { + drag.fromGrid.layoutUpdated.emit(drag.fromGrid.drag.newLayout); + } else if (drag.lastGrid.drag !== null && drag.lastGrid.drag.newLayout !== null) { + drag.lastGrid.layoutUpdated.emit(drag.lastGrid.drag!.newLayout!); + } + + // + // if (drag.fromGrid === drag.currentGrid) { + // drag.currentGrid!.layoutUpdated.emit(drag.currentGrid!.drag!.newLayout!); + // } else { + // /* + // * This emit is not required, but when it is not here, it cases a bug where the grid-element, + // * does not return to its original position when the resize ends on another grid than the one it started. + // */ + // if (drag.fromGrid !== null && drag.fromGrid.drag !== null) { + // drag.fromGrid.layoutUpdated.emit(drag.fromGrid.drag.newLayout!); + // } + // } + + } else { + const currentLayoutItem = drag.fromGrid === null ? { + ...drag.newLayoutItem, + id: drag.currentGrid!.getNextId() + } : drag.newLayoutItem; + + // Dragging between two distinct grids + if (drag.fromGrid !== drag.currentGrid) { + // Notify the previous grid that the item has left it + drag.fromGrid?.layoutUpdated.emit(drag.fromGrid!.layout.filter(item => item.id !== drag.dragRef.id)); + + // Notify the new grid that we dropped new item that was not in any grid + drag.currentGrid?.dropped.emit({ + event: drag.moveEvent, + currentLayout: drag.currentGrid.drag!.newLayout!.map(item => ({...item})), + currentLayoutItem: currentLayoutItem, + }); + } else { + // Skip the update if the layout did not change, + // this may happen when the user only clicks on the drag handle, but does not drag + if (drag.currentGrid!.drag === null) { + return; + } + + // Update the new grid layout + drag.currentGrid!.layoutUpdated.emit(drag.currentGrid!.drag!.newLayout!); + } + } + + // Clean up + drag.fromGrid?.stopDragSequence(drag); + drag.currentGrid?.stopDragSequence(drag); + this.registryService._ktgGrids.forEach(grid => grid.clearDragSequence()); + } + + dispose() { + this.pointerMoveSubscription.unsubscribe(); + this.pointerEndSubscription.unsubscribe(); + } } diff --git a/projects/angular-grid-layout/src/lib/ktd-registry.service.ts b/projects/angular-grid-layout/src/lib/ktd-registry.service.ts new file mode 100644 index 0000000..c800975 --- /dev/null +++ b/projects/angular-grid-layout/src/lib/ktd-registry.service.ts @@ -0,0 +1,113 @@ +import {ElementRef, Injectable, NgZone} from '@angular/core'; +import {KtdDrag} from "./directives/ktd-drag"; +import {BehaviorSubject} from "rxjs"; +import {KtdGridService} from "./grid.service"; +import {DragRef} from "./utils/drag-ref"; +import {KtdGridItemComponent} from "./grid-item/grid-item.component"; +import {KtdGridComponent} from "./grid.component"; + +@Injectable({ + providedIn: 'root' +}) +export class KtdRegistryService { + private _ktdDragItems: KtdDrag[] = []; + public ktdDragItems$: BehaviorSubject[]> = new BehaviorSubject[]>(this._ktdDragItems); + + // Two way binding between grids and drag items + private gridConnectedToKtdDragItems: {[gridId: string]: BehaviorSubject[]>} = {}; + private gridConnectedToGridItems: {[gridId: string]: BehaviorSubject[]>} = {}; + private ktdDragItemConnectedToGrid: {[ktdDragItemId: string]: KtdGridComponent[]} = {}; + private gridItemConnectedToGrid: {[gridItemId: string]: KtdGridComponent[]} = {}; + + private _dragRefItems: DragRef[] = []; + public dragRefItems$: BehaviorSubject[]> = new BehaviorSubject[]>(this._dragRefItems); + + public _ktgGrids: KtdGridComponent[] = []; + public ktgGrids$: BehaviorSubject = new BehaviorSubject(this._ktgGrids); + + constructor( + private _ngZone: NgZone, + ) { } + + public createKtgDrag(element: ElementRef, gridService: KtdGridService, itemRef: KtdGridItemComponent | KtdDrag): DragRef { + const dragRef = new DragRef(element, gridService, this._ngZone, itemRef); + this._dragRefItems.push(dragRef); + this.dragRefItems$.next(this._dragRefItems); + return dragRef; + } + + public destroyKtgDrag(dragRef: DragRef) { + this._dragRefItems.splice(this._dragRefItems.indexOf(dragRef), 1); + this.dragRefItems$.next(this._dragRefItems); + dragRef.dispose(); + } + + public registerKtgDragItem(item: KtdDrag) { + this._ktdDragItems.push(item); + + // Check if each item has unique id + const ids = this._dragRefItems.map(item => item.id); + if (new Set(ids).size !== ids.length) { + throw new Error(`KtdDrag: dragRef id must be unique`); + } + + this.ktdDragItems$.next(this._ktdDragItems); + } + + public unregisterKtgDragItem(item: KtdDrag) { + this._ktdDragItems.splice(this._ktdDragItems.indexOf(item), 1); + this.ktdDragItems$.next(this._ktdDragItems); + } + + public registerKtdGrid(grid: KtdGridComponent) { + this._ktgGrids.push(grid); + + // Check if each grid has unique id + const ids = this._dragRefItems.map(item => item.id); + if (new Set(ids).size !== ids.length) { + throw new Error(`ktd-grid id must be unique`); + } + + this.ktgGrids$.next(this._ktgGrids); + } + + public unregisterKtdGrid(grid: KtdGridComponent) { + this._ktgGrids.splice(this._ktgGrids.indexOf(grid), 1); + this.ktgGrids$.next(this._ktgGrids); + } + + public updateConnectedTo(dragRef: DragRef, connectedTo: KtdGridComponent[]) { + if (dragRef.itemRef instanceof KtdGridItemComponent) { + connectedTo.forEach(grid => { + if (!this.gridConnectedToGridItems[grid.id]) { + this.gridConnectedToGridItems[grid.id] = new BehaviorSubject[]>([]); + } + const connectedToSubject = this.gridConnectedToGridItems[grid.id]; + const connectedTo = connectedToSubject.getValue(); + connectedTo.push(dragRef.itemRef as KtdGridItemComponent); + connectedToSubject.next(connectedTo); + + if (!this.gridItemConnectedToGrid[dragRef.id]) { + this.gridItemConnectedToGrid[dragRef.id] = []; + } + this.gridItemConnectedToGrid[dragRef.id].push(grid); + }); + return; + } + + connectedTo.forEach(grid => { + if (!this.gridConnectedToKtdDragItems[grid.id]) { + this.gridConnectedToKtdDragItems[grid.id] = new BehaviorSubject[]>([]); + } + const connectedToSubject = this.gridConnectedToKtdDragItems[grid.id]; + const connectedTo = connectedToSubject.getValue(); + connectedTo.push(dragRef.itemRef as KtdDrag); + connectedToSubject.next(connectedTo); + + if (!this.ktdDragItemConnectedToGrid[dragRef.id]) { + this.ktdDragItemConnectedToGrid[dragRef.id] = []; + } + this.ktdDragItemConnectedToGrid[dragRef.id].push(grid); + }); + } +} diff --git a/projects/angular-grid-layout/src/lib/utils/drag-ref.ts b/projects/angular-grid-layout/src/lib/utils/drag-ref.ts new file mode 100644 index 0000000..d7e373b --- /dev/null +++ b/projects/angular-grid-layout/src/lib/utils/drag-ref.ts @@ -0,0 +1,256 @@ +import {ElementRef, NgZone} from "@angular/core"; +import {coerceBooleanProperty} from "../coercion/boolean-property"; +import {BehaviorSubject, combineLatest, iif, merge, NEVER, Observable, of, Subject, Subscription} from "rxjs"; +import {exhaustMap, filter, map, startWith, switchMap, take, takeUntil} from "rxjs/operators"; +import {ktdPointerClient, ktdPointerClientX, ktdPointerClientY, ktdPointerDown} from './pointer.utils'; +import {ktdOutsideZone} from "./operators"; +import {KtdGridService} from "../grid.service"; +import {KtdGridDragHandle} from "../directives/drag-handle"; +import {KtdGridItemPlaceholder} from "../directives/placeholder"; +import {KtdGridItemComponent} from "../grid-item/grid-item.component"; +import {KtdDrag} from "../directives/ktd-drag"; +import {ktdGetScrollTotalRelativeDifference$, ktdScrollIfNearElementClientRect$} from "./scroll"; +import {PointingDeviceEvent} from "../grid.component"; +import {KtdGridResizeHandle} from "../directives/resize-handle"; + +export class DragRef { + private static _nextUniqueId: number = 0; + + id: string = `ktd-drag-${DragRef._nextUniqueId++}`; + width: number = 1; + height: number = 1; + dragStartThreshold: number = 0; + data: T; + + get scrollableParent(): HTMLElement | Document | string | null { + return this._scrollableParent; + } + set scrollableParent(val: HTMLElement | Document | string | null) { + this._scrollableParent = val; + this.scrollableParent$.next(this._scrollableParent); + } + private _scrollableParent: HTMLElement | Document | string | null = null; + private scrollableParent$: BehaviorSubject = new BehaviorSubject(this._scrollableParent); + placeholder: KtdGridItemPlaceholder; + scrollSpeed: number = 2; + + transformX: number = 0; + transformY: number = 0; + + get dragHandles(): KtdGridDragHandle[] { + return this._dragHandles; + } + set dragHandles(val: KtdGridDragHandle[]) { + this._dragHandles = val; + this._dragHandles$.next(this._dragHandles); + } + private _dragHandles: KtdGridDragHandle[] = []; + private _dragHandles$: BehaviorSubject = new BehaviorSubject(this._dragHandles); + + get resizeHandles(): KtdGridDragHandle[] { + return this._resizeHandles; + } + set resizeHandles(val: KtdGridResizeHandle[]) { + this._resizeHandles = val; + this._resizeHandles$.next(this._resizeHandles); + } + private _resizeHandles: KtdGridResizeHandle[] = []; + private _resizeHandles$: BehaviorSubject = new BehaviorSubject(this._resizeHandles); + + get draggable(): boolean { + return this._draggable; + } + set draggable(val: boolean) { + this._draggable = coerceBooleanProperty(val); + this._draggable$.next(this._draggable); + // Re-subscribe to the drag start observable to update the draggable state. + // this.dragStartSubscription?.unsubscribe(); + // this.dragStartSubscription = this._dragStart$().subscribe(this.dragStartSubject); + } + private _draggable: boolean = true; + private _draggable$: BehaviorSubject = new BehaviorSubject(this._draggable); + + get itemRef(): KtdGridItemComponent | KtdDrag { + return this._itemRef; + } + + get isDragging(): boolean { + return this._isDragging; + } + private _isDragging: boolean = false; + + private _manualDragEvents$: Subject = new Subject(); + + private dragStartSubject: Subject<{source: DragRef; event: MouseEvent | TouchEvent}> = new Subject<{source: DragRef; event: MouseEvent | TouchEvent}>(); + private dragMoveSubject: Subject<{source: DragRef; event: MouseEvent | TouchEvent}> = new Subject<{source: DragRef; event: MouseEvent | TouchEvent}>(); + private dragEndSubject: Subject<{source: DragRef; event: MouseEvent | TouchEvent}> = new Subject<{source: DragRef; event: MouseEvent | TouchEvent}>(); + + readonly dragStart$ = new Observable<{source: DragRef; event: MouseEvent | TouchEvent}>(); + readonly dragMove$ = new Observable<{source: DragRef; event: MouseEvent | TouchEvent}>(); + readonly dragEnd$ = new Observable<{source: DragRef; event: MouseEvent | TouchEvent}>(); + + private subscriptions: Subscription[] = []; + private dragStartSubscription: Subscription; + + private readonly element: HTMLElement; + + constructor( + public elementRef: ElementRef, + private _gridService: KtdGridService, + private _ngZone: NgZone, + private _itemRef: KtdGridItemComponent | KtdDrag, + ) { + this.dragStart$ = this.dragStartSubject.asObservable(); + this.dragMove$ = this.dragMoveSubject.asObservable(); + this.dragEnd$ = this.dragEndSubject.asObservable(); + + this.dragStartSubscription = this._dragStart$().subscribe(this.dragStartSubject); + + this.element = this.elementRef.nativeElement as HTMLElement; + this.initDrag(); + } + + public dispose() { + this._manualDragEvents$.complete(); + this.dragStartSubject.complete(); + this.dragMoveSubject.complete(); + this.dragEndSubject.complete(); + + this._manualDragEvents$.unsubscribe(); + this.dragStartSubject.unsubscribe(); + this.dragMoveSubject.unsubscribe(); + this.dragEndSubject.unsubscribe(); + this.dragStartSubscription.unsubscribe(); + + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + } + + /** + * Initialize the drag of ktd-drag element, placeholder dragging is handled by ktd-grid. + * The element will be freely draggable, when drag ends it will snap back to its initial place. + */ + private initDrag(): Subscription { + return this.scrollableParent$.pipe( + switchMap((newScrollableParent) => { + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + + const scrollableParent = typeof newScrollableParent === 'string' ? document.getElementById(newScrollableParent) : newScrollableParent; + + let scrollSubscription: Subscription | null = null; + let dragSubscription: Subscription | null = null; + + const dragStart$ = this.dragStart$.subscribe(({event}) => { + const initialX = ktdPointerClientX(event) - this.transformX; + const initialY = ktdPointerClientY(event) - this.transformY; + + scrollSubscription = this._ngZone.runOutsideAngular(() => + (!scrollableParent ? NEVER : this.dragMove$.pipe( + map(({event}) => ({ + pointerX: ktdPointerClientX(event), + pointerY: ktdPointerClientY(event) + })), + ktdScrollIfNearElementClientRect$(scrollableParent, {scrollStep: 2}), + takeUntil(this.dragEnd$) + )).subscribe()); + + dragSubscription = this._ngZone.runOutsideAngular(() => + merge( + combineLatest([ + this.dragMove$, + ...(!scrollableParent ? [of({top: 0, left: 0})] : [ + ktdGetScrollTotalRelativeDifference$(scrollableParent).pipe( + startWith({top: 0, left: 0}) // Force first emission to allow CombineLatest to emit even no scroll event has occurred + ) + ]) + ]) + ).pipe( + takeUntil(this.dragEnd$), + ).subscribe(([{event}, scrollDifference]: [{source: DragRef, event: PointingDeviceEvent}, { top: number, left: number }]) => { + event.preventDefault(); + + const currentX = ktdPointerClientX(event) - initialX - scrollDifference.left; + const currentY = ktdPointerClientY(event) - initialY - scrollDifference.top; + + this.element.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`; + })); + }); + + const dragEnd$ = this.dragEnd$.subscribe(() => { + scrollSubscription?.unsubscribe(); + dragSubscription?.unsubscribe(); + this.element.style.transform = `translate3d(${this.transformX}px, ${this.transformY}px, 0)`; + }); + + this.subscriptions = [dragStart$, dragEnd$]; + // Return an observable that completes when the dragEnd$ observable completes + return this.dragEnd$.pipe(takeUntil(this.dragEnd$)); + }) + ).subscribe(); + } + + public startDragManual(event: MouseEvent | TouchEvent) { + this._manualDragEvents$.next(event); + } + + private _dragStart$(): Observable<{source: DragRef, event: MouseEvent | TouchEvent}> { + return this._draggable$.pipe( + switchMap(draggable => + draggable ? + merge( + this._manualDragEvents$, + this._dragHandles$.pipe( + switchMap(dragHandles => + iif(() => dragHandles.length > 0, + merge(...dragHandles.map(dragHandle => ktdPointerDown(dragHandle.element.nativeElement))), + ktdPointerDown(this.elementRef.nativeElement) + ) + ) + ) + ) : NEVER) + ).pipe( + exhaustMap((startEvent) => { + // If the event started from an element with the native HTML drag&drop, it'll interfere + // with our positioning logic since it'll start dragging the native element. + if (startEvent.target && (startEvent.target as HTMLElement).draggable && startEvent.type === 'pointerdown') { + startEvent.preventDefault(); + } + + const startPointer = ktdPointerClient(startEvent); + return this._gridService.pointerMove$.pipe( + takeUntil(this._gridService.pointerEnd$), + ktdOutsideZone(this._ngZone), + filter((moveEvent) => { + moveEvent.preventDefault(); + const movePointer = ktdPointerClient(moveEvent); + const distanceX = Math.abs(startPointer.clientX - movePointer.clientX); + const distanceY = Math.abs(startPointer.clientY - movePointer.clientY); + // When this conditions returns true mean that we are over threshold. + return distanceX + distanceY >= this.dragStartThreshold; + }), + take(1), + map((moveEvent) => { + // Emit the move event, so the user can perform any action while dragging. + this._gridService.pointerMove$.pipe( + takeUntil(this._gridService.pointerEnd$), + ).subscribe((moveEvent) => { + this._isDragging = true; + this.dragMoveSubject.next({source: this, event: moveEvent}); + }); + + // Emit the end event, so the user can perform any action when the drag stops; + this._gridService.pointerEnd$.pipe( + filter(() => this._isDragging), + take(1), + ).subscribe((event) => { + this._isDragging = false; + this.dragEndSubject.next({source: this, event}); + }); + + // Emit the start event, so the user can perform any action when the drag starts. + return {source: this, event: moveEvent}; + }), + ); + }) + ); + } +} diff --git a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts index e91e4aa..5eed39b 100644 --- a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts @@ -1,10 +1,15 @@ -import { compact, CompactType, getFirstCollision, Layout, LayoutItem, moveElement } from './react-grid-layout.utils'; import { - KtdDraggingData, KtdGridCfg, KtdGridCompactType, KtdGridItemRect, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem -} from '../grid.definitions'; + compact, + compactLayout, + CompactType, + getFirstCollision, + Layout, + LayoutItem, + moveElement +} from './react-grid-layout.utils'; +import { KtdDraggingData, KtdGridCfg, KtdGridCompactType, KtdGridItemRect, KtdGridLayout, KtdGridLayoutItem } from '../grid.definitions'; import { ktdPointerClientX, ktdPointerClientY } from './pointer.utils'; import { KtdDictionary } from '../../types'; -import { KtdGridItemComponent } from '../grid-item/grid-item.component'; /** Tracks items by id. This function is mean to be used in conjunction with the ngFor that renders the 'ktd-grid-items' */ export function ktdTrackById(index: number, item: {id: string}) { @@ -22,13 +27,13 @@ export function ktdGetGridItemRowHeight(layout: KtdGridLayout, gridHeight: numbe /** * Call react-grid-layout utils 'compact()' function and return the compacted layout. * @param layout to be compacted. - * @param compactType, type of compaction. - * @param cols, number of columns of the grid. + * @param compactType type of compaction. + * @param cols number of columns of the grid. */ export function ktdGridCompact(layout: KtdGridLayout, compactType: KtdGridCompactType, cols: number): KtdGridLayout { return compact(layout, compactType, cols) // Prune react-grid-layout compact extra properties. - .map(item => ({ id: item.id, x: item.x, y: item.y, w: item.w, h: item.h, minW: item.minW, minH: item.minH, maxW: item.maxW, maxH: item.maxH })); + .map(item => ({ id: item.id, x: item.x, y: item.y, w: item.w, h: item.h, minW: item.minW, minH: item.minH, maxW: item.maxW, maxH: item.maxH, data: item.data })); } function screenXToGridX(screenXPos: number, cols: number, width: number, gap: number): number { @@ -75,17 +80,28 @@ export function ktdGetGridLayoutDiff(gridLayoutA: KtdGridLayoutItem[], gridLayou /** * Given the grid config & layout data and the current drag position & information, returns the corresponding layout and drag item position - * @param gridItem grid item that is been dragged + * @param item grid item that is been dragged * @param config current grid configuration * @param compactionType type of compaction that will be performed * @param draggingData contains all the information about the drag */ -export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdGridCfg, compactionType: CompactType, draggingData: KtdDraggingData): { layout: KtdGridLayoutItem[]; draggedItemPos: KtdGridItemRect } { - const {pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElemClientRect, scrollDifference} = draggingData; - - const gridItemId = gridItem.id; - - const draggingElemPrevItem = config.layout.find(item => item.id === gridItemId)!; +export function ktdGridItemDragging( + item: KtdGridLayoutItem, + config: KtdGridCfg, + compactionType: CompactType, + draggingData: KtdDraggingData +): { layout: KtdGridLayoutItem[]; draggedItemPos: KtdGridItemRect, draggedLayoutItem: KtdGridLayoutItem } { + const { + pointerDownEvent, + pointerDragEvent, + gridElemClientRect, + dragElemClientRect, + scrollDifference, + draggingFromOutside + } = draggingData; + + const tmpGridItemId = 'tmp-dragging-item'; + const gridItemId = draggingFromOutside ? tmpGridItemId + item.id : item.id; const clientStartX = ktdPointerClientX(pointerDownEvent); const clientStartY = ktdPointerClientY(pointerDownEvent); @@ -109,7 +125,8 @@ export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdG // Get layout item position const layoutItem: KtdGridLayoutItem = { - ...draggingElemPrevItem, + ...item, + id: gridItemId, x: screenXToGridX(gridRelXPos , config.cols, gridElemClientRect.width, config.gap), y: screenYToGridY(gridRelYPos, rowHeightInPixels, gridElemClientRect.height, config.gap) }; @@ -120,10 +137,12 @@ export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdG if (layoutItem.x + layoutItem.w > config.cols) { layoutItem.x = Math.max(0, config.cols - layoutItem.w); } - // Parse to LayoutItem array data in order to use 'react.grid-layout' utils const layoutItems: LayoutItem[] = config.layout; - const draggedLayoutItem: LayoutItem = layoutItems.find(item => item.id === gridItemId)!; + const draggedLayoutItem = draggingFromOutside ? { + ...item, + id: gridItemId + } : layoutItems.find(item => item.id === gridItemId)!; let newLayoutItems: LayoutItem[] = moveElement( layoutItems, @@ -136,6 +155,26 @@ export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdG config.cols ); + if (draggingFromOutside) { + newLayoutItems = compactLayout(newLayoutItems, layoutItem, compactionType, config.cols); + const newLayoutItem = newLayoutItems.find(item => item.id === gridItemId)!; + newLayoutItems = newLayoutItems.filter((item) => item.id !== gridItemId); + + return { + layout: newLayoutItems, + draggedItemPos: { + top: gridRelYPos, + left: gridRelXPos, + width: dragElemClientRect.width, + height: dragElemClientRect.height, + }, + draggedLayoutItem: { + ...newLayoutItem, + id: newLayoutItem.id.replace(tmpGridItemId, '') + }, + }; + } + newLayoutItems = compact(newLayoutItems, compactionType, config.cols); return { @@ -145,20 +184,21 @@ export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdG left: gridRelXPos, width: dragElemClientRect.width, height: dragElemClientRect.height, - } + }, + draggedLayoutItem: draggedLayoutItem, }; } /** * Given the grid config & layout data and the current drag position & information, returns the corresponding layout and drag item position - * @param gridItem grid item that is been dragged + * @param item grid item that is been dragged * @param config current grid configuration * @param compactionType type of compaction that will be performed * @param draggingData contains all the information about the drag */ -export function ktdGridItemResizing(gridItem: KtdGridItemComponent, config: KtdGridCfg, compactionType: CompactType, draggingData: KtdDraggingData): { layout: KtdGridLayoutItem[]; draggedItemPos: KtdGridItemRect } { +export function ktdGridItemResizing(item: KtdGridLayoutItem, config: KtdGridCfg, compactionType: CompactType, draggingData: KtdDraggingData): { layout: KtdGridLayoutItem[]; draggedItemPos: KtdGridItemRect, draggedLayoutItem: KtdGridLayoutItem } { const {pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElemClientRect, scrollDifference} = draggingData; - const gridItemId = gridItem.id; + const gridItemId = item.id; const clientStartX = ktdPointerClientX(pointerDownEvent); const clientStartY = ktdPointerClientY(pointerDownEvent); @@ -184,8 +224,8 @@ export function ktdGridItemResizing(gridItem: KtdGridItemComponent, config: KtdG h: screenHeightToGridHeight(height, rowHeightInPixels, gridElemClientRect.height, config.gap) }; - layoutItem.w = limitNumberWithinRange(layoutItem.w, gridItem.minW ?? layoutItem.minW, gridItem.maxW ?? layoutItem.maxW); - layoutItem.h = limitNumberWithinRange(layoutItem.h, gridItem.minH ?? layoutItem.minH, gridItem.maxH ?? layoutItem.maxH); + layoutItem.w = limitNumberWithinRange(layoutItem.w, item.minW ?? layoutItem.minW, item.maxW ?? layoutItem.maxW); + layoutItem.h = limitNumberWithinRange(layoutItem.h, item.minH ?? layoutItem.minH, item.maxH ?? layoutItem.maxH); if (layoutItem.x + layoutItem.w > config.cols) { layoutItem.w = Math.max(1, config.cols - layoutItem.x); @@ -236,7 +276,8 @@ export function ktdGridItemResizing(gridItem: KtdGridItemComponent, config: KtdG left: dragElemClientRect.left - gridElemClientRect.left, width, height, - } + }, + draggedLayoutItem: layoutItem, }; } @@ -257,7 +298,7 @@ function getDimensionToShrink(layoutItem, lastShrunk): 'w' | 'h' { /** * Given the current number and min/max values, returns the number within the range - * @param number can be any numeric value + * @param num can be any numeric value * @param min minimum value of range * @param max maximum value of range */ diff --git a/projects/angular-grid-layout/src/lib/utils/pointer.utils.ts b/projects/angular-grid-layout/src/lib/utils/pointer.utils.ts index ada274b..4685960 100644 --- a/projects/angular-grid-layout/src/lib/utils/pointer.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/pointer.utils.ts @@ -43,19 +43,24 @@ export function ktdPointerClientY(event: MouseEvent | TouchEvent): number { return ktdIsMouseEvent(event) ? event.clientY : event.touches[0].clientY; } -export function ktdPointerClient(event: MouseEvent | TouchEvent): {clientX: number, clientY: number} { - return { +export function ktdPointerClient(event: MouseEvent | TouchEvent): { clientX: number, clientY: number } { + return { clientX: ktdIsMouseEvent(event) ? event.clientX : event.touches[0].clientX, clientY: ktdIsMouseEvent(event) ? event.clientY : event.touches[0].clientY }; } +/** Returns true if browser supports pointer events */ +export function ktdSupportsPointerEvents(): boolean { + return !!window.PointerEvent; +} + /** * Emits when a mousedown or touchstart emits. Avoids conflicts between both events. * @param element, html element where to listen the events. * @param touchNumber number of the touch to track the event, default to the first one. */ -export function ktdMouseOrTouchDown(element, touchNumber = 1): Observable { +function ktdMouseOrTouchDown(element, touchNumber = 1): Observable { return iif( () => ktdIsMobileOrTablet(), fromEvent(element, 'touchstart', passiveEventListenerOptions as AddEventListenerOptions).pipe( @@ -79,7 +84,7 @@ export function ktdMouseOrTouchDown(element, touchNumber = 1): Observable { +function ktdMouserOrTouchMove(element: HTMLElement, touchNumber = 1): Observable { return iif( () => ktdIsMobileOrTablet(), fromEvent(element, 'touchmove', activeEventListenerOptions as AddEventListenerOptions).pipe( @@ -105,10 +110,49 @@ export function ktdTouchEnd(element, touchNumber = 1): Observable { * @param element, html element where to listen the events. * @param touchNumber number of the touch to track the event, default to the first one. */ -export function ktdMouseOrTouchEnd(element, touchNumber = 1): Observable { +function ktdMouserOrTouchEnd(element: HTMLElement, touchNumber = 1): Observable { return iif( () => ktdIsMobileOrTablet(), ktdTouchEnd(element, touchNumber), fromEvent(element, 'mouseup'), ); } + + +/** + * Emits when a 'pointerdown' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. + * @param element, html element where to listen the events. + */ +export function ktdPointerDown(element): Observable { + if (!ktdSupportsPointerEvents()) { + return ktdMouseOrTouchDown(element); + } + + return fromEvent(element, 'pointerdown', activeEventListenerOptions as AddEventListenerOptions).pipe( + filter((pointerEvent) => pointerEvent.isPrimary) + ) +} + +/** + * Emits when a 'pointermove' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. + * @param element, html element where to listen the events. + */ +export function ktdPointerMove(element): Observable { + if (!ktdSupportsPointerEvents()) { + return ktdMouserOrTouchMove(element); + } + return fromEvent(element, 'pointermove', activeEventListenerOptions as AddEventListenerOptions).pipe( + filter((pointerEvent) => pointerEvent.isPrimary), + ); +} + +/** + * Emits when a 'pointerup' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. + * @param element, html element where to listen the events. + */ +export function ktdPointerUp(element): Observable { + if (!ktdSupportsPointerEvents()) { + return ktdMouserOrTouchEnd(element); + } + return fromEvent(element, 'pointerup').pipe(filter(pointerEvent => pointerEvent.isPrimary)); +} diff --git a/projects/angular-grid-layout/src/lib/utils/react-grid-layout.utils.ts b/projects/angular-grid-layout/src/lib/utils/react-grid-layout.utils.ts index ae6ebc0..18f4e57 100644 --- a/projects/angular-grid-layout/src/lib/utils/react-grid-layout.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/react-grid-layout.utils.ts @@ -20,6 +20,7 @@ export type LayoutItem = { static?: boolean; isDraggable?: boolean | null | undefined; isResizable?: boolean | null | undefined; + data?: any; }; export type Layout = Array; export type Position = { @@ -117,6 +118,7 @@ export function cloneLayoutItem(layoutItem: LayoutItem): LayoutItem { if (layoutItem.minH !== undefined) { clonedLayoutItem.minH = layoutItem.minH;} if (layoutItem.maxH !== undefined) { clonedLayoutItem.maxH = layoutItem.maxH;} // These can be null + if (layoutItem.data !== undefined) { clonedLayoutItem.data = layoutItem.data;} if (layoutItem.isDraggable !== undefined) { clonedLayoutItem.isDraggable = layoutItem.isDraggable;} if (layoutItem.isResizable !== undefined) { clonedLayoutItem.isResizable = layoutItem.isResizable;} @@ -159,6 +161,19 @@ export function compact( compactType: CompactType, cols: number, ): Layout { + return compactLayout(layout, null, compactType, cols); +} + + +export function compactLayout( + layout: Layout, + layoutItem: LayoutItem | null, + compactType: CompactType, + cols: number, +): Layout { + if (layoutItem != null) { + layout = [...layout, layoutItem]; + } // Statics go in the compareWith array right away so items flow around them. const compareWith = getStatics(layout); // We go through the items by row and column. diff --git a/projects/angular-grid-layout/src/public-api.ts b/projects/angular-grid-layout/src/public-api.ts index 6081ecd..1d550fc 100644 --- a/projects/angular-grid-layout/src/public-api.ts +++ b/projects/angular-grid-layout/src/public-api.ts @@ -6,6 +6,7 @@ export { KtdClientRect } from './lib/utils/client-rect'; export * from './lib/directives/drag-handle'; export * from './lib/directives/resize-handle'; export * from './lib/directives/placeholder'; +export * from './lib/directives/ktd-drag'; export * from './lib/grid-item/grid-item.component'; export * from './lib/grid.definitions'; export * from './lib/grid.component'; diff --git a/projects/demo-app/src/app/app-routing.module.ts b/projects/demo-app/src/app/app-routing.module.ts index 43e5372..6c7fca7 100644 --- a/projects/demo-app/src/app/app-routing.module.ts +++ b/projects/demo-app/src/app/app-routing.module.ts @@ -31,6 +31,11 @@ const routes: Routes = [ loadComponent: () => import('./row-height-fit/row-height-fit.component').then(m => m.KtdRowHeightFitComponent), data: {title: 'Angular Grid Layout - Row Height Fit'} }, + { + path: 'drag-from-outside', + loadComponent: () => import('./drag-from-outside/drag-from-outside.component').then(m => m.KtdDragFromOutsideComponent), + data: {title: 'Angular Grid Layout - Drag From Outside'} + }, { path: '**', redirectTo: 'playground' diff --git a/projects/demo-app/src/app/components/footer/footer.component.html b/projects/demo-app/src/app/components/footer/footer.component.html index 37d5b57..eeac86d 100644 --- a/projects/demo-app/src/app/components/footer/footer.component.html +++ b/projects/demo-app/src/app/components/footer/footer.component.html @@ -5,4 +5,5 @@

Other examples:

Real life example Scroll test Row Height Fit + Drag From Outside diff --git a/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.html b/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.html new file mode 100644 index 0000000..e6829bd --- /dev/null +++ b/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.html @@ -0,0 +1,63 @@ + +
+
+ + {{pokemon.name}} +
+
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+ diff --git a/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.scss b/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.scss new file mode 100644 index 0000000..6959334 --- /dev/null +++ b/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.scss @@ -0,0 +1,82 @@ +:host { + display: block; + width: 100%; + padding: 48px 32px; + box-sizing: border-box; + + .pokemons-container { + display: flex; + flex-wrap: wrap; + } + + .pokemon-drag-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px; + + img { + width: 60px; + height: 60px; + object-fit: cover; + } + } + + .grids-container { + width: 100%; + display: flex; + gap: 24px; + + .grid-container { + width: 100%; + padding: 4px; + box-sizing: border-box; + border-radius: 2px; + border: 1px solid var(--ktd-border-color); + background-color: var(--ktd-background-color); + + ktd-grid { + min-height: 500px; + } + } + } + + + + .grid-item-content { + box-sizing: border-box; + background: #ccc; + border: 1px solid black; + color: black; + width: 100%; + height: 100%; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + } + + ktd-grid { + transition: height 500ms ease; + } + + ktd-grid-item { + background: #444444; + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + &.ktd-grid-item-dragging { + } + + + } + + // customize placeholder + ::ng-deep .ktd-grid-item-placeholder { + background-color: #ffa726; + } + +} diff --git a/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.ts b/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.ts new file mode 100644 index 0000000..26aa8ad --- /dev/null +++ b/projects/demo-app/src/app/drag-from-outside/drag-from-outside.component.ts @@ -0,0 +1,81 @@ +import { Component, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { fromEvent, merge, Subscription } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { KtdGridModule, KtdGridComponent, KtdGridLayout, ktdTrackById, KtdGridBackgroundCfg, KtdGridCompactType } from '@katoid/angular-grid-layout'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { KtdFooterComponent } from '../components/footer/footer.component'; +import { pokemonsGen1 } from './pokemons-gen1'; +import {compact} from "../../../../angular-grid-layout/src/lib/utils/react-grid-layout.utils"; +import {KtdDropped} from "../../../../angular-grid-layout/src/lib/grid.component"; + + +interface Pokemon { + name: string, + url: string, + img: string +} +@Component({ + selector: 'ktd-drag-from-outside', + standalone: true, + imports: [CommonModule, KtdGridModule, RouterModule, KtdFooterComponent], + templateUrl: './drag-from-outside.component.html', + styleUrls: ['./drag-from-outside.component.scss'] +}) +export class KtdDragFromOutsideComponent implements OnInit, OnDestroy { + @ViewChild(KtdGridComponent, {static: true}) grid: KtdGridComponent; + trackById = ktdTrackById; + layout: KtdGridLayout = []; + layout2: KtdGridLayout = []; + compactType: KtdGridCompactType = null; + backgroundConfig: KtdGridBackgroundCfg = {show: 'always'}; + + pokemonsGen1Dict: Pokemon[] = pokemonsGen1.map((pokemon, index) => ({ + ...pokemon, + img: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${index + 1}.png` + })); + + private resizeSubscription: Subscription; + + constructor(@Inject(DOCUMENT) public document: Document) { } + + ngOnInit() { + this.resizeSubscription = merge( + fromEvent(window, 'resize'), + fromEvent(window, 'orientationchange') + ).pipe( + debounceTime(50) + ).subscribe(() => { + this.grid.resize(); + }); + } + + ngOnDestroy() { + this.resizeSubscription.unsubscribe(); + } + + onLayoutUpdated(layout: KtdGridLayout) { + console.log('onLayoutUpdated', layout); + this.layout = layout; + } + + onLayoutDropped(event: KtdDropped) { + console.log('onLayoutDropped', event); + + const id = crypto.getRandomValues(new Uint32Array(1))[0].toString(16); + this.layout = [{...event.currentLayoutItem, id}, ...event.currentLayout]; + this.layout = compact(this.layout, this.compactType, this.grid.cols); + } + + onLayout2Updated(layout: KtdGridLayout) { + console.log('onLayout2Updated', layout); + this.layout2 = layout; + } + + onLayout2Dropped(event: KtdDropped) { + console.log('onLayoutDropped', event); + const id = crypto.getRandomValues(new Uint32Array(1))[0].toString(16); + this.layout2 = [{...event.currentLayoutItem, id}, ...event.currentLayout]; + this.layout2 = compact(this.layout2, this.compactType, this.grid.cols); + } +} diff --git a/projects/demo-app/src/app/drag-from-outside/pokemons-gen1.ts b/projects/demo-app/src/app/drag-from-outside/pokemons-gen1.ts new file mode 100644 index 0000000..61bd99d --- /dev/null +++ b/projects/demo-app/src/app/drag-from-outside/pokemons-gen1.ts @@ -0,0 +1,606 @@ +export const pokemonsGen1 = [ + { + "name": "bulbasaur", + "url": "https://pokeapi.co/api/v2/pokemon/1/" + }, + { + "name": "ivysaur", + "url": "https://pokeapi.co/api/v2/pokemon/2/" + }, + { + "name": "venusaur", + "url": "https://pokeapi.co/api/v2/pokemon/3/" + }, + { + "name": "charmander", + "url": "https://pokeapi.co/api/v2/pokemon/4/" + }, + { + "name": "charmeleon", + "url": "https://pokeapi.co/api/v2/pokemon/5/" + }, + { + "name": "charizard", + "url": "https://pokeapi.co/api/v2/pokemon/6/" + }, + { + "name": "squirtle", + "url": "https://pokeapi.co/api/v2/pokemon/7/" + }, + { + "name": "wartortle", + "url": "https://pokeapi.co/api/v2/pokemon/8/" + }, + { + "name": "blastoise", + "url": "https://pokeapi.co/api/v2/pokemon/9/" + }, + { + "name": "caterpie", + "url": "https://pokeapi.co/api/v2/pokemon/10/" + }, + { + "name": "metapod", + "url": "https://pokeapi.co/api/v2/pokemon/11/" + }, + { + "name": "butterfree", + "url": "https://pokeapi.co/api/v2/pokemon/12/" + }, + { + "name": "weedle", + "url": "https://pokeapi.co/api/v2/pokemon/13/" + }, + { + "name": "kakuna", + "url": "https://pokeapi.co/api/v2/pokemon/14/" + }, + { + "name": "beedrill", + "url": "https://pokeapi.co/api/v2/pokemon/15/" + }, + { + "name": "pidgey", + "url": "https://pokeapi.co/api/v2/pokemon/16/" + }, + { + "name": "pidgeotto", + "url": "https://pokeapi.co/api/v2/pokemon/17/" + }, + { + "name": "pidgeot", + "url": "https://pokeapi.co/api/v2/pokemon/18/" + }, + { + "name": "rattata", + "url": "https://pokeapi.co/api/v2/pokemon/19/" + }, + { + "name": "raticate", + "url": "https://pokeapi.co/api/v2/pokemon/20/" + }, + { + "name": "spearow", + "url": "https://pokeapi.co/api/v2/pokemon/21/" + }, + { + "name": "fearow", + "url": "https://pokeapi.co/api/v2/pokemon/22/" + }, + { + "name": "ekans", + "url": "https://pokeapi.co/api/v2/pokemon/23/" + }, + { + "name": "arbok", + "url": "https://pokeapi.co/api/v2/pokemon/24/" + }, + { + "name": "pikachu", + "url": "https://pokeapi.co/api/v2/pokemon/25/" + }, + { + "name": "raichu", + "url": "https://pokeapi.co/api/v2/pokemon/26/" + }, + { + "name": "sandshrew", + "url": "https://pokeapi.co/api/v2/pokemon/27/" + }, + { + "name": "sandslash", + "url": "https://pokeapi.co/api/v2/pokemon/28/" + }, + { + "name": "nidoran-f", + "url": "https://pokeapi.co/api/v2/pokemon/29/" + }, + { + "name": "nidorina", + "url": "https://pokeapi.co/api/v2/pokemon/30/" + }, + { + "name": "nidoqueen", + "url": "https://pokeapi.co/api/v2/pokemon/31/" + }, + { + "name": "nidoran-m", + "url": "https://pokeapi.co/api/v2/pokemon/32/" + }, + { + "name": "nidorino", + "url": "https://pokeapi.co/api/v2/pokemon/33/" + }, + { + "name": "nidoking", + "url": "https://pokeapi.co/api/v2/pokemon/34/" + }, + { + "name": "clefairy", + "url": "https://pokeapi.co/api/v2/pokemon/35/" + }, + { + "name": "clefable", + "url": "https://pokeapi.co/api/v2/pokemon/36/" + }, + { + "name": "vulpix", + "url": "https://pokeapi.co/api/v2/pokemon/37/" + }, + { + "name": "ninetales", + "url": "https://pokeapi.co/api/v2/pokemon/38/" + }, + { + "name": "jigglypuff", + "url": "https://pokeapi.co/api/v2/pokemon/39/" + }, + { + "name": "wigglytuff", + "url": "https://pokeapi.co/api/v2/pokemon/40/" + }, + { + "name": "zubat", + "url": "https://pokeapi.co/api/v2/pokemon/41/" + }, + { + "name": "golbat", + "url": "https://pokeapi.co/api/v2/pokemon/42/" + }, + { + "name": "oddish", + "url": "https://pokeapi.co/api/v2/pokemon/43/" + }, + { + "name": "gloom", + "url": "https://pokeapi.co/api/v2/pokemon/44/" + }, + { + "name": "vileplume", + "url": "https://pokeapi.co/api/v2/pokemon/45/" + }, + { + "name": "paras", + "url": "https://pokeapi.co/api/v2/pokemon/46/" + }, + { + "name": "parasect", + "url": "https://pokeapi.co/api/v2/pokemon/47/" + }, + { + "name": "venonat", + "url": "https://pokeapi.co/api/v2/pokemon/48/" + }, + { + "name": "venomoth", + "url": "https://pokeapi.co/api/v2/pokemon/49/" + }, + { + "name": "diglett", + "url": "https://pokeapi.co/api/v2/pokemon/50/" + }, + { + "name": "dugtrio", + "url": "https://pokeapi.co/api/v2/pokemon/51/" + }, + { + "name": "meowth", + "url": "https://pokeapi.co/api/v2/pokemon/52/" + }, + { + "name": "persian", + "url": "https://pokeapi.co/api/v2/pokemon/53/" + }, + { + "name": "psyduck", + "url": "https://pokeapi.co/api/v2/pokemon/54/" + }, + { + "name": "golduck", + "url": "https://pokeapi.co/api/v2/pokemon/55/" + }, + { + "name": "mankey", + "url": "https://pokeapi.co/api/v2/pokemon/56/" + }, + { + "name": "primeape", + "url": "https://pokeapi.co/api/v2/pokemon/57/" + }, + { + "name": "growlithe", + "url": "https://pokeapi.co/api/v2/pokemon/58/" + }, + { + "name": "arcanine", + "url": "https://pokeapi.co/api/v2/pokemon/59/" + }, + { + "name": "poliwag", + "url": "https://pokeapi.co/api/v2/pokemon/60/" + }, + { + "name": "poliwhirl", + "url": "https://pokeapi.co/api/v2/pokemon/61/" + }, + { + "name": "poliwrath", + "url": "https://pokeapi.co/api/v2/pokemon/62/" + }, + { + "name": "abra", + "url": "https://pokeapi.co/api/v2/pokemon/63/" + }, + { + "name": "kadabra", + "url": "https://pokeapi.co/api/v2/pokemon/64/" + }, + { + "name": "alakazam", + "url": "https://pokeapi.co/api/v2/pokemon/65/" + }, + { + "name": "machop", + "url": "https://pokeapi.co/api/v2/pokemon/66/" + }, + { + "name": "machoke", + "url": "https://pokeapi.co/api/v2/pokemon/67/" + }, + { + "name": "machamp", + "url": "https://pokeapi.co/api/v2/pokemon/68/" + }, + { + "name": "bellsprout", + "url": "https://pokeapi.co/api/v2/pokemon/69/" + }, + { + "name": "weepinbell", + "url": "https://pokeapi.co/api/v2/pokemon/70/" + }, + { + "name": "victreebel", + "url": "https://pokeapi.co/api/v2/pokemon/71/" + }, + { + "name": "tentacool", + "url": "https://pokeapi.co/api/v2/pokemon/72/" + }, + { + "name": "tentacruel", + "url": "https://pokeapi.co/api/v2/pokemon/73/" + }, + { + "name": "geodude", + "url": "https://pokeapi.co/api/v2/pokemon/74/" + }, + { + "name": "graveler", + "url": "https://pokeapi.co/api/v2/pokemon/75/" + }, + { + "name": "golem", + "url": "https://pokeapi.co/api/v2/pokemon/76/" + }, + { + "name": "ponyta", + "url": "https://pokeapi.co/api/v2/pokemon/77/" + }, + { + "name": "rapidash", + "url": "https://pokeapi.co/api/v2/pokemon/78/" + }, + { + "name": "slowpoke", + "url": "https://pokeapi.co/api/v2/pokemon/79/" + }, + { + "name": "slowbro", + "url": "https://pokeapi.co/api/v2/pokemon/80/" + }, + { + "name": "magnemite", + "url": "https://pokeapi.co/api/v2/pokemon/81/" + }, + { + "name": "magneton", + "url": "https://pokeapi.co/api/v2/pokemon/82/" + }, + { + "name": "farfetchd", + "url": "https://pokeapi.co/api/v2/pokemon/83/" + }, + { + "name": "doduo", + "url": "https://pokeapi.co/api/v2/pokemon/84/" + }, + { + "name": "dodrio", + "url": "https://pokeapi.co/api/v2/pokemon/85/" + }, + { + "name": "seel", + "url": "https://pokeapi.co/api/v2/pokemon/86/" + }, + { + "name": "dewgong", + "url": "https://pokeapi.co/api/v2/pokemon/87/" + }, + { + "name": "grimer", + "url": "https://pokeapi.co/api/v2/pokemon/88/" + }, + { + "name": "muk", + "url": "https://pokeapi.co/api/v2/pokemon/89/" + }, + { + "name": "shellder", + "url": "https://pokeapi.co/api/v2/pokemon/90/" + }, + { + "name": "cloyster", + "url": "https://pokeapi.co/api/v2/pokemon/91/" + }, + { + "name": "gastly", + "url": "https://pokeapi.co/api/v2/pokemon/92/" + }, + { + "name": "haunter", + "url": "https://pokeapi.co/api/v2/pokemon/93/" + }, + { + "name": "gengar", + "url": "https://pokeapi.co/api/v2/pokemon/94/" + }, + { + "name": "onix", + "url": "https://pokeapi.co/api/v2/pokemon/95/" + }, + { + "name": "drowzee", + "url": "https://pokeapi.co/api/v2/pokemon/96/" + }, + { + "name": "hypno", + "url": "https://pokeapi.co/api/v2/pokemon/97/" + }, + { + "name": "krabby", + "url": "https://pokeapi.co/api/v2/pokemon/98/" + }, + { + "name": "kingler", + "url": "https://pokeapi.co/api/v2/pokemon/99/" + }, + { + "name": "voltorb", + "url": "https://pokeapi.co/api/v2/pokemon/100/" + }, + { + "name": "electrode", + "url": "https://pokeapi.co/api/v2/pokemon/101/" + }, + { + "name": "exeggcute", + "url": "https://pokeapi.co/api/v2/pokemon/102/" + }, + { + "name": "exeggutor", + "url": "https://pokeapi.co/api/v2/pokemon/103/" + }, + { + "name": "cubone", + "url": "https://pokeapi.co/api/v2/pokemon/104/" + }, + { + "name": "marowak", + "url": "https://pokeapi.co/api/v2/pokemon/105/" + }, + { + "name": "hitmonlee", + "url": "https://pokeapi.co/api/v2/pokemon/106/" + }, + { + "name": "hitmonchan", + "url": "https://pokeapi.co/api/v2/pokemon/107/" + }, + { + "name": "lickitung", + "url": "https://pokeapi.co/api/v2/pokemon/108/" + }, + { + "name": "koffing", + "url": "https://pokeapi.co/api/v2/pokemon/109/" + }, + { + "name": "weezing", + "url": "https://pokeapi.co/api/v2/pokemon/110/" + }, + { + "name": "rhyhorn", + "url": "https://pokeapi.co/api/v2/pokemon/111/" + }, + { + "name": "rhydon", + "url": "https://pokeapi.co/api/v2/pokemon/112/" + }, + { + "name": "chansey", + "url": "https://pokeapi.co/api/v2/pokemon/113/" + }, + { + "name": "tangela", + "url": "https://pokeapi.co/api/v2/pokemon/114/" + }, + { + "name": "kangaskhan", + "url": "https://pokeapi.co/api/v2/pokemon/115/" + }, + { + "name": "horsea", + "url": "https://pokeapi.co/api/v2/pokemon/116/" + }, + { + "name": "seadra", + "url": "https://pokeapi.co/api/v2/pokemon/117/" + }, + { + "name": "goldeen", + "url": "https://pokeapi.co/api/v2/pokemon/118/" + }, + { + "name": "seaking", + "url": "https://pokeapi.co/api/v2/pokemon/119/" + }, + { + "name": "staryu", + "url": "https://pokeapi.co/api/v2/pokemon/120/" + }, + { + "name": "starmie", + "url": "https://pokeapi.co/api/v2/pokemon/121/" + }, + { + "name": "mr-mime", + "url": "https://pokeapi.co/api/v2/pokemon/122/" + }, + { + "name": "scyther", + "url": "https://pokeapi.co/api/v2/pokemon/123/" + }, + { + "name": "jynx", + "url": "https://pokeapi.co/api/v2/pokemon/124/" + }, + { + "name": "electabuzz", + "url": "https://pokeapi.co/api/v2/pokemon/125/" + }, + { + "name": "magmar", + "url": "https://pokeapi.co/api/v2/pokemon/126/" + }, + { + "name": "pinsir", + "url": "https://pokeapi.co/api/v2/pokemon/127/" + }, + { + "name": "tauros", + "url": "https://pokeapi.co/api/v2/pokemon/128/" + }, + { + "name": "magikarp", + "url": "https://pokeapi.co/api/v2/pokemon/129/" + }, + { + "name": "gyarados", + "url": "https://pokeapi.co/api/v2/pokemon/130/" + }, + { + "name": "lapras", + "url": "https://pokeapi.co/api/v2/pokemon/131/" + }, + { + "name": "ditto", + "url": "https://pokeapi.co/api/v2/pokemon/132/" + }, + { + "name": "eevee", + "url": "https://pokeapi.co/api/v2/pokemon/133/" + }, + { + "name": "vaporeon", + "url": "https://pokeapi.co/api/v2/pokemon/134/" + }, + { + "name": "jolteon", + "url": "https://pokeapi.co/api/v2/pokemon/135/" + }, + { + "name": "flareon", + "url": "https://pokeapi.co/api/v2/pokemon/136/" + }, + { + "name": "porygon", + "url": "https://pokeapi.co/api/v2/pokemon/137/" + }, + { + "name": "omanyte", + "url": "https://pokeapi.co/api/v2/pokemon/138/" + }, + { + "name": "omastar", + "url": "https://pokeapi.co/api/v2/pokemon/139/" + }, + { + "name": "kabuto", + "url": "https://pokeapi.co/api/v2/pokemon/140/" + }, + { + "name": "kabutops", + "url": "https://pokeapi.co/api/v2/pokemon/141/" + }, + { + "name": "aerodactyl", + "url": "https://pokeapi.co/api/v2/pokemon/142/" + }, + { + "name": "snorlax", + "url": "https://pokeapi.co/api/v2/pokemon/143/" + }, + { + "name": "articuno", + "url": "https://pokeapi.co/api/v2/pokemon/144/" + }, + { + "name": "zapdos", + "url": "https://pokeapi.co/api/v2/pokemon/145/" + }, + { + "name": "moltres", + "url": "https://pokeapi.co/api/v2/pokemon/146/" + }, + { + "name": "dratini", + "url": "https://pokeapi.co/api/v2/pokemon/147/" + }, + { + "name": "dragonair", + "url": "https://pokeapi.co/api/v2/pokemon/148/" + }, + { + "name": "dragonite", + "url": "https://pokeapi.co/api/v2/pokemon/149/" + }, + { + "name": "mewtwo", + "url": "https://pokeapi.co/api/v2/pokemon/150/" + }, + { + "name": "mew", + "url": "https://pokeapi.co/api/v2/pokemon/151/" + } +] diff --git a/projects/demo-app/src/app/playground/playground.component.html b/projects/demo-app/src/app/playground/playground.component.html index c102a6d..8a73921 100644 --- a/projects/demo-app/src/app/playground/playground.component.html +++ b/projects/demo-app/src/app/playground/playground.component.html @@ -1,6 +1,13 @@
+
+ {{element}} +
+
25
@@ -155,7 +162,9 @@
- + (layoutUpdated)="onLayoutUpdated($event)" + (dropped)="onItemDrop($event)"> { + [key: string]: T; +}