INIT
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
import { executeCallbacks, getDocument, getWindow, } from "svelte-toolbelt";
|
||||
import { on } from "svelte/events";
|
||||
import { watch } from "runed";
|
||||
import { boxAutoReset } from "./box-auto-reset.svelte.js";
|
||||
import { isElement, isHTMLElement } from "./is.js";
|
||||
export class GraceArea {
|
||||
#opts;
|
||||
#enabled;
|
||||
#isPointerInTransit;
|
||||
#pointerGraceArea = $state(null);
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#enabled = $derived(this.#opts.enabled());
|
||||
this.#isPointerInTransit = boxAutoReset(false, {
|
||||
afterMs: opts.transitTimeout ?? 300,
|
||||
onChange: (value) => {
|
||||
if (!this.#enabled)
|
||||
return;
|
||||
this.#opts.setIsPointerInTransit?.(value);
|
||||
},
|
||||
getWindow: () => getWindow(this.#opts.triggerNode()),
|
||||
});
|
||||
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
|
||||
if (!triggerNode || !contentNode || !enabled)
|
||||
return;
|
||||
const handleTriggerLeave = (e) => {
|
||||
this.#createGraceArea(e, contentNode);
|
||||
};
|
||||
const handleContentLeave = (e) => {
|
||||
this.#createGraceArea(e, triggerNode);
|
||||
};
|
||||
return executeCallbacks(on(triggerNode, "pointerleave", handleTriggerLeave), on(contentNode, "pointerleave", handleContentLeave));
|
||||
});
|
||||
watch(() => this.#pointerGraceArea, () => {
|
||||
const handleTrackPointerGrace = (e) => {
|
||||
if (!this.#pointerGraceArea)
|
||||
return;
|
||||
const target = e.target;
|
||||
if (!isElement(target))
|
||||
return;
|
||||
const pointerPosition = { x: e.clientX, y: e.clientY };
|
||||
const hasEnteredTarget = opts.triggerNode()?.contains(target) ||
|
||||
opts.contentNode()?.contains(target);
|
||||
const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, this.#pointerGraceArea);
|
||||
if (hasEnteredTarget) {
|
||||
this.#removeGraceArea();
|
||||
}
|
||||
else if (isPointerOutsideGraceArea) {
|
||||
this.#removeGraceArea();
|
||||
opts.onPointerExit();
|
||||
}
|
||||
};
|
||||
const doc = getDocument(opts.triggerNode() ?? opts.contentNode());
|
||||
if (!doc)
|
||||
return;
|
||||
return on(doc, "pointermove", handleTrackPointerGrace);
|
||||
});
|
||||
}
|
||||
#removeGraceArea() {
|
||||
this.#pointerGraceArea = null;
|
||||
this.#isPointerInTransit.current = false;
|
||||
}
|
||||
#createGraceArea(e, hoverTarget) {
|
||||
const currentTarget = e.currentTarget;
|
||||
if (!isHTMLElement(currentTarget))
|
||||
return;
|
||||
const exitPoint = { x: e.clientX, y: e.clientY };
|
||||
const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect());
|
||||
const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide);
|
||||
const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect());
|
||||
const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints]);
|
||||
this.#pointerGraceArea = graceArea;
|
||||
this.#isPointerInTransit.current = true;
|
||||
}
|
||||
}
|
||||
function getExitSideFromRect(point, rect) {
|
||||
const top = Math.abs(rect.top - point.y);
|
||||
const bottom = Math.abs(rect.bottom - point.y);
|
||||
const right = Math.abs(rect.right - point.x);
|
||||
const left = Math.abs(rect.left - point.x);
|
||||
switch (Math.min(top, bottom, right, left)) {
|
||||
case left:
|
||||
return "left";
|
||||
case right:
|
||||
return "right";
|
||||
case top:
|
||||
return "top";
|
||||
case bottom:
|
||||
return "bottom";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
function getPaddedExitPoints(exitPoint, exitSide, padding = 5) {
|
||||
// we extend the tip of the exit point to make it easier to navigate without
|
||||
// a minor jitter triggering a pointer exit
|
||||
const tipPadding = padding * 1.5;
|
||||
switch (exitSide) {
|
||||
case "top":
|
||||
return [
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
|
||||
{ x: exitPoint.x, y: exitPoint.y - tipPadding },
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
|
||||
];
|
||||
case "bottom":
|
||||
return [
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
|
||||
{ x: exitPoint.x, y: exitPoint.y + tipPadding },
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
|
||||
];
|
||||
case "left":
|
||||
return [
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
|
||||
{ x: exitPoint.x - tipPadding, y: exitPoint.y },
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
|
||||
];
|
||||
case "right":
|
||||
return [
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
|
||||
{ x: exitPoint.x + tipPadding, y: exitPoint.y },
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
|
||||
];
|
||||
}
|
||||
}
|
||||
function getPointsFromRect(rect) {
|
||||
const { top, right, bottom, left } = rect;
|
||||
return [
|
||||
{ x: left, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
];
|
||||
}
|
||||
// Determine if a point is inside of a polygon.
|
||||
// Based on https://github.com/substack/point-in-polygon
|
||||
function isPointInPolygon(point, polygon) {
|
||||
const { x, y } = point;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
// prettier-ignore
|
||||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
||||
if (intersect)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
// Returns a new array of points representing the convex hull of the given set of points.
|
||||
// https://www.nayuki.io/page/convex-hull-algorithm
|
||||
function getHull(points) {
|
||||
const newPoints = points.slice();
|
||||
newPoints.sort((a, b) => {
|
||||
if (a.x < b.x)
|
||||
return -1;
|
||||
else if (a.x > b.x)
|
||||
return +1;
|
||||
else if (a.y < b.y)
|
||||
return -1;
|
||||
else if (a.y > b.y)
|
||||
return +1;
|
||||
else
|
||||
return 0;
|
||||
});
|
||||
return getHullPresorted(newPoints);
|
||||
}
|
||||
// Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time.
|
||||
function getHullPresorted(points) {
|
||||
if (points.length <= 1)
|
||||
return points.slice();
|
||||
const upperHull = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
while (upperHull.length >= 2) {
|
||||
const q = upperHull[upperHull.length - 1];
|
||||
const r = upperHull[upperHull.length - 2];
|
||||
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
|
||||
upperHull.pop();
|
||||
else
|
||||
break;
|
||||
}
|
||||
upperHull.push(p);
|
||||
}
|
||||
upperHull.pop();
|
||||
const lowerHull = [];
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
const p = points[i];
|
||||
while (lowerHull.length >= 2) {
|
||||
const q = lowerHull[lowerHull.length - 1];
|
||||
const r = lowerHull[lowerHull.length - 2];
|
||||
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
|
||||
lowerHull.pop();
|
||||
else
|
||||
break;
|
||||
}
|
||||
lowerHull.push(p);
|
||||
}
|
||||
lowerHull.pop();
|
||||
if (upperHull.length === 1 &&
|
||||
lowerHull.length === 1 &&
|
||||
upperHull[0].x === lowerHull[0].x &&
|
||||
upperHull[0].y === lowerHull[0].y)
|
||||
return upperHull;
|
||||
else
|
||||
return upperHull.concat(lowerHull);
|
||||
}
|
||||
Reference in New Issue
Block a user