Merge branch 'r3/drop' into ship/core

This commit is contained in:
2026-08-23 15:48:19 -07:00
10 changed files with 446 additions and 246 deletions
+147 -96
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { classifyDrop, dropIsBlocked, isDropTarget } from "./dropTarget";
function pane(rect: Partial<DOMRect>): HTMLElement {
@@ -23,16 +23,22 @@ function pane(rect: Partial<DOMRect>): HTMLElement {
/**
* **jsdom has no `elementFromPoint`.** That single fact is how a z-order gate
* that refused every drop under a button, a toast and a dialog shipped green
* through 81 drop tests: the branch was never entered, in any of them. So the
* tests below install one. A test that passes because the environment lacks
* the API under test is not a test.
* through 81 drop tests: the branch was never entered, in any of them.
*
* The gate no longer asks a per-point question at all, so the gap can no
* longer hide anything here — but the tests below still *install* an
* `elementFromPoint` and hand it the most misleading answer available, because
* "the module ignores it" is now a property worth pinning. `spy.mock.calls`
* proves it directly.
*/
function stubElementFromPoint(top: Element | null): void {
function stubElementFromPoint(top: Element | null): ReturnType<typeof vi.fn> {
const spy = vi.fn(() => top);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => top,
value: spy,
});
return spy;
}
function removeElementFromPoint(): void {
@@ -125,148 +131,193 @@ describe("dropTarget", () => {
});
// ---------------------------------------------------------------------
// Z-order — the branch jsdom cannot reach on its own
// The gate itself: document-wide, and provably not geometric
// ---------------------------------------------------------------------
describe("z-order, with elementFromPoint actually present", () => {
it("confirms the environment gap this whole block exists for", () => {
// If jsdom ever grows layout, this fails and the stubs below can be
// reconsidered — but until then, *nothing* reaches the z-order branch
// unless a test puts the API there itself.
expect(typeof document.elementFromPoint).toBe("undefined");
});
it("accepts a drop onto chrome the pane paints over itself", () => {
// The regression. `TerminalView`'s "▼ Following / ▽ Paused" toggle is
// `absolute top-2 right-4 z-50` and is rendered *unconditionally*, as a
// sibling of the xterm host rather than a child — so a gate asking "does
// the pane contain what is painted here?" turned the terminal's top-right
// corner into a dead zone that no user action could clear.
const el = pane({});
const following = document.createElement("button");
document.body.appendChild(following); // sibling, not a child of the pane
stubElementFromPoint(following);
expect(classifyDrop(el, { x: 90, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("accepts a drop onto a toast floating above every pane", () => {
// `ToastHost` is `fixed bottom-4 right-4 z-[60]`, 24rem wide, and its
// error cards never time out — so under the containment rule the
// bottom-right corner of both the terminal and the Files pane stopped
// accepting drops for as long as one error stayed on screen.
const el = pane({});
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
stubElementFromPoint(toastCard);
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("accepts a drop onto the pane's own content", () => {
describe("the blocking gate is document-wide, with no z-order in it", () => {
it("never consults elementFromPoint, however tempting its answer", () => {
// Round 2 asked `elementFromPoint` whether a *blocker* was painted at
// the drop point, and trusted the answer absolutely. Anything painted
// above the `z-50` backdrop in the same stacking context — `ToastHost`
// at `z-[60]`, `TerminalContextMenu` at `z-[60]` — answered "no blocker
// here" on a point the dialog was covering. The fix is not a better
// answer, it is not asking: this pins that the call is gone, so a
// future edit that reintroduces it fails here rather than in the wild.
const el = pane({});
const child = document.createElement("span");
el.appendChild(child);
stubElementFromPoint(child);
const spy = stubElementFromPoint(child);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
expect(spy).not.toHaveBeenCalled();
});
it("refuses a drop released onto a dialog's backdrop", () => {
it("refuses a covered drop even when a toast is painted over the dialog", () => {
// C1, exactly. A refused drop pushes a toast; `ToastHost` is
// `fixed bottom-4 right-4 z-[60]` and an error card stays until
// dismissed; the *next* drop released on that card had a topmost
// element with no blocker in it, and landed in the directory the dialog
// was covering. The gate had armed its own hole.
const el = pane({});
openModal(); // z-50 backdrop, covering the pane
const toastCard = document.createElement("div"); // z-[60], over the backdrop
document.body.appendChild(toastCard);
const spy = stubElementFromPoint(toastCard);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(spy).not.toHaveBeenCalled();
});
it("refuses a drop anywhere in the window while a dialog is open", () => {
// Deliberately stricter than "the points the dialog covers". A dialog
// is a state the user entered on purpose and leaves with Escape, the
// refusal is announced, and nothing is written — whereas being precise
// about coverage has silently uploaded into a covered directory twice.
const el = pane({});
const { backdrop } = openModal();
stubElementFromPoint(backdrop);
stubElementFromPoint(document.createElement("div")); // "nothing here"
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(classifyDrop(el, { x: 5, y: 5 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("blocked");
backdrop.remove();
expect(classifyDrop(el, { x: 5, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("refuses a drop released onto the dialog panel or anything inside it", () => {
it("refuses under the shutdown overlay, which is not a dialog", () => {
const el = pane({});
const { panel, button } = openModal();
stubElementFromPoint(panel);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
stubElementFromPoint(button);
const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true");
document.body.appendChild(overlay);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
});
it("refuses a drop under an unmarked overlay that wraps a dialog", () => {
// Belt to the braces: a dialog built without `ui/Modal`'s marked
// backdrop is still refused, because the element painted at the point
// *contains* something modal.
it("still refuses a dialog built without ui/Modal's marked backdrop", () => {
// Only `aria-modal` is needed; `ui/Modal` is the supported route, but a
// hand-rolled dialog must not be a hole either.
const el = pane({});
const backdrop = document.createElement("div");
const panel = document.createElement("div");
panel.setAttribute("aria-modal", "true");
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
stubElementFromPoint(backdrop);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
});
it("refuses a drop under the shutdown overlay", () => {
it("counts a blocker that is only decoratively hidden from assistive tech", () => {
// `aria-hidden="true"` used to disqualify a blocker, and it is not a
// visibility statement — `ui/Modal`'s own ✕ glyph carries it while
// perfectly visible. A blocker nested inside such a wrapper would have
// silently stopped blocking. Only `[hidden]` counts now.
const el = pane({});
const wrapper = document.createElement("div");
wrapper.setAttribute("aria-hidden", "true");
const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true");
document.body.appendChild(overlay);
stubElementFromPoint(overlay);
wrapper.appendChild(overlay);
document.body.appendChild(wrapper);
expect(dropIsBlocked()).toBe(true);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
});
it("scopes a dialog's refusal to the points it actually covers", () => {
// `dropIsBlocked` is document-wide and `ui/Modal` portals to
// `document.body`, so an open dialog used to refuse every drop in the
// window. Asked per point, a dialog only swallows what lands on it.
const el = pane({});
openModal();
const paneContent = document.createElement("span");
el.appendChild(paneContent);
stubElementFromPoint(paneContent);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("ignores a blocker whose pane has stepped aside", () => {
// `ui/Modal` marks itself `hidden` when the tab that owns it is not the
// one on screen. A dialog left open in project A must not keep refusing
// drops in project B.
// drops in project B — this is the one case where over-refusal would be
// unbounded, since the user cannot see the dialog to close it.
const el = pane({});
const { backdrop } = openModal();
backdrop.setAttribute("hidden", "");
expect(dropIsBlocked()).toBe(false);
stubElementFromPoint(backdrop);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
});
});
it("falls back to the document-wide question when the point cannot be resolved", () => {
// `elementFromPoint` answers `null` for a point outside the viewport, and
// `<body>` for a point over nothing in particular. Neither is evidence
// that the pane is clear, so the conservative answer is the old one.
// ---------------------------------------------------------------------
// Round 1's shape: chrome painted over a pane must never refuse a drop
// ---------------------------------------------------------------------
describe("chrome over a pane, with no dialog open", () => {
/** Everything that is painted over a pane and is not a blocker. */
const CHROME: Array<[string, () => HTMLElement]> = [
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
// host — so "does the pane contain what is painted here?" made the
// terminal's top-right corner a dead zone no user action could clear.
["the Following/Paused toggle", () => document.createElement("button")],
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
// pane, and its error cards stay until dismissed.
["a toast card", () => document.createElement("div")],
// The drop hint is `pointer-events-none`, so a real `elementFromPoint`
// skips it — but nothing may depend on that any more.
["the pane's own drop hint", () => document.createElement("div")],
// `Tooltip` portals to `document.body`, so it is nobody's child.
["a portaled tooltip", () => document.createElement("div")],
];
for (const [name, make] of CHROME) {
it(`accepts a drop released onto ${name}`, () => {
const el = pane({});
const chrome = make();
document.body.appendChild(chrome); // a sibling, not a child of the pane
stubElementFromPoint(chrome);
expect(classifyDrop(el, { x: 90, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("accept");
});
}
it("accepts a drop on the gutter around the terminal, and on its content", () => {
const el = pane({});
openModal();
const child = document.createElement("span");
el.appendChild(child);
stubElementFromPoint(child);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
stubElementFromPoint(null);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
stubElementFromPoint(document.body);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
stubElementFromPoint(el); // the gutter: the wrapper itself is topmost
expect(classifyDrop(el, { x: 1, y: 99 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("tells a refused drop apart from someone else's drop", () => {
// The caller reports one and stays silent about the other: a drop that
// was aimed at this pane and swallowed by an overlay is invisible unless
// something says so, while a drop into another pane is not ours to
// narrate.
it("accepts a drop the view could not resolve to any element", () => {
// `elementFromPoint` answers `null` outside the viewport and `<body>`
// over nothing in particular. With no dialog open neither is a reason
// to refuse a drop that is inside the pane's rect.
const el = pane({});
const { backdrop } = openModal();
stubElementFromPoint(backdrop);
stubElementFromPoint(null);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
stubElementFromPoint(document.body);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
});
});
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(classifyDrop(el, { x: 900, y: 900 }, { devicePixelRatio: 1 })).toBe("elsewhere");
// ---------------------------------------------------------------------
// Routing: exactly one listener speaks for a given drop
// ---------------------------------------------------------------------
describe("routing", () => {
it("lets only the pane the drop landed on report a refusal", () => {
// Geometry is asked before the gate for this reason: both listeners are
// live for every drop, and if the gate came first they would both push
// "File drop ignored" for one drop.
const hit = pane({});
const missed = pane({ left: 200, right: 300, top: 200, bottom: 300 });
openModal();
expect(classifyDrop(hit, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(classifyDrop(missed, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(
"elsewhere",
);
});
it("says elsewhere, not blocked, for a hidden pane's zero-size rect", () => {
const hidden = pane({ right: 0, bottom: 0, width: 0, height: 0 });
openModal();
expect(classifyDrop(hidden, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(
"elsewhere",
);
});
});
+86 -85
View File
@@ -2,47 +2,58 @@
* 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.
* the same event — so the module answers two separate questions, and keeping
* them separate is the whole design:
*
* So the hit test is: the point is inside my rect, **and** nothing that
* *swallows* drops is painted at that point.
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
* payload position inside my rect? A hidden pane is `display:none` and so
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab`
* both claiming the same drop.
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
* document-wide, no geometry, no z-order. While a modal or a blocking
* overlay is on screen anywhere, every drop is refused.
*
* ## The question the z-order test asks — and the one it must not ask
* ## Why there is no z-order test here, and must not be one
*
* The first version of this asked `el.contains(document.elementFromPoint(x,y))`
* — "is the thing painted here mine?" That is the wrong question, and it
* created permanent dead zones. Panes have chrome painted *over* them that is
* not part of the element handed to this function and does not swallow
* anything: `TerminalView`'s always-present "▼ Following" button, the URL
* toast, and `ToastHost`'s bottom-right stack — which is `fixed` at `z-[60]`
* over *every* pane and whose error cards stay until dismissed. Under the
* containment rule a drop onto any of them was silently refused, forever.
* A drop that lands underneath a dialog and silently uploads into the
* directory the dialog is covering is the failure mode that matters: it is
* invisible, it writes to the container, and the user did not ask for it.
* Every attempt to be *precise* about which points a dialog covers has gone
* wrong, twice, in opposite directions:
*
* The question that matches the intent is "is a *blocking overlay* painted
* here?". A button, a toast or a tooltip over the pane is not one; a modal
* backdrop is. Anything else painted at the point belongs to the pane's own
* subtree or is chrome that is happy for the drop to fall through to it.
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
* painted here mine?" — refused drops onto anything painted *over* a pane
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
* Permanent dead zones no user action could clear.
* - Replacing that with "is a *blocking overlay* painted here?" removed the
* dead zones and opened a hole instead. `elementFromPoint` returns the
* topmost painted element, and plenty of things paint above a `z-50` modal
* backdrop in the same stacking context: `ToastHost` is `z-[60]`, so is
* `TerminalContextMenu`. A refused drop pushed a toast; the toast then sat
* over the dialog; the next drop released on that toast was reported as
* "clear" and landed in the covered directory. The gate armed its own hole.
*
* That rule is also what *scopes* the blocking question. `dropIsBlocked` is
* document-wide, and `ui/Modal` portals to `document.body`, so any dialog
* anywhere used to refuse every drop in the window. Asking per-point means a
* dialog only refuses the points it actually covers.
* Both bugs are the same mistake: trusting a per-point answer to decide
* whether the app should be accepting work at all. The document-wide question
* has no z-index in it, so no future overlay can become a drop hole by being
* painted high enough, and no chrome can become a dead zone by being painted
* at all.
*
* What it costs: while any dialog is open, drops are refused *everywhere*,
* including on parts of a pane the dialog does not cover. That is a state the
* user put the app in deliberately and can leave in one keystroke, the
* refusal is announced, and nothing is written. It is strictly the better
* failure.
*
* ## jsdom
*
* jsdom implements no layout and has no `elementFromPoint`, so the z-order
* branch cannot be exercised by simply rendering — which is exactly how the
* containment bug shipped green through 81 drop tests. Every test that cares
* about z-order therefore stubs `elementFromPoint` (see `dropTarget.test.ts`),
* and the fallback below — when there is no such API, or it cannot resolve the
* point — is the conservative document-wide question this used to ask.
* Note for future changes: jsdom implements no layout and has no
* `elementFromPoint`, which is how the first of those two bugs shipped green
* through 81 drop tests — the branch was never entered in any of them. This
* module no longer calls it (`dropTarget.test.ts` asserts that it does not),
* so the gap can no longer hide a bug here. Anything that reintroduces a
* geometric z-order test reintroduces the gap as well.
*/
export interface DropPoint {
@@ -51,24 +62,30 @@ export interface DropPoint {
}
/**
* Anything that swallows a drop wherever it lands.
* Anything that swallows drops while it is on screen.
*
* `[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), and
* `ui/Modal` puts it on its backdrop as well: the backdrop is what
* `elementFromPoint` returns for a point outside the dialog panel, and it is
* the element that is really covering the pane.
* `ui/Modal` puts it on its backdrop as well.
*/
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
/**
* A blocker inside one of these is in the DOM but not on screen — `ui/Modal`
* marks itself this way when the pane that owns it is not the visible one, so
* a dialog left open in project A stops covering project B the moment the tab
* changes.
* A blocker inside this is in the DOM but not on screen — `ui/Modal` marks
* itself `hidden` when the pane that owns it is not the visible one, so a
* dialog left open in project A stops refusing drops in project B the moment
* the tab changes.
*
* `[hidden]` only, deliberately. `aria-hidden="true"` used to count too, and
* it is not a visibility statement: it is routinely put on *visible*
* decorative content (`ui/Modal`'s own ✕ glyph, every `StatusIndicator`
* dot). An overlay that happened to sit inside such a wrapper would have
* silently stopped blocking — the exact class of hole this gate exists to
* close. `ui/Modal` sets `hidden`, the `hidden` attribute, and inline
* `display:none` together, so nothing in the app depended on the aria half.
*/
const OFFSCREEN_SELECTOR = '[hidden],[aria-hidden="true"]';
const OFFSCREEN_SELECTOR = "[hidden]";
/** A blocker that is actually painted, rather than merely mounted. */
function isOnScreen(el: Element): boolean {
@@ -80,6 +97,22 @@ export function dropIsBlocked(doc: Document = document): boolean {
return Array.from(doc.querySelectorAll(BLOCKING_SELECTOR)).some(isOnScreen);
}
/**
* What both listeners say when they refuse a drop.
*
* `kind: "info"`, so it times out on its own: a drop refused because the user
* has a dialog open is expected behaviour, not an error, and an error card
* would sit on screen until dismissed. `dedupeKey` means three refused drops
* leave one notice rather than a stack of three.
*/
export const DROP_BLOCKED_TOAST = {
kind: "info",
message: "File drop ignored",
detail:
"A dialog or full-window overlay is open, so nothing accepts dropped files. Close it and drop again.",
dedupeKey: "drop-blocked",
} as const;
export interface DropTargetOptions {
doc?: Document;
/** Override the ratio used to convert physical pixels to CSS pixels. */
@@ -99,10 +132,8 @@ export interface DropTargetOptions {
* (`wry/src/webview2/drag_drop.rs`), while the macOS and GTK backends deliver
* logical points and `tauri-runtime-wry`'s forwarding does not rescale them.
* Dividing by `devicePixelRatio` unconditionally therefore halved every drop
* position on a HiDPI Mac or Linux box — which used to be a silent
* mis-aimed-but-usually-still-inside-the-pane error and, with a z-order test
* in place, becomes a drop refused because the *halved* point lands on
* something else.
* position on a HiDPI Mac or Linux box, aiming the hit test at a point the
* user never touched.
*
* Verified by reading the wry/tauri sources named above. **Not** verified on a
* real HiDPI macOS or GTK machine — neither is available here — which is why
@@ -120,15 +151,15 @@ function payloadIsPhysical(
/**
* Why a native drop at `pos` did or did not belong to `el`.
*
* - `accept` — it is ours.
* - `blocked` — it landed on our rect, but a modal or a blocking overlay is
* painted there and swallowed it. Worth *saying* to the user: the drop
* visibly did nothing.
* - `accept` — it is ours, and the app is in a state to take it.
* - `blocked` — it was aimed at us, but a modal or a blocking overlay is on
* screen. Worth *saying* to the user: the drop visibly did nothing.
* - `elsewhere` — not our drop. Silence is the right response; some other
* pane's listener is about to accept it.
* pane's listener may be about to accept it.
*
* A hidden pane is `display:none` and therefore has a zero-size rect, which is
* what stops two panes both claiming the same drop.
* Geometry is asked **first**, so exactly one pane can ever answer `blocked`
* for a given drop and the refusal is announced once rather than once per
* listener.
*/
export type DropVerdict = "accept" | "blocked" | "elsewhere";
@@ -152,41 +183,11 @@ export function classifyDrop(
return "elsewhere";
}
// 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.
const top =
typeof doc.elementFromPoint === "function" ? doc.elementFromPoint(x, y) : null;
if (top && top !== doc.body && top !== doc.documentElement) {
return coveredByBlocker(top, doc) ? "blocked" : "accept";
}
// No layout information — jsdom, or a point the view could not resolve. Fall
// back to the document-wide question, which is the conservative answer: a
// dialog somewhere refuses everything rather than risking a drop landing
// underneath one.
// Whose drop it is has been settled. Whether the app should be taking drops
// at all is a separate, document-wide question — see the header.
return dropIsBlocked(doc) ? "blocked" : "accept";
}
/**
* Is the element painted at the drop point part of something that swallows
* drops?
*
* Two directions, because a dialog is two elements: the panel carries
* `aria-modal`, and the backdrop around it is what is painted over the pane.
* `ui/Modal` marks its own backdrop, so `closest` covers both; the `contains`
* half is the safety net for any overlay that wraps a dialog without marking
* itself, and is deliberately not asked of `<body>`/`<html>` — those contain
* every portal in the app and would make the answer "blocked" always.
*/
function coveredByBlocker(top: Element, doc: Document): boolean {
const nearest = top.closest(BLOCKING_SELECTOR);
if (nearest && isOnScreen(nearest)) return true;
if (top === doc.body || top === doc.documentElement) return false;
const inside = top.querySelector(BLOCKING_SELECTOR);
return inside !== null && isOnScreen(inside);
}
/**
* Whether a native drop at `pos` (physical pixels on Windows, logical
* elsewhere) belongs to `el`.