mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
560349819f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
/**
|
|
* 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<DragDropContextValue>({
|
|
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(
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
left: state.position.x + 12,
|
|
top: state.position.y - 20,
|
|
pointerEvents: 'none',
|
|
zIndex: 99999,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
background: 'var(--bg-card, #1e1e2e)',
|
|
border: '1px solid var(--border, rgba(255,255,255,0.1))',
|
|
borderRadius: 8,
|
|
padding: '6px 12px',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
|
color: 'var(--text-primary, #fff)',
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
maxWidth: 280,
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
opacity: 0.95,
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{coverUrl && (
|
|
<img
|
|
src={coverUrl}
|
|
alt=""
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 4,
|
|
objectFit: 'cover',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{label}
|
|
</span>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// ── Provider ──────────────────────────────────────────────────────
|
|
export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
|
const [state, setState] = useState<DragState>({
|
|
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 (
|
|
<Ctx.Provider value={ctxValue}>
|
|
{children}
|
|
<DragGhost state={state} />
|
|
</Ctx.Provider>
|
|
);
|
|
}
|
|
|
|
// ── useDragSource hook ────────────────────────────────────────────
|
|
const DRAG_THRESHOLD = 5; // px before drag starts
|
|
|
|
/**
|
|
* Returns an onMouseDown handler for a draggable element.
|
|
* Usage: <div {...useDragSource(payload)} />
|
|
*/
|
|
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 };
|
|
}
|