diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index c4bfbe7..55d7691 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -4,12 +4,12 @@ import { } 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 { combineLatest, 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 { ktdGetGridItemRowHeight, ktdGridItemDragging, ktdGridItemLayoutItemAreEqual, ktdGridItemResizing, ktdGridItemsDragging } from './utils/grid.utils'; import { compact } from './utils/react-grid-layout.utils'; import { - GRID_ITEM_GET_RENDER_DATA_TOKEN, KtdGridBackgroundCfg, KtdGridCfg, KtdGridCompactType, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem + GRID_ITEM_GET_RENDER_DATA_TOKEN, KtdGridBackgroundCfg, KtdGridCfg, KtdGridCompactType, KtdGridItemRect, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem } from './grid.definitions'; import { ktdPointerUp, ktdPointerClientX, ktdPointerClientY } from './utils/pointer.utils'; import { KtdDictionary } from '../types'; @@ -25,6 +25,10 @@ interface KtdDragResizeEvent { layout: KtdGridLayout; layoutItem: KtdGridLayoutItem; gridItemRef: KtdGridItemComponent; + selectedItems?: { + layoutItem: KtdGridLayoutItem; + gridItemRef: KtdGridItemComponent; + }[]; } export type KtdDragStart = KtdDragResizeEvent; @@ -40,11 +44,17 @@ export interface KtdGridItemResizeEvent { type DragActionType = 'drag' | 'resize'; -function getDragResizeEventData(gridItem: KtdGridItemComponent, layout: KtdGridLayout): KtdDragResizeEvent { +function getDragResizeEventData(gridItem: KtdGridItemComponent, layout: KtdGridLayout, multipleSelection?: KtdGridItemComponent[]): KtdDragResizeEvent { return { layout, layoutItem: layout.find((item) => item.id === gridItem.id)!, - gridItemRef: gridItem + gridItemRef: gridItem, + selectedItems: multipleSelection && multipleSelection.map(selectedItem=>( + { + layoutItem: layout.find((layoutItem: KtdGridLayoutItem) => layoutItem.id === selectedItem.id)!, + gridItemRef: selectedItem + }) + ) }; } @@ -269,6 +279,34 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte private _height: number | null = null; + /** + * Multiple items drag/resize + * A list of selected items to move (drag or resize) together as a group. + * The multi-selection of items is managed externally. By default, the library manages a single item, but if a set of item IDs is provided, the specified group will be handled as a unit." + */ + @Input() + get selectedItemsIds(): string[] | null { + return this._selectedItemsIds; + } + + set selectedItemsIds(val: string[] | null) { + this._selectedItemsIds = val; + if(val){ + this.selectedItems = val.map( + (layoutItemId: string) => + this._gridItems.find( + (gridItem: KtdGridItemComponent) => + gridItem.id === layoutItemId + )! + ); + } else { + this.selectedItems = undefined; + } + } + + private _selectedItemsIds: string[] | null; + selectedItems: KtdGridItemComponent[] | undefined; + @Input() get backgroundConfig(): KtdGridBackgroundCfg | null { @@ -302,11 +340,11 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte }; } - /** Reference to the view of the placeholder element. */ - private placeholderRef: EmbeddedViewRef | null; + /** References to the views of the placeholder elements. */ + private placeholderRef: KtdDictionary | null>={}; - /** Element that is rendered as placeholder when a grid item is being dragged */ - private placeholder: HTMLElement | null; + /** Elements that are rendered as placeholder when a list of grid items are being dragged */ + private placeholder: KtdDictionary={}; private _gridItemsRenderData: KtdDictionary>; private subscriptions: Subscription[] = []; @@ -423,7 +461,7 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte } private updateGridItemsStyles() { - this._gridItems.forEach(item => { + this._gridItems.forEach(item => { const gridItemRenderData: KtdGridItemRenderData | undefined = this._gridItemsRenderData[item.id]; if (gridItemRenderData == null) { console.error(`Couldn\'t find the specified grid item for the id: ${item.id}`); @@ -452,23 +490,26 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte type: 'resize' as DragActionType })))), ).pipe(exhaustMap(({event, gridItem, type}) => { + const multipleSelection: KtdGridItemComponent[] | undefined = this.selectedItems && [...this.selectedItems]; // 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))); - + this.ngZone.run(() => (type === 'drag' ? this.dragStarted : this.resizeStarted).emit(getDragResizeEventData(gridItem, this.layout, multipleSelection))); this.setGridBackgroundVisible(this._backgroundConfig?.show === 'whenDragging' || this._backgroundConfig?.show === 'always'); - // Perform drag sequence - return this.performDragSequence$(gridItem, event, type).pipe( - map((layout) => ({layout, gridItem, type}))); + let gridItemsSelected: KtdGridItemComponent[] = [gridItem]; + if(multipleSelection && multipleSelection.some((currItem)=>currItem.id===gridItem.id)) { + gridItemsSelected = multipleSelection + } + return this.performDragSequence$(gridItemsSelected, event, type).pipe( + map((layout) => ({layout, gridItem, type, multipleSelection}))); })); }) - ).subscribe(({layout, gridItem, type}) => { + ).subscribe(({layout, gridItem, type, multipleSelection} : {layout: KtdGridLayout, gridItem: KtdGridItemComponent, type: DragActionType, multipleSelection?: KtdGridItemComponent[]}) => { 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)); + (type === 'drag' ? this.dragEnded : this.resizeEnded).emit(getDragResizeEventData(gridItem, layout, multipleSelection)); // Notify that the layout has been updated. this.layoutUpdated.emit(layout); @@ -485,24 +526,29 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte * @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 */ - private performDragSequence$(gridItem: KtdGridItemComponent, pointerDownEvent: MouseEvent | TouchEvent, type: DragActionType): Observable { + private performDragSequence$(gridItems: KtdGridItemComponent[], pointerDownEvent: MouseEvent | TouchEvent, type: DragActionType): Observable { 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' ? this.document.getElementById(this.scrollableParent) : this.scrollableParent; + // Retrieve grid (parent) client rect. + const gridElemClientRect: KtdClientRect = getMutableClientRect(this.elementRef.nativeElement as HTMLElement); - this.renderer.addClass(gridItem.elementRef.nativeElement, 'no-transitions'); - this.renderer.addClass(gridItem.elementRef.nativeElement, 'ktd-grid-item-dragging'); - - const placeholderClientRect: KtdClientRect = { - ...dragElemClientRect, - left: dragElemClientRect.left - gridElemClientRect.left, - top: dragElemClientRect.top - gridElemClientRect.top - } - this.createPlaceholderElement(placeholderClientRect, gridItem.placeholder); + const dragElemClientRect: KtdDictionary={}; + const newGridItemRenderData: KtdDictionary>={}; + let draggedItemsPos: KtdDictionary={}; + + gridItems.forEach((gridItem)=>{ + // Retrieve gridItem (draggedElem) client rect. + dragElemClientRect[gridItem.id] = getMutableClientRect(gridItem.elementRef.nativeElement as HTMLElement); + this.renderer.addClass(gridItem.elementRef.nativeElement, 'no-transitions'); + this.renderer.addClass(gridItem.elementRef.nativeElement, 'ktd-grid-item-dragging'); + const placeholderClientRect: KtdClientRect = { + ...dragElemClientRect[gridItem.id], + left: dragElemClientRect[gridItem.id].left - gridElemClientRect.left, + top: dragElemClientRect[gridItem.id].top - gridElemClientRect.top + } + this.createPlaceholderElement(gridItem.id, placeholderClientRect, gridItem.placeholder); + }); let newLayout: KtdGridLayoutItem[]; @@ -537,35 +583,54 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte takeUntil(ktdPointerUp(this.document)), ).subscribe(([pointerDragEvent, scrollDifference]: [MouseEvent | TouchEvent | PointerEvent, { 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; + if (type === 'drag' && gridItems.length > 1) { + const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { + layout: currentLayout, + rowHeight: this.rowHeight, + height: this.height, + cols: this.cols, + preventCollision: this.preventCollision, + gap: this.gap, + }, this.compactType, { + pointerDownEvent, + pointerDragEvent, + gridElemClientRect, + dragElementsClientRect: dragElemClientRect, + scrollDifference + }); + newLayout = layout; + draggedItemsPos = draggedItemPos; + } else { + const calcNewStateFunc = type === 'drag' ? ktdGridItemDragging : ktdGridItemResizing; + newLayout = currentLayout; + gridItems.forEach((gridItem)=>{ + const {layout, draggedItemPos} = calcNewStateFunc(gridItem, { + layout: newLayout, + rowHeight: this.rowHeight, + height: this.height, + cols: this.cols, + preventCollision: this.preventCollision, + gap: this.gap, + }, this.compactType, { + pointerDownEvent, + pointerDragEvent, + gridElemClientRect, + dragElemClientRect: dragElemClientRect[gridItem.id], + scrollDifference + }); + newLayout = layout; + draggedItemsPos[gridItem.id]=draggedItemPos; + }); + } this.gridCurrentHeight = this.height ?? (this.rowHeight === 'fit' ? gridElemClientRect.height : getGridHeight(newLayout, this.rowHeight, this.gap)) - this._gridItemsRenderData = layoutToRenderItems({ cols: this.cols, rowHeight: this.rowHeight, @@ -575,49 +640,54 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte gap: this.gap, }, gridElemClientRect.width, gridElemClientRect.height); - const newGridItemRenderData = {...this._gridItemsRenderData[gridItem.id]} - const placeholderStyles = parseRenderItemToPixels(newGridItemRenderData); + // Modify the position of the dragged item to be the once we want (for example the mouse position or whatever) + gridItems.forEach((gridItem)=>{ + newGridItemRenderData[gridItem.id] = {...this._gridItemsRenderData[gridItem.id]} + const placeholderStyles = parseRenderItemToPixels(newGridItemRenderData[gridItem.id]); - // 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})`; + // Put the real final position to the placeholder element + this.placeholder[gridItem.id]!.style.width = placeholderStyles.width; + this.placeholder[gridItem.id]!.style.height = placeholderStyles.height; + this.placeholder[gridItem.id]!.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._gridItemsRenderData[gridItem.id] = { + ...draggedItemsPos[gridItem.id], + 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 - }); + gridItems.forEach((gridItem)=>{ + // 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[gridItem.id].width, + height: newGridItemRenderData[gridItem.id].height, + gridItemRef: getDragResizeEventData(gridItem, newLayout).gridItemRef as KtdGridItemComponent + }); + } } - } + }); }, (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'); + gridItems.forEach((gridItem)=>{ + // 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(); + this.addGridItemAnimatingClass(gridItem).subscribe(); + // Consider destroying the placeholder after the animation has finished. + this.destroyPlaceholder(gridItem.id); + }); if (newLayout) { // TODO: newLayout should already be pruned. If not, it should have type Layout, not KtdGridLayout as it is now. @@ -689,33 +759,33 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte } /** Creates placeholder element */ - private createPlaceholderElement(clientRect: KtdClientRect, gridItemPlaceholder?: KtdGridItemPlaceholder) { - this.placeholder = this.renderer.createElement('div'); - this.placeholder!.style.width = `${clientRect.width}px`; - 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); + private createPlaceholderElement(gridItemId: string, clientRect: KtdClientRect, gridItemPlaceholder?: KtdGridItemPlaceholder) { + this.placeholder[gridItemId] = this.renderer.createElement('div'); + this.placeholder[gridItemId]!.style.width = `${clientRect.width}px`; + this.placeholder[gridItemId]!.style.height = `${clientRect.height}px`; + this.placeholder[gridItemId]!.style.transform = `translateX(${clientRect.left}px) translateY(${clientRect.top}px)`; + this.placeholder[gridItemId]!.classList.add('ktd-grid-item-placeholder'); + this.renderer.appendChild(this.elementRef.nativeElement, this.placeholder[gridItemId]); // 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. if (gridItemPlaceholder) { - this.placeholderRef = this.viewContainerRef.createEmbeddedView( + this.placeholderRef[gridItemId] = this.viewContainerRef.createEmbeddedView( gridItemPlaceholder.templateRef, gridItemPlaceholder.data ); - this.placeholderRef.rootNodes.forEach(node => this.placeholder!.appendChild(node)); - this.placeholderRef.detectChanges(); + this.placeholderRef[gridItemId]!.rootNodes.forEach(node => this.placeholder[gridItemId]!.appendChild(node)); + this.placeholderRef[gridItemId]!.detectChanges(); } else { - this.placeholder!.classList.add('ktd-grid-item-placeholder-default'); + this.placeholder[gridItemId]!.classList.add('ktd-grid-item-placeholder-default'); } } /** Destroys the placeholder element and its ViewRef. */ - private destroyPlaceholder() { - this.placeholder?.remove(); - this.placeholderRef?.destroy(); - this.placeholder = this.placeholderRef = null!; + private destroyPlaceholder(gridItemId: string) { + this.placeholder[gridItemId]?.remove(); + this.placeholderRef[gridItemId]?.destroy(); + this.placeholder[gridItemId] = this.placeholderRef[gridItemId] = null!; } static ngAcceptInputType_cols: NumberInput; diff --git a/projects/angular-grid-layout/src/lib/grid.definitions.ts b/projects/angular-grid-layout/src/lib/grid.definitions.ts index 12e976b..b41131c 100644 --- a/projects/angular-grid-layout/src/lib/grid.definitions.ts +++ b/projects/angular-grid-layout/src/lib/grid.definitions.ts @@ -1,6 +1,7 @@ import { InjectionToken } from '@angular/core'; import { CompactType } from './utils/react-grid-layout.utils'; import { KtdClientRect } from './utils/client-rect'; +import { KtdDictionary } from '../types'; export interface KtdGridLayoutItem { id: string; @@ -67,3 +68,11 @@ export interface KtdDraggingData { dragElemClientRect: KtdClientRect; scrollDifference: { top: number, left: number }; } + +export interface KtdDraggingMultipleData { + pointerDownEvent: MouseEvent | TouchEvent; + pointerDragEvent: MouseEvent | TouchEvent; + gridElemClientRect: KtdClientRect; + dragElementsClientRect: KtdDictionary; + scrollDifference: { top: number, left: number }; +} 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 2c0d76f..f4eca5b 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,11 @@ -import { compact, CompactType, getFirstCollision, Layout, LayoutItem, moveElement } from './react-grid-layout.utils'; +import { compact, CompactType, getFirstCollision, Layout, LayoutItem, moveElement, sortLayoutItems } from './react-grid-layout.utils'; import { - KtdDraggingData, KtdGridCfg, KtdGridCompactType, KtdGridItemRect, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem + KtdDraggingData, KtdDraggingMultipleData, KtdGridCfg, KtdGridCompactType, KtdGridItemRect, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem } from '../grid.definitions'; import { ktdPointerClientX, ktdPointerClientY } from './pointer.utils'; import { KtdDictionary } from '../../types'; import { KtdGridItemComponent } from '../grid-item/grid-item.component'; +import { KtdMoveMultipleElements } from './react-grid-layout-multiple.utils'; /** 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}) { @@ -31,6 +32,19 @@ export function ktdGridCompact(layout: KtdGridLayout, compactType: KtdGridCompac .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 })); } +/** + * Call react-grid-layout utils 'sortLayoutItems()' function to return the 'layout' sorted by 'compactType' + * @param {Layout} layout + * @param {CompactType} compactType + * @returns {Layout} + */ +export function ktdGridSortLayoutItems( + layout: Layout, + compactType: CompactType, +): Layout { + return sortLayoutItems(layout,compactType) +} + function screenXToGridX(screenXPos: number, cols: number, width: number, gap: number): number { if (cols <= 1) { return 0; @@ -152,6 +166,121 @@ export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdG }; } + + +/** + * 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 config current grid configuration + * @param compactionType type of compaction that will be performed + * @param draggingData contains all the information about the drag + */ +export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: KtdGridCfg, compactionType: CompactType, draggingData: KtdDraggingMultipleData): { layout: KtdGridLayoutItem[]; draggedItemPos: KtdDictionary } { + const {pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElementsClientRect, scrollDifference} = draggingData; + + const draggingElemPrevItem: KtdDictionary = {} + gridItems.forEach(gridItem=> { + draggingElemPrevItem[gridItem.id] = config.layout.find(item => item.id === gridItem.id)! + }); + + const clientStartX = ktdPointerClientX(pointerDownEvent); + const clientStartY = ktdPointerClientY(pointerDownEvent); + const clientX = ktdPointerClientX(pointerDragEvent); + const clientY = ktdPointerClientY(pointerDragEvent); + + // Grid element positions taking into account the possible scroll total difference from the beginning. + const gridElementLeftPosition = gridElemClientRect.left + scrollDifference.left; + const gridElementTopPosition = gridElemClientRect.top + scrollDifference.top; + + const rowHeightInPixels = config.rowHeight === 'fit' + ? ktdGetGridItemRowHeight(config.layout, config.height ?? gridElemClientRect.height, config.gap) + : config.rowHeight; + + const layoutItemsToMove: KtdDictionary={}; + const gridRelPos: KtdDictionary<{x:number,y:number}>={} + let maxXMove: number = 0; + let maxYMove: number = 0; + gridItems.forEach((gridItem: KtdGridItemComponent)=>{ + const offsetX = clientStartX - dragElementsClientRect[gridItem.id].left; + const offsetY = clientStartY - dragElementsClientRect[gridItem.id].top; + // Calculate position relative to the grid element. + gridRelPos[gridItem.id]={ + x: clientX - gridElementLeftPosition - offsetX, + y: clientY - gridElementTopPosition - offsetY + }; + // Get layout item position + layoutItemsToMove[gridItem.id] = { + ...draggingElemPrevItem[gridItem.id], + x: screenXToGridX(gridRelPos[gridItem.id].x , config.cols, gridElemClientRect.width, config.gap), + y: screenYToGridY(gridRelPos[gridItem.id].y, rowHeightInPixels, gridElemClientRect.height, config.gap) + }; + // Determine the maximum X and Y displacement where an item has gone outside the grid + if(0>layoutItemsToMove[gridItem.id].x && maxXMove>layoutItemsToMove[gridItem.id].x){ + maxXMove = layoutItemsToMove[gridItem.id].x; + } + if(0>layoutItemsToMove[gridItem.id].y && maxYMove>layoutItemsToMove[gridItem.id].y){ + maxYMove = layoutItemsToMove[gridItem.id].y; + } + if(layoutItemsToMove[gridItem.id].x + layoutItemsToMove[gridItem.id].w > config.cols && maxXMove { + layoutItemsToMove[key] = { + ...item, + x: item.x - maxXMove, + y: item.y - maxYMove + }; + }) + + // Parse to LayoutItem array data in order to use 'react.grid-layout' utils + const layoutItems: LayoutItem[] = config.layout; + const draggedLayoutItems: { + l: LayoutItem, + x: number | null | undefined, + y: number | null | undefined + }[] = gridItems.map((gridItem:KtdGridItemComponent)=>{ + const draggedLayoutItem: LayoutItem = layoutItems.find(item => item.id === gridItem.id)!; + draggedLayoutItem.static = true; + return { + l: draggedLayoutItem, + x: layoutItemsToMove[gridItem.id].x, + y: layoutItemsToMove[gridItem.id].y + } + }); + + // Move all elements in group + let newLayoutItems: LayoutItem[] = KtdMoveMultipleElements( + layoutItems, + draggedLayoutItems, + true, + compactionType, + config.cols, + ); + + // Compact with selected items as static to preserve the structure of the selected items group + newLayoutItems = compact(newLayoutItems, compactionType, config.cols); + gridItems.forEach(gridItem=>newLayoutItems.find(layoutItem=>layoutItem.id === gridItem.id)!.static = false); + // Compact normal to display the layout correctly + newLayoutItems = compact(newLayoutItems, compactionType, config.cols); + + const draggedItemPos: KtdDictionary={}; + gridItems.forEach(gridItem=> + draggedItemPos[gridItem.id]={ + left: gridRelPos[gridItem.id].x, + top: gridRelPos[gridItem.id].y, + width: dragElementsClientRect[gridItem.id].width, + height: dragElementsClientRect[gridItem.id].height, + } + ); + + return { + layout: newLayoutItems, + draggedItemPos + }; +} + /** * 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 diff --git a/projects/angular-grid-layout/src/lib/utils/react-grid-layout-multiple.utils.ts b/projects/angular-grid-layout/src/lib/utils/react-grid-layout-multiple.utils.ts new file mode 100644 index 0000000..b23bf48 --- /dev/null +++ b/projects/angular-grid-layout/src/lib/utils/react-grid-layout-multiple.utils.ts @@ -0,0 +1,206 @@ +/** + * IMPORTANT: + * This utils are taken from the project: https://github.com/STRML/react-grid-layout. + * The code should be as less modified as possible for easy maintenance. + */ + +import { CompactType, getAllCollisions, getFirstCollision, Layout, LayoutItem, sortLayoutItems } from "./react-grid-layout.utils"; + +const DEBUG = false; + + +/** + * Move a set of elements "items". Responsible for doing cascading movements of other elements. + * + * @export + * @param {Layout} layout + * @param {({ + * l: LayoutItem, + * x: number | null | undefined, + * y: number | null | undefined + * }[])} items + * @param {(boolean | null | undefined)} isUserAction + * @param {(boolean | null | undefined)} preventCollision + * @param {CompactType} compactType + * @param {number} cols + * @returns {Layout} + */ +export function KtdMoveMultipleElements( + layout: Layout, + items: { + l: LayoutItem, + x: number | null | undefined, + y: number | null | undefined + }[], + isUserAction: boolean | null | undefined, + compactType: CompactType, + cols: number +): Layout { + // Short-circuit if nothing to do. + if(items.every((item)=>item.l.y === item.y && item.l.x === item.x)){ + return layout; + } + // Old coordinates to detect the cursor movement direction (up, down, left, right) + const oldX = items[0].l.x; + const oldY = items[0].l.y; + // Old coordinates before mutation, to retrieve it if the element cant move + const oldCoord = {} + + // Move the selected elements + items.forEach((item)=>{ + oldCoord[item.l.id]={ + x: item.l.x, + y: item.l.y + } + if (typeof item.x === 'number') { + item.l.x = item.x; + } + if (typeof item.y === 'number') { + item.l.y = item.y; + } + item.l.moved = true; + }) + + let sorted = sortLayoutItems(layout, compactType); + const itemsSorted = sortLayoutItems(items.map(item=>item.l),compactType); + + // If this collides with anything, move it. + // When doing this comparison, we have to sort the items we compare with + // to ensure, in the case of multiple collisions, that we're getting the + // nearest collision. + const movingUp = + compactType === 'vertical' && typeof items[0].y === 'number' + ? oldY >= items[0].y + : compactType === 'horizontal' && typeof items[0].x === 'number' + ? oldX >= items[0].x + : false; + if (movingUp) { + sorted = sorted.reverse(); + } + + // For each element, detect collisions and move the collided element by +1 + itemsSorted.forEach((item)=>{ + const collisions: LayoutItem[] = getAllCollisions(sorted, item); + // Move each item that collides away from this element. + for (let i = 0, len = collisions.length; i < len; i++) { + const collision = collisions[i]; + logMulti( + `Resolving collision between ${item.id}] and ${ + collision.id + } at [${collision.x},${collision.y}]`, + ); + // Short circuit so we can't infinite loop + if (collision.moved) { + continue; + } + // Don't move static items - we have to move *this* element away + if (collision.static && !item.static) { + layout = KtdMoveElementsAwayFromCollision( + layout, + collision, + item, + isUserAction, + compactType, + cols + ); + } else { + layout = KtdMoveElementsAwayFromCollision( + layout, + item, + collision, + isUserAction, + compactType, + cols + ); + } + } + }); + + return layout; +} + +/** + * Move the element "itemToMove" away from the collision with "collidesWith" + * @export + * @param {Layout} layout + * @param {LayoutItem} collidesWith + * @param {LayoutItem} itemToMove + * @param {(boolean | null | undefined)} isUserAction + * @param {CompactType} compactType + * @param {number} cols + * @returns {Layout} + */ +export function KtdMoveElementsAwayFromCollision( + layout: Layout, + collidesWith: LayoutItem, + itemToMove: LayoutItem, + isUserAction: boolean | null | undefined, + compactType: CompactType, + cols: number, +): Layout { + const compactH = compactType === 'horizontal'; + // Compact vertically if not set to horizontal + const compactV = compactType !== 'horizontal'; + + // If there is enough space above the collision to put this element, move it there. + // We only do this on the main collision as this can get funky in cascades and cause + // unwanted swapping behavior. + if (isUserAction) { + // Reset isUserAction flag because we're not in the main collision anymore. + isUserAction = false; + + // Make a mock item so we don't modify the item here, only modify in moveElement. + const fakeItem: LayoutItem = { + x: compactH + ? Math.max(collidesWith.x - itemToMove.w, 0) + : itemToMove.x, + y: compactV + ? Math.max(collidesWith.y - itemToMove.h, 0) + : itemToMove.y, + w: itemToMove.w, + h: itemToMove.h, + id: '-1', + }; + + // No collision? If so, we can go up there; otherwise, we'll end up moving down as normal + if (!getFirstCollision(layout, fakeItem)) { + logMulti( + `Doing reverse collision on ${itemToMove.id} up to [${ + fakeItem.x + },${fakeItem.y}].`, + ); + return KtdMoveMultipleElements( + layout, + [{ + l: itemToMove, + x: compactH ? fakeItem.x : undefined, + y: compactV ? fakeItem.y : undefined, + }], + isUserAction, + compactType, + cols + ); + } + } + + return KtdMoveMultipleElements( + layout, + [{ + l: itemToMove, + x: compactH ? itemToMove.x+1 : undefined, + y: compactV ? itemToMove.y+1 : undefined, + }], + isUserAction, + compactType, + cols + ); +} + +function logMulti(...args) { + if (!DEBUG) { + return; + } + // eslint-disable-next-line no-console + console.log(...args); +} + 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..b260188 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 @@ -165,7 +165,6 @@ export function compact( const sorted = sortLayoutItems(layout, compactType); // Holding for new items. const out = Array(layout.length); - for (let i = 0, len = sorted.length; i < len; i++) { let l = cloneLayoutItem(sorted[i]); @@ -217,10 +216,10 @@ function resolveCompactionCollision( // Optimization: we can break early if we know we're past this el // We can do this b/c it's a sorted layout - if (otherItem.y > item.y + item.h) { + if (otherItem[axis] > moveToCoord+item[sizeProp]) { break; - } + } if (collides(item, otherItem)) { resolveCompactionCollision( layout, @@ -268,7 +267,7 @@ export function compactItem( if (compactH) { resolveCompactionCollision(fullLayout, l, collides.x + collides.w, 'x'); } else { - resolveCompactionCollision(fullLayout, l, collides.y + collides.h, 'y',); + resolveCompactionCollision(fullLayout, l, collides.y + collides.h, 'y'); } // Since we can't grow without bounds horizontally, if we've overflown, let's move it down and try again. if (compactH && l.x + l.w > cols) { @@ -281,7 +280,6 @@ export function compactItem( } } } - // Ensure that there are no negative positions l.y = Math.max(l.y, 0); l.x = Math.max(l.x, 0); diff --git a/projects/angular-grid-layout/src/lib/utils/tests/grid.spec.ts b/projects/angular-grid-layout/src/lib/utils/tests/grid.spec.ts index fe7b5d0..3a99fd1 100644 --- a/projects/angular-grid-layout/src/lib/utils/tests/grid.spec.ts +++ b/projects/angular-grid-layout/src/lib/utils/tests/grid.spec.ts @@ -1,5 +1,6 @@ import { ktdGetGridLayoutDiff } from '../grid.utils'; -import { compact } from '../react-grid-layout.utils'; +import { KtdMoveMultipleElements } from '../react-grid-layout-multiple.utils'; +import { compact, Layout } from '../react-grid-layout.utils'; describe('Grid utils', () => { @@ -96,3 +97,69 @@ describe('compact (custom tests)', () => { ]); }); }) + +/// Multiple items movements +describe('moveMultipleElementsAffectingOtherItems', () => { + + function compactAndMoveMultiple( + layout, + items, + isUserAction, + compactType, + cols + ) { + let l: Layout = compact( + KtdMoveMultipleElements( + layout, + items, + isUserAction, + compactType, + cols + ), + compactType, + cols + ); + l = l.map(lItem => { + lItem.static = false; + return lItem; + }); + return compact(l,compactType,cols); + } + + it('Move the selected elements set down, pushing the rest of the grid up', () => { + const layout = [ + {id:"1",x:9,y:0,w:8,h:3,static:true}, + {id:"2",x:17,y:0,w:7,h:3,static:true}, + {id:"3",x:9,y:3,w:8,h:3,static:true}, + {id:"4",x:17,y:3,w:7,h:3,static:true}, + {id:"5",x:1,y:6,w:15,h:1}, + {id:"6",x:16,y:6,w:15,h:1}, + {id:"7",x:1,y:7,w:7,h:3}, + {id:"8",x:24,y:7,w:7,h:3} + ]; + const layoutItems = [ + {l:layout[0],x:9,y:7}, + {l:layout[1],x:17,y:7}, + {l:layout[2],x:9,y:10}, + {l:layout[3],x:17,y:10} + ] + expect( + compactAndMoveMultiple( + layout, + layoutItems, + true, + 'vertical', + 30 + ) + ).toEqual([ + {id:"1", x:9, y:1, w:8, h:3, moved: false, static: false}, + {id:"2", x:17, y:1, w:7, h:3, moved: false, static: false}, + {id:"3", x:9, y:4, w:8, h:3, moved: false, static: false}, + {id:"4", x:17, y:4, w:7, h:3, moved: false, static: false}, + {id:"5", x:1, y:0, w:15, h:1, moved: false, static: false}, + {id:"6", x:16, y:0, w:15, h:1, moved: false, static: false}, + {id:"7", x:1, y:1, w:7, h:3, moved: false, static: false}, + {id:"8", x:24, y:1, w:7, h:3, moved: false, static: false} + ]); + }); +}); diff --git a/projects/angular-grid-layout/src/lib/utils/tests/react-grid-layout-utils.spec.ts b/projects/angular-grid-layout/src/lib/utils/tests/react-grid-layout-utils.spec.ts index 7b9f425..1b9b8e4 100644 --- a/projects/angular-grid-layout/src/lib/utils/tests/react-grid-layout-utils.spec.ts +++ b/projects/angular-grid-layout/src/lib/utils/tests/react-grid-layout-utils.spec.ts @@ -404,3 +404,62 @@ describe('compact horizontal', () => { ]); }); }); + +describe('moveElementAffectingOtherItems', () => { + function compactAndMove( + layout, + layoutItem, + x, + y, + isUserAction, + preventCollision, + compactType, + cols + ) { + return compact( + moveElement( + layout, + layoutItem, + x, + y, + isUserAction, + preventCollision, + compactType, + cols + ), + compactType, + cols + ); + } + + it('Move element up, pushing the rest of the grid down', () => { + const layout = [ + {id: '0', x: 1, y: 0, w: 24, h: 1}, + {id: '1', x: 1, y: 1, w: 8, h: 1}, + {id: '2', x: 1, y: 2, w: 8, h: 3}, + {id: '3', x: 9, y: 2, w: 8, h: 8}, + {id: '4', x: 17, y: 1, w: 8, h: 2}, + {id: '5', x: 17, y: 3, w: 8, h: 3}, + ]; + const layoutItem = layout[3]; + expect( + compactAndMove( + layout, + layoutItem, + 9, + 0, // x, y + true, + false, // isUserAction, preventCollision + 'vertical', + 30 // compactType, cols + ) + ).toEqual([ + {id: '0', x: 1, y: 0+8, w: 24, h: 1, moved: false, static: false}, + {id: '1', x: 1, y: 1+8, w: 8, h: 1, moved: false, static: false}, + {id: '2', x: 1, y: 2+8, w: 8, h: 3, moved: false, static: false}, + {id: '3', x: 9, y: 0, w: 8, h: 8, moved: false, static: false}, + {id: '4', x: 17, y: 1+8, w: 8, h: 2, moved: false, static: false}, + {id: '5', x: 17, y: 3+8, w: 8, h: 3, moved: false, static: false}, + ]); + }); +}); diff --git a/projects/angular-grid-layout/src/public-api.ts b/projects/angular-grid-layout/src/public-api.ts index 6081ecd..a361e6f 100644 --- a/projects/angular-grid-layout/src/public-api.ts +++ b/projects/angular-grid-layout/src/public-api.ts @@ -1,7 +1,7 @@ /* * Public API Surface of grid */ -export { ktdGridCompact, ktdTrackById } from './lib/utils/grid.utils'; +export { ktdGridCompact, ktdGridSortLayoutItems, ktdTrackById } from './lib/utils/grid.utils'; export { KtdClientRect } from './lib/utils/client-rect'; export * from './lib/directives/drag-handle'; export * from './lib/directives/resize-handle'; diff --git a/projects/demo-app/src/app/app-routing.routes.ts b/projects/demo-app/src/app/app-routing.routes.ts index 9a3ed82..e810620 100644 --- a/projects/demo-app/src/app/app-routing.routes.ts +++ b/projects/demo-app/src/app/app-routing.routes.ts @@ -27,6 +27,11 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./row-height-fit/row-height-fit.component').then(m => m.KtdRowHeightFitComponent), data: {title: 'Angular Grid Layout - Row Height Fit'} }, + { + path: 'multi-item-handler', + loadComponent: () => import('./multi-item-handler/multi-item-handler.component').then(m => m.KtdMultiItemHandlerComponent), + data: {title: 'Angular Grid Layout - Multi-Item Drag & Resize'} + }, { 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..135e7c9 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 + Multi Item Handler diff --git a/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.html b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.html new file mode 100644 index 0000000..ca122e8 --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.html @@ -0,0 +1,89 @@ +
+
+ +
+

USAGE GUIDE

+
    +
  • Click on a grid item to select it
  • +
  • Ctrl (or ⌘ on Mac) + Click to add or remove an item from the group
  • +
  • Drag/resize any item in the group to apply the action to all selected items
  • +
  • Drag/resize an item outside the group to move it individually without clearing the current selection
  • +
  • While a group of elements is selected, you can paste it below the last element with Ctrl(or ⌘ on Mac) + V.
  • +
  • Click on an item outside the group to start a new selection
  • +
+
+ +
+

GRID CONTROLS

+
+ + + + Compact type + + vertical + horizontal + - + + + + Prevent Collision + +
+
+
+ + +

GRID

+
+ + +
+ {{ item.id }} +
+
+
+
+
+
+ diff --git a/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.scss b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.scss new file mode 100644 index 0000000..58b190c --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.scss @@ -0,0 +1,95 @@ +:host { + display: block; + width: 100%; + padding: 48px 32px; + box-sizing: border-box; + + .container { + width: 100%; + p { + margin-top: 0px; + } + } + + .header-container { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 40px; + margin-bottom: 40px; + &__usage-guide { + li { + font-size: 14px; + margin-bottom: 4px; + } + } + &__controls { + display:block; + .controls-content { + display: flex; + align-items: center; + flex-wrap: wrap; + + & > * { + margin: 8px 24px 8px 0; + } + } + + } + } + + .grid-container { + padding: 4px; + box-sizing: border-box; + border: 1px solid var(--ktd-border-color); + background-color: var(--ktd-background-color); + border-radius: 2px; + } + + ktd-grid-item { + color: #121212; + cursor: grab; + &.ktd-grid-item-dragging { + cursor: grabbing; + } + } + + ktd-grid { + transition: height 500ms ease; + } + + .grid-item-content { + box-sizing: border-box; + background: #ccc; + border: 1px solid; + width: 100%; + height: 100%; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + &--selected { + border: 3px solid rgb(163, 0, 0); + } + } + + .grid-item-remove-handle { + position: absolute; + cursor: pointer; + display: flex; + justify-content: center; + width: 20px; + height: 20px; + top: 0; + right: 0; + + &::after { + content: 'x'; + color: #121212; + font-size: 16px; + font-weight: 300; + font-family: Arial, sans-serif; + } + } +} diff --git a/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.ts b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.ts new file mode 100644 index 0000000..4f44774 --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.ts @@ -0,0 +1,550 @@ +import { Component, ElementRef, Inject, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { MatSelectChange, MatSelectModule } from '@angular/material/select'; +import { + KtdDragEnd, KtdDragStart, ktdGridCompact, KtdGridComponent, KtdGridItemComponent, KtdGridItemPlaceholder, KtdGridLayout, KtdGridLayoutItem, + KtdResizeEnd, KtdResizeStart, ktdTrackById, ktdGridSortLayoutItems +} from '@katoid/angular-grid-layout'; +import { ktdArrayRemoveItem } from '../utils'; +import { DOCUMENT, NgClass, NgFor } from '@angular/common'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { KtdFooterComponent } from '../components/footer/footer.component'; +import { ColorPickerModule } from 'ngx-color-picker'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatInputModule } from '@angular/material/input'; +import { MatOptionModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonModule } from '@angular/material/button'; +import { ktdGetOS } from './multi-item-handler.utils'; +import { fromEvent, merge, Subscription } from 'rxjs'; +import { debounceTime, filter } from 'rxjs/operators'; + +const realLifeLayout: KtdGridLayout = [ + {id: '0', x: 0, y: 0, w: 62, h: 3}, + {id: '1', x: 15, y: 5, w: 17, h: 4}, + {id: '2', x: 1, y: 11, w: 61, h: 1}, + {id: '3', x: 1, y: 12, w: 15, h: 1}, + {id: '4', x: 1, y: 13, w: 8, h: 3}, + {id: '5', x: 9, y: 13, w: 7, h: 3}, + {id: '6', x: 16, y: 12, w: 15, h: 1}, + {id: '7', x: 16, y: 13, w: 8, h: 3}, + {id: '8', x: 24, y: 13, w: 7, h: 3}, + {id: '9', x: 31, y: 12, w: 15, h: 1}, + {id: '10', x: 31, y: 13, w: 8, h: 3}, + {id: '11', x: 39, y: 13, w: 7, h: 3}, + {id: '12', x: 46, y: 12, w: 15, h: 1}, + {id: '13', x: 46, y: 17, w: 8, h: 3}, + {id: '14', x: 54, y: 13, w: 7, h: 3}, + {id: '15', x: 1, y: 16, w: 15, h: 1}, + {id: '16', x: 1, y: 17, w: 8, h: 3}, + {id: '17', x: 9, y: 17, w: 7, h: 3}, + {id: '18', x: 16, y: 16, w: 15, h: 1}, + {id: '19', x: 16, y: 17, w: 8, h: 3}, + {id: '20', x: 24, y: 17, w: 7, h: 3}, + {id: '21', x: 31, y: 16, w: 15, h: 1}, + {id: '22', x: 31, y: 17, w: 8, h: 3}, + {id: '23', x: 39, y: 17, w: 7, h: 3}, + {id: '24', x: 46, y: 16, w: 15, h: 1}, + {id: '25', x: 46, y: 13, w: 8, h: 3}, + {id: '26', x: 1, y: 21, w: 60, h: 1}, + {id: '27', x: 0, y: 9, w: 62, h: 1}, + {id: '28', x: 32, y: 5, w: 16, h: 4}, + {id: '29', x: 1, y: 22, w: 15, h: 1}, + {id: '30', x: 1, y: 23, w: 8, h: 3}, + {id: '31', x: 9, y: 23, w: 7, h: 3}, + {id: '32', x: 1, y: 31, w: 60, h: 1}, + {id: '33', x: 1, y: 41, w: 59, h: 1}, + {id: '34', x: 16, y: 22, w: 15, h: 1}, + {id: '35', x: 16, y: 23, w: 8, h: 3}, + {id: '36', x: 24, y: 23, w: 7, h: 3}, + {id: '37', x: 31, y: 32, w: 15, h: 1}, + {id: '38', x: 31, y: 23, w: 8, h: 3}, + {id: '39', x: 39, y: 23, w: 7, h: 3}, + {id: '40', x: 46, y: 22, w: 15, h: 1}, + {id: '41', x: 46, y: 23, w: 8, h: 3}, + {id: '42', x: 54, y: 23, w: 7, h: 3}, + {id: '43', x: 12, y: 49, w: 50, h: 8}, + {id: '44', x: 0, y: 50, w: 12, h: 2}, + {id: '45', x: 0, y: 52, w: 6, h: 4}, + {id: '46', x: 6, y: 52, w: 6, h: 4}, + {id: '47', x: 0, y: 49, w: 12, h: 1}, + {id: '48', x: 0, y: 48, w: 62, h: 1}, + {id: '49', x: 0, y: 98, w: 12, h: 2}, + {id: '50', x: 0, y: 100, w: 6, h: 4}, + {id: '51', x: 6, y: 100, w: 6, h: 4}, + {id: '52', x: 1, y: 26, w: 15, h: 1}, + {id: '53', x: 1, y: 27, w: 8, h: 3}, + {id: '54', x: 9, y: 27, w: 7, h: 3}, + {id: '55', x: 1, y: 32, w: 15, h: 1}, + {id: '56', x: 1, y: 36, w: 15, h: 1}, + {id: '57', x: 1, y: 33, w: 8, h: 3}, + {id: '58', x: 9, y: 33, w: 7, h: 3}, + {id: '59', x: 31, y: 33, w: 8, h: 3}, + {id: '60', x: 16, y: 32, w: 15, h: 1}, + {id: '61', x: 39, y: 33, w: 7, h: 3}, + {id: '62', x: 31, y: 22, w: 15, h: 1}, + {id: '63', x: 16, y: 33, w: 8, h: 3}, + {id: '64', x: 54, y: 33, w: 7, h: 3}, + {id: '65', x: 24, y: 33, w: 7, h: 3}, + {id: '66', x: 46, y: 33, w: 8, h: 3}, + {id: '67', x: 46, y: 32, w: 15, h: 1}, + {id: '68', x: 1, y: 37, w: 8, h: 3}, + {id: '69', x: 9, y: 37, w: 7, h: 3}, + {id: '70', x: 16, y: 36, w: 15, h: 1}, + {id: '71', x: 31, y: 36, w: 15, h: 1}, + {id: '72', x: 24, y: 37, w: 7, h: 3}, + {id: '73', x: 39, y: 37, w: 7, h: 3}, + {id: '74', x: 46, y: 36, w: 15, h: 1}, + {id: '75', x: 16, y: 37, w: 8, h: 3}, + {id: '76', x: 31, y: 37, w: 8, h: 3}, + {id: '77', x: 1, y: 42, w: 15, h: 1}, + {id: '78', x: 1, y: 43, w: 8, h: 3}, + {id: '79', x: 9, y: 43, w: 7, h: 3}, + {id: '80', x: 31, y: 42, w: 15, h: 1}, + {id: '81', x: 46, y: 42, w: 15, h: 1}, + {id: '82', x: 16, y: 42, w: 15, h: 1}, + {id: '83', x: 24, y: 43, w: 7, h: 3}, + {id: '84', x: 16, y: 43, w: 8, h: 3}, + {id: '85', x: 39, y: 43, w: 7, h: 3}, + {id: '86', x: 54, y: 43, w: 7, h: 3}, + {id: '87', x: 31, y: 43, w: 8, h: 3}, + {id: '88', x: 46, y: 43, w: 8, h: 3}, + {id: '89', x: 0, y: 56, w: 12, h: 2}, + {id: '90', x: 12, y: 57, w: 50, h: 8}, + {id: '91', x: 0, y: 60, w: 6, h: 4}, + {id: '92', x: 6, y: 60, w: 6, h: 4}, + {id: '93', x: 0, y: 58, w: 12, h: 2}, + {id: '94', x: 0, y: 66, w: 12, h: 2}, + {id: '95', x: 0, y: 68, w: 6, h: 4}, + {id: '96', x: 6, y: 68, w: 6, h: 4}, + {id: '97', x: 0, y: 74, w: 12, h: 2}, + {id: '98', x: 0, y: 76, w: 6, h: 4}, + {id: '99', x: 6, y: 76, w: 6, h: 4}, + {id: '100', x: 0, y: 82, w: 12, h: 2}, + {id: '101', x: 0, y: 84, w: 6, h: 4}, + {id: '102', x: 6, y: 84, w: 6, h: 4}, + {id: '103', x: 0, y: 90, w: 12, h: 2}, + {id: '104', x: 0, y: 92, w: 6, h: 4}, + {id: '105', x: 6, y: 92, w: 6, h: 4}, + {id: '106', x: 16, y: 26, w: 15, h: 1}, + {id: '107', x: 16, y: 27, w: 8, h: 3}, + {id: '108', x: 24, y: 27, w: 7, h: 3}, + {id: '109', x: 0, y: 80, w: 12, h: 2}, + {id: '110', x: 0, y: 72, w: 12, h: 2}, + {id: '111', x: 0, y: 88, w: 12, h: 2}, + {id: '112', x: 0, y: 64, w: 12, h: 2}, + {id: '113', x: 0, y: 96, w: 12, h: 2}, + {id: '114', x: 0, y: 104, w: 12, h: 2}, + {id: '115', x: 0, y: 106, w: 12, h: 2}, + {id: '116', x: 6, y: 108, w: 6, h: 4}, + {id: '117', x: 0, y: 108, w: 6, h: 4}, + {id: '118', x: 0, y: 112, w: 12, h: 2}, + {id: '119', x: 0, y: 120, w: 12, h: 2}, + {id: '120', x: 0, y: 128, w: 12, h: 2}, + {id: '121', x: 0, y: 144, w: 12, h: 2}, + {id: '122', x: 0, y: 114, w: 12, h: 2}, + {id: '123', x: 0, y: 116, w: 6, h: 4}, + {id: '124', x: 6, y: 116, w: 6, h: 4}, + {id: '125', x: 0, y: 122, w: 12, h: 2}, + {id: '126', x: 0, y: 130, w: 12, h: 2}, + {id: '127', x: 0, y: 124, w: 6, h: 4}, + {id: '128', x: 6, y: 124, w: 6, h: 4}, + {id: '129', x: 0, y: 132, w: 6, h: 4}, + {id: '130', x: 6, y: 132, w: 6, h: 4}, + {id: '131', x: 0, y: 136, w: 12, h: 2}, + {id: '132', x: 0, y: 138, w: 12, h: 2}, + {id: '133', x: 6, y: 140, w: 6, h: 4}, + {id: '134', x: 0, y: 140, w: 6, h: 4}, + {id: '135', x: 0, y: 146, w: 12, h: 2}, + {id: '136', x: 0, y: 148, w: 6, h: 4}, + {id: '137', x: 6, y: 148, w: 6, h: 4}, + {id: '138', x: 0, y: 154, w: 12, h: 2}, + {id: '139', x: 0, y: 156, w: 6, h: 4}, + {id: '140', x: 6, y: 156, w: 6, h: 4}, + {id: '141', x: 0, y: 170, w: 12, h: 2}, + {id: '142', x: 0, y: 164, w: 6, h: 4}, + {id: '143', x: 6, y: 164, w: 6, h: 4}, + {id: '144', x: 0, y: 160, w: 12, h: 2}, + {id: '145', x: 0, y: 152, w: 12, h: 2}, + {id: '146', x: 0, y: 162, w: 12, h: 2}, + {id: '147', x: 0, y: 168, w: 12, h: 2}, + {id: '148', x: 0, y: 176, w: 12, h: 2}, + {id: '149', x: 6, y: 172, w: 6, h: 4}, + {id: '150', x: 0, y: 172, w: 6, h: 4}, + {id: '151', x: 0, y: 186, w: 12, h: 2}, + {id: '152', x: 0, y: 210, w: 12, h: 2}, + {id: '153', x: 0, y: 212, w: 6, h: 4}, + {id: '154', x: 6, y: 212, w: 6, h: 4}, + {id: '155', x: 0, y: 218, w: 12, h: 2}, + {id: '156', x: 0, y: 220, w: 6, h: 4}, + {id: '157', x: 6, y: 220, w: 6, h: 4}, + {id: '158', x: 0, y: 226, w: 12, h: 2}, + {id: '159', x: 0, y: 228, w: 6, h: 4}, + {id: '160', x: 6, y: 228, w: 6, h: 4}, + {id: '161', x: 0, y: 234, w: 12, h: 2}, + {id: '162', x: 0, y: 236, w: 6, h: 4}, + {id: '163', x: 6, y: 236, w: 6, h: 4}, + {id: '164', x: 0, y: 178, w: 12, h: 2}, + {id: '165', x: 0, y: 184, w: 12, h: 2}, + {id: '166', x: 0, y: 194, w: 12, h: 2}, + {id: '167', x: 0, y: 192, w: 12, h: 2}, + {id: '168', x: 0, y: 196, w: 6, h: 4}, + {id: '169', x: 6, y: 196, w: 6, h: 4}, + {id: '170', x: 0, y: 200, w: 12, h: 2}, + {id: '171', x: 0, y: 202, w: 12, h: 2}, + {id: '172', x: 0, y: 208, w: 12, h: 2}, + {id: '173', x: 0, y: 232, w: 12, h: 2}, + {id: '174', x: 0, y: 224, w: 12, h: 2}, + {id: '175', x: 0, y: 216, w: 12, h: 2}, + {id: '176', x: 54, y: 17, w: 7, h: 3}, + {id: '177', x: 12, y: 65, w: 50, h: 8}, + {id: '178', x: 12, y: 73, w: 50, h: 8}, + {id: '179', x: 12, y: 81, w: 50, h: 8}, + {id: '180', x: 46, y: 37, w: 8, h: 3}, + {id: '181', x: 12, y: 89, w: 50, h: 8}, + {id: '182', x: 12, y: 97, w: 50, h: 8}, + {id: '183', x: 12, y: 105, w: 50, h: 8}, + {id: '184', x: 12, y: 113, w: 50, h: 8}, + {id: '185', x: 12, y: 121, w: 50, h: 8}, + {id: '186', x: 12, y: 129, w: 50, h: 8}, + {id: '187', x: 12, y: 137, w: 50, h: 8}, + {id: '188', x: 12, y: 145, w: 50, h: 8}, + {id: '189', x: 12, y: 153, w: 50, h: 8}, + {id: '190', x: 12, y: 161, w: 50, h: 8}, + {id: '191', x: 12, y: 169, w: 50, h: 8}, + {id: '192', x: 12, y: 177, w: 50, h: 8}, + {id: '193', x: 6, y: 180, w: 6, h: 4}, + {id: '194', x: 0, y: 180, w: 6, h: 4}, + {id: '195', x: 6, y: 188, w: 6, h: 4}, + {id: '196', x: 0, y: 188, w: 6, h: 4}, + {id: '197', x: 12, y: 185, w: 50, h: 8}, + {id: '198', x: 12, y: 193, w: 50, h: 8}, + {id: '199', x: 12, y: 209, w: 50, h: 8}, + {id: '200', x: 12, y: 201, w: 50, h: 8}, + {id: '201', x: 12, y: 217, w: 50, h: 8}, + {id: '202', x: 12, y: 225, w: 50, h: 8}, + {id: '203', x: 12, y: 233, w: 50, h: 8}, + {id: '204', x: 0, y: 30, w: 62, h: 1}, + {id: '205', x: 0, y: 46, w: 62, h: 2}, + {id: '206', x: 0, y: 20, w: 62, h: 1}, + {id: '207', x: 0, y: 40, w: 62, h: 1}, + {id: '208', x: 54, y: 37, w: 7, h: 3}, + {id: '209', x: 0, y: 10, w: 62, h: 1}, + {id: '210', x: 46, y: 26, w: 15, h: 1}, + {id: '211', x: 31, y: 26, w: 15, h: 1}, + {id: '212', x: 31, y: 27, w: 8, h: 3}, + {id: '213', x: 46, y: 27, w: 8, h: 3}, + {id: '214', x: 54, y: 27, w: 7, h: 3}, + {id: '215', x: 39, y: 27, w: 7, h: 3}, + {id: '216', x: 0, y: 3, w: 62, h: 2}, + {id: '217', x: 0, y: 204, w: 6, h: 4}, + {id: '218', x: 6, y: 204, w: 6, h: 4} +]; +const realLifeLayoutSmall: KtdGridLayout = [ + {id: '2', x: 1, y: 0, w: 61, h: 1}, + {id: '3', x: 1, y: 1, w: 15, h: 1}, + {id: '4', x: 1, y: 2, w: 8, h: 3}, + {id: '5', x: 9, y: 2, w: 7, h: 3}, + {id: '6', x: 16, y: 1, w: 15, h: 1}, + {id: '7', x: 16, y: 2, w: 8, h: 3}, + {id: '8', x: 24, y: 2, w: 7, h: 3}, + {id: '9', x: 31, y: 1, w: 15, h: 1}, + {id: '10', x: 31, y: 2, w: 8, h: 3}, + {id: '11', x: 39, y: 2, w: 7, h: 3}, + {id: '12', x: 46, y: 1, w: 15, h: 1}, + {id: '13', x: 46, y: 6, w: 8, h: 3}, + {id: '14', x: 54, y: 2, w: 7, h: 3}, + {id: '15', x: 1, y: 5, w: 15, h: 1}, + {id: '16', x: 1, y: 6, w: 8, h: 3}, + {id: '17', x: 9, y: 6, w: 7, h: 3}, + {id: '18', x: 16, y: 5, w: 15, h: 1}, + {id: '19', x: 16, y: 6, w: 8, h: 3}, + {id: '20', x: 24, y: 6, w: 7, h: 3}, + {id: '21', x: 31, y: 5, w: 15, h: 1}, + {id: '22', x: 31, y: 6, w: 8, h: 3}, + {id: '23', x: 39, y: 6, w: 7, h: 3}, + {id: '24', x: 46, y: 5, w: 15, h: 1}, + {id: '25', x: 46, y: 2, w: 8, h: 3}, + {id: '32', x: 1, y: 9, w: 60, h: 1}, + {id: '39', x: 54, y: 6, w: 7, h: 3} +]; + +// Reproduce bug using default grid layout algorithm executed N times per selected item. +const multipleDragBugMutation = [ + {w: 8, h: 3, x: 2, y: 0, id: 'DRAG ME 1'}, + {w: 8, h: 3, x: 8, y: 3, id: 'DRAG ME 2'}, + {w: 15, h: 3, x: 16, y: 0, id: '6'}, + {w: 15, h: 1, x: 16, y: 3, id: '18'}, + {w: 8, h: 3, x: 16, y: 4, id: '19'}, + {w: 7, h: 3, x: 24, y: 4, id: '20'}, + {w: 60, h: 1, x: 1, y: 7, id: '32'} +]; + +// Reproduce bug with a break by previous item position instead of the moved one on compact function +const simpleMoveBugGridMutation = [ + {id: '0', x: 1, y: 0, w: 24, h: 1}, + {id: '1', x: 1, y: 1, w: 8, h: 1}, + {id: '2', x: 1, y: 2, w: 8, h: 3}, + {id: '3', x: 9, y: 2, w: 8, h: 8}, + {id: '4', x: 17, y: 1, w: 8, h: 2}, + {id: '5', x: 17, y: 3, w: 8, h: 3}, +]; + +@Component({ + standalone: true, + selector: 'ktd-playground', + templateUrl: './multi-item-handler.component.html', + styleUrls: ['./multi-item-handler.component.scss'], + imports: [ + MatButtonModule, + MatFormFieldModule, + MatSelectModule, + MatOptionModule, + MatInputModule, + MatCheckboxModule, + NgFor, + NgClass, + MatChipsModule, + ColorPickerModule, + KtdGridComponent, + KtdGridItemComponent, + KtdGridItemPlaceholder, + KtdFooterComponent + ] +}) +export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { + @ViewChild(KtdGridComponent, {static: true}) grid: KtdGridComponent; + trackById = ktdTrackById; + + cols = 62; + rowHeight = 32; + compactType: 'vertical' | 'horizontal' | null = 'vertical'; + preventCollision = false; + selectedItems: string[] = []; + copiedItems: number + layout: KtdGridLayout = realLifeLayout; + + resizeSubscription: Subscription; + + private _isDraggingResizing: boolean = false; + + constructor( + private ngZone: NgZone, + public elementRef: ElementRef, + @Inject(DOCUMENT) public document: Document + ) { + fromEvent(document, 'keydown').pipe( + filter(event => { + const isCtrlV = event.ctrlKey && event.key.toLowerCase() === 'v'; // Windows + const isCmdV = event.metaKey && event.key.toLowerCase() === 'v'; // Mac + return isCtrlV || isCmdV; + }) + ).subscribe(() => { + this.duplicateSelectedElements(); + }); + } + + ngOnInit() { + this.resizeSubscription = merge( + fromEvent(window, 'resize'), + fromEvent(window, 'orientationchange') + ).pipe( + debounceTime(50), + ).subscribe(() => { + this.grid.resize(); + }); + } + + ngOnDestroy() { + this.resizeSubscription.unsubscribe(); + } + + onDragStarted(event: KtdDragStart) { + this._isDraggingResizing = true; + console.log('onDragStarted', event); + } + + onDragEnded(event: KtdDragEnd) { + this._isDraggingResizing = false; + console.log('onDragEnded', event); + } + + onResizeStarted(event: KtdResizeStart) { + this._isDraggingResizing = true; + console.log('onResizeStarted', event); + } + + onResizeEnded(event: KtdResizeEnd) { + this._isDraggingResizing = false; + console.log('onResizeEnded', event); + } + + onCompactTypeChange(change: MatSelectChange) { + console.log('onCompactTypeChange', change); + this.compactType = change.value; + } + + onPreventCollisionChange(checked: boolean) { + console.log('onPreventCollisionChange', checked); + this.preventCollision = checked; + } + + onLayoutUpdated(layout: KtdGridLayout) { + console.log('onLayoutUpdated', layout); + this.layout = layout; + } + + generateLayout() { + const layout: KtdGridLayout = []; + for (let i = 0; i < this.cols; i++) { + const y = Math.ceil(Math.random() * 4) + 1; + const width = 10; + layout.push({ + x: + Math.round(Math.random() * Math.floor(this.cols / width - 1)) * + width, + y: Math.floor(i / 6) * y, + w: width, + h: y, + id: i.toString() + // static: Math.random() < 0.05 + }); + } + this.layout = ktdGridCompact(layout, this.compactType, this.cols); + console.log('generateLayout', this.layout); + } + + /** Adds a grid item to the layout */ + addItemToLayout(item?: KtdGridLayoutItem) { + let newLayoutItem: KtdGridLayoutItem | undefined = item; + if(!newLayoutItem){ + + + const maxId = this.layout.reduce( + (acc, cur) => Math.max(acc, parseInt(cur.id, 10)), + -1 + ); + const nextId = maxId + 1; + newLayoutItem = { + id: nextId.toString(), + x: -1, + y: -1, + w: 2, + h: 2 + }; + } + // Important: Don't mutate the array, create new instance. This way notifies the Grid component that the layout has changed. + this.layout = [newLayoutItem, ...this.layout]; + this.layout = ktdGridCompact(this.layout, this.compactType, this.cols); + console.log('addItemToLayout', newLayoutItem); + } + + /** + * Fired when a mousedown happens on the remove grid item button. + * Stops the event from propagating an causing the drag to start. + * We don't want to drag when mousedown is fired on remove icon button. + */ + stopEventPropagation(event: Event) { + event.preventDefault(); + event.stopPropagation(); + } + + /** Removes the item from the layout */ + removeItem(id: string) { + this.selectedItems = []; + // Important: Don't mutate the array. Let Angular know that the layout has changed creating a new reference. + this.layout = ktdArrayRemoveItem(this.layout, item => item.id === id); + } + + /** + * Check if 'selectedItem' is on the multi item selection + */ + isItemSelected(selectedItem: KtdGridLayoutItem): boolean { + return this.selectedItems.includes(selectedItem.id); + } + + /* + * Select an item outside of the group + */ + pointerDownItemSelection( + event: MouseEvent, + selectedItem: KtdGridLayoutItem + ) { + const ctrlOrCmd = ktdGetOS() == 'macos' ? event.metaKey : event.ctrlKey; + if (!ctrlOrCmd) { + const selectedItemExist = this.selectedItems.includes( + selectedItem.id + ); + if (!selectedItemExist) { + // Click an element outside selection group + // Clean all selections and select the new item + if (event.button == 2) { + this.selectedItems = []; + } else { + this.selectedItems = [selectedItem.id]; + } + } + } + } + + /* + * Select an item inside the group or multiselect with Control button + */ + pointerUpItemSelection(event: MouseEvent, selectedItem: KtdGridLayoutItem) { + const ctrlOrCmd = ktdGetOS() == 'macos' ? event.metaKey : event.ctrlKey; + if (event.button !== 2) { + //Only select with primary button click + const selectedItemExist = this.selectedItems.includes( + selectedItem.id + ); + if (ctrlOrCmd) { + if (selectedItemExist) { + // Control + click an element inside the selection group + if (!this._isDraggingResizing) { + // If not dragging, remove the selected item from the group + this.selectedItems = ktdArrayRemoveItem( + this.selectedItems, + itemId => itemId === selectedItem.id + ); + } + } else { + // Control + click an element outside the selection group + // Add the new selected item to the current group + this.selectedItems = [ + ...this.selectedItems, + selectedItem.id + ]; + } + } else if (!this._isDraggingResizing && selectedItemExist) { + // Click an element inside the selection group + this.selectedItems = [selectedItem.id]; + } + } + } + + /* + * Paste a copy of "this.selectedItems" below the last selected item (preserving their positions) + */ + private duplicateSelectedElements(){ + const maxId = this.layout.reduce( + (acc, cur) => Math.max(acc, parseInt(cur.id, 10)), + -1 + ); + let nextId = maxId; + const lastY: number = this.selectedItems.length>0 ? this.layout.find((l)=>l.id===this.selectedItems[this.selectedItems.length-1])!.y : 0; + const layoutItemsSorted: KtdGridLayoutItem[] = ktdGridSortLayoutItems(this.selectedItems.map((gridItemId: string)=> this.layout.find((l)=>l.id===gridItemId)!), this.compactType); + layoutItemsSorted.reverse().forEach((layoutItem) => { + nextId++; + const newLayoutItem = { + id: nextId.toString(), + w: layoutItem.w, + h: layoutItem.h, + x: layoutItem.x, + y: lastY + 0.5 + }; + + this.addItemToLayout(newLayoutItem); + }); + console.log('duplicateSelectedElements', this.selectedItems) + } + +} diff --git a/projects/demo-app/src/app/multi-item-handler/multi-item-handler.utils.ts b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.utils.ts new file mode 100644 index 0000000..56319d3 --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.utils.ts @@ -0,0 +1,22 @@ +export type OS = 'macos' | 'ios' | 'windows' | 'android' | 'linux'; + +// Precondition: Should be executed in a Browser environment. +export function ktdGetOS(): OS | null { + const userAgent = window.navigator.userAgent.toLowerCase(); + const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos)/i; + const windowsPlatforms = /(win32|win64|windows|wince)/i; + const iosPlatforms = /(iphone|ipad|ipod)/i; + + if (macosPlatforms.test(userAgent)) { + return 'macos'; + } else if (iosPlatforms.test(userAgent)) { + return 'ios'; + } else if (windowsPlatforms.test(userAgent)) { + return 'windows'; + } else if (/android/.test(userAgent)) { + return 'android'; + } else if (/linux/.test(userAgent)) { + return 'linux'; + } + return null; +}