/** * Mouse-event-based Drag & Drop system. * * Replaces the HTML5 Drag & Drop API for cross-component drags (song → queue, * album → queue) because WebKitGTK on Linux always shows a "forbidden" cursor * during native HTML5 DnD and there is no way to fix it at the GTK level * without breaking DnD entirely. * * This system uses mousedown / mousemove / mouseup which keeps cursor control * in CSS and avoids the native DnD subsystem completely. */ import React, { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; // ── Types ───────────────────────────────────────────────────────── export interface DragPayload { /** Serialised JSON identical to what was previously in dataTransfer */ data: string; /** Label shown on the ghost element */ label: string; /** Optional cover URL for the ghost */ coverUrl?: string; } interface DragState { payload: DragPayload | null; position: { x: number; y: number }; } interface DragDropContextValue { /** Begin a drag. Called from mousedown (after threshold). */ startDrag: (payload: DragPayload, x: number, y: number) => void; /** Current drag payload (null when idle). */ payload: DragPayload | null; /** Whether a drag is in progress. */ isDragging: boolean; } const Ctx = createContext({ startDrag: () => {}, payload: null, isDragging: false, }); export const useDragDrop = () => useContext(Ctx); // ── Ghost overlay ───────────────────────────────────────────────── function DragGhost({ state }: { state: DragState }) { if (!state.payload) return null; const { label, coverUrl } = state.payload; return createPortal(
{coverUrl && ( )} {label}
, document.body, ); } // ── Provider ────────────────────────────────────────────────────── export function DragDropProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState({ payload: null, position: { x: 0, y: 0 }, }); const stateRef = useRef(state); stateRef.current = state; const startDrag = useCallback( (payload: DragPayload, x: number, y: number) => { // Clear any text selection the browser may have started during the // threshold detection phase (mousedown → mousemove before startDrag). window.getSelection()?.removeAllRanges(); setState({ payload, position: { x, y } }); }, [], ); // Global mousemove + mouseup listeners (only while dragging) useEffect(() => { if (!state.payload) return; const onMove = (e: MouseEvent) => { // preventDefault stops the browser from treating the mouse movement as // a text-selection drag, which causes element highlighting and // horizontal auto-scroll in grid containers. e.preventDefault(); setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } })); }; const onUp = () => { // Clear any residual selection (from the pre-threshold phase). window.getSelection()?.removeAllRanges(); // Dispatch a custom event so drop targets can react. // The payload is in `detail`. const evt = new CustomEvent('psy-drop', { bubbles: true, detail: stateRef.current.payload, }); // Find element under cursor const el = document.elementFromPoint( stateRef.current.position.x, stateRef.current.position.y, ); if (el) el.dispatchEvent(evt); setState({ payload: null, position: { x: 0, y: 0 } }); }; document.addEventListener('mousemove', onMove, { passive: false }); document.addEventListener('mouseup', onUp); // Add a class so CSS can show grab cursor and suppress selection document.body.classList.add('psy-dragging'); return () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.classList.remove('psy-dragging'); }; }, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps const ctxValue: DragDropContextValue = { startDrag, payload: state.payload, isDragging: state.payload !== null, }; return ( {children} ); } // ── useDragSource hook ──────────────────────────────────────────── const DRAG_THRESHOLD = 5; // px before drag starts /** * Returns an onMouseDown handler for a draggable element. * Usage:
*/ export function useDragSource(getPayload: () => DragPayload) { const { startDrag } = useDragDrop(); const startPosRef = useRef<{ x: number; y: number } | null>(null); const payloadRef = useRef(getPayload); payloadRef.current = getPayload; const onMouseDown = useCallback( (e: React.MouseEvent) => { // Only left-click if (e.button !== 0) return; // Prevent the browser from starting a text-selection drag during the // threshold detection phase (mousedown → mousemove before startDrag). e.preventDefault(); const startX = e.clientX; const startY = e.clientY; startPosRef.current = { x: startX, y: startY }; const onMove = (me: MouseEvent) => { if (!startPosRef.current) return; const dx = me.clientX - startX; const dy = me.clientY - startY; if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) { startPosRef.current = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); startDrag(payloadRef.current(), me.clientX, me.clientY); } }; const onUp = () => { startPosRef.current = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, [startDrag], ); return { onMouseDown }; }