Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cdk/drag-drop): support immediate drag with preview snapped to cursor on mousedown #30728

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cdk/drag-drop/directives/drag-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export class CdkDragPreview<T = any> implements OnDestroy {
/** Whether the preview should preserve the same size as the item that is being dragged. */
@Input({transform: booleanAttribute}) matchSize: boolean = false;

/** Whether the preview should snap the starting position centered under the cursor. */
@Input({transform: booleanAttribute}) snapToCursor: boolean = false;

constructor(...args: unknown[]);

constructor() {
Expand Down
1 change: 1 addition & 0 deletions src/cdk/drag-drop/directives/drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnChanges, OnDestroy {
template: this._previewTemplate.templateRef,
context: this._previewTemplate.data,
matchSize: this._previewTemplate.matchSize,
snapToCursor: this._previewTemplate.snapToCursor,
viewContainer: this._viewContainerRef,
}
: null;
Expand Down
86 changes: 86 additions & 0 deletions src/cdk/drag-drop/directives/drop-list-shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,92 @@ export function defineCommonDropListTests(config: {
expect(anchor!.parentNode).toBeFalsy();
}));

it('should display preview on mousedown for config.dragStartThreshold = 0', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZoneWithCustomPreview, {
providers: [
{
provide: CDK_DRAG_CONFIG,
useValue: {
dragStartThreshold: 0,
},
},
],
});
fixture.detectChanges();
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
const itemRect = item.getBoundingClientRect();
flush();
dispatchMouseEvent(item, 'mousedown', undefined, undefined);
fixture.detectChanges();

const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;
expect(preview).not.toBeNull();
const previewRect = preview.getBoundingClientRect();
expect(previewRect).not.toBeNull();
}));

it('should snap preview to cursor when snapToCursor is set on preview template', fakeAsync(() => {
@Component({
styles: `
.list {
display: flex;
width: 100px;
flex-direction: row;
}

.item {
display: flex;
flex-grow: 1;
flex-basis: 0;
min-height: 50px;
}
`,
template: `
<div class="list" cdkDropList>
@for (item of items; track item) {
<div class="item" cdkDrag>
{{item}}
<ng-template cdkDragPreview snapToCursor>
<div class="item">{{item}}</div>
</ng-template>
</div>
}
</div>
`,
imports: [CdkDropList, CdkDrag, CdkDragPreview],
})
class DraggableInHorizontalFlexDropZoneWithSnapToCursorPreview {
@ViewChild(CdkDropList) dropInstance: CdkDropList;
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
items = ['Zero', 'One', 'Two'];
}

const fixture = createComponent(DraggableInHorizontalFlexDropZoneWithSnapToCursorPreview);
fixture.detectChanges();
const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement;
const listRect =
fixture.componentInstance.dropInstance.element.nativeElement.getBoundingClientRect();

startDraggingViaMouse(fixture, item);

const preview = document.querySelector('.cdk-drag-preview')! as HTMLElement;

startDraggingViaMouse(fixture, item, listRect.right + 50, listRect.bottom + 50);
flush();
dispatchMouseEvent(document, 'mousemove', listRect.right + 50, listRect.bottom + 50);
fixture.detectChanges();

const previewRect = preview.getBoundingClientRect();

// centered on the cursor
expect(Math.floor(previewRect.bottom - previewRect.height / 2)).toBe(
Math.floor(listRect.bottom + 50),
);
expect(Math.floor(previewRect.right - previewRect.width / 2)).toBe(
Math.floor(listRect.right + 50),
);
}));

