// Web transport #1: the browser's own HTML5 drag, for a mouse. // // Riding the platform drag rather than synthesising one from pointer events is // what makes a `` droppable on things it knows nothing about — an // existing `dragover`/`drop` listener, another window, the OS. `` receives these with no adapter for exactly that reason, and it // is also what lets a payload leave the page entirely. // // It has to be wired through the DOM node: react-native-web renders View as a div // but drops unknown HTML attributes, so `draggable` and `onDragStart` passed as // JSX props reach nothing. // // Touch is the pan's (`use-draggable-pointer.ts`) — but it can knock on this door: // Chromium starts a *native* drag from a touch long-press on any `draggable=true` // element, and the moment it does, the pointer stream gets a `pointercancel` that // kills the pan mid-gesture. `onDragStart` below refuses that drag outright. import { type RefObject, useEffect } from 'react'; import { Platform, type View } from 'react-native'; import type { PressTimeline } from '../use-press-timeline'; import type { DraggableSession } from './draggable-session'; /** * HTML5 drag image set from the preview React element. * * The browser draws its own drag image by default (a semi-transparent snapshot of * the dragged element), but when a consumer passes a custom `preview` — a chip * naming the count rather than a full row — that is what should appear under the * cursor. We clone the offscreen DOM node the component keeps rendered, append it * to `` so every engine can capture it, call `setDragImage`, and remove the * clone on the next microtask. */ function setDragImageFromRef(dataTransfer: DataTransfer, previewRef: RefObject): void { // biome-ignore lint/plugin: RN View refs resolve to HTMLElement in react-native-web const source = previewRef.current as unknown as HTMLElement | null; if (!source?.isConnected) return; // biome-ignore lint/plugin: cloneNode returns Node, but we only clone HTMLElements const image = source.cloneNode(true) as HTMLElement; image.style.opacity = '1'; image.style.position = 'fixed'; image.style.left = '-9999px'; image.style.top = '0'; document.body.appendChild(image); dataTransfer.setDragImage(image, 0, 0); setTimeout(() => image.remove(), 0); } /** A 1×1 transparent GIF — used as the drag image to hide the browser's native ghost. */ const EMPTY_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; /** * The image `setDragImage` draws, pre-loaded once for the whole page. * * The engine snapshots the drag image only *after* the `dragstart` handler returns, * so the element handed to `setDragImage` must already be decoded by then. An * `` created inside the handler has no dimensions yet on its first use — its * data-URI decode is still pending — and a drag image the engine cannot produce * aborts the whole drag, which reads as "the first drag dies instantly and every * later one works" once the decode is cached. Loading once, before any drag, makes * the first drag behave like the rest. A single shared element is fine: the engine * snapshots its bitmap, not a live DOM position. */ let emptyDragImage: HTMLImageElement | null = null; function getEmptyDragImage(): HTMLImageElement { if (emptyDragImage === null) { emptyDragImage = document.createElement('img'); emptyDragImage.src = EMPTY_IMAGE; } return emptyDragImage; } // Kick the decode off at module load rather than on the first `dragstart` — that // first handler is the one drag we cannot afford to lose. Guarded because this // module is also imported on native, where `document` does not exist. if (typeof document !== 'undefined') getEmptyDragImage(); export type UseDraggableHtml5Params = { enabled: boolean; nodeRef: RefObject; /** * When non-null, a `` above this source will draw the ghost in its * overlay, so the browser's own drag image must be hidden to prevent a double ghost * and to keep the ghost position under our control (Safari snaps the native image * back to the lift point when the cursor leaves the window). */ overlayHostId: string | null; /** When set, the preview element that `setDragImage` draws instead of the browser's default screenshot. */ previewElementRef?: RefObject; session: DraggableSession; /** The pan's press timeline — how a touch-initiated native drag is recognised and refused. */ timeline: PressTimeline; }; export function useDraggableHtml5({ enabled, nodeRef, overlayHostId, previewElementRef, session, timeline, }: UseDraggableHtml5Params): void { // biome-ignore lint/plugin: DOM event wiring must run in an effect; no data-fetching or render-driving state useEffect(() => { if (Platform.OS !== 'web' || !enabled) return; // biome-ignore lint/plugin: RN View refs resolve to HTMLElement in react-native-web const node = nodeRef.current as unknown as HTMLElement | null; if (!node?.addEventListener) return; // Safari fires `drag` events with the grab point when the cursor leaves the // browser window — non-zero coordinates that look valid but teleport the ghost // back to the lift position. Tracking the grab lets us reject those events. let grab: { x: number; y: number } | null = null; let lastPoint: { x: number; y: number } | null = null; function onDragStart(e: DragEvent) { // A press the timeline is tracking is the pan's gesture — this `dragstart` // is Chromium turning a touch long-press into a native drag, and letting // it proceed fires the `pointercancel` that kills the pan (and the hold // menu riding it). Same for a pan session already up on a hybrid device. // Prevented, not just ignored: an unprevented native drag runs — and // cancels the pointer stream — whether or not we begin a session for it. if (timeline.phase.current !== 'idle' || session.isDragging()) { e.preventDefault(); return; } // No dataTransfer means no drag to run — bail rather than lift a session // whose payload nothing could read. if (!e.dataTransfer) return; grab = { x: e.clientX, y: e.clientY }; lastPoint = grab; session.begin({ point: grab, transfer: e.dataTransfer, transport: 'html5' }); // When a DragManager overlay will draw the ghost instead, hide the browser's // own drag image so Safari cannot snap it back to the lift point when the // cursor leaves the window. Without an overlay host the browser's image is // the only ghost, so keep it. if (overlayHostId !== null) e.dataTransfer.setDragImage(getEmptyDragImage(), 0, 0); else if (previewElementRef) setDragImageFromRef(e.dataTransfer, previewElementRef); } function onDrag(e: DragEvent) { // The last `drag` before `dragend` reports (0, 0) in every engine. Passing it // through would snap the hit test — and any consumer's read-out — to the // top-left corner one frame before the drop. if (e.clientX === 0 && e.clientY === 0) return; // Safari fires `drag` with the grab point when the cursor leaves the window — // the browser stops tracking and reports the lift coordinates. Reject those // so the ghost does not snap back to the source position mid-drag. // A legitimate return to the exact grab point (step-by-step, not a teleport) // still passes because `lastPoint` was already at or near the grab. // `grab` is set in `dragstart` before any `drag` fires and cleared only // in `dragend` which arrives after the last `drag` — safe to narrow. if ( grab !== null && e.clientX === grab.x && e.clientY === grab.y && lastPoint !== null && (lastPoint.x !== grab.x || lastPoint.y !== grab.y) ) return; lastPoint = { x: e.clientX, y: e.clientY }; session.move({ x: e.clientX, y: e.clientY }); } function onDragEnd(e: DragEvent) { // The browser's verdict, read off the event rather than the transfer kept at // lift time. A zone of ours claimed the drag in its own `dragover`; anything // else that did — or nothing at all — is reported here and nowhere else. grab = null; lastPoint = null; session.finish({ commit: true, point: { x: e.clientX, y: e.clientY }, transportDropEffect: e.dataTransfer?.dropEffect ?? 'none', }); } node.draggable = true; node.addEventListener('dragstart', onDragStart); node.addEventListener('drag', onDrag); node.addEventListener('dragend', onDragEnd); return () => { node.draggable = false; node.removeEventListener('dragstart', onDragStart); node.removeEventListener('drag', onDrag); node.removeEventListener('dragend', onDragEnd); }; // `timeline`, `previewElementRef` and `overlayHostId` are stable for the life of // the component, so they never rebind here. }, [enabled, nodeRef, overlayHostId, previewElementRef, session, timeline]); }