UNPKG

js.foresight

Version:

Predicts mouse trajectory to trigger actions as users approach elements, enabling anticipatory UI updates or pre-loading. Made with vanilla javascript and usable in every framework.

639 lines 31.4 kB
var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; import { tabbable } from "tabbable"; import { evaluateRegistrationConditions } from "../helpers/shouldRegister"; import { DEFAULT_ENABLE_MOUSE_PREDICTION, DEFAULT_ENABLE_SCROLL_PREDICTION, DEFAULT_ENABLE_TAB_PREDICTION, DEFAULT_HITSLOP, DEFAULT_POSITION_HISTORY_SIZE, DEFAULT_SCROLL_MARGIN, DEFAULT_TAB_OFFSET, DEFAULT_TRAJECTORY_PREDICTION_TIME, MAX_POSITION_HISTORY_SIZE, MAX_SCROLL_MARGIN, MAX_TAB_OFFSET, MAX_TRAJECTORY_PREDICTION_TIME, MIN_POSITION_HISTORY_SIZE, MIN_SCROLL_MARGIN, MIN_TAB_OFFSET, MIN_TRAJECTORY_PREDICTION_TIME, } from "./constants"; import { clampNumber } from "./helpers/clampNumber"; import { lineSegmentIntersectsRect } from "./helpers/lineSigmentIntersectsRect"; import { predictNextMousePosition } from "./helpers/predictNextMousePosition"; import { areRectsEqual, getExpandedRect, isPointInRectangle, normalizeHitSlop, } from "./helpers/rectAndHitSlop"; import { shouldUpdateSetting } from "./helpers/shouldUpdateSetting"; import { getFocusedElementIndex } from "./helpers/getFocusedElementIndex"; import { getScrollDirection } from "./helpers/getScrollDirection"; import { predictNextScrollPosition } from "./helpers/predictNextScrollPosition"; import { PositionObserver } from "position-observer"; /** * Manages the prediction of user intent based on mouse trajectory and element interactions. * * ForesightManager is a singleton class responsible for: * - Registering HTML elements to monitor. * - Tracking mouse movements and predicting future cursor positions. * - Detecting when a predicted trajectory intersects with a registered element's bounds. * - Invoking callbacks associated with elements upon predicted or actual interaction. * - Optionally unregistering elements after their callback is triggered. * - Handling global settings for prediction behavior (e.g., history size, prediction time). * - Automatically updating element bounds on resize using {@link ResizeObserver}. * - Automatically unregistering elements removed from the DOM using {@link MutationObserver}. * - Detecting broader layout shifts via {@link MutationObserver} to update element positions. * * It should be initialized once using {@link ForesightManager.initialize} and then * accessed via the static getter {@link ForesightManager.instance}. */ var ForesightManager = /** @class */ (function () { // Never put something in the constructor, use initialize instead function ForesightManager() { var _this = this; this.elements = new Map(); this.isSetup = false; this._globalCallbackHits = { mouse: { hover: 0, trajectory: 0, }, tab: { forwards: 0, reverse: 0, }, scroll: { down: 0, left: 0, right: 0, up: 0, }, total: 0, }; this._globalSettings = { debug: false, enableMousePrediction: DEFAULT_ENABLE_MOUSE_PREDICTION, enableScrollPrediction: DEFAULT_ENABLE_SCROLL_PREDICTION, positionHistorySize: DEFAULT_POSITION_HISTORY_SIZE, trajectoryPredictionTime: DEFAULT_TRAJECTORY_PREDICTION_TIME, scrollMargin: DEFAULT_SCROLL_MARGIN, defaultHitSlop: { top: DEFAULT_HITSLOP, left: DEFAULT_HITSLOP, right: DEFAULT_HITSLOP, bottom: DEFAULT_HITSLOP, }, enableTabPrediction: DEFAULT_ENABLE_TAB_PREDICTION, tabOffset: DEFAULT_TAB_OFFSET, onAnyCallbackFired: function (_elementData, _managerData) { }, }; this.trajectoryPositions = { positions: [], currentPoint: { x: 0, y: 0 }, predictedPoint: { x: 0, y: 0 }, }; this.tabbableElementsCache = []; this.lastFocusedIndex = null; this.predictedScrollPoint = null; this.scrollDirection = null; this.domObserver = null; this.positionObserver = null; // Track the last keydown event to determine if focus change was due to Tab this.lastKeyDown = null; // AbortController for managing global event listeners this.globalListenersController = null; this.eventListeners = new Map(); this.handleMouseMove = function (e) { _this.updatePointerState(e); _this.elements.forEach(function (currentData) { if (!currentData.isIntersectingWithViewport) { return; } _this.handleCallbackInteraction(currentData); }); _this.emit({ type: "mouseTrajectoryUpdate", predictionEnabled: _this._globalSettings.enableMousePrediction, timestamp: Date.now(), trajectoryPositions: _this.trajectoryPositions, }); }; /** * Detects when registered elements are removed from the DOM and automatically unregisters them to prevent stale references. * * @param mutationsList - Array of MutationRecord objects describing the DOM changes * */ this.handleDomMutations = function (mutationsList) { // Invalidate tabbale elements cache if (mutationsList.length) { _this.tabbableElementsCache = []; _this.lastFocusedIndex = null; } for (var _i = 0, mutationsList_1 = mutationsList; _i < mutationsList_1.length; _i++) { var mutation = mutationsList_1[_i]; if (mutation.type === "childList" && mutation.removedNodes.length > 0) { for (var _a = 0, _b = Array.from(_this.elements.keys()); _a < _b.length; _a++) { var element = _b[_a]; if (!element.isConnected) { _this.unregister(element, "disconnected"); } } } } }; // We store the last key for the FocusIn event, meaning we know if the user is tabbing around the page. // We dont use handleKeyDown for the full event because of 2 main reasons: // 1: handleKeyDown e.target returns the target on which the keydown is pressed (meaning we dont know which target got the focus) // 2: handleKeyUp does return the correct e.target however when holding tab the event doesnt repeat (handleKeyDown does) this.handleKeyDown = function (e) { if (e.key === "Tab") { _this.lastKeyDown = e; } }; this.handleFocusIn = function (e) { if (!_this.lastKeyDown || !_this._globalSettings.enableTabPrediction) { return; } var targetElement = e.target; if (!(targetElement instanceof HTMLElement)) { return; } // tabbable uses element.GetBoundingClientRect under the hood, to avoid alot of computations we cache its values if (!_this.tabbableElementsCache.length) { _this.tabbableElementsCache = tabbable(document.documentElement); } // Determine the range of elements to check based on the tab direction and offset var isReversed = _this.lastKeyDown.shiftKey; var currentIndex = getFocusedElementIndex(isReversed, _this.lastFocusedIndex, _this.tabbableElementsCache, targetElement); _this.lastFocusedIndex = currentIndex; _this.lastKeyDown = null; var elementsToPredict = []; for (var i = 0; i <= _this._globalSettings.tabOffset; i++) { if (isReversed) { var element = _this.tabbableElementsCache[currentIndex - i]; if (_this.elements.has(element)) { elementsToPredict.push(element); } } else { var element = _this.tabbableElementsCache[currentIndex + i]; if (_this.elements.has(element)) { elementsToPredict.push(element); } } } elementsToPredict.forEach(function (element) { _this.callCallback(_this.elements.get(element), { kind: "tab", subType: isReversed ? "reverse" : "forwards", }); }); }; this.handlePositionChange = function (entries) { for (var _i = 0, entries_1 = entries; _i < entries_1.length; _i++) { var entry = entries_1[_i]; var elementData = _this.elements.get(entry.target); if (!elementData) continue; var wasPreviouslyIntersecting = elementData.isIntersectingWithViewport; var isNowIntersecting = entry.isIntersecting; elementData.isIntersectingWithViewport = isNowIntersecting; if (wasPreviouslyIntersecting !== isNowIntersecting) { // TODO check if visibility status is changing _this.emit({ type: "elementDataUpdated", elementData: elementData, timestamp: Date.now(), updatedProp: "visibility", }); } if (isNowIntersecting) { _this.updateElementBounds(entry.boundingClientRect, elementData); _this.handleScrollPrefetch(elementData, entry.boundingClientRect); } } _this.scrollDirection = null; _this.predictedScrollPoint = null; }; } ForesightManager.initialize = function (props) { if (!this.isInitiated) { ForesightManager.manager = new ForesightManager(); } if (props !== undefined) { ForesightManager.manager.alterGlobalSettings(props); } return ForesightManager.manager; }; ForesightManager.prototype.addEventListener = function (eventType, listener, options) { var _this = this; var _a, _b; if ((_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.aborted) { return function () { }; } if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, []); } this.eventListeners.get(eventType).push(listener); (_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.addEventListener("abort", function () { return _this.removeEventListener(eventType, listener); }); }; ForesightManager.prototype.removeEventListener = function (eventType, listener) { var listeners = this.eventListeners.get(eventType); if (listeners) { var index = listeners.indexOf(listener); if (index > -1) { listeners.splice(index, 1); } } }; // Used for debugging only ForesightManager.prototype.logSubscribers = function () { var _this = this; console.log("%c[ForesightManager] Current Subscribers:", "font-weight: bold; color: #3b82f6;"); var eventTypes = Array.from(this.eventListeners.keys()); if (eventTypes.length === 0) { console.log(" No active subscribers."); return; } eventTypes.forEach(function (eventType) { var listeners = _this.eventListeners.get(eventType); if (listeners && listeners.length > 0) { // Use groupCollapsed so the log isn't too noisy by default. // The user can expand the events they are interested in. console.groupCollapsed("Event: %c".concat(eventType), "font-weight: bold;", "(".concat(listeners.length, " listener").concat(listeners.length > 1 ? "s" : "", ")")); listeners.forEach(function (listener, index) { console.log("[".concat(index, "]:"), listener); }); console.groupEnd(); } }); }; ForesightManager.prototype.emit = function (event) { var listeners = this.eventListeners.get(event.type); if (listeners) { listeners.forEach(function (listener) { try { listener(event); } catch (error) { console.error("Error in ForesightManager event listener for ".concat(event.type, ":"), error); } }); } }; Object.defineProperty(ForesightManager.prototype, "getManagerData", { get: function () { return { registeredElements: this.elements, globalSettings: this._globalSettings, globalCallbackHits: this._globalCallbackHits, }; }, enumerable: false, configurable: true }); Object.defineProperty(ForesightManager, "isInitiated", { get: function () { return !!ForesightManager.manager; }, enumerable: false, configurable: true }); Object.defineProperty(ForesightManager, "instance", { get: function () { return this.initialize(); }, enumerable: false, configurable: true }); Object.defineProperty(ForesightManager.prototype, "registeredElements", { get: function () { return this.elements; }, enumerable: false, configurable: true }); ForesightManager.prototype.register = function (_a) { var _this = this; var _b, _c; var element = _a.element, callback = _a.callback, hitSlop = _a.hitSlop, name = _a.name; var _d = evaluateRegistrationConditions(), shouldRegister = _d.shouldRegister, isTouchDevice = _d.isTouchDevice, isLimitedConnection = _d.isLimitedConnection; if (!shouldRegister) { return { isLimitedConnection: isLimitedConnection, isTouchDevice: isTouchDevice, isRegistered: false, unregister: function () { }, }; } // Setup global listeners on every first element added to the manager. It gets removed again when the map is emptied if (!this.isSetup) { this.initializeGlobalListeners(); } var normalizedHitSlop = hitSlop ? normalizeHitSlop(hitSlop) : this._globalSettings.defaultHitSlop; // const elementRect = element.getBoundingClientRect() var elementData = { element: element, callback: callback, callbackHits: { mouse: { hover: 0, trajectory: 0, }, tab: { forwards: 0, reverse: 0, }, scroll: { down: 0, left: 0, right: 0, up: 0, }, total: 0, }, elementBounds: { originalRect: undefined, expandedRect: { top: 0, left: 0, right: 0, bottom: 0 }, hitSlop: normalizedHitSlop, }, isHovering: false, trajectoryHitData: { isTrajectoryHit: false, trajectoryHitTime: 0, trajectoryHitExpirationTimeoutId: undefined, }, name: (_b = name !== null && name !== void 0 ? name : element.id) !== null && _b !== void 0 ? _b : "", isIntersectingWithViewport: true, }; this.elements.set(element, elementData); (_c = this.positionObserver) === null || _c === void 0 ? void 0 : _c.observe(element); this.emit({ type: "elementRegistered", timestamp: Date.now(), elementData: elementData, }); return { isTouchDevice: isTouchDevice, isLimitedConnection: isLimitedConnection, isRegistered: true, unregister: function () { return _this.unregister(element, "apiCall"); }, }; }; ForesightManager.prototype.unregister = function (element, unregisterReason) { var _a; if (!this.elements.has(element)) { return; } var foresightElementData = this.elements.get(element); if (foresightElementData) { this.emit({ type: "elementUnregistered", elementData: foresightElementData, timestamp: Date.now(), unregisterReason: unregisterReason, }); } // Clear any pending trajectory expiration timeout if (foresightElementData === null || foresightElementData === void 0 ? void 0 : foresightElementData.trajectoryHitData.trajectoryHitExpirationTimeoutId) { clearTimeout(foresightElementData.trajectoryHitData.trajectoryHitExpirationTimeoutId); } (_a = this.positionObserver) === null || _a === void 0 ? void 0 : _a.unobserve(element); this.elements.delete(element); if (this.elements.size === 0 && this.isSetup) { this.removeGlobalListeners(); } }; ForesightManager.prototype.updateNumericSettings = function (newValue, setting, min, max) { if (!shouldUpdateSetting(newValue, this._globalSettings[setting])) { return false; } this._globalSettings[setting] = clampNumber(newValue, min, max, setting); return true; }; ForesightManager.prototype.updateBooleanSetting = function (newValue, setting) { if (!shouldUpdateSetting(newValue, this._globalSettings[setting])) { return false; } this._globalSettings[setting] = newValue; return true; }; ForesightManager.prototype.alterGlobalSettings = function (props) { // Call each update function and store whether it made a change. // This ensures every update function is executed. var oldPositionHistorySize = this._globalSettings.positionHistorySize; var positionHistoryChanged = this.updateNumericSettings(props === null || props === void 0 ? void 0 : props.positionHistorySize, "positionHistorySize", MIN_POSITION_HISTORY_SIZE, MAX_POSITION_HISTORY_SIZE); if (positionHistoryChanged && this._globalSettings.positionHistorySize < oldPositionHistorySize) { if (this.trajectoryPositions.positions.length > this._globalSettings.positionHistorySize) { this.trajectoryPositions.positions = this.trajectoryPositions.positions.slice(this.trajectoryPositions.positions.length - this._globalSettings.positionHistorySize); } } var trajectoryTimeChanged = this.updateNumericSettings(props === null || props === void 0 ? void 0 : props.trajectoryPredictionTime, "trajectoryPredictionTime", MIN_TRAJECTORY_PREDICTION_TIME, MAX_TRAJECTORY_PREDICTION_TIME); var scrollMarginChanged = this.updateNumericSettings(props === null || props === void 0 ? void 0 : props.scrollMargin, "scrollMargin", MIN_SCROLL_MARGIN, MAX_SCROLL_MARGIN); var tabOffsetChanged = this.updateNumericSettings(props === null || props === void 0 ? void 0 : props.tabOffset, "tabOffset", MIN_TAB_OFFSET, MAX_TAB_OFFSET); var mousePredictionChanged = this.updateBooleanSetting(props === null || props === void 0 ? void 0 : props.enableMousePrediction, "enableMousePrediction"); var scrollPredictionChanged = this.updateBooleanSetting(props === null || props === void 0 ? void 0 : props.enableScrollPrediction, "enableScrollPrediction"); var tabPredictionChanged = this.updateBooleanSetting(props === null || props === void 0 ? void 0 : props.enableTabPrediction, "enableTabPrediction"); if ((props === null || props === void 0 ? void 0 : props.onAnyCallbackFired) !== undefined) { this._globalSettings.onAnyCallbackFired = props.onAnyCallbackFired; } var hitSlopChanged = false; if ((props === null || props === void 0 ? void 0 : props.defaultHitSlop) !== undefined) { var normalizedNewHitSlop = normalizeHitSlop(props.defaultHitSlop); if (!areRectsEqual(this._globalSettings.defaultHitSlop, normalizedNewHitSlop)) { this._globalSettings.defaultHitSlop = normalizedNewHitSlop; hitSlopChanged = true; this.forceUpdateAllElementBounds(); } } var settingsActuallyChanged = positionHistoryChanged || trajectoryTimeChanged || tabOffsetChanged || mousePredictionChanged || tabPredictionChanged || scrollPredictionChanged || hitSlopChanged || scrollMarginChanged; if (settingsActuallyChanged) { this.emit({ type: "managerSettingsChanged", timestamp: Date.now(), newSettings: this._globalSettings, }); } }; ForesightManager.prototype.forceUpdateAllElementBounds = function () { var _this = this; this.elements.forEach(function (_, element) { var elementData = _this.elements.get(element); // For performance only update rects that are currently intersecting with the viewport if (elementData && elementData.isIntersectingWithViewport) { _this.forceUpdateElementBounds(elementData); } }); }; ForesightManager.prototype.updatePointerState = function (e) { this.trajectoryPositions.currentPoint = { x: e.clientX, y: e.clientY }; this.trajectoryPositions.predictedPoint = this._globalSettings.enableMousePrediction ? predictNextMousePosition(this.trajectoryPositions.currentPoint, this.trajectoryPositions.positions, // History before the currentPoint was added this._globalSettings.positionHistorySize, this._globalSettings.trajectoryPredictionTime) : __assign({}, this.trajectoryPositions.currentPoint); }; /** * Processes elements that unregister after a single callback. * * This is a "fire-and-forget" handler. Its only goal is to trigger the * callback once. It does so if the mouse trajectory is predicted to hit the * element (if prediction is on) OR if the mouse physically hovers over it. * It does not track state, as the element is immediately unregistered. * * @param elementData - The data object for the foresight element. * @param element - The HTML element being interacted with. */ ForesightManager.prototype.handleCallbackInteraction = function (elementData) { var expandedRect = elementData.elementBounds.expandedRect; // when enable mouse prediction is off, we only check if the mouse is physically hovering over the element if (!this._globalSettings.enableMousePrediction) { if (isPointInRectangle(this.trajectoryPositions.currentPoint, expandedRect)) { this.callCallback(elementData, { kind: "mouse", subType: "hover" }); return; } } else if (lineSegmentIntersectsRect(this.trajectoryPositions.currentPoint, this.trajectoryPositions.predictedPoint, expandedRect)) { this.callCallback(elementData, { kind: "mouse", subType: "trajectory" }); } }; ForesightManager.prototype.updateHitCounters = function (elementData, hitType) { switch (hitType.kind) { case "mouse": elementData.callbackHits.mouse[hitType.subType]++; this._globalCallbackHits.mouse[hitType.subType]++; break; case "tab": elementData.callbackHits.tab[hitType.subType]++; this._globalCallbackHits.tab[hitType.subType]++; break; case "scroll": elementData.callbackHits.scroll[hitType.subType]++; this._globalCallbackHits.scroll[hitType.subType]++; break; } elementData.callbackHits.total++; this._globalCallbackHits.total++; }; ForesightManager.prototype.callCallback = function (elementData, hitType) { if (elementData) { this.updateHitCounters(elementData, hitType); elementData.callback(); this._globalSettings.onAnyCallbackFired(elementData, this.getManagerData); this.emit({ type: "callbackFired", timestamp: Date.now(), elementData: elementData, hitType: hitType, }); this.unregister(elementData.element, "callbackHit"); } }; /** * ONLY use this function when you want to change the rect bounds via code, if the rects are changing because of updates in the DOM do not use this function. * We need an observer for that */ ForesightManager.prototype.forceUpdateElementBounds = function (elementData) { var newOriginalRect = elementData.element.getBoundingClientRect(); var expandedRect = getExpandedRect(newOriginalRect, elementData.elementBounds.hitSlop); if (!areRectsEqual(expandedRect, elementData.elementBounds.expandedRect)) { var updatedElementData = __assign(__assign({}, elementData), { elementBounds: __assign(__assign({}, elementData.elementBounds), { originalRect: newOriginalRect, expandedRect: expandedRect }) }); this.elements.set(elementData.element, updatedElementData); this.emit({ type: "elementDataUpdated", timestamp: Date.now(), elementData: updatedElementData, updatedProp: "bounds", }); } }; ForesightManager.prototype.updateElementBounds = function (newRect, elementData) { var updatedElementData = __assign(__assign({}, elementData), { elementBounds: __assign(__assign({}, elementData.elementBounds), { originalRect: newRect, expandedRect: getExpandedRect(newRect, elementData.elementBounds.hitSlop) }) }); this.elements.set(elementData.element, updatedElementData); this.emit({ type: "elementDataUpdated", timestamp: Date.now(), elementData: updatedElementData, updatedProp: "bounds", }); }; ForesightManager.prototype.handleScrollPrefetch = function (elementData, newRect) { var _a, _b; if (this._globalSettings.enableScrollPrediction) { // This means the foresightmanager is initializing registered elements, we dont want to calc the scroll direction here if (!elementData.elementBounds.originalRect) { return; } // ONCE per animation frame we decide what the scroll direction is this.scrollDirection = (_a = this.scrollDirection) !== null && _a !== void 0 ? _a : getScrollDirection(elementData.elementBounds.originalRect, newRect); if (this.scrollDirection === "none") { return; } // ONCE per animation frame we decide the predicted scroll point this.predictedScrollPoint = (_b = this.predictedScrollPoint) !== null && _b !== void 0 ? _b : predictNextScrollPosition(this.trajectoryPositions.currentPoint, this.scrollDirection, this._globalSettings.scrollMargin); if (lineSegmentIntersectsRect(this.trajectoryPositions.currentPoint, this.predictedScrollPoint, elementData === null || elementData === void 0 ? void 0 : elementData.elementBounds.expandedRect)) { this.callCallback(elementData, { kind: "scroll", subType: this.scrollDirection, }); } this.emit({ type: "scrollTrajectoryUpdate", timestamp: Date.now(), currentPoint: this.trajectoryPositions.currentPoint, predictedPoint: this.predictedScrollPoint, }); } else { if (isPointInRectangle(this.trajectoryPositions.currentPoint, elementData.elementBounds.expandedRect)) { this.callCallback(elementData, { kind: "mouse", subType: "hover", }); } } }; ForesightManager.prototype.initializeGlobalListeners = function () { if (this.isSetup) { return; } // To avoid setting up listeners while ssr if (typeof window === "undefined" || typeof document === "undefined") { return; } this.globalListenersController = new AbortController(); var signal = this.globalListenersController.signal; document.addEventListener("mousemove", this.handleMouseMove); // Dont add signal we still need to emit events even without elements document.addEventListener("keydown", this.handleKeyDown, { signal: signal }); document.addEventListener("focusin", this.handleFocusIn, { signal: signal }); //Mutation observer is to automatically unregister elements when they leave the DOM. Its a fail-safe for if the user forgets to do it. this.domObserver = new MutationObserver(this.handleDomMutations); this.domObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: false, }); // Handles all position based changes and update the rects of the elements. completely async to avoid dirtying the main thread. // Handles resize of elements // Handles resize of viewport // Handles scrolling this.positionObserver = new PositionObserver(this.handlePositionChange); this.isSetup = true; }; ForesightManager.prototype.removeGlobalListeners = function () { var _a, _b, _c; this.isSetup = false; (_a = this.globalListenersController) === null || _a === void 0 ? void 0 : _a.abort(); // Remove all event listeners only in non debug mode this.globalListenersController = null; (_b = this.domObserver) === null || _b === void 0 ? void 0 : _b.disconnect(); this.domObserver = null; (_c = this.positionObserver) === null || _c === void 0 ? void 0 : _c.disconnect(); this.positionObserver = null; }; return ForesightManager; }()); export { ForesightManager }; //# sourceMappingURL=ForesightManager.js.map