UNPKG

@atlaskit/pragmatic-drag-and-drop-docs

Version:

This package holds the documentation for Pragmatic drag and drop in one place. It is not intended to be consumed directly by consumers. The package is used by other tools for sharing examples

488 lines (386 loc) 18 kB
--- order: 1 title: Drag previews description: How to control what is under the users pointer while a drag is occurring --- import SectionMessage from '@atlaskit/section-message'; import CheckIcon from '../../../assets/check-icon'; import CrossIcon from '../../../assets/cross-icon'; A **drag preview** is the thing that a user drags around during a drag operation. We have a number of supported techniques for controlling what the drag preview looks like. ## Native drag previews <SectionMessage appearance="success"> We recommend using **native drag previews** where possible as they have great performance characteristics (they are not rendered on the main thread) and they can be dragged between applications </SectionMessage> Browsers have built in "native" mechanisms for rendering a drag preview There are a few techniques you can use to control what a native drag preview will look like: ### Approach 1: Use a custom native drag preview You can ask the browser to take a photo of another visible element on the page and use that as the drag preview. There are some [design constraints when leveraging native drag previews](/components/pragmatic-drag-and-drop/web-platform-design-constraints). <SectionMessage appearance="discovery"> There are lots of platform gotchas when working with custom native drag previews. We recommend using our `setCustomNativeDragPreview()` as it makes it safe and easy to work with custom native drag previews. </SectionMessage> #### Mounting a new element with `setCustomNativeDragPreview` You can use `setCustomNativeDragPreview` to mount a new element to the page to be used as the drag preview. `setCustomNativeDragPreview` will call your `cleanup` function to remove the preview element from the page after the browser has taken a photo of the element. `setCustomNativeDragPreview` adds the `container` `Element` to the `document.body` and will remove the `container` `Element` after your `cleanup` function is called. `setCustomNativeDragPreview` has been designed to work with any view abstraction. Note: you are welcome to use the [`onGenerateDragPreview | nativeSetDragImage`](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setDragImage) API directly. However, we recommend you use `setCustomNativeDragPreview` as it covers over a number of gotchas. #### Usage example: `react` portals This technique requires your component to be re-rendered, but maintains the current `react` `context` ```tsx type State = | { type: 'idle'; } | { type: 'preview'; container: HTMLElement; }; function Item() { const [state, setState] = useState<State>({ type: 'idle' }); const ref = useRef<HTMLDivElement | null>(null); useEffect(() => { invariant(ref.current); return draggable({ element: ref.current, onGenerateDragPreview({ nativeSetDragImage }) { setCustomNativeDragPreview({ render({ container }) { // Cause a `react` re-render to create your portal synchronously setState({ type: 'preview', container }); // In our cleanup function: cause a `react` re-render to create remove your portal // Note: you can also remove the portal in `onDragStart`, // which is when the cleanup function is called return () => setState({ type: 'idle' }); }, nativeSetDragImage, }); }, }); }, []); return ( <> <div ref={ref}>Drag Me</div> {state.type === 'preview' ? ReactDOM.createPortal(<Preview />, state.container) : null} </> ); } ``` #### Usage example: A new `react` application This technique requires no re-rendering of your component, but does not maintain the current `react` `context` ```tsx import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; draggable({ element: myElement, onGenerateDragPreview: ({ nativeSetDragImage }) => { setCustomNativeDragPreview({ render({ container }) { ReactDOM.render(<Preview item={item} />, container); return function cleanup() { ReactDOM.unmountComponentAtNode(container); }; }, nativeSetDragImage, }); }, }); ``` #### Usage example: plain JavaScript ```ts import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; draggable({ element: myElement, onGenerateDragPreview: ({ nativeSetDragImage }) => { setCustomNativeDragPreview({ render({ container }) { // Create our preview element const preview = document.createElement('div'); // Populate and style the preview element however you like preview.textContent = 'My Preview'; Object.assign(preview.style, { padding: '20px', backgroundColor: 'lightpink', }); // put the "preview" element into the container element container.appendChild(preview); }, nativeSetDragImage, }); }, }); ``` #### Positioning the drag preview You can control where the custom native drag preview is placed by using the `getOffset()` argument. You can return an `{x: number, y: number}` object from `getOffset()` which will control where the native drag preview is rendered relative to the users pointer. `{x: 0, y: 0}` represents having the users pointer user the top left corner of the drag preview. For clarity: > `const rect = container.getBoundingClientRect()` - `{x: 0, y: 0}` top left of the `container` will be under the users pointer **(default)** - `{x: rect.width, y: 0}` top right of the `container` will be under the users pointer - `{x: rect.width, y: rect.height}` bottom right of the `container` will be under the users pointer - `{x: 0, y: rect.height}` bottom left of the `container` will be under the users pointer ```ts type GetOffsetFn = (args: { container: HTMLElement }) => { x: number; y: number; }; ``` Notes: - `GetOffsetFn` needs to return `x` and `y` as numbers as that is what the platform requires - You cannot use negative values (not supported by browsers). If you want to push the drag preview away from the users pointer, use `pointerOutsideOfPreview` (see below) - The max offset value for an axis is the `border-box`. Values greater than the `border-box` get trimmed to be the `border-box` value - `getOffset` is called in the next [`microtask`](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide) after `setCustomNativeDragPreview:render`. This helps ensure that the drag preview element has finished rendering into the `container` before `getOffset` is called. Some frameworks like `react@18` won't render the element to be used for the drag preview into the `container` until the next `microtask`. `{x: rect.width + 1, y: rect.height + 1}` effectively becomes `{x: rect.width, y: rect.height}`. ```tsx import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; draggable({ element: myElement, onGenerateDragPreview: ({ nativeSetDragImage }) => { setCustomNativeDragPreview({ // place the (near) top middle of the `container` under the users pointer getOffset: () => { const rect = container.getBoundingClientRect(); return { x: rect.width / 2, y: 16 }; }, render({ container }) { ReactDOM.render(<Preview item={item} />, container); return function cleanup() { ReactDOM.unmountComponentAtNode(container); }; }, nativeSetDragImage, }); }, }); ``` We have `getOffset()` helpers for `setCustomnativeDragPreview()`: 1. `centerUnderPointer`: centers the custom native drag preview under the users cursor ```ts import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer'; draggable({ element: myElement, onGenerateDragPreview: ({ nativeSetDragImage }) => { setCustomNativeDragPreview({ getOffset: centerUnderPointer, render({ container }) { /* ... */ }, nativeSetDragImage, }); }, }); ``` 2. `pointerOutsideOfPreview`: a cross browser mechanism to push the drag preview in front of the users pointer. ```ts import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; draggable({ element: myElement, onGenerateDragPreview: ({ nativeSetDragImage }) => { setCustomNativeDragPreview({ // `x` and `y` can be any CSS value getOffset: pointerOutsideOfPreview({ x: '8px', y: 'calc(var(--grid) * 2)', }), render({ container }) { /* ... */ }, nativeSetDragImage, }); }, }); ``` When in left to right (`ltr`) languages, the drag preview is pushed forward, to the right of the users pointer. For right to left (`rtl`) languages, the drag preview is also pushed forward, and the preview will be on the _left_ hand side of the users pointer. The direction (based on the `dir` attribute) will be looked up on the `container` element; so you can control the `dir` by setting it on the `body`; or on the `container` element itself. ```ts onGenerateDragPreview({ nativeSetDragImage, source, location }) { setCustomNativeDragPreview({ nativeSetDragImage, getOffset: pointerOutsideOfPreview({ x: token('space.200'), y: token('space.100'), }), render({ container }) { // Forcing the direction for the container. container.dir = 'ltr'; // By default, the `dir` inherited on `container` (which is a child of `body`) will be used. }, }); }, ``` If you are using css variables inside of your `getOffset()` you need to be sure your css variables are available at the `<body>` element, as the `container` is temporarily mounted as a child of `<body>`. On iOS, iPad and Android `pointerOutsideOfPreview` will center the drag preview under the users pointer. Browsers on iOS, iPadOS and Android put the center of the drag preview under the users pointer during the drag, even when we try to "push" the drag preview away from the pointer. 3. `preserveOffsetOnSource`: applies the initial cursor offset to the custom native drag preview for a seamless experience ```ts import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source'; draggable({ element: myElement, onGenerateDragPreview: ({ nativeSetDragImage, location, source }) => { setCustomNativeDragPreview({ getOffset: preserveOffsetOnSource({ element: source.element, input: location.current.input, }), render({ container }) { /* ... */ }, nativeSetDragImage, }); }, }); ``` Notes: - This helper works best when the rendered preview has the same dimensions as the dragged element - On Android the center of the drag preview is always under the users pointer (platform limitation) - On iOS and iPadOS, the drag preview location will initially match the `source`, but during the drag the native preview will perform an animated slide so that it is centered on the users pointer. The centering of the drag preview during the drag on the users pointer is iOS and iPad platform behaviour. #### Gotcha: CSS transforms When creating custom drag preview element with `setCustomNativeDragPreview`, there is mixed support for applying CSS transforms to the drag preview element. | | Scale | Rotate | Translate (avoid) | | ----------------- | ---------------------- | ---------------------- | ---------------------- | | Chrome (`114.0`) | <CheckIcon /> | <CheckIcon /> | <CheckIcon /> | | Firefox (`115.0`) | <CheckIcon /> | <CheckIcon /> | <CheckIcon /> | | Safari (`16.5.2`) | <CrossIcon /> (broken) | <CrossIcon /> (broken) | <CrossIcon /> (broken) | <SectionMessage> Avoid using `translate` for positioning a drag preview (or pushing it away from the cursor). Please use `setCustomNativeDragPreview > getOffset` for that (see above) </SectionMessage> You can use CSS transforms as a _progressive enhancement_. For Chrome and Firefox you can use CSS transforms, but for Safari you cannot. You will need do a browser check for Safari, and only add CSS transforms to your preview element when the browser is not Safari. ```tsx const isSafari: boolean = navigator.userAgent.includes('AppleWebKit') && !navigator.userAgent.includes('Chrome'); const transformStyles = css({ transform: 'scale(4deg)', }); function Preview() { return <div css={isSafari ? transformStyles : undefined}>Drag preview</div>; } ``` ### Approach 2: Change the appearance of a `draggable` <SectionMessage> This approach has the best performance characteristics, but is subject to a number of gotchas. For most consumers we recommend using `setCustomNativeDragPreview` </SectionMessage> If you do nothing, then the browser will use a picture of the `draggable` element as the drag preview. By leveraging event timings you can control the appearance of the drag preview that the browser generates: 1. in `onGenerateDragPreview` make whatever visual changes you want to the `draggable` element and those changes will be captured in the drag preview 2. in `onDragStart`: 2a. revert changes of step 1. The user will never see the `draggable` element with the styles applied in `onGenerateDragPreview` due to paint timings 2b. apply visual changes to the `draggable` element to make it clear to the user what element is being dragged 3. in `onDrop` remove any visual changes you applied to the `draggable` element during the drag > [More information about how this technique works 🧑‍🔬](https://twitter.com/alexandereardon/status/1510826920023248900) There are a few constraints imposed by browsers that you need to follow if you want to use this technique: - Your `draggable` needs to be _completely_ visible and unobfiscated at the start of the drag. This can involve insuring that your `draggable` is not cut off by scroll (see `scrollJustEnoughIntoView`), and has no layers currently on top of the `draggable` (for example, you might need to close some popups) - The users pointer still needs to be over the `draggable` after the changes you make to the `draggable` element in `onGenerateDragPreview`. Generally this means that you should not be changing the dimensions of the `draggable` element. - Avoid CSS `transform` on your `draggable`. In Safari, CSS `transform`s that impact a `draggable` can mess up native drag previews. - [Bug 1](https://bugs.webkit.org/show_bug.cgi?id=246734) when a `transform` impacts a `draggable` _before_ a drag starts: - [Bug 2](https://twitter.com/alexandereardon/status/1511148574943240194) when CSS `transform` is applied to a `draggable` element in `onGenerateDragPreview` ## Non-native custom drag previews In some situations, you might want to completely disable the native drag preview and render your own drag preview. The advantage of this technique is that you can update the drag preview during a drag. The downsides of this approach is that it is not as fast, and you cannot drag the non-native drag preview outside of a browser window. To use this technique: 1. disable the native drag preview ```ts import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; draggable({ element: myElement, onGenerateDragPreview({ nativeSetDragPreview }) { disableNativeDragPreview({ nativeSetDragPreview }); }, }); ``` > This technique renders a `1x1` transparent image as the native drag preview. There are a few > alternative techniques for hiding the drag preview, but this technique yielded the best results > across many browsers and devices. 2. render your own element in `onDragStart` (ideally in a portal), and under the user's pointer (you can use `location.initial.input` to get the users initial position) 3. move the new element around in response to `onDrag` events (use `location.current.input` to get the users current pointer position) 4. remove the new element in `onDrop` If you are doing this technique, you will likely want to use the [`preventUnhandled` utility](/components/pragmatic-drag-and-drop/core-package/utilities). Using that addon will prevent the strange situation where when the user does not drop on a drop target there is a fairly large pause before the drop event. This is because the browser does a drop animation when the user does not drop on a drop target; a "return home" animation. Because you have hidden the native drag preview, the user won't see this return home drop animation, but will experience a delay. Using the [`preventUnhandled` utility](/components/pragmatic-drag-and-drop/core-package/utilities) ensures that the return home drop animation won't run ## No drag preview For some experiences you might not want any drag preview (for example, resizing). All you need to do is disable the native drag preview and you are good to go. ```ts import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; draggable({ element: myElement, onGenerateDragPreview({ nativeSetDragPreview }) { disableNativeDragPreview({ nativeSetDragPreview }); }, }); ``` ## `scrollJustEnoughIntoView` A little utility to quickly scroll something into view before a drag preview is captured. This is helpful if you are leveraging default drag previews (ie not using `setCustomNativeDragPreview`). If the `draggable` element is not completely in view, then the drag preview can be cut off. ```ts import { scrollJustEnoughIntoView } from '@atlaskit/pragmatic-drag-and-drop/element/scroll-just-enough-into-view'; draggable({ element: myElement, onGenerateDragPreview({ source }) { scrollJustEnoughIntoView({ element: source.element }); }, }); ```