Skip to content

feat(dnd): useDrag: custom drag preview offset #8445

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

Open
wants to merge 1 commit 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
59 changes: 53 additions & 6 deletions packages/@react-aria/dnd/src/useDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ export interface DragOptions {
preview?: RefObject<DragPreviewRenderer | null>,
/** Function that returns the drop operations that are allowed for the dragged items. If not provided, all drop operations are allowed. */
getAllowedDropOperations?: () => DropOperation[],
/**
* A function that computes the offset of the drag preview relative to the pointer.
*
* If not provided, a default offset is automatically calculated based on the click/touch
* position, falling back to the center of the preview in cases where the preview is smaller
* than the interaction point.
*/
getPreviewOffset?: (options: {
/** Bounding rect for the preview element returned from `preview`. */
previewRect: DOMRect,
/** Bounding rect for the element that initiated the drag. */
sourceRect: DOMRect,
/** The pointer coordinates at the start of the drag. */
pointerPosition: {x: number, y: number},
/** The default offset that would be used if no custom offset is provided. */
defaultOffset: {x: number, y: number}
}) => {x: number, y: number},
/**
* Whether the item has an explicit focusable drag affordance to initiate accessible drag and drop mode.
* If true, the dragProps will omit these event handlers, and they will be applied to dragButtonProps instead.
Expand Down Expand Up @@ -143,18 +160,48 @@ export function useDrag(options: DragOptions): DragResult {
// If the preview is much smaller, then just use the center point of the preview.
let size = node.getBoundingClientRect();
let rect = e.currentTarget.getBoundingClientRect();
let x = e.clientX - rect.x;
let y = e.clientY - rect.y;
if (x > size.width || y > size.height) {
x = size.width / 2;
y = size.height / 2;
let defaultX = e.clientX - rect.x;
let defaultY = e.clientY - rect.y;
if (defaultX > size.width || defaultY > size.height) {
defaultX = size.width / 2;
defaultY = size.height / 2;
}

// Allow callers to override the preview offset.
let {getPreviewOffset} = options;
let offsetX = defaultX;
let offsetY = defaultY;
if (typeof getPreviewOffset === 'function') {
try {
let custom = getPreviewOffset({
previewRect: size,
sourceRect: rect,
pointerPosition: {x: e.clientX, y: e.clientY},
defaultOffset: {x: defaultX, y: defaultY}
});

if (custom && typeof custom.x === 'number' && typeof custom.y === 'number') {
offsetX = custom.x;
offsetY = custom.y;
}
} catch (err) {
// Fail gracefully if the callback throws, and use the default offset instead.
console.error('Error in getPreviewOffset callback', err);
}
}

// Clamp the offset so it stays within the preview bounds. Browsers
// automatically clamp out-of-range values, but doing it ourselves
// prevents the visible "snap" that can occur when the browser adjusts
// them after the first drag update.
offsetX = Math.max(0, Math.min(offsetX, size.width));
offsetY = Math.max(0, Math.min(offsetY, size.height));

// Rounding height to an even number prevents blurry preview seen on some screens
let height = 2 * Math.round(size.height / 2);
node.style.height = `${height}px`;

e.dataTransfer.setDragImage(node, x, y);
e.dataTransfer.setDragImage(node, offsetX, offsetY);
});
}

Expand Down
101 changes: 101 additions & 0 deletions packages/@react-aria/dnd/stories/dnd.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,104 @@ export const DroppableEnabledDisabledControl: DnDStoryObj = {
}
}
};

interface PreviewOffsetArgs {
/** Strategy for positioning the preview. */
mode: 'default' | 'center' | 'custom',
/** X offset in pixels (only used when mode = custom). */
offsetX: number,
/** Y offset in pixels (only used when mode = custom). */
offsetY: number
}

