82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
/**
|
|||
|
|
* Routing for Tauri's *native* drag-drop event.
|
||
|
|
*
|
||
|
|
* The listener is window-wide — every pane that wants dropped file paths gets
|
||
|
|
* the same event — so each one decides for itself whether the drop was meant
|
||
|
|
* for it. That decision used to be purely geometric: is the payload position
|
||
|
|
* inside my rect? A rect is not what the user sees, though. An open `Modal` is
|
||
|
|
* a `fixed inset-0` portal at `z-50` painted *over* the whole window, and the
|
||
|
|
* pane underneath still had its rect, so releasing a drag onto a dialog
|
||
|
|
* uploaded the file into the directory the dialog was covering. Same for the
|
||
|
|
* shutdown overlay, which is on screen precisely while nothing should be
|
||
|
|
* accepting work at all.
|
||
|
|
*
|
||
|
|
* So the hit test is now: nothing is covering the window, **and** the point is
|
||
|
|
* inside my rect, **and** whatever is actually painted at that point is mine.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export interface DropPoint {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Anything that swallows a drop wherever it lands.
|
||
|
|
*
|
||
|
|
* `[aria-modal="true"]` is every dialog in the app for free — `ui/Modal` is
|
||
|
|
* the only way one is built, and it sets that attribute. `data-blocks-drop`
|
||
|
|
* is for full-window overlays that are not dialogs (the shutdown overlay).
|
||
|
|
*/
|
||
|
|
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
|
||
|
|
|
||
|
|
/** True while a modal or a blocking overlay is on screen. */
|
||
|
|
export function dropIsBlocked(doc: Document = document): boolean {
|
||
|
|
return doc.querySelector(BLOCKING_SELECTOR) !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DropTargetOptions {
|
||
|
|
doc?: Document;
|
||
|
|
/** Override the ratio used to convert physical pixels to CSS pixels. */
|
||
|
|
devicePixelRatio?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether a native drop at `pos` (physical pixels) belongs to `el`.
|
||
|
|
*
|
||
|
|
* A hidden pane is `display:none` and therefore has a zero-size rect, which is
|
||
|
|
* what stops two panes both claiming the same drop.
|
||
|
|
*/
|
||
|
|
export function isDropTarget(
|
||
|
|
el: HTMLElement | null | undefined,
|
||
|
|
pos: DropPoint,
|
||
|
|
options: DropTargetOptions = {},
|
||
|
|
): boolean {
|
||
|
|
const doc = options.doc ?? el?.ownerDocument ?? document;
|
||
|
|
if (dropIsBlocked(doc)) return false;
|
||
|
|
|
||
|
|
const rect = el?.getBoundingClientRect();
|
||
|
|
if (!el || !rect || rect.width === 0 || rect.height === 0) return false;
|
||
|
|
|
||
|
|
const dpr =
|
||
|
|
options.devicePixelRatio ??
|
||
|
|
(doc.defaultView?.devicePixelRatio || 1);
|
||
|
|
const x = pos.x / dpr;
|
||
|
|
const y = pos.y / dpr;
|
||
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Z-order, where the environment can answer it. `elementFromPoint` skips
|
||
|
|
// `pointer-events: none`, so the pane's own decorative drop hint does not
|
||
|
|
// count as something covering it. jsdom has no layout and returns null,
|
||
|
|
// which is treated as "no opinion" rather than "not mine".
|
||
|
|
if (typeof doc.elementFromPoint === "function") {
|
||
|
|
const top = doc.elementFromPoint(x, y);
|
||
|
|
if (top && top !== doc.body && top !== doc.documentElement && !el.contains(top)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|