fix(linux): stop Wayland GTK drag proxy and PsyDnD ghost (#268)

Block capture-phase dragstart in the webview so stray native HTML5 drags
(e.g. SVG reorder grips) do not create a stuck translucent GTK surface on
Wayland compositors.

Harden PsyDnD lifecycle when mouseup never reaches the webview: blur,
visibilitychange, pointerup/cancel in capture, and Escape cancel the
floating label without dispatching a drop.

Add -webkit-user-drag:none on sidebar customizer grips.
This commit is contained in:
cucadmuh
2026-04-22 23:28:24 +03:00
committed by GitHub
parent db72ed9e5a
commit 2d320d8681
3 changed files with 58 additions and 26 deletions
+11 -7
View File
@@ -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);
};
+46 -19
View File
@@ -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,
+1
View File
@@ -3232,6 +3232,7 @@
display: flex;
align-items: center;
flex-shrink: 0;
-webkit-user-drag: none;
}
.sidebar-customizer-grip:active {