function DraggableWithPreview({mode, offsetX, offsetY}: PreviewOffsetArgs): JSX.Element {
const preview = React.useRef(null);

const getPreviewOffset = React.useCallback(({previewRect, defaultOffset}: any) => {
switch (mode) {
case 'center':
return {x: previewRect.width / 2, y: previewRect.height / 2};
case 'custom':
return {x: offsetX, y: offsetY};
case 'default':
default:
return defaultOffset;
}
}, [mode, offsetX, offsetY]);

const {dragProps, isDragging} = useDrag({
getItems() {
return [{
'text/plain': 'preview offset demo'
}];
},
preview,
getPreviewOffset,
onDragStart: action('onDragStart'),
onDragEnd: action('onDragEnd')
});

const {clipboardProps} = useClipboard({
getItems() {
return [{
'text/plain': 'preview offset demo'
}];
}
});

const ref = React.useRef<HTMLDivElement>(null);
const {buttonProps} = useButton({elementType: 'div'}, ref);

return (
<>
<div
ref={ref}
{...mergeProps(dragProps, buttonProps, clipboardProps)}
className={classNames(dndStyles, 'draggable', {'is-dragging': isDragging})}
style={{cursor: 'grab'}}>
<ShowMenu size="XS" />
<span>Drag me</span>
</div>

{/* Custom drag preview */}
<DragPreview ref={preview}>
{() => (
<div className={classNames(dndStyles, 'draggable', 'is-drag-preview')}>
<ShowMenu size="XS" />
<span>Preview</span>
</div>
)}
</DragPreview>
</>
);
}

export const PreviewOffset: DnDStoryObj = {
render: (args) => (
<Flex direction="column" gap="size-200" alignItems="center">
<DraggableWithPreview {...args} />
<Droppable />
</Flex>
),
name: 'Preview offset',
argTypes: {
mode: {
control: 'select',
options: ['default', 'center', 'custom'],
defaultValue: 'default'
},
offsetX: {
control: 'number',
defaultValue: 20
},
offsetY: {
control: 'number',
defaultValue: 20
}
},
args: {
mode: 'default',
offsetX: 20,
offsetY: 20
}
};
55 changes: 55 additions & 0 deletions packages/@react-aria/dnd/test/dnd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,61 @@ describe('useDrag and useDrop', function () {
expect(dataTransfer._dragImage.x).toBe(10);
expect(dataTransfer._dragImage.y).toBe(10);
});

it('should use the offset returned by getPreviewOffset', () => {
let renderPreview = jest.fn().mockImplementation(() => <div>Drag preview</div>);
let getPreviewOffset = jest.fn().mockReturnValue({x: 12, y: 15});
let tree = render(<Draggable renderPreview={renderPreview} getPreviewOffset={getPreviewOffset} />);

let draggable = tree.getByText('Drag me');

// Ensure consistent element sizes between draggable source and preview.
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
return {
left: 0,
top: 0,
x: 0,
y: 0,
width: this.style.position === 'absolute' ? 20 : 100,
height: this.style.position === 'absolute' ? 20 : 50
};
});

let dataTransfer = new DataTransfer();
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 10, clientY: 10}));

// getPreviewOffset should have been called and its return value used without modification.
expect(getPreviewOffset).toHaveBeenCalledTimes(1);
expect(dataTransfer._dragImage.x).toBe(12);
expect(dataTransfer._dragImage.y).toBe(15);
});

it('should clamp the offset returned by getPreviewOffset to the preview bounds', () => {
let renderPreview = jest.fn().mockImplementation(() => <div>Drag preview</div>);
// Return values outside of the preview bounds to verify clamping logic.
let getPreviewOffset = jest.fn().mockReturnValue({x: 50, y: -10});
let tree = render(<Draggable renderPreview={renderPreview} getPreviewOffset={getPreviewOffset} />);

let draggable = tree.getByText('Drag me');

jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
return {
left: 0,
top: 0,
x: 0,
y: 0,
width: this.style.position === 'absolute' ? 20 : 100,
height: this.style.position === 'absolute' ? 20 : 50
};
});

let dataTransfer = new DataTransfer();
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));

// Offsets should be clamped to 0 <= offset <= width/height (20 in this mock).
expect(dataTransfer._dragImage.x).toBe(20);
expect(dataTransfer._dragImage.y).toBe(0);
});
});
});

Expand Down
Loading