it('should create a preview element while the item is dragged', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
Expand Down
20 changes: 17 additions & 3 deletions src/cdk/drag-drop/drag-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ export class DragRef<T = any> {
};

/** Handler that is invoked when the user moves their pointer after they've initiated a drag. */
private _pointerMove = (event: MouseEvent | TouchEvent) => {
private _pointerMove = (event: MouseEvent | TouchEvent, mouseDown: boolean = false) => {
const pointerPosition = this._getPointerPositionOnPage(event);

if (!this._hasStartedDragging()) {
Expand Down Expand Up @@ -711,8 +711,9 @@ export class DragRef<T = any> {
this._ngZone.run(() => this._startDragSequence(event));
}
}

return;
if (!mouseDown) {
return;
}
}

// We prevent the default action down here so that we know that dragging has started. This is
Expand Down Expand Up @@ -977,6 +978,16 @@ export class DragRef<T = any> {
this._pointerPositionAtLastDirectionChange = {x: pointerPosition.x, y: pointerPosition.y};
this._dragStartTime = Date.now();
this._dragDropRegistry.startDragging(this, event);

// when pixel threshold = 0 and dragStartDelay = 0 and a preview container/position exists we immediately drag
if (
(event.type == 'mousedown' || event.type == 'touchstart') &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow this. So the only behavior difference here is that the preview will show up on the mousedown rather than the next mousemove?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See this chessboard for example:

Chess.com Explorer

Click on any piece to move it and you will see the drag immediately starts. This is the effect of the snapToCursor combined with immediate preview rendering (on mousedown/touchstart).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in that case a better way to approach it would be to check that threshold/delay are at 0 and then call _startDragSequence from the "down" handler. It might be necessary to move some logic out of _pointerMove so it can be reused.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crisbeto I had looked at that -- however the entirety of the _pointerMove function must be executed in this case especially around the constrainPosition and other logic executed in that method in addition to _startDragSequence as a setup step being called.

I had tried to limit it to just a few calls but that either breaks functionality or results in excess redundant code where this version cleanly was backwards-compatible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was that we can move the whole block that's guarded by !this._hasStartedDragging() out into a separate function. The rest shouldn't be executed before dragging has started anyways.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrisbeto unfortunately that doesn't work since the dragging must start when the mouse goes down...so the entirety of the pointerMove function is executed (note the return is skipped when it's a mouse down).

previewTemplate &&
this._config.dragStartThreshold === 0 &&
this._getDragStartDelay(event) === 0
) {
this._pointerMove(event, true);
}
}

/** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */
Expand Down Expand Up @@ -1086,6 +1097,9 @@ export class DragRef<T = any> {

if (this.constrainPosition) {
this._applyPreviewTransform(x, y);
} else if (this._previewTemplate?.snapToCursor) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if centering the element under the cursor is common enough that it should be a default behavior. Also couldn't you get the same result by offsetting it using a margin?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @crisbeto -- this won't affect default behavior, it's fenced off to only occur when snapToCursor input is set on cdkDragPreview.

Margin/etc. won't work because the transform is applied internally. As I wrote in the PR notes, the CDK dragdrop transform is fixed and automatically calculated transform on _pointerMove. So there are several issues with margin -- one is that it would have to be calculated in an attempt to override the transform by implementor using (cdkDragMoved) event which then defeats the ability to start preview on mousedown (since cdkDragMoved isn't fired until movement), and secondarily the shift in the container position would change location calculations that are used internally on CDK dragdrop for determining entered/exited events, lastly you cannot access the preview containers boundaries until it is rendered and it's all private members so even then requires a class DOM selector to get to it from handler in the first place.

I explored a lot of different options to get around the limitations of the CDK Dragdrop (DOM access to the preview inside a template, controlling/recalc position of the preview dynamically externally that messed up the entered/exited, and the separate issue of having drag behavior correctly computed immediately on mousedown/touchstart). This PR elegantly handles those scenarios while maintaining existing behavior of the dragdrop.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure the behavior is fenced off, but it's still a public API that we have to support and document. We also have to ensure that it works correctly with other APIs (e.g. constrainPosition). It doesn't mean that we shouldn't do it, but we should come up with something that is a bit more flexible.

Copy link
Author

@matthewerwin matthewerwin Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crisbeto I'm not opposed to that & happy to do some more coding around that. I'm not sure what the additional use-case requirements would be though -- did you have a specific alternate scenarios in mind?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Off the top of my head, it could be a function that lets you determine the offset of the preview.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crisbeto having the offset of the preview doesn't change anything b/c you'd then have to be trying to replace the transform: property again on every mouse move event. Not only would that be jumpy...it would also mean end users subscribing to mousemove which is expensive and warned against in the public API docs.

Not sure if I'm missing what you're saying but would be happy to get on a Zoom call to go through it. LMK - just inbox me if so.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crisbeto since you were the original implementer of constrainPosition logic -- do you have specific knowledge about how this behavior might be meaningfully accomplished while providing the broadest possible flexibility? I reviewed again today and don't have a clear direction to investigate other possibilities.

const previewRect = this._getPreviewRect();
this._applyPreviewTransform(rawX - previewRect.width / 2, rawY - previewRect.height / 2);
} else {
this._applyPreviewTransform(
x - this._pickupPositionInElement.x,
Expand Down
1 change: 1 addition & 0 deletions src/cdk/drag-drop/preview-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {getTransformTransitionDurationInMs} from './dom/transition-duration';
/** Template that can be used to create a drag preview element. */
export interface DragPreviewTemplate<T = any> {
matchSize?: boolean;
snapToCursor?: boolean;
template: TemplateRef<T> | null;
viewContainer: ViewContainerRef;
context: T;
Expand Down
Loading