Files
Triple-C/app/src/lib/dropTarget.ts
T

82 lines
2.9 KiB
TypeScript
Raw Normal View History

2026-08-23 11:11:43 -07:00
/**
* 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;
}