diff --git a/src/App.tsx b/src/App.tsx index 17bbcdaa..43b989be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -324,13 +324,11 @@ function AppShell() { }; }, [isDraggingQueue, handleMouseMove, handleMouseUp]); - // ── Global DnD fix for Linux/WebKitGTK ────────────────────────── - // WebKitGTK (used by Tauri on Linux) requires the document itself to - // accept drags via preventDefault() on dragover/dragenter. Without - // this, the webview shows a "forbidden" cursor for all in-app HTML5 - // drag-and-drop because it never sees a valid drop target at the - // document level. This is harmless on Windows/macOS where DnD already - // works correctly. + // ── Global DnD fix for Linux/WebKitGTK / Wayland ───────────────── + // dragover/dragenter: WebKitGTK needs preventDefault so external drops are not + // a permanent "forbidden" cursor. dragstart (capture): cancel native drags from + // the page (e.g. SVG grips); Wayland can otherwise leave a stuck GTK drag-proxy. + // In-app moves use psy-drag (mouse events). Harmless on Windows/macOS. useEffect(() => { const allow = (e: DragEvent) => { e.preventDefault(); @@ -358,9 +356,14 @@ function AppShell() { e.preventDefault(); }; + const blockDragStart = (e: DragEvent) => { + e.preventDefault(); + }; + document.addEventListener('dragover', allow); document.addEventListener('dragenter', allow); document.addEventListener('drop', blockDrop); + document.addEventListener('dragstart', blockDragStart, true); document.addEventListener('keydown', blockSelectAll, true); document.addEventListener('selectstart', blockSelectStart); @@ -368,6 +371,7 @@ function AppShell() { document.removeEventListener('dragover', allow); document.removeEventListener('dragenter', allow); document.removeEventListener('drop', blockDrop); + document.removeEventListener('dragstart', blockDragStart, true); document.removeEventListener('keydown', blockSelectAll, true); document.removeEventListener('selectstart', blockSelectStart); }; diff --git a/src/contexts/DragDropContext.tsx b/src/contexts/DragDropContext.tsx index 7ebfd099..31d304ac 100644 --- a/src/contexts/DragDropContext.tsx +++ b/src/contexts/DragDropContext.tsx @@ -128,6 +128,8 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!state.payload) return; + let ended = false; + const onMove = (e: MouseEvent) => { // preventDefault stops the browser from treating the mouse movement as // a text-selection drag, which causes element highlighting and @@ -136,38 +138,63 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) { setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } })); }; - const onUp = () => { - // Clear any residual selection (from the pre-threshold phase). + /** End drag; optionally fire `psy-drop` at the last known cursor position. */ + const endDrag = (dispatchDrop: boolean) => { + if (ended || !stateRef.current.payload) return; + ended = true; + 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); + if (dispatchDrop) { + const evt = new CustomEvent('psy-drop', { + bubbles: true, + detail: stateRef.current.payload, + }); + 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); + const onUp = () => endDrag(true); + /** Wayland: webview may not get `mouseup` when the pointer leaves the surface — clear the ghost without a drop. */ + const onBlur = () => endDrag(false); + const onVisibility = () => { + if (document.hidden) endDrag(false); + }; + const onPointerCancel = () => endDrag(false); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + endDrag(false); + } + }; + + document.addEventListener('mousemove', onMove, { passive: false }); + document.addEventListener('mouseup', onUp, true); + document.addEventListener('pointerup', onUp, true); + document.addEventListener('pointercancel', onPointerCancel, true); + window.addEventListener('blur', onBlur); + document.addEventListener('visibilitychange', onVisibility); + document.addEventListener('keydown', onKeyDown, true); - // 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.removeEventListener('mouseup', onUp, true); + document.removeEventListener('pointerup', onUp, true); + document.removeEventListener('pointercancel', onPointerCancel, true); + window.removeEventListener('blur', onBlur); + document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('keydown', onKeyDown, true); document.body.classList.remove('psy-dragging'); }; - }, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps + }, [state.payload]); const ctxValue: DragDropContextValue = { startDrag, diff --git a/src/styles/components.css b/src/styles/components.css index c553f371..9eee6b10 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -3232,6 +3232,7 @@ display: flex; align-items: center; flex-shrink: 0; + -webkit-user-drag: none; } .sidebar-customizer-grip:active {