From b2c051d2c1b67f4c149aee1573a4aceddb496157 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 8 Jul 2024 16:47:25 +0000 Subject: [PATCH] feat(cdk/drag-drop): add input to specify dragged item scale (#29392) In some cases the parent of the dragged element might be scaled (e.g. when implementing zoom) which can throw off the positioning. Detecting this would be expensive, because we'd have to check the entire DOM tree so instead these changes add an input so the user can specify the scale amount. --- src/cdk/drag-drop/directives/drag.ts | 14 +++++ .../directives/drop-list-shared.spec.ts | 2 + .../directives/single-axis-drop-list.spec.ts | 25 +++++++++ .../directives/standalone-drag.spec.ts | 53 +++++++++++-------- src/cdk/drag-drop/drag-ref.ts | 9 +++- .../sorting/single-axis-sort-strategy.ts | 6 ++- tools/public_api_guard/cdk/drag-drop.md | 6 ++- 7 files changed, 89 insertions(+), 26 deletions(-) diff --git a/src/cdk/drag-drop/directives/drag.ts b/src/cdk/drag-drop/directives/drag.ts index 8d0f5c120113..f4469556e027 100644 --- a/src/cdk/drag-drop/directives/drag.ts +++ b/src/cdk/drag-drop/directives/drag.ts @@ -30,6 +30,7 @@ import { AfterViewInit, inject, Injector, + numberAttribute, } from '@angular/core'; import {coerceElement, coerceNumberProperty} from '@angular/cdk/coercion'; import {BehaviorSubject, Observable, Observer, Subject, merge} from 'rxjs'; @@ -159,6 +160,13 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { */ @Input('cdkDragPreviewContainer') previewContainer: PreviewContainer; + /** + * If the parent of the dragged element has a `scale` transform, it can throw off the + * positioning when the user starts dragging. Use this input to notify the CDK of the scale. + */ + @Input({alias: 'cdkDragScale', transform: numberAttribute}) + scale: number = 1; + /** Emits when the user starts dragging the item. */ @Output('cdkDragStarted') readonly started: EventEmitter = new EventEmitter(); @@ -261,6 +269,11 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { if (dropContainer) { this._dragRef._withDropContainer(dropContainer._dropListRef); dropContainer.addItem(this); + + // The drop container reads this so we need to sync it here. + dropContainer._dropListRef.beforeStarted.pipe(takeUntil(this._destroyed)).subscribe(() => { + this._dragRef.scale = this.scale; + }); } this._syncInputs(this._dragRef); @@ -448,6 +461,7 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { ref.disabled = this.disabled; ref.lockAxis = this.lockAxis; + ref.scale = this.scale; ref.dragStartDelay = typeof dragStartDelay === 'object' && dragStartDelay ? dragStartDelay diff --git a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts index 4e9a6bf2b32b..fef579b3891a 100644 --- a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts +++ b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts @@ -5006,6 +5006,7 @@ const DROP_ZONE_FIXTURE_TEMPLATE = ` [cdkDragBoundary]="boundarySelector" [cdkDragPreviewClass]="previewClass" [cdkDragPreviewContainer]="previewContainer" + [cdkDragScale]="scale" [style.height.px]="item.height" [style.margin-bottom.px]="item.margin" (cdkDragStarted)="startedSpy($event)" @@ -5041,6 +5042,7 @@ export class DraggableInDropZone implements AfterViewInit { previewContainer: PreviewContainer = 'global'; dropDisabled = signal(false); dropLockAxis = signal(undefined); + scale = 1; constructor(protected _elementRef: ElementRef) {} diff --git a/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts b/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts index 324ccc8b8776..28e8938b6c32 100644 --- a/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts +++ b/src/cdk/drag-drop/directives/single-axis-drop-list.spec.ts @@ -311,4 +311,29 @@ describe('Single-axis drop list', () => { dispatchMouseEvent(document, 'mouseup'); })); + + it('should lay out the elements correctly when scaled', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.componentInstance.scale = 0.5; + fixture.detectChanges(); + + const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement); + const {top, left} = items[0].getBoundingClientRect(); + + startDraggingViaMouse(fixture, items[0], left, top); + + const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement; + const target = items[1]; + const targetRect = target.getBoundingClientRect(); + + dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top + 5); + fixture.detectChanges(); + + expect(placeholder.style.transform).toBe(`translate3d(0px, ${ITEM_HEIGHT * 2}px, 0px)`); + expect(target.style.transform).toBe(`translate3d(0px, ${-ITEM_HEIGHT * 2}px, 0px)`); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + })); }); diff --git a/src/cdk/drag-drop/directives/standalone-drag.spec.ts b/src/cdk/drag-drop/directives/standalone-drag.spec.ts index 4a7e14a8d9a8..33f0588260cf 100644 --- a/src/cdk/drag-drop/directives/standalone-drag.spec.ts +++ b/src/cdk/drag-drop/directives/standalone-drag.spec.ts @@ -1470,34 +1470,41 @@ describe('Standalone CdkDrag', () => { cleanup(); })); - it( - 'should update the free drag position if the user moves their pointer after the page ' + - 'is scrolled', - fakeAsync(() => { - const fixture = createComponent(StandaloneDraggable); - fixture.detectChanges(); + it('should update the free drag position if the user moves their pointer after the page is scrolled', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); - const cleanup = makeScrollable(); - const dragElement = fixture.componentInstance.dragElement.nativeElement; + const cleanup = makeScrollable(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; - expect(dragElement.style.transform).toBeFalsy(); - startDraggingViaMouse(fixture, dragElement, 0, 0); - dispatchMouseEvent(document, 'mousemove', 50, 100); - fixture.detectChanges(); + expect(dragElement.style.transform).toBeFalsy(); + startDraggingViaMouse(fixture, dragElement, 0, 0); + dispatchMouseEvent(document, 'mousemove', 50, 100); + fixture.detectChanges(); - expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); + expect(dragElement.style.transform).toBe('translate3d(50px, 100px, 0px)'); - scrollTo(0, 500); - dispatchFakeEvent(document, 'scroll'); - fixture.detectChanges(); - dispatchMouseEvent(document, 'mousemove', 50, 200); - fixture.detectChanges(); + scrollTo(0, 500); + dispatchFakeEvent(document, 'scroll'); + fixture.detectChanges(); + dispatchMouseEvent(document, 'mousemove', 50, 200); + fixture.detectChanges(); - expect(dragElement.style.transform).toBe('translate3d(50px, 700px, 0px)'); + expect(dragElement.style.transform).toBe('translate3d(50px, 700px, 0px)'); - cleanup(); - }), - ); + cleanup(); + })); + + it('should account for scale when moving the element', fakeAsync(() => { + const fixture = createComponent(StandaloneDraggable); + fixture.componentInstance.scale = 0.5; + fixture.detectChanges(); + const dragElement = fixture.componentInstance.dragElement.nativeElement; + + expect(dragElement.style.transform).toBeFalsy(); + dragElementViaMouse(fixture, dragElement, 50, 100); + expect(dragElement.style.transform).toBe('translate3d(100px, 200px, 0px)'); + })); describe('with a handle', () => { it('should not be able to drag the entire element if it has a handle', fakeAsync(() => { @@ -1718,6 +1725,7 @@ describe('Standalone CdkDrag', () => { [cdkDragFreeDragPosition]="freeDragPosition" [cdkDragDisabled]="dragDisabled()" [cdkDragLockAxis]="dragLockAxis()" + [cdkDragScale]="scale" (cdkDragStarted)="startedSpy($event)" (cdkDragReleased)="releasedSpy($event)" (cdkDragEnded)="endedSpy($event)" @@ -1745,6 +1753,7 @@ class StandaloneDraggable { freeDragPosition?: {x: number; y: number}; dragDisabled = signal(false); dragLockAxis = signal(undefined); + scale = 1; } @Component({ diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 2b751e373171..a8ed7bb55f5f 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -288,6 +288,12 @@ export class DragRef { /** Class to be added to the preview element. */ previewClass: string | string[] | undefined; + /** + * If the parent of the dragged element has a `scale` transform, it can throw off the + * positioning when the user starts dragging. Use this input to notify the CDK of the scale. + */ + scale: number = 1; + /** Whether starting to drag this element is disabled. */ get disabled(): boolean { return this._disabled || !!(this._dropContainer && this._dropContainer.disabled); @@ -1288,7 +1294,8 @@ export class DragRef { * @param y New transform value along the Y axis. */ private _applyRootElementTransform(x: number, y: number) { - const transform = getTransform(x, y); + const scale = 1 / this.scale; + const transform = getTransform(x * scale, y * scale); const styles = this._rootElement.style; // Cache the previous transform amount only after the first drag sequence, because diff --git a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts index 28ed95c6517b..9d3ad3a99702 100644 --- a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts @@ -128,6 +128,8 @@ export class SingleAxisSortStrategy implements DropListSortStrategy { // Update the offset to reflect the new position. sibling.offset += offset; + const transformAmount = Math.round(sibling.offset * (1 / sibling.drag.scale)); + // Since we're moving the items with a `transform`, we need to adjust their cached // client rects to reflect their new position, as well as swap their positions in the cache. // Note that we shouldn't use `getBoundingClientRect` here to update the cache, because the @@ -136,13 +138,13 @@ export class SingleAxisSortStrategy implements DropListSortStrategy { // Round the transforms since some browsers will // blur the elements, for sub-pixel transforms. elementToOffset.style.transform = combineTransforms( - `translate3d(${Math.round(sibling.offset)}px, 0, 0)`, + `translate3d(${transformAmount}px, 0, 0)`, sibling.initialTransform, ); adjustDomRect(sibling.clientRect, 0, offset); } else { elementToOffset.style.transform = combineTransforms( - `translate3d(0, ${Math.round(sibling.offset)}px, 0)`, + `translate3d(0, ${transformAmount}px, 0)`, sibling.initialTransform, ); adjustDomRect(sibling.clientRect, offset, 0); diff --git a/tools/public_api_guard/cdk/drag-drop.md b/tools/public_api_guard/cdk/drag-drop.md index 82b57b513f0d..6611f665f4f7 100644 --- a/tools/public_api_guard/cdk/drag-drop.md +++ b/tools/public_api_guard/cdk/drag-drop.md @@ -76,6 +76,8 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_scale: unknown; + // (undocumented) ngAfterViewInit(): void; // (undocumented) ngOnChanges(changes: SimpleChanges): void; @@ -92,6 +94,7 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { // (undocumented) _resetPreviewTemplate(preview: CdkDragPreview): void; rootElementSelector: string; + scale: number; setFreeDragPosition(value: Point): void; // (undocumented) _setPlaceholderTemplate(placeholder: CdkDragPlaceholder): void; @@ -99,7 +102,7 @@ export class CdkDrag implements AfterViewInit, OnChanges, OnDestroy { _setPreviewTemplate(preview: CdkDragPreview): void; readonly started: EventEmitter; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkDrag]", ["cdkDrag"], { "data": { "alias": "cdkDragData"; "required": false; }; "lockAxis": { "alias": "cdkDragLockAxis"; "required": false; }; "rootElementSelector": { "alias": "cdkDragRootElement"; "required": false; }; "boundaryElement": { "alias": "cdkDragBoundary"; "required": false; }; "dragStartDelay": { "alias": "cdkDragStartDelay"; "required": false; }; "freeDragPosition": { "alias": "cdkDragFreeDragPosition"; "required": false; }; "disabled": { "alias": "cdkDragDisabled"; "required": false; }; "constrainPosition": { "alias": "cdkDragConstrainPosition"; "required": false; }; "previewClass": { "alias": "cdkDragPreviewClass"; "required": false; }; "previewContainer": { "alias": "cdkDragPreviewContainer"; "required": false; }; }, { "started": "cdkDragStarted"; "released": "cdkDragReleased"; "ended": "cdkDragEnded"; "entered": "cdkDragEntered"; "exited": "cdkDragExited"; "dropped": "cdkDragDropped"; "moved": "cdkDragMoved"; }, never, never, true, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkDrag]", ["cdkDrag"], { "data": { "alias": "cdkDragData"; "required": false; }; "lockAxis": { "alias": "cdkDragLockAxis"; "required": false; }; "rootElementSelector": { "alias": "cdkDragRootElement"; "required": false; }; "boundaryElement": { "alias": "cdkDragBoundary"; "required": false; }; "dragStartDelay": { "alias": "cdkDragStartDelay"; "required": false; }; "freeDragPosition": { "alias": "cdkDragFreeDragPosition"; "required": false; }; "disabled": { "alias": "cdkDragDisabled"; "required": false; }; "constrainPosition": { "alias": "cdkDragConstrainPosition"; "required": false; }; "previewClass": { "alias": "cdkDragPreviewClass"; "required": false; }; "previewContainer": { "alias": "cdkDragPreviewContainer"; "required": false; }; "scale": { "alias": "cdkDragScale"; "required": false; }; }, { "started": "cdkDragStarted"; "released": "cdkDragReleased"; "ended": "cdkDragEnded"; "entered": "cdkDragEntered"; "exited": "cdkDragExited"; "dropped": "cdkDragDropped"; "moved": "cdkDragMoved"; }, never, never, true, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [null, { optional: true; skipSelf: true; }, null, null, null, { optional: true; }, { optional: true; }, null, null, { optional: true; self: true; }, { optional: true; skipSelf: true; }]>; } @@ -440,6 +443,7 @@ export class DragRef { event: MouseEvent | TouchEvent; }>; reset(): void; + scale: number; setFreeDragPosition(value: Point): this; _sortFromLastPointerPosition(): void; readonly started: Subject<{