Files
eewing ec317eb17c
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m11s
INIT
2026-02-18 15:17:47 -06:00

313 lines
13 KiB
JavaScript

import { getDocument } from "svelte-toolbelt";
import { on } from "svelte/events";
import { watch } from "runed";
import { isElement } from "./is.js";
function isPointInPolygon(point, polygon) {
const [x, y] = point;
let isInside = false;
const length = polygon.length;
for (let i = 0, j = length - 1; i < length; j = i++) {
const [xi, yi] = polygon[i] ?? [0, 0];
const [xj, yj] = polygon[j] ?? [0, 0];
const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) {
isInside = !isInside;
}
}
return isInside;
}
function isInsideRect(point, rect) {
return (point[0] >= rect.left &&
point[0] <= rect.right &&
point[1] >= rect.top &&
point[1] <= rect.bottom);
}
function getSide(triggerRect, contentRect) {
// determine which side the content is on relative to trigger
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
const contentCenterX = contentRect.left + contentRect.width / 2;
const contentCenterY = contentRect.top + contentRect.height / 2;
const deltaX = contentCenterX - triggerCenterX;
const deltaY = contentCenterY - triggerCenterY;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? "right" : "left";
}
return deltaY > 0 ? "bottom" : "top";
}
/**
* Creates a safe polygon area that allows users to move their cursor between
* the trigger and floating content without closing it.
*/
export class SafePolygon {
#opts;
#buffer;
// tracks the cursor position when leaving trigger or content
#exitPoint = null;
// tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
#exitTarget = null;
#triggerRect = null;
#contentRect = null;
#pointerMoveRafId = null;
#pendingClientPoint = null;
#leaveFallbackRafId = null;
#cancelLeaveFallback() {
if (this.#leaveFallbackRafId !== null) {
cancelAnimationFrame(this.#leaveFallbackRafId);
this.#leaveFallbackRafId = null;
}
}
#scheduleLeaveFallback() {
this.#cancelLeaveFallback();
this.#leaveFallbackRafId = requestAnimationFrame(() => {
this.#leaveFallbackRafId = null;
if (!this.#exitPoint || !this.#exitTarget)
return;
this.#clearTracking();
this.#opts.onPointerExit();
});
}
#closeIfPointerEnteredOutside(target, triggerNode, contentNode) {
if (!isElement(target))
return false;
if (triggerNode.contains(target) || contentNode.contains(target))
return false;
this.#clearTracking();
this.#opts.onPointerExit();
return true;
}
constructor(opts) {
this.#opts = opts;
this.#buffer = opts.buffer ?? 1;
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
if (!triggerNode || !contentNode || !enabled) {
this.#clearTracking();
return;
}
const doc = getDocument(triggerNode);
const handlePointerMove = (e) => {
if (this.#opts.throttlePointerMove) {
this.#onPointerMoveThrottled(e, triggerNode, contentNode);
}
else {
this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
}
};
const handleTriggerLeave = (e) => {
// when leaving trigger toward content, record exit point
const target = e.relatedTarget;
// if going directly to content, no need for polygon tracking
if (isElement(target) && contentNode.contains(target)) {
return;
}
if (this.#closeIfPointerEnteredOutside(target, triggerNode, contentNode)) {
return;
}
this.#exitPoint = [e.clientX, e.clientY];
this.#exitTarget = "content";
this.#captureRects(triggerNode, contentNode);
this.#scheduleLeaveFallback();
};
const handleTriggerEnter = () => {
// reached trigger, clear tracking
this.#clearTracking();
};
const handleContentEnter = () => {
// reached content, clear tracking
this.#clearTracking();
};
const handleContentLeave = (e) => {
// when leaving content, check if going directly back to trigger
const target = e.relatedTarget;
if (isElement(target) && triggerNode.contains(target)) {
// going directly to trigger, no polygon tracking needed
return;
}
if (this.#closeIfPointerEnteredOutside(target, triggerNode, contentNode)) {
return;
}
// might be traversing gap back to trigger, set up polygon tracking
this.#exitPoint = [e.clientX, e.clientY];
this.#exitTarget = "trigger";
this.#captureRects(triggerNode, contentNode);
this.#scheduleLeaveFallback();
};
return [
on(doc, "pointermove", handlePointerMove),
on(triggerNode, "pointerleave", handleTriggerLeave),
on(triggerNode, "pointerenter", handleTriggerEnter),
on(contentNode, "pointerenter", handleContentEnter),
on(contentNode, "pointerleave", handleContentLeave),
].reduce((acc, cleanup) => () => {
acc();
cleanup();
}, () => { });
});
}
#onPointerMoveThrottled(e, triggerNode, contentNode) {
if (this.#pointerMoveRafId === null) {
// handle the first move in the frame immediately so close checks
// are not deferred when only a single pointermove fires.
this.#pointerMoveRafId = requestAnimationFrame(() => {
this.#pointerMoveRafId = null;
const point = this.#pendingClientPoint;
this.#pendingClientPoint = null;
if (!point)
return;
this.#onPointerMove(point, triggerNode, contentNode);
});
this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
return;
}
this.#pendingClientPoint = [e.clientX, e.clientY];
}
#onPointerMove(clientPoint, triggerNode, contentNode) {
// if no exit point recorded, nothing to check
if (!this.#exitPoint || !this.#exitTarget)
return;
this.#cancelLeaveFallback();
if (!this.#triggerRect || !this.#contentRect) {
this.#captureRects(triggerNode, contentNode);
}
const triggerRect = this.#triggerRect ?? triggerNode.getBoundingClientRect();
const contentRect = this.#contentRect ?? contentNode.getBoundingClientRect();
// check if pointer reached the target
if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
this.#clearTracking();
return;
}
if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
this.#clearTracking();
return;
}
// check if pointer is in the rectangular corridor between trigger and content
const side = getSide(triggerRect, contentRect);
const corridorPoly = this.#getCorridorPolygon(triggerRect, contentRect, side);
if (corridorPoly && isPointInPolygon(clientPoint, corridorPoly)) {
return;
}
// check if pointer is within the safe polygon from exit point to target
const targetRect = this.#exitTarget === "content" ? contentRect : triggerRect;
const safePoly = this.#getSafePolygon(this.#exitPoint, targetRect, side, this.#exitTarget);
if (isPointInPolygon(clientPoint, safePoly)) {
return;
}
// pointer is outside all safe zones - close
this.#clearTracking();
this.#opts.onPointerExit();
}
#captureRects(triggerNode, contentNode) {
this.#triggerRect = triggerNode.getBoundingClientRect();
this.#contentRect = contentNode.getBoundingClientRect();
}
#clearTracking() {
this.#exitPoint = null;
this.#exitTarget = null;
this.#triggerRect = null;
this.#contentRect = null;
this.#pendingClientPoint = null;
if (this.#pointerMoveRafId !== null) {
cancelAnimationFrame(this.#pointerMoveRafId);
this.#pointerMoveRafId = null;
}
this.#cancelLeaveFallback();
}
/**
* Creates a rectangular corridor between trigger and content
* This prevents closing when cursor is in the gap between them
*/
#getCorridorPolygon(triggerRect, contentRect, side) {
const buffer = this.#buffer;
switch (side) {
case "top":
return [
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.top],
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.bottom],
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.bottom],
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.top],
];
case "bottom":
return [
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.bottom],
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.top],
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.top],
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.bottom],
];
case "left":
return [
[triggerRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
[contentRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
[contentRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
[triggerRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
];
case "right":
return [
[triggerRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
[contentRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
[contentRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
[triggerRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
];
}
}
/**
* Creates a triangular/trapezoidal safe zone from the exit point to the target
*/
#getSafePolygon(exitPoint, targetRect, side, exitTarget) {
const buffer = this.#buffer * 4;
const [x, y] = exitPoint;
// when going back to trigger, we need to flip the side
const effectiveSide = exitTarget === "trigger" ? this.#flipSide(side) : side;
// create polygon points from cursor to target edges
switch (effectiveSide) {
case "top":
return [
[x - buffer, y + buffer],
[x + buffer, y + buffer],
[targetRect.right + buffer, targetRect.bottom],
[targetRect.right + buffer, targetRect.top],
[targetRect.left - buffer, targetRect.top],
[targetRect.left - buffer, targetRect.bottom],
];
case "bottom":
return [
[x - buffer, y - buffer],
[x + buffer, y - buffer],
[targetRect.right + buffer, targetRect.top],
[targetRect.right + buffer, targetRect.bottom],
[targetRect.left - buffer, targetRect.bottom],
[targetRect.left - buffer, targetRect.top],
];
case "left":
return [
[x + buffer, y - buffer],
[x + buffer, y + buffer],
[targetRect.right, targetRect.bottom + buffer],
[targetRect.left, targetRect.bottom + buffer],
[targetRect.left, targetRect.top - buffer],
[targetRect.right, targetRect.top - buffer],
];
case "right":
return [
[x - buffer, y - buffer],
[x - buffer, y + buffer],
[targetRect.left, targetRect.bottom + buffer],
[targetRect.right, targetRect.bottom + buffer],
[targetRect.right, targetRect.top - buffer],
[targetRect.left, targetRect.top - buffer],
];
}
}
#flipSide(side) {
switch (side) {
case "top":
return "bottom";
case "bottom":
return "top";
case "left":
return "right";
case "right":
return "left";
}
}
}