From cd0bea077072b476614770d089e5dc630889c5c4 Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Wed, 16 Jul 2025 12:49:57 +0200 Subject: [PATCH 01/11] feat: allow only drag/resize with primary (left) button Only left-click or touch can drag/resize, to avoid macOS right-click pointerUp inconsistency. --- .../src/lib/utils/pointer.utils.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 45bd146..24f105e 100644 --- a/projects/angular-grid-layout/src/lib/utils/pointer.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/pointer.utils.ts @@ -125,7 +125,7 @@ function ktdMouserOrTouchEnd(element: HTMLElement, touchNumber = 1): Observable< /** - * Emits when a 'pointerdown' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. + * Emits when a 'pointerdown' event occurs (only for the primary pointer and mousePrimaryButton/touch). 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 { @@ -134,12 +134,12 @@ export function ktdPointerDown(element): Observable(element, 'pointerdown', activeEventListenerOptions as AddEventListenerOptions).pipe( - filter((pointerEvent) => pointerEvent.isPrimary) + filter((pointerEvent) => pointerEvent.isPrimary && pointerEvent.button === 0) ) } /** - * Emits when a 'pointermove' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. + * Emits when a 'pointermove' event occurs (only for the primary pointer and mousePrimaryButton/touch). 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 { @@ -147,17 +147,17 @@ export function ktdPointerMove(element): Observable(element, 'pointermove', activeEventListenerOptions as AddEventListenerOptions).pipe( - filter((pointerEvent) => pointerEvent.isPrimary), + filter((pointerEvent) => pointerEvent.isPrimary && pointerEvent.button === 0), ); } /** - * Emits when a 'pointerup' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. + * Emits when a 'pointerup' event occurs (only for the primary pointer and mousePrimaryButton/touch). 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)); + return fromEvent(element, 'pointerup').pipe(filter(pointerEvent => pointerEvent.isPrimary && pointerEvent.button === 0)); } From 960db3783600238933749cd48219eec71b572987 Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Thu, 24 Jul 2025 09:06:36 +0200 Subject: [PATCH 02/11] feat: support multiple item drag/resize Allow drag/resize of a group of items via `selectedItemsIds` input, overriding default behavior. --- .../src/lib/grid.component.ts | 262 +++++++++++------- .../demo-app/src/app/app-routing.routes.ts | 5 + .../components/footer/footer.component.html | 1 + .../multi-item-handler.component.html | 68 +++++ .../multi-item-handler.component.scss | 75 +++++ .../multi-item-handler.component.ts | 252 +++++++++++++++++ .../multi-item-handler.utils.ts | 22 ++ 7 files changed, 582 insertions(+), 103 deletions(-) create mode 100644 projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.html create mode 100644 projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.scss create mode 100644 projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.ts create mode 100644 projects/demo-app/src/app/multi-item-handler/multi-item-handler.utils.ts diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index c4bfbe7..6cef16e 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 { 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>={}; + const 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[]; @@ -544,80 +590,90 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte * some utilities from 'react-grid-layout' would not work as expected. */ const currentLayout: KtdGridLayout = newLayout || this.layout; + newLayout=currentLayout; // 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 + 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, + height: this.height, + layout: newLayout, + preventCollision: this.preventCollision, + gap: this.gap, + }, gridElemClientRect.width, gridElemClientRect.height); + + newGridItemRenderData[gridItem.id] = {...this._gridItemsRenderData[gridItem.id]} + const placeholderStyles = parseRenderItemToPixels(newGridItemRenderData[gridItem.id]); + + // 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})`; }); - 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 - }; + // Modify the position of the dragged item to be the once we want (for example the mouse position or whatever) + gridItems.forEach((gridItem)=>{ + 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 +745,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/demo-app/src/app/app-routing.routes.ts b/projects/demo-app/src/app/app-routing.routes.ts index 9a3ed82..faa49f9 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 - Multiple item handler'} + }, { 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..c0e1c85 --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.html @@ -0,0 +1,68 @@ +
+
+
+ + + + Compact type + + vertical + horizontal + - + + + + Prevent Collision + +
+ +
+ + +
+ {{ 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..50a4e23 --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.scss @@ -0,0 +1,75 @@ +:host { + display: block; + width: 100%; + padding: 48px 32px; + box-sizing: border-box; + + .container { + width: 100%; + } + + .controls-container { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-bottom: 16px; + + & > * { + 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..70d81b7 --- /dev/null +++ b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.ts @@ -0,0 +1,252 @@ +import { + Component, + ElementRef, + Inject, + NgZone, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import {MatSelectChange, MatSelectModule} from '@angular/material/select'; +import { + KtdDragEnd, + KtdDragStart, + ktdGridCompact, + KtdGridComponent, + KtdGridLayout, + KtdGridLayoutItem, + KtdResizeEnd, + KtdResizeStart, + ktdTrackById, + KtdGridItemComponent, + KtdGridItemPlaceholder +} from '@katoid/angular-grid-layout'; +import {ktdArrayRemoveItem} from '../utils'; +import {DOCUMENT, NgClass, NgFor, NgIf} 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'; + +@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 = 12; + rowHeight = 50; + compactType: 'vertical' | 'horizontal' | null = 'vertical'; + preventCollision = false; + selectedItems: string[] = []; + layout: KtdGridLayout = [ + {id: '0', x: 5, y: 0, w: 2, h: 3}, + {id: '1', x: 2, y: 2, w: 1, h: 2}, + {id: '2', x: 3, y: 7, w: 1, h: 2}, + {id: '3', x: 2, y: 0, w: 3, h: 2}, + {id: '4', x: 5, y: 3, w: 2, h: 3}, + {id: '5', x: 0, y: 4, w: 1, h: 3}, + {id: '6', x: 9, y: 0, w: 2, h: 4}, + {id: '7', x: 9, y: 4, w: 2, h: 2}, + {id: '8', x: 3, y: 2, w: 2, h: 5}, + {id: '9', x: 7, y: 0, w: 1, h: 3}, + {id: '10', x: 2, y: 4, w: 1, h: 4}, + {id: '11', x: 0, y: 0, w: 2, h: 4} + ]; + + private _isDraggingResizing: boolean = false; + + constructor( + private ngZone: NgZone, + public elementRef: ElementRef, + @Inject(DOCUMENT) public document: Document + ) { + // this.ngZone.onUnstable.subscribe(() => console.log('UnStable')); + } + + ngOnInit() {} + + ngOnDestroy() {} + + 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; + layout.push({ + x: + Math.round(Math.random() * Math.floor(this.cols / 2 - 1)) * + 2, + y: Math.floor(i / 6) * y, + w: 2, + 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() { + const maxId = this.layout.reduce( + (acc, cur) => Math.max(acc, parseInt(cur.id, 10)), + -1 + ); + const nextId = maxId + 1; + const newLayoutItem: KtdGridLayoutItem = { + 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]; + } + } + } +} 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; +} From 20a913ae4495cb16da6c53dedf06805dbc423d0c Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Tue, 29 Jul 2025 10:17:39 +0200 Subject: [PATCH 03/11] feat: improvements for proposals 1 and 2 --- .../demo-app/src/app/app-routing.routes.ts | 2 +- .../multi-item-handler.component.html | 62 ++++++++++++------- .../multi-item-handler.component.scss | 30 +++++++-- 3 files changed, 66 insertions(+), 28 deletions(-) diff --git a/projects/demo-app/src/app/app-routing.routes.ts b/projects/demo-app/src/app/app-routing.routes.ts index faa49f9..e810620 100644 --- a/projects/demo-app/src/app/app-routing.routes.ts +++ b/projects/demo-app/src/app/app-routing.routes.ts @@ -30,7 +30,7 @@ export const APP_ROUTES: Routes = [ { path: 'multi-item-handler', loadComponent: () => import('./multi-item-handler/multi-item-handler.component').then(m => m.KtdMultiItemHandlerComponent), - data: {title: 'Angular Grid Layout - Multiple item handler'} + data: {title: 'Angular Grid Layout - Multi-Item Drag & Resize'} }, { path: '**', 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 index c0e1c85..7e3855b 100644 --- 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 @@ -1,28 +1,46 @@
-
-
- - - - Compact type - - vertical - horizontal - - - - - - Prevent Collision - +
+ +
+

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
  • +
  • Click on an item outside the group to start a new selection
  • +
+
+ +
+

GRID CONTROLS

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

GRID

* { + margin: 8px 24px 8px 0; + } + } - & > * { - margin: 8px 24px 8px 0; } } From 32e8c7610b27e70632cbf0bf85c1a5074ef51e22 Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Tue, 5 Aug 2025 11:41:17 +0200 Subject: [PATCH 04/11] feat: improvements for proposals 4,5 and 6 --- .../src/lib/grid.component.ts | 87 +++++++---- .../src/lib/grid.definitions.ts | 9 ++ .../src/lib/utils/grid.utils.ts | 104 ++++++++++++- .../utils/react-grid-layout-multiple.utils.ts | 145 ++++++++++++++++++ 4 files changed, 310 insertions(+), 35 deletions(-) create mode 100644 projects/angular-grid-layout/src/lib/utils/react-grid-layout-multiple.utils.ts diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index 6cef16e..a13d580 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -6,7 +6,7 @@ import { coerceNumberProperty, NumberInput } from './coercion/number-property'; import { KtdGridItemComponent } from './grid-item/grid-item.component'; 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, KtdGridItemRect, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem @@ -535,7 +535,8 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte const dragElemClientRect: KtdDictionary={}; const newGridItemRenderData: KtdDictionary>={}; - const draggedItemsPos: KtdDictionary={}; + let draggedItemsPos: KtdDictionary={}; + const originalLayout: KtdGridLayout = structuredClone(this.layout); gridItems.forEach((gridItem)=>{ // Retrieve gridItem (draggedElem) client rect. @@ -593,38 +594,59 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte newLayout=currentLayout; // Get the correct newStateFunc depending on if we are dragging or resizing - const calcNewStateFunc = type === 'drag' ? ktdGridItemDragging : ktdGridItemResizing; - - 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 + if(type === 'drag' && gridItems.length>1){ + const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { + layout: originalLayout, + 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; + 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; }); + } - 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, - height: this.height, - layout: newLayout, - preventCollision: this.preventCollision, - gap: this.gap, - }, gridElemClientRect.width, gridElemClientRect.height); + 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); + // 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]); @@ -632,10 +654,7 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte 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) - gridItems.forEach((gridItem)=>{ this._gridItemsRenderData[gridItem.id] = { ...draggedItemsPos[gridItem.id], id: this._gridItemsRenderData[gridItem.id].id 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..d6266e6 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 { - 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 { moveElements } 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}) { @@ -152,6 +153,107 @@ 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 = {} + gridItems.forEach(gridItems=> { + draggingElemPrevItem[gridItems.id] = config.layout.find(item => item.id === gridItems.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}>={} + 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) + }; + // Correct the values if they overflow, since 'moveElement' function doesn't do it + layoutItemsToMove[gridItem.id].x = Math.max(0, layoutItemsToMove[gridItem.id].x); + layoutItemsToMove[gridItem.id].y = Math.max(0, layoutItemsToMove[gridItem.id].y); + if (layoutItemsToMove[gridItem.id].x + layoutItemsToMove[gridItem.id].w > config.cols) { + layoutItemsToMove[gridItem.id].x = Math.max(0, config.cols - layoutItemsToMove[gridItem.id].w); + } + }) + + + + // Parse to LayoutItem array data in order to use 'react.grid-layout' utils + const layoutItems: LayoutItem[] = config.layout; + const draggedLayoutItem: { + l: LayoutItem, + x: number | null | undefined, + y: number | null | undefined + }[] = gridItems.map((gridItem:KtdGridItemComponent)=>{ + draggingElemPrevItem[gridItem.id].static = true; + return { + l: draggingElemPrevItem[gridItem.id], + x: layoutItemsToMove[gridItem.id].x, + y: layoutItemsToMove[gridItem.id].y + } + }); + + let newLayoutItems: LayoutItem[] = moveElements( + layoutItems, + draggedLayoutItem, + true, + config.preventCollision, + compactionType, + config.cols, + ); + + newLayoutItems = compact(newLayoutItems, compactionType, config.cols); + gridItems.forEach(gridItem=>newLayoutItems.find(layoutItem=>layoutItem.id === gridItem.id)!.static = false); + 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..b6dd5a9 --- /dev/null +++ b/projects/angular-grid-layout/src/lib/utils/react-grid-layout-multiple.utils.ts @@ -0,0 +1,145 @@ +/** + * 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, Layout, LayoutItem, sortLayoutItems } from "./react-grid-layout.utils"; + +const DEBUG = false; + +// Disable lint since we don't want to modify this code +/* eslint-disable */ + +/** + * Move an element. Responsible for doing cascading movements of other elements. + * + * @param {Array} layout Full layout to modify. + * @param {LayoutItem} l element to move. + * @param {Number} [x] X position in grid units. + * @param {Number} [y] Y position in grid units. + */ +export function moveElements( + layout: Layout, + items: { + l: LayoutItem, + x: number | null | undefined, + y: number | null | undefined + }[], + isUserAction: boolean | null | undefined, + preventCollision: boolean | null | undefined, + compactType: CompactType, + cols: number, +): Layout { + const oldX = items[0].l.x; + const oldY = items[0].l.y; + items.forEach((item)=>{ + 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); + // 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(); + items = items.reverse() + } + + items.forEach((item)=>{ + const collisions: LayoutItem[] = getAllCollisions(sorted, item.l); + + // 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 ${items}] 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) { + layout = moveElementsAwayFromCollision( + layout, + collision, + item.l, + isUserAction, + compactType, + cols + ); + } else { + layout = moveElementsAwayFromCollision( + layout, + item.l, + collision, + isUserAction, + compactType, + cols + ); + } + } + }); + + return layout; +} + +export function moveElementsAwayFromCollision( + 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'; + const preventCollision = collidesWith.static; // we're already colliding (not for static items) + + // 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; + } + + return moveElements( + layout, + [{ + l: itemToMove, + x: compactH ? collidesWith.x+1 : undefined, + y: compactV ? collidesWith.y+1 : undefined, + }], + isUserAction, + preventCollision, + compactType, + cols + ); +} + +function logMulti(...args) { + if (!DEBUG) { + return; + } + // eslint-disable-next-line no-console + console.log(...args); +} + From fa921c75da98fb4630e842cada204b21b45bbb3c Mon Sep 17 00:00:00 2001 From: llorenspujol Date: Tue, 5 Aug 2025 12:15:11 +0200 Subject: [PATCH 05/11] chore(demo-app): multi-drag example added real life layout scenario --- .../multi-item-handler.component.html | 2 + .../multi-item-handler.component.ts | 342 +++++++++++++++--- 2 files changed, 291 insertions(+), 53 deletions(-) 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 index 7e3855b..57d0dd8 100644 --- 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 @@ -57,6 +57,8 @@ [compactType]="compactType" [preventCollision]="preventCollision" [gap]="10" + [scrollableParent]="document" + [scrollSpeed]="4" [selectedItemsIds]="selectedItems" (dragStarted)="onDragStarted($event)" (resizeStarted)="onResizeStarted($event)" 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 index 70d81b7..ef04b30 100644 --- 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 @@ -1,37 +1,272 @@ +import { Component, ElementRef, Inject, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { MatSelectChange, MatSelectModule } from '@angular/material/select'; import { - Component, - ElementRef, - Inject, - NgZone, - OnDestroy, - OnInit, - ViewChild -} from '@angular/core'; -import {MatSelectChange, MatSelectModule} from '@angular/material/select'; -import { - KtdDragEnd, - KtdDragStart, - ktdGridCompact, - KtdGridComponent, - KtdGridLayout, - KtdGridLayoutItem, - KtdResizeEnd, - KtdResizeStart, - ktdTrackById, - KtdGridItemComponent, - KtdGridItemPlaceholder + KtdDragEnd, KtdDragStart, ktdGridCompact, KtdGridComponent, KtdGridItemComponent, KtdGridItemPlaceholder, KtdGridLayout, KtdGridLayoutItem, + KtdResizeEnd, KtdResizeStart, ktdTrackById } from '@katoid/angular-grid-layout'; -import {ktdArrayRemoveItem} from '../utils'; -import {DOCUMENT, NgClass, NgFor, NgIf} 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 { 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 } 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} +]; @Component({ standalone: true, @@ -59,25 +294,14 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { @ViewChild(KtdGridComponent, {static: true}) grid: KtdGridComponent; trackById = ktdTrackById; - cols = 12; - rowHeight = 50; + cols = 62; + rowHeight = 32; compactType: 'vertical' | 'horizontal' | null = 'vertical'; preventCollision = false; selectedItems: string[] = []; - layout: KtdGridLayout = [ - {id: '0', x: 5, y: 0, w: 2, h: 3}, - {id: '1', x: 2, y: 2, w: 1, h: 2}, - {id: '2', x: 3, y: 7, w: 1, h: 2}, - {id: '3', x: 2, y: 0, w: 3, h: 2}, - {id: '4', x: 5, y: 3, w: 2, h: 3}, - {id: '5', x: 0, y: 4, w: 1, h: 3}, - {id: '6', x: 9, y: 0, w: 2, h: 4}, - {id: '7', x: 9, y: 4, w: 2, h: 2}, - {id: '8', x: 3, y: 2, w: 2, h: 5}, - {id: '9', x: 7, y: 0, w: 1, h: 3}, - {id: '10', x: 2, y: 4, w: 1, h: 4}, - {id: '11', x: 0, y: 0, w: 2, h: 4} - ]; + layout: KtdGridLayout = realLifeLayout; + + resizeSubscription: Subscription; private _isDraggingResizing: boolean = false; @@ -89,9 +313,20 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { // this.ngZone.onUnstable.subscribe(() => console.log('UnStable')); } - ngOnInit() {} + ngOnInit() { + this.resizeSubscription = merge( + fromEvent(window, 'resize'), + fromEvent(window, 'orientationchange') + ).pipe( + debounceTime(50), + ).subscribe(() => { + this.grid.resize(); + }); + } - ngOnDestroy() {} + ngOnDestroy() { + this.resizeSubscription.unsubscribe(); + } onDragStarted(event: KtdDragStart) { this._isDraggingResizing = true; @@ -132,12 +367,13 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { 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 / 2 - 1)) * - 2, + Math.round(Math.random() * Math.floor(this.cols / width - 1)) * + width, y: Math.floor(i / 6) * y, - w: 2, + w: width, h: y, id: i.toString() // static: Math.random() < 0.05 From 3bb883284330988cb7f1adeaa7d836de1d123961 Mon Sep 17 00:00:00 2001 From: llorenspujol Date: Wed, 6 Aug 2025 07:32:53 +0200 Subject: [PATCH 06/11] feat(grid): added 2nd algorithm to multi drag selected items --- .../src/lib/grid.component.ts | 62 ++++++++++++++++++- .../multi-item-handler.component.ts | 12 ++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index a13d580..8273a28 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -594,7 +594,10 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte newLayout=currentLayout; // Get the correct newStateFunc depending on if we are dragging or resizing - if(type === 'drag' && gridItems.length>1){ + if (type === 'drag' && gridItems.length > 1) { + const MULTI_SELECTION_DRAG_METHOD: 'default' | 'static' = 'default' as any + + if (MULTI_SELECTION_DRAG_METHOD === 'static') { const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { layout: originalLayout, rowHeight: this.rowHeight, @@ -611,8 +614,61 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte }); newLayout = layout; - draggedItemsPos=draggedItemPos; - } else { + draggedItemsPos = draggedItemPos; + } else { + // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout + + newLayout = structuredClone(originalLayout); + console.log('----------'); + + // Sort grid items from top-left to bottom-right + const gridItemsSorted = gridItems.sort((a, b) => { + const rectA = dragElemClientRect[a.id]; + const rectB = dragElemClientRect[b.id]; + + // First sort by top, then by left + if (rectA.top !== rectB.top) { + return rectA.top - rectB.top; + } + return rectA.left - rectB.left; + }); + + // Virtually put aLL elements at the infinity bottom if compact vertical and infinity right if compact horizontal! + newLayout.forEach(layoutItem => { + // If it is a dragged item, move to infinity!! We cleanup the space for the drag + if (dragElemClientRect[layoutItem.id]) { + if (this.compactType !== 'horizontal') { layoutItem.y = Infinity; } + if (this.compactType !== 'vertical') { layoutItem.x = Infinity; } + } + }); + newLayout = compact(newLayout, this.compactType, this.cols) + + gridItemsSorted.forEach((gridItem) => { + const {layout, draggedItemPos} = ktdGridItemDragging(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 + }); + // const pre = newLayout.find(item => item.id === gridItem.id); + // const act = layout.find(item => item.id === gridItem.id); + // const orig = originalLayout.find(item => item.id === gridItem.id); + // console.log(`Calc dragging ${gridItem.id}`, `pre: (${pre?.x}, ${pre?.y})`, `orig: (${orig?.x}, ${orig?.y})`, `act: (${act?.x}, ${act?.y})`); + + newLayout = layout; + draggedItemsPos[gridItem.id] = draggedItemPos; + }); + } + + } else { const calcNewStateFunc = type === 'drag' ? ktdGridItemDragging : ktdGridItemResizing; gridItems.forEach((gridItem)=>{ const {layout, draggedItemPos} = calcNewStateFunc(gridItem, { 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 index ef04b30..d4ac2dc 100644 --- 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 @@ -268,6 +268,18 @@ const realLifeLayoutSmall: KtdGridLayout = [ {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'} +]; + + @Component({ standalone: true, selector: 'ktd-playground', From 3aeb4dcb5510e6e6743423460ff21fc25db326a7 Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Wed, 6 Aug 2025 13:47:44 +0200 Subject: [PATCH 07/11] feat: multiItemAlgorithm input The multiItemAlgorithm input defines which multiple-drag algorithm to use --- .../src/lib/grid.component.ts | 7 +- .../src/lib/utils/grid.utils.ts | 12 ++-- .../utils/react-grid-layout-multiple.utils.ts | 68 ++++++++++++++++--- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index 8273a28..2197af7 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -279,6 +279,8 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte private _height: number | null = null; + @Input() multiItemAlgorithm: 'default' | 'static' = 'default'; + /** * Multiple items drag/resize * A list of selected items to move (drag or resize) together as a group. @@ -595,9 +597,8 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte // Get the correct newStateFunc depending on if we are dragging or resizing if (type === 'drag' && gridItems.length > 1) { - const MULTI_SELECTION_DRAG_METHOD: 'default' | 'static' = 'default' as any - if (MULTI_SELECTION_DRAG_METHOD === 'static') { + if (this.multiItemAlgorithm === 'static') { const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { layout: originalLayout, rowHeight: this.rowHeight, @@ -619,8 +620,6 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout newLayout = structuredClone(originalLayout); - console.log('----------'); - // Sort grid items from top-left to bottom-right const gridItemsSorted = gridItems.sort((a, b) => { const rectA = dragElemClientRect[a.id]; 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 d6266e6..7a8cd70 100644 --- a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts @@ -165,9 +165,9 @@ export function ktdGridItemDragging(gridItem: KtdGridItemComponent, config: KtdG export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: KtdGridCfg, compactionType: CompactType, draggingData: KtdDraggingMultipleData): { layout: KtdGridLayoutItem[]; draggedItemPos: KtdDictionary } { const {pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElementsClientRect, scrollDifference} = draggingData; - const draggingElemPrevItem = {} - gridItems.forEach(gridItems=> { - draggingElemPrevItem[gridItems.id] = config.layout.find(item => item.id === gridItems.id)! + const draggingElemPrevItem: KtdDictionary = {} + gridItems.forEach(gridItem=> { + draggingElemPrevItem[gridItem.id] = config.layout.find(item => item.id === gridItem.id)! }); const clientStartX = ktdPointerClientX(pointerDownEvent); @@ -217,9 +217,10 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: x: number | null | undefined, y: number | null | undefined }[] = gridItems.map((gridItem:KtdGridItemComponent)=>{ - draggingElemPrevItem[gridItem.id].static = true; + const draggedLayoutItem: LayoutItem = layoutItems.find(item => item.id === gridItem.id)!; + draggedLayoutItem.static = true; return { - l: draggingElemPrevItem[gridItem.id], + l: draggedLayoutItem, x: layoutItemsToMove[gridItem.id].x, y: layoutItemsToMove[gridItem.id].y } @@ -229,7 +230,6 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: layoutItems, draggedLayoutItem, true, - config.preventCollision, compactionType, config.cols, ); 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 index b6dd5a9..d542474 100644 --- 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 @@ -4,7 +4,7 @@ * The code should be as less modified as possible for easy maintenance. */ -import { CompactType, getAllCollisions, Layout, LayoutItem, sortLayoutItems } from "./react-grid-layout.utils"; +import { CompactType, getAllCollisions, getFirstCollision, Layout, LayoutItem, sortLayoutItems } from "./react-grid-layout.utils"; const DEBUG = false; @@ -27,13 +27,20 @@ export function moveElements( y: number | null | undefined }[], isUserAction: boolean | null | undefined, - preventCollision: boolean | null | undefined, compactType: CompactType, cols: number, + movingUpUser?:boolean ): Layout { const oldX = items[0].l.x; const oldY = items[0].l.y; + const oldCoord = {} + + items.forEach 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; } @@ -41,6 +48,7 @@ export function moveElements( item.l.y = item.y; } item.l.moved = true; + }) let sorted = sortLayoutItems(layout, compactType); @@ -56,12 +64,13 @@ export function moveElements( : false; if (movingUp) { sorted = sorted.reverse(); - items = items.reverse() + } + if(isUserAction){ + movingUpUser = movingUp; } items.forEach((item)=>{ const collisions: LayoutItem[] = getAllCollisions(sorted, item.l); - // Move each item that collides away from this element. for (let i = 0, len = collisions.length; i < len; i++) { const collision = collisions[i]; @@ -82,7 +91,8 @@ export function moveElements( item.l, isUserAction, compactType, - cols + cols, + movingUpUser ); } else { layout = moveElementsAwayFromCollision( @@ -91,7 +101,8 @@ export function moveElements( collision, isUserAction, compactType, - cols + cols, + movingUpUser ); } } @@ -106,7 +117,8 @@ export function moveElementsAwayFromCollision( itemToMove: LayoutItem, isUserAction: boolean | null | undefined, compactType: CompactType, - cols: number + cols: number, + movingUp: boolean | undefined ): Layout { const compactH = compactType === 'horizontal'; // Compact vertically if not set to horizontal @@ -119,19 +131,53 @@ export function moveElementsAwayFromCollision( 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 moveElements( + layout, + [{ + l: itemToMove, + x: compactH ? fakeItem.x : undefined, + y: compactV ? fakeItem.y : undefined, + }], + isUserAction, + compactType, + cols, + movingUp + ); + } } return moveElements( layout, [{ l: itemToMove, - x: compactH ? collidesWith.x+1 : undefined, - y: compactV ? collidesWith.y+1 : undefined, + x: compactH ? itemToMove.x+1 : undefined, + y: compactV ? itemToMove.y+1 : undefined, }], isUserAction, - preventCollision, compactType, - cols + cols, + movingUp ); } From 555260c7eb09376b3c2a5eab78c06f1a12a9a4fa Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Mon, 18 Aug 2025 11:50:12 +0200 Subject: [PATCH 08/11] feat: multiItemAlgorithm overflow Maintain the selected structure when one element of the group overflows the layout --- .../src/lib/grid.component.ts | 11 +-- .../src/lib/utils/grid.utils.ts | 29 ++++-- .../utils/react-grid-layout-multiple.utils.ts | 91 ++++++++++--------- .../src/lib/utils/react-grid-layout.utils.ts | 9 -- .../multi-item-handler.component.html | 3 +- 5 files changed, 71 insertions(+), 72 deletions(-) diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index 2197af7..fd5ef7c 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -593,14 +593,13 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte * some utilities from 'react-grid-layout' would not work as expected. */ const currentLayout: KtdGridLayout = newLayout || this.layout; - newLayout=currentLayout; - + // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout + newLayout=structuredClone(originalLayout); // Get the correct newStateFunc depending on if we are dragging or resizing if (type === 'drag' && gridItems.length > 1) { - if (this.multiItemAlgorithm === 'static') { const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { - layout: originalLayout, + layout: newLayout, rowHeight: this.rowHeight, height: this.height, cols: this.cols, @@ -613,13 +612,9 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte dragElementsClientRect: dragElemClientRect, scrollDifference }); - newLayout = layout; draggedItemsPos = draggedItemPos; } else { - // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout - - newLayout = structuredClone(originalLayout); // Sort grid items from top-left to bottom-right const gridItemsSorted = gridItems.sort((a, b) => { const rectA = dragElemClientRect[a.id]; 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 7a8cd70..fb504db 100644 --- a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts @@ -185,30 +185,39 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: 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 + 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) }; - // Correct the values if they overflow, since 'moveElement' function doesn't do it - layoutItemsToMove[gridItem.id].x = Math.max(0, layoutItemsToMove[gridItem.id].x); - layoutItemsToMove[gridItem.id].y = Math.max(0, layoutItemsToMove[gridItem.id].y); - if (layoutItemsToMove[gridItem.id].x + layoutItemsToMove[gridItem.id].w > config.cols) { - layoutItemsToMove[gridItem.id].x = Math.max(0, config.cols - layoutItemsToMove[gridItem.id].w); + 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; @@ -230,13 +239,13 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: layoutItems, draggedLayoutItem, true, + config.preventCollision, compactionType, config.cols, ); newLayoutItems = compact(newLayoutItems, compactionType, config.cols); gridItems.forEach(gridItem=>newLayoutItems.find(layoutItem=>layoutItem.id === gridItem.id)!.static = false); - newLayoutItems = compact(newLayoutItems, compactionType, config.cols); const draggedItemPos: KtdDictionary={}; gridItems.forEach(gridItem=> 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 index d542474..79be0ef 100644 --- 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 @@ -27,15 +27,19 @@ export function moveElements( y: number | null | undefined }[], isUserAction: boolean | null | undefined, + preventCollision: boolean | null | undefined, compactType: CompactType, - cols: number, - movingUpUser?:boolean + 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; + } + const oldX = items[0].l.x; const oldY = items[0].l.y; const oldCoord = {} - items.forEach items.forEach((item)=>{ oldCoord[item.l.id]={ x: item.l.x, @@ -48,7 +52,6 @@ export function moveElements( item.l.y = item.y; } item.l.moved = true; - }) let sorted = sortLayoutItems(layout, compactType); @@ -65,45 +68,46 @@ export function moveElements( if (movingUp) { sorted = sorted.reverse(); } - if(isUserAction){ - movingUpUser = movingUp; - } items.forEach((item)=>{ const collisions: LayoutItem[] = getAllCollisions(sorted, item.l); - // 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 ${items}] 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) { - layout = moveElementsAwayFromCollision( - layout, - collision, - item.l, - isUserAction, - compactType, - cols, - movingUpUser - ); - } else { - layout = moveElementsAwayFromCollision( - layout, - item.l, - collision, - isUserAction, - compactType, - cols, - movingUpUser + if (preventCollision && collisions.length) { + item.l.x = oldCoord[item.l.id].x; + item.l.y = oldCoord[item.l.id].y; + item.l.moved = false; + } else { + // 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 ${items}] 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) { + layout = moveElementsAwayFromCollision( + layout, + collision, + item.l, + isUserAction, + compactType, + cols + ); + } else { + layout = moveElementsAwayFromCollision( + layout, + item.l, + collision, + isUserAction, + compactType, + cols + ); + } } } }); @@ -118,7 +122,6 @@ export function moveElementsAwayFromCollision( isUserAction: boolean | null | undefined, compactType: CompactType, cols: number, - movingUp: boolean | undefined ): Layout { const compactH = compactType === 'horizontal'; // Compact vertically if not set to horizontal @@ -160,9 +163,9 @@ export function moveElementsAwayFromCollision( y: compactV ? fakeItem.y : undefined, }], isUserAction, + preventCollision, compactType, - cols, - movingUp + cols ); } } @@ -175,9 +178,9 @@ export function moveElementsAwayFromCollision( y: compactV ? itemToMove.y+1 : undefined, }], isUserAction, + preventCollision, compactType, - cols, - movingUp + cols ); } 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..f8c9eb2 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]); @@ -214,13 +213,6 @@ function resolveCompactionCollision( if (otherItem.static) { continue; } - - // 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) { - break; - } - if (collides(item, otherItem)) { resolveCompactionCollision( layout, @@ -281,7 +273,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/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 index 57d0dd8..37ebfea 100644 --- 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 @@ -64,7 +64,8 @@ (resizeStarted)="onResizeStarted($event)" (dragEnded)="onDragEnded($event)" (resizeEnded)="onResizeEnded($event)" - (layoutUpdated)="onLayoutUpdated($event)"> + (layoutUpdated)="onLayoutUpdated($event)" + multiItemAlgorithm="static"> Date: Mon, 25 Aug 2025 11:27:17 +0200 Subject: [PATCH 09/11] feat: control+v into multi-item-handler example Add copy-paste functionality for multiple items in the example --- .../src/lib/utils/grid.utils.ts | 15 +++- .../src/lib/utils/react-grid-layout.utils.ts | 7 ++ .../angular-grid-layout/src/public-api.ts | 2 +- .../multi-item-handler.component.html | 1 + .../multi-item-handler.component.ts | 74 ++++++++++++++----- 5 files changed, 80 insertions(+), 19 deletions(-) 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 fb504db..50adc1f 100644 --- a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts @@ -1,4 +1,4 @@ -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, KtdDraggingMultipleData, KtdGridCfg, KtdGridCompactType, KtdGridItemRect, KtdGridItemRenderData, KtdGridLayout, KtdGridLayoutItem } from '../grid.definitions'; @@ -32,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; 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 f8c9eb2..49f35ed 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 @@ -213,6 +213,13 @@ function resolveCompactionCollision( if (otherItem.static) { continue; } + + // 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 ((axis==='x' && otherItem.y > item.y + item.h ) || (axis==='y' && otherItem.y > moveToCoord+item.h)) { + break; + } + if (collides(item, otherItem)) { resolveCompactionCollision( layout, 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/multi-item-handler/multi-item-handler.component.html b/projects/demo-app/src/app/multi-item-handler/multi-item-handler.component.html index 37ebfea..c1ee8ad 100644 --- 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 @@ -8,6 +8,7 @@
  • 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
  • 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 index d4ac2dc..bc4210e 100644 --- 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 @@ -2,7 +2,7 @@ import { Component, ElementRef, Inject, NgZone, OnDestroy, OnInit, ViewChild } f import { MatSelectChange, MatSelectModule } from '@angular/material/select'; import { KtdDragEnd, KtdDragStart, ktdGridCompact, KtdGridComponent, KtdGridItemComponent, KtdGridItemPlaceholder, KtdGridLayout, KtdGridLayoutItem, - KtdResizeEnd, KtdResizeStart, ktdTrackById + KtdResizeEnd, KtdResizeStart, ktdTrackById, ktdGridSortLayoutItems } from '@katoid/angular-grid-layout'; import { ktdArrayRemoveItem } from '../utils'; import { DOCUMENT, NgClass, NgFor } from '@angular/common'; @@ -16,7 +16,7 @@ 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 } from 'rxjs/operators'; +import { debounceTime, filter } from 'rxjs/operators'; const realLifeLayout: KtdGridLayout = [ {id: '0', x: 0, y: 0, w: 62, h: 3}, @@ -311,7 +311,8 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { compactType: 'vertical' | 'horizontal' | null = 'vertical'; preventCollision = false; selectedItems: string[] = []; - layout: KtdGridLayout = realLifeLayout; + copiedItems: number + layout: KtdGridLayout = realLifeLayoutSmall; resizeSubscription: Subscription; @@ -322,7 +323,15 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { public elementRef: ElementRef, @Inject(DOCUMENT) public document: Document ) { - // this.ngZone.onUnstable.subscribe(() => console.log('UnStable')); + 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() { @@ -396,19 +405,24 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { } /** Adds a grid item to the layout */ - addItemToLayout() { - const maxId = this.layout.reduce( - (acc, cur) => Math.max(acc, parseInt(cur.id, 10)), - -1 - ); - const nextId = maxId + 1; - const newLayoutItem: KtdGridLayoutItem = { - id: nextId.toString(), - x: -1, - y: -1, - w: 2, - h: 2 - }; + 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); @@ -497,4 +511,30 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { } } } + + /* + * 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); + }); + } + } From 09f6fdd8838b28b84a3f05365ae3734df93ef1e5 Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Tue, 26 Aug 2025 16:01:29 +0200 Subject: [PATCH 10/11] fix: compact algorithm efficiency Fix a bug maintaining the efficiency on react compact grid algorithm --- .../src/lib/grid.component.ts | 90 ++++--------------- .../src/lib/utils/grid.utils.ts | 5 ++ .../src/lib/utils/react-grid-layout.utils.ts | 4 +- .../tests/react-grid-layout-utils.spec.ts | 59 ++++++++++++ .../multi-item-handler.component.ts | 12 ++- 5 files changed, 96 insertions(+), 74 deletions(-) diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index fd5ef7c..3df5265 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -279,8 +279,6 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte private _height: number | null = null; - @Input() multiItemAlgorithm: 'default' | 'static' = 'default'; - /** * Multiple items drag/resize * A list of selected items to move (drag or resize) together as a group. @@ -586,84 +584,35 @@ 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; - // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout - newLayout=structuredClone(originalLayout); // Get the correct newStateFunc depending on if we are dragging or resizing if (type === 'drag' && gridItems.length > 1) { - if (this.multiItemAlgorithm === 'static') { - const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { - layout: newLayout, - 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 { - // Sort grid items from top-left to bottom-right - const gridItemsSorted = gridItems.sort((a, b) => { - const rectA = dragElemClientRect[a.id]; - const rectB = dragElemClientRect[b.id]; - - // First sort by top, then by left - if (rectA.top !== rectB.top) { - return rectA.top - rectB.top; - } - return rectA.left - rectB.left; - }); - - // Virtually put aLL elements at the infinity bottom if compact vertical and infinity right if compact horizontal! - newLayout.forEach(layoutItem => { - // If it is a dragged item, move to infinity!! We cleanup the space for the drag - if (dragElemClientRect[layoutItem.id]) { - if (this.compactType !== 'horizontal') { layoutItem.y = Infinity; } - if (this.compactType !== 'vertical') { layoutItem.x = Infinity; } - } - }); - newLayout = compact(newLayout, this.compactType, this.cols) - - gridItemsSorted.forEach((gridItem) => { - const {layout, draggedItemPos} = ktdGridItemDragging(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 - }); - // const pre = newLayout.find(item => item.id === gridItem.id); - // const act = layout.find(item => item.id === gridItem.id); - // const orig = originalLayout.find(item => item.id === gridItem.id); - // console.log(`Calc dragging ${gridItem.id}`, `pre: (${pre?.x}, ${pre?.y})`, `orig: (${orig?.x}, ${orig?.y})`, `act: (${act?.x}, ${act?.y})`); - - newLayout = layout; - draggedItemsPos[gridItem.id] = draggedItemPos; - }); - } - + // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout + newLayout=structuredClone(originalLayout); + const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { + layout: newLayout, + 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, @@ -679,7 +628,6 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte dragElemClientRect: dragElemClientRect[gridItem.id], scrollDifference }); - newLayout = layout; draggedItemsPos[gridItem.id]=draggedItemPos; }); 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 50adc1f..5aefd24 100644 --- a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts @@ -214,6 +214,7 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: 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; } @@ -224,6 +225,7 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: maxXMove = layoutItemsToMove[gridItem.id].w + layoutItemsToMove[gridItem.id].x - config.cols } }) + // Correct all the x and y position of the group decreasing/increasing the maximum overflow of an item, to maintain the structure Object.entries(layoutItemsToMove).forEach(([key, item]) => { layoutItemsToMove[key] = { ...item, @@ -257,8 +259,11 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: 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=> 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 49f35ed..d2f3c86 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 @@ -216,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 ((axis==='x' && otherItem.y > item.y + item.h ) || (axis==='y' && otherItem.y > moveToCoord+item.h)) { + if (otherItem[axis] > moveToCoord+item[sizeProp]) { break; - } + } if (collides(item, otherItem)) { resolveCompactionCollision( layout, 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/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 index bc4210e..4f44774 100644 --- 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 @@ -279,6 +279,15 @@ const multipleDragBugMutation = [ {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, @@ -312,7 +321,7 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { preventCollision = false; selectedItems: string[] = []; copiedItems: number - layout: KtdGridLayout = realLifeLayoutSmall; + layout: KtdGridLayout = realLifeLayout; resizeSubscription: Subscription; @@ -535,6 +544,7 @@ export class KtdMultiItemHandlerComponent implements OnInit, OnDestroy { this.addItemToLayout(newLayoutItem); }); + console.log('duplicateSelectedElements', this.selectedItems) } } From 5fbfe01539b2df441b8df0808b8883bd78e5e8e7 Mon Sep 17 00:00:00 2001 From: amanosacrous Date: Thu, 28 Aug 2025 11:47:15 +0200 Subject: [PATCH 11/11] feat: move items algorithm without deep clone Move multiple items without deep clone the layout on each mouse movement --- .../src/lib/grid.component.ts | 5 +- .../src/lib/utils/grid.utils.ts | 10 +- .../utils/react-grid-layout-multiple.utils.ts | 120 ++++++++++-------- .../src/lib/utils/react-grid-layout.utils.ts | 2 +- .../src/lib/utils/tests/grid.spec.ts | 69 +++++++++- .../multi-item-handler.component.html | 3 +- 6 files changed, 142 insertions(+), 67 deletions(-) diff --git a/projects/angular-grid-layout/src/lib/grid.component.ts b/projects/angular-grid-layout/src/lib/grid.component.ts index 3df5265..55d7691 100644 --- a/projects/angular-grid-layout/src/lib/grid.component.ts +++ b/projects/angular-grid-layout/src/lib/grid.component.ts @@ -536,7 +536,6 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte const dragElemClientRect: KtdDictionary={}; const newGridItemRenderData: KtdDictionary>={}; let draggedItemsPos: KtdDictionary={}; - const originalLayout: KtdGridLayout = structuredClone(this.layout); gridItems.forEach((gridItem)=>{ // Retrieve gridItem (draggedElem) client rect. @@ -592,10 +591,8 @@ export class KtdGridComponent implements OnChanges, AfterContentInit, AfterConte const currentLayout: KtdGridLayout = newLayout || this.layout; // Get the correct newStateFunc depending on if we are dragging or resizing if (type === 'drag' && gridItems.length > 1) { - // TODO: cloning the full layout can be expensive! We should investigate workarounds, maybe by using a ktdGridItemDragging function that does not mutate the layout - newLayout=structuredClone(originalLayout); const {layout, draggedItemPos} = ktdGridItemsDragging(gridItems, { - layout: newLayout, + layout: currentLayout, rowHeight: this.rowHeight, height: this.height, cols: this.cols, 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 5aefd24..f4eca5b 100644 --- a/projects/angular-grid-layout/src/lib/utils/grid.utils.ts +++ b/projects/angular-grid-layout/src/lib/utils/grid.utils.ts @@ -5,7 +5,7 @@ import { import { ktdPointerClientX, ktdPointerClientY } from './pointer.utils'; import { KtdDictionary } from '../../types'; import { KtdGridItemComponent } from '../grid-item/grid-item.component'; -import { moveElements } from './react-grid-layout-multiple.utils'; +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}) { @@ -236,7 +236,7 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: // Parse to LayoutItem array data in order to use 'react.grid-layout' utils const layoutItems: LayoutItem[] = config.layout; - const draggedLayoutItem: { + const draggedLayoutItems: { l: LayoutItem, x: number | null | undefined, y: number | null | undefined @@ -250,11 +250,11 @@ export function ktdGridItemsDragging(gridItems: KtdGridItemComponent[], config: } }); - let newLayoutItems: LayoutItem[] = moveElements( + // Move all elements in group + let newLayoutItems: LayoutItem[] = KtdMoveMultipleElements( layoutItems, - draggedLayoutItem, + draggedLayoutItems, true, - config.preventCollision, compactionType, config.cols, ); 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 index 79be0ef..b23bf48 100644 --- 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 @@ -8,18 +8,24 @@ import { CompactType, getAllCollisions, getFirstCollision, Layout, LayoutItem, s const DEBUG = false; -// Disable lint since we don't want to modify this code -/* eslint-disable */ /** - * Move an element. Responsible for doing cascading movements of other elements. + * Move a set of elements "items". Responsible for doing cascading movements of other elements. * - * @param {Array} layout Full layout to modify. - * @param {LayoutItem} l element to move. - * @param {Number} [x] X position in grid units. - * @param {Number} [y] Y position in grid units. + * @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 moveElements( +export function KtdMoveMultipleElements( layout: Layout, items: { l: LayoutItem, @@ -27,7 +33,6 @@ export function moveElements( y: number | null | undefined }[], isUserAction: boolean | null | undefined, - preventCollision: boolean | null | undefined, compactType: CompactType, cols: number ): Layout { @@ -35,11 +40,13 @@ export function moveElements( 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, @@ -55,6 +62,8 @@ export function moveElements( }) 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 @@ -69,45 +78,40 @@ export function moveElements( sorted = sorted.reverse(); } - items.forEach((item)=>{ - const collisions: LayoutItem[] = getAllCollisions(sorted, item.l); - if (preventCollision && collisions.length) { - item.l.x = oldCoord[item.l.id].x; - item.l.y = oldCoord[item.l.id].y; - item.l.moved = false; - } else { - // 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 ${items}] and ${ - collision.id - } at [${collision.x},${collision.y}]`, + // 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 ); - // 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) { - layout = moveElementsAwayFromCollision( - layout, - collision, - item.l, - isUserAction, - compactType, - cols - ); - } else { - layout = moveElementsAwayFromCollision( - layout, - item.l, - collision, - isUserAction, - compactType, - cols - ); - } } } }); @@ -115,7 +119,18 @@ export function moveElements( return layout; } -export function moveElementsAwayFromCollision( +/** + * 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, @@ -126,7 +141,6 @@ export function moveElementsAwayFromCollision( const compactH = compactType === 'horizontal'; // Compact vertically if not set to horizontal const compactV = compactType !== 'horizontal'; - const preventCollision = collidesWith.static; // we're already colliding (not for static items) // 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 @@ -155,7 +169,7 @@ export function moveElementsAwayFromCollision( fakeItem.x },${fakeItem.y}].`, ); - return moveElements( + return KtdMoveMultipleElements( layout, [{ l: itemToMove, @@ -163,14 +177,13 @@ export function moveElementsAwayFromCollision( y: compactV ? fakeItem.y : undefined, }], isUserAction, - preventCollision, compactType, cols ); } } - return moveElements( + return KtdMoveMultipleElements( layout, [{ l: itemToMove, @@ -178,7 +191,6 @@ export function moveElementsAwayFromCollision( y: compactV ? itemToMove.y+1 : undefined, }], isUserAction, - preventCollision, compactType, cols ); 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 d2f3c86..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 @@ -267,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) { 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/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 index c1ee8ad..ca122e8 100644 --- 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 @@ -65,8 +65,7 @@ (resizeStarted)="onResizeStarted($event)" (dragEnded)="onDragEnded($event)" (resizeEnded)="onResizeEnded($event)" - (layoutUpdated)="onLayoutUpdated($event)" - multiItemAlgorithm="static"> + (layoutUpdated)="onLayoutUpdated($event)">