@supermousejs/core 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Supermouse.ts CHANGED
@@ -1,339 +1,731 @@
1
- declare const __VERSION__: string;
2
-
3
- import type { MouseState, SupermouseOptions, SupermousePlugin } from "./types";
4
- import { Stage, Input } from "./systems";
5
- import { damp, angle } from "./utils/math";
6
-
7
- export const DEFAULT_HOVER_SELECTORS = [
8
- "a",
9
- "button",
10
- "input",
11
- "textarea",
12
- "[data-hover]",
13
- "[data-cursor]"
14
- ];
15
-
16
- /**
17
- * Runtime Loop of Supermouse.
18
- *
19
- * This class orchestrates the application state, manages the animation loop (`requestAnimationFrame`),
20
- * and coordinates data flow between the Input system, the Stage system, and the Plugins.
21
- */
22
- export class Supermouse {
23
- public static readonly version: string = __VERSION__;
24
- public readonly version: string = __VERSION__;
25
-
26
- state: MouseState;
27
-
28
- /**
29
- * Configuration options.
30
- */
31
- options: SupermouseOptions;
32
-
33
- private plugins: SupermousePlugin[] = [];
34
- private stage: Stage;
35
- private input: Input;
36
-
37
- private rafId: number = 0;
38
- private lastTime: number = 0;
39
- private isRunning: boolean = false;
40
-
41
- private hoverSelectors: Set<string>;
42
-
43
- /**
44
- * Creates a new Supermouse instance.
45
- *
46
- * @param options - Global configuration options.
47
- * @throws Will throw if running in a non-browser environment (window/document undefined).
48
- */
49
- constructor(options: SupermouseOptions = {}) {
50
- this.options = {
51
- smoothness: 0.15,
52
- enableTouch: false,
53
- autoDisableOnMobile: true,
54
- ignoreOnNative: "auto",
55
- hideCursor: true,
56
- hideOnLeave: true,
57
- autoStart: true,
58
- container: document.body,
59
- ...options
60
- };
61
-
62
- if (!this.options.container) {
63
- this.options.container = document.body;
64
- }
65
-
66
- this.state = {
67
- pointer: { x: -100, y: -100 },
68
- target: { x: -100, y: -100 },
69
- smooth: { x: -100, y: -100 },
70
- velocity: { x: 0, y: 0 },
71
- angle: 0,
72
- isDown: false,
73
- isHover: false,
74
- isNative: false,
75
- forcedCursor: null,
76
- hoverTarget: null,
77
- reducedMotion: false,
78
- hasReceivedInput: false,
79
- shape: null,
80
- interaction: {}
81
- };
82
-
83
- if (this.options.hoverSelectors) {
84
- this.hoverSelectors = new Set(this.options.hoverSelectors);
85
- } else {
86
- this.hoverSelectors = new Set(DEFAULT_HOVER_SELECTORS);
87
- }
88
-
89
- this.stage = new Stage(this.options.container, !!this.options.hideCursor);
90
- this.hoverSelectors.forEach((s) => this.stage.addSelector(s));
91
-
92
- this.input = new Input(
93
- this.state,
94
- this.options,
95
- () => Array.from(this.hoverSelectors).join(", "),
96
- (enabled) => {
97
- if (!enabled) this.resetPosition();
98
- }
99
- );
100
-
101
- if (this.options.plugins) {
102
- this.options.plugins.forEach((p) => this.use(p));
103
- }
104
-
105
- this.init();
106
- }
107
-
108
- /**
109
- * Retrieves a registered plugin instance by its unique name.
110
- */
111
- public getPlugin(name: string) {
112
- return this.plugins.find((p) => p.name === name);
113
- }
114
-
115
- /**
116
- * Returns whether the cursor system is currently enabled (processing input).
117
- */
118
- public get isEnabled(): boolean {
119
- return this.input.isEnabled;
120
- }
121
-
122
- /**
123
- * Enables a specific plugin by name.
124
- * Triggers the `onEnable` lifecycle hook of the plugin.
125
- */
126
- public enablePlugin(name: string) {
127
- const plugin = this.getPlugin(name);
128
- if (plugin && plugin.isEnabled === false) {
129
- plugin.isEnabled = true;
130
- plugin.onEnable?.(this);
131
- }
132
- }
133
-
134
- /**
135
- * Disables a specific plugin by name.
136
- * Triggers the `onDisable` lifecycle hook.
137
- */
138
- public disablePlugin(name: string) {
139
- const plugin = this.getPlugin(name);
140
- if (plugin && plugin.isEnabled !== false) {
141
- plugin.isEnabled = false;
142
- plugin.onDisable?.(this);
143
- }
144
- }
145
-
146
- /**
147
- * Toggles the enabled state of a plugin.
148
- */
149
- public togglePlugin(name: string) {
150
- const plugin = this.getPlugin(name);
151
- if (plugin) {
152
- if (plugin.isEnabled === false) this.enablePlugin(name);
153
- else this.disablePlugin(name);
154
- }
155
- }
156
-
157
- public registerHoverTarget(selector: string) {
158
- if (!this.hoverSelectors.has(selector)) {
159
- this.hoverSelectors.add(selector);
160
- this.stage.addSelector(selector);
161
- }
162
- }
163
-
164
- /**
165
- * The fixed container element where plugins should append their DOM nodes.
166
- */
167
- public get container(): HTMLDivElement {
168
- return this.stage.element;
169
- }
170
-
171
- /**
172
- * Manually override the native cursor visibility.
173
- *
174
- * @param type 'auto' (Show Native), 'none' (Hide Native), or null (Resume Auto-detection)
175
- */
176
- public setCursor(type: "auto" | "none" | null) {
177
- this.state.forcedCursor = type;
178
- }
179
-
180
- private init() {
181
- if (this.options.autoStart) {
182
- this.startLoop();
183
- }
184
- }
185
-
186
- public enable() {
187
- this.input.isEnabled = true;
188
- this.stage.setNativeCursor("none");
189
- }
190
- public disable() {
191
- this.input.isEnabled = false;
192
- this.stage.setNativeCursor("auto");
193
- this.resetPosition();
194
- }
195
-
196
- /**
197
- * Registers a new plugin.
198
- *
199
- * @param plugin - The plugin object to install.
200
- */
201
- public use(plugin: SupermousePlugin) {
202
- if (this.plugins.find((p) => p.name === plugin.name)) {
203
- console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
204
- return this;
205
- }
206
-
207
- if (plugin.isEnabled === undefined) {
208
- plugin.isEnabled = true;
209
- }
210
-
211
- this.plugins.push(plugin);
212
- this.plugins.sort((a, b) => (a.priority || 0) - (b.priority || 0));
213
-
214
- plugin.install?.(this);
215
- return this;
216
- }
217
-
218
- private resetPosition() {
219
- const off = { x: -100, y: -100 };
220
- this.state.pointer = { ...off };
221
- this.state.target = { ...off };
222
- this.state.smooth = { ...off };
223
- this.state.velocity = { x: 0, y: 0 };
224
- this.state.angle = 0;
225
- this.state.hasReceivedInput = false;
226
- this.state.shape = null;
227
- this.state.interaction = {};
228
- }
229
-
230
- private startLoop() {
231
- if (this.isRunning) return;
232
- this.isRunning = true;
233
- this.lastTime = performance.now();
234
- this.tick(this.lastTime);
235
- }
236
-
237
- /**
238
- * Manually steps the animation loop.
239
- *
240
- * @param time Current timestamp in milliseconds.
241
- */
242
- public step(time: number) {
243
- this.tick(time);
244
- }
245
-
246
- private runPluginSafe(plugin: SupermousePlugin, deltaTime: number) {
247
- if (plugin.isEnabled === false) return;
248
- try {
249
- plugin.update?.(this, deltaTime);
250
- } catch (e) {
251
- console.error(`[Supermouse] Plugin '${plugin.name}' crashed and has been disabled.`, e);
252
- plugin.isEnabled = false;
253
- try {
254
- plugin.onDisable?.(this);
255
- } catch (err) {}
256
- }
257
- }
258
-
259
- /**
260
- * Runs on every animation frame.
261
- */
262
- private tick = (time: number) => {
263
- const dtMs = time - this.lastTime;
264
- const dt = Math.min(dtMs / 1000, 0.1);
265
- this.lastTime = time;
266
-
267
- if (this.state.hoverTarget && !this.state.hoverTarget.isConnected) {
268
- this.input.clearHover();
269
- }
270
-
271
- const shouldShowStage =
272
- this.input.isEnabled && !this.state.isNative && this.state.hasReceivedInput;
273
- this.stage.setVisibility(shouldShowStage);
274
-
275
- if (this.input.isEnabled && this.options.hideCursor) {
276
- let targetState: "none" | "auto" = "auto";
277
-
278
- if (this.state.forcedCursor !== null) {
279
- targetState = this.state.forcedCursor;
280
- } else {
281
- const showNative = this.state.isNative || !this.state.hasReceivedInput;
282
- targetState = showNative ? "auto" : "none";
283
- }
284
-
285
- this.stage.setNativeCursor(targetState);
286
- }
287
-
288
- if (this.input.isEnabled) {
289
- this.state.target.x = this.state.pointer.x;
290
- this.state.target.y = this.state.pointer.y;
291
-
292
- for (let i = 0; i < this.plugins.length; i++) {
293
- this.runPluginSafe(this.plugins[i], dtMs);
294
- }
295
-
296
- const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness!) * 2;
297
-
298
- this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
299
- this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, factor, dt);
300
-
301
- const vx = this.state.target.x - this.state.smooth.x;
302
- const vy = this.state.target.y - this.state.smooth.y;
303
-
304
- this.state.velocity.x = vx;
305
- this.state.velocity.y = vy;
306
-
307
- if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
308
- this.state.angle = angle(vx, vy);
309
- }
310
- } else {
311
- this.state.smooth.x = -100;
312
- this.state.smooth.y = -100;
313
- this.state.pointer.x = -100;
314
- this.state.pointer.y = -100;
315
- this.state.velocity.x = 0;
316
- this.state.velocity.y = 0;
317
-
318
- for (let i = 0; i < this.plugins.length; i++) {
319
- this.runPluginSafe(this.plugins[i], dtMs);
320
- }
321
- }
322
-
323
- if (this.options.autoStart && this.isRunning) {
324
- this.rafId = requestAnimationFrame(this.tick);
325
- }
326
- };
327
-
328
- /**
329
- * Destroys the instance.
330
- */
331
- public destroy() {
332
- this.isRunning = false;
333
- cancelAnimationFrame(this.rafId);
334
- this.input.destroy();
335
- this.stage.destroy();
336
- this.plugins.forEach((p) => p.destroy?.(this));
337
- this.plugins = [];
338
- }
339
- }
1
+ declare const __VERSION__: string | undefined;
2
+ const VERSION: string = typeof __VERSION__ !== "undefined" ? __VERSION__ : "0.0.0";
3
+
4
+ import type { MouseState, SupermouseOptions, SupermousePlugin } from "./types";
5
+
6
+ function lerp(start: number, end: number, factor: number): number {
7
+ return start + (end - start) * factor;
8
+ }
9
+
10
+ function damp(a: number, b: number, lambda: number, dt: number): number {
11
+ return lerp(a, b, 1 - Math.exp(-lambda * dt));
12
+ }
13
+
14
+ /**
15
+ * Input.ts
16
+ *
17
+ * This class listens to browser events and mutates the shared `MouseState` object.
18
+ *
19
+ * @internal This is an internal system class instantiated by `Supermouse`.
20
+ */
21
+ export class Input {
22
+ private mediaQueryList?: MediaQueryList;
23
+ private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
24
+ private motionQuery?: MediaQueryList;
25
+ private dataPrefix: string;
26
+ private normalizedDataPrefix: string;
27
+ private ignoreAttribute: string;
28
+
29
+ /**
30
+ * Master switch for input processing.
31
+ * Toggled by `Supermouse.enable()`/`disable()` or automatically by device capability checks.
32
+ */
33
+ public isEnabled: boolean = true;
34
+
35
+ constructor(
36
+ private state: MouseState,
37
+ private options: SupermouseOptions,
38
+ private getHoverSelector: () => string,
39
+ private onEnableChange: (enabled: boolean) => void
40
+ ) {
41
+ this.dataPrefix = this.options.dataPrefix ?? "supermouse";
42
+ this.normalizedDataPrefix = this.dataPrefix.toLowerCase();
43
+ this.ignoreAttribute = `data-${this.dataPrefix}-ignore`;
44
+
45
+ this.checkDeviceCapability();
46
+ this.checkMotionPreference();
47
+ this.bindEvents();
48
+ }
49
+
50
+ private abortController = new AbortController();
51
+
52
+ /**
53
+ * Automatically disables the custom cursor on devices without fine pointer control.
54
+ */
55
+ private checkDeviceCapability(): void {
56
+ if (!this.options.autoDisableOnMobile) return;
57
+
58
+ this.mediaQueryList = window.matchMedia("(pointer: fine)");
59
+ this.updateEnabledState(this.mediaQueryList.matches);
60
+
61
+ this.mediaQueryHandler = (e: MediaQueryListEvent) => {
62
+ this.updateEnabledState(e.matches);
63
+ };
64
+ this.mediaQueryList.addEventListener("change", this.mediaQueryHandler, {
65
+ signal: this.abortController.signal
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Checks for `prefers-reduced-motion`.
71
+ * If true, the core physics engine will switch to instant snapping (high damping) to avoid motion sickness.
72
+ */
73
+ private checkMotionPreference(): void {
74
+ this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
75
+ this.state.reducedMotion = this.motionQuery.matches;
76
+
77
+ this.motionQuery.addEventListener(
78
+ "change",
79
+ (e) => {
80
+ this.state.reducedMotion = e.matches;
81
+ },
82
+ { signal: this.abortController.signal }
83
+ );
84
+ }
85
+
86
+ private updateEnabledState(enabled: boolean): void {
87
+ this.isEnabled = enabled;
88
+ this.onEnableChange(enabled);
89
+ }
90
+
91
+ private parseDOMInteraction(element: HTMLElement): void {
92
+ if (this.options.resolveInteraction) {
93
+ this.state.interaction = this.options.resolveInteraction(element) || {};
94
+ return;
95
+ }
96
+ const data: Record<string, string | boolean> = {};
97
+ if (this.options.rules) {
98
+ for (const [sel, rules] of Object.entries(this.options.rules)) {
99
+ if (element.matches(sel)) Object.assign(data, rules);
100
+ }
101
+ }
102
+ const pre = this.normalizedDataPrefix,
103
+ len = this.dataPrefix.length;
104
+ for (const key in element.dataset) {
105
+ if (!key.toLowerCase().startsWith(pre)) continue;
106
+ const prop = key.slice(len);
107
+ if (!prop) continue;
108
+ const val = element.dataset[key];
109
+ data[prop[0].toLowerCase() + prop.slice(1)] = val === "" ? true : val!;
110
+ }
111
+ this.state.interaction = data;
112
+ }
113
+
114
+ private handleMove(e: PointerEvent): void {
115
+ if (!this.isEnabled) return;
116
+
117
+ if (this.options.autoDisableOnMobile && e.pointerType === "touch" && !this.options.enableTouch)
118
+ return;
119
+
120
+ let x = e.clientX;
121
+ let y = e.clientY;
122
+
123
+ if (this.options.container && this.options.container !== document.body) {
124
+ const rect = this.options.container.getBoundingClientRect();
125
+ x -= rect.left;
126
+ y -= rect.top;
127
+ }
128
+
129
+ this.state.pointer.x = x;
130
+ this.state.pointer.y = y;
131
+
132
+ if (!this.state.hasReceivedInput) {
133
+ this.state.hasReceivedInput = true;
134
+ this.state.target.x = this.state.smooth.x = x;
135
+ this.state.target.y = this.state.smooth.y = y;
136
+ }
137
+ }
138
+
139
+ private handleDown(): void {
140
+ if (this.isEnabled) this.state.isDown = true;
141
+ }
142
+
143
+ private handleUp(): void {
144
+ if (this.isEnabled) this.state.isDown = false;
145
+ }
146
+
147
+ private handleMouseOver(e: MouseEvent): void {
148
+ if (!this.isEnabled) return;
149
+ const target = e.target as HTMLElement;
150
+
151
+ if (target.closest(`[${this.ignoreAttribute}]`)) {
152
+ this.state.isNative = true;
153
+ return;
154
+ }
155
+
156
+ const selector = this.getHoverSelector();
157
+ const hoverable = target.closest(selector);
158
+
159
+ if (hoverable) {
160
+ this.state.isHover = true;
161
+ this.state.hoverTarget = hoverable as HTMLElement;
162
+ this.parseDOMInteraction(this.state.hoverTarget);
163
+ }
164
+
165
+ const strategy = this.options.ignoreOnNative;
166
+
167
+ if (strategy && strategy !== null) {
168
+ const checkTags = strategy === "auto" || strategy === "tag";
169
+ const checkCSS = strategy === "auto" || strategy === "css";
170
+ let isNative = false;
171
+
172
+ if (checkTags) {
173
+ const tag = target.localName;
174
+ if (tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable) {
175
+ isNative = true;
176
+ }
177
+ }
178
+
179
+ if (!isNative && checkCSS) {
180
+ const style = window.getComputedStyle(target).cursor;
181
+ const supermouseAllowed = ["default", "auto", "pointer", "none", "inherit"];
182
+ if (!supermouseAllowed.includes(style)) {
183
+ isNative = true;
184
+ }
185
+ }
186
+
187
+ if (isNative) {
188
+ this.state.isNative = true;
189
+ }
190
+ }
191
+ }
192
+
193
+ private handleMouseOut(e: MouseEvent): void {
194
+ if (!this.isEnabled) return;
195
+ const target = e.target as HTMLElement;
196
+
197
+ if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
198
+ if (!e.relatedTarget || !this.state.hoverTarget?.contains(e.relatedTarget as Node)) {
199
+ this.state.isHover = false;
200
+ this.state.hoverTarget = null;
201
+ this.state.interaction = {};
202
+ }
203
+ }
204
+
205
+ if (this.state.isNative) {
206
+ this.state.isNative = false;
207
+ }
208
+ }
209
+
210
+ private handleWindowLeave(): void {
211
+ if (this.options.hideOnLeave) {
212
+ this.state.hasReceivedInput = false;
213
+ this.state.pointer = { ...OFFSCREEN };
214
+ }
215
+ }
216
+
217
+ public clearHover(): void {
218
+ this.state.isHover = false;
219
+ this.state.hoverTarget = null;
220
+ this.state.isNative = false;
221
+ this.state.interaction = {};
222
+ }
223
+
224
+ private bindEvents(): void {
225
+ const { signal } = this.abortController;
226
+ window.addEventListener("pointermove", this.handleMove.bind(this), { passive: true, signal });
227
+ window.addEventListener("pointerdown", this.handleDown.bind(this), { passive: true, signal });
228
+ window.addEventListener("pointerup", this.handleUp.bind(this), { signal });
229
+
230
+ document.addEventListener("mouseover", this.handleMouseOver.bind(this), { signal });
231
+ document.addEventListener("mouseout", this.handleMouseOut.bind(this), { signal });
232
+ document.addEventListener("mouseleave", this.handleWindowLeave.bind(this), { signal });
233
+ }
234
+
235
+ public destroy(): void {
236
+ this.abortController.abort();
237
+ }
238
+ }
239
+
240
+ let stageCount = 0;
241
+
242
+ /**
243
+ * Stage.ts
244
+ *
245
+ * This class manages the DOM container for the custom cursor and handles native cursor visibility.
246
+ * It is instantiated by the `Supermouse` class and is not intended for direct use by plugins.
247
+ *
248
+ * @internal
249
+ */
250
+ export class Stage {
251
+ /** The container element appended to the document. */
252
+ public readonly element: HTMLDivElement;
253
+ private styleTag: HTMLStyleElement;
254
+ private id: string;
255
+ private scopeClass: string;
256
+
257
+ private currentCursorState: "none" | "auto" | "" | null = null;
258
+ private originalContainerPosition: string = "";
259
+ private originalContainerCursor: string = "";
260
+ private selectors: Set<string> = new Set([
261
+ "a",
262
+ "button",
263
+ "input",
264
+ "textarea",
265
+ "select",
266
+ '[role="button"]',
267
+ "[tabindex]"
268
+ ]);
269
+
270
+ constructor(
271
+ private container: HTMLElement = document.body,
272
+ private hideNativeCursor: boolean
273
+ ) {
274
+ if (!container || !(container instanceof HTMLElement)) {
275
+ throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
276
+ }
277
+
278
+ const instanceId = stageCount++;
279
+ this.id = `supermouse-style-${instanceId}`;
280
+ this.scopeClass = `supermouse-scope-${instanceId}`;
281
+
282
+ const isBody = container === document.body;
283
+
284
+ this.element = document.createElement("div");
285
+ Object.assign(this.element.style, {
286
+ position: isBody ? "fixed" : "absolute",
287
+ inset: "0px",
288
+ pointerEvents: "none",
289
+ zIndex: "9999",
290
+ opacity: "1",
291
+ transition: "opacity 0.15s ease"
292
+ });
293
+
294
+ if (!isBody) {
295
+ const computed = window.getComputedStyle(container);
296
+ this.originalContainerPosition = computed.position;
297
+ if (computed.position === "static") {
298
+ container.style.position = "relative";
299
+ }
300
+ }
301
+
302
+ this.originalContainerCursor = container.style.cursor;
303
+
304
+ container.appendChild(this.element);
305
+
306
+ this.styleTag = document.createElement("style");
307
+ this.styleTag.id = this.id;
308
+ document.head.appendChild(this.styleTag);
309
+
310
+ this.container.classList.add(this.scopeClass);
311
+
312
+ if (this.hideNativeCursor) {
313
+ this.setNativeCursor("none");
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Adds a new CSS selector to the `selectors` set.
319
+ * Called by `Supermouse` and subsequently plugins during install to ensure
320
+ * the native cursor is hidden on their specific interactive targets.
321
+ */
322
+ public addSelector(selector: string): void {
323
+ this.selectors.add(selector);
324
+ if (this.hideNativeCursor) {
325
+ this.updateCursorCSS();
326
+ }
327
+ }
328
+
329
+ public setVisibility(visible: boolean): void {
330
+ this.element.style.opacity = visible ? "1" : "0";
331
+ }
332
+
333
+ /**
334
+ * Toggles the visibility of the native cursor via CSS injection.
335
+ * @param type 'none' to hide, 'auto' to show.
336
+ */
337
+ public setNativeCursor(type: "none" | "auto"): void {
338
+ if (!this.hideNativeCursor && type === "none") return;
339
+
340
+ if (type === this.currentCursorState) return;
341
+ this.currentCursorState = type;
342
+
343
+ if (type === "none") {
344
+ this.container.style.cursor = "none";
345
+ this.updateCursorCSS();
346
+ } else {
347
+ this.container.style.cursor = "";
348
+ this.styleTag.innerText = "";
349
+ }
350
+ }
351
+
352
+ private updateCursorCSS(): void {
353
+ const rawSelectors = Array.from(this.selectors);
354
+ if (rawSelectors.length === 0) {
355
+ this.styleTag.innerText = "";
356
+ return;
357
+ }
358
+
359
+ const scopedSelectors = rawSelectors.map((s) => `.${this.scopeClass} ${s}`).join(", ");
360
+
361
+ this.styleTag.innerText = `
362
+ ${scopedSelectors} {
363
+ cursor: none !important;
364
+ }
365
+ `;
366
+ }
367
+
368
+ public destroy(): void {
369
+ this.element.remove();
370
+ this.styleTag.remove();
371
+
372
+ this.container.style.cursor = this.originalContainerCursor;
373
+ this.container.classList.remove(this.scopeClass);
374
+
375
+ if (this.container !== document.body && this.originalContainerPosition === "static") {
376
+ this.container.style.position = "";
377
+ }
378
+ }
379
+ }
380
+
381
+ const OFFSCREEN = { x: -100, y: -100 } as const;
382
+ export const DEFAULT_HOVER_SELECTORS = [
383
+ "a",
384
+ "button",
385
+ "input",
386
+ "textarea",
387
+ "[data-hover]",
388
+ "[data-cursor]"
389
+ ];
390
+
391
+ /**
392
+ * Supermouse Runtime Loop
393
+ *
394
+ * This class orchestrates the application state, manages the animation loop,
395
+ * and coordinates data flow between the internal systems, and the plugins.
396
+ *
397
+ * @default
398
+ */
399
+ export class Supermouse {
400
+ public static readonly version: string = VERSION;
401
+ public readonly version: string = VERSION;
402
+
403
+ state: MouseState;
404
+
405
+ /**
406
+ * Configuration options.
407
+ */
408
+ options: SupermouseOptions;
409
+
410
+ private plugins: SupermousePlugin[] = [];
411
+ private stage: Stage;
412
+ private input: Input;
413
+
414
+ private rafId: number = 0;
415
+ private lastTime: number = 0;
416
+ private isRunning: boolean = false;
417
+
418
+ private hoverSelectors: Set<string>;
419
+
420
+ /**
421
+ * Creates a new Supermouse instance.
422
+ *
423
+ * @param options - Global configuration options.
424
+ * @throws Will throw if running in a non-browser environment (window/document undefined).
425
+ */
426
+ constructor(options: SupermouseOptions = {}) {
427
+ this.options = {
428
+ smoothness: 0.15,
429
+ enableTouch: false,
430
+ autoDisableOnMobile: true,
431
+ ignoreOnNative: "auto",
432
+ hideCursor: true,
433
+ hideOnLeave: true,
434
+ autoStart: true,
435
+ container: document.body,
436
+ dataPrefix: "supermouse",
437
+ ...options
438
+ };
439
+
440
+ this.state = {
441
+ pointer: { x: -100, y: -100 },
442
+ target: { x: -100, y: -100 },
443
+ smooth: { x: -100, y: -100 },
444
+ velocity: { x: 0, y: 0 },
445
+ angle: 0,
446
+ isDown: false,
447
+ isHover: false,
448
+ isNative: false,
449
+ forcedCursor: null,
450
+ hoverTarget: null,
451
+ reducedMotion: false,
452
+ hasReceivedInput: false,
453
+ shape: null,
454
+ interaction: {}
455
+ };
456
+
457
+ if (this.options.hoverSelectors) {
458
+ this.hoverSelectors = new Set(this.options.hoverSelectors);
459
+ } else {
460
+ this.hoverSelectors = new Set(DEFAULT_HOVER_SELECTORS);
461
+ }
462
+
463
+ this.stage = new Stage(this.options.container, !!this.options.hideCursor);
464
+ this.hoverSelectors.forEach((s) => this.stage.addSelector(s));
465
+
466
+ this.input = new Input(
467
+ this.state,
468
+ this.options,
469
+ () => Array.from(this.hoverSelectors).join(", "),
470
+ (enabled) => {
471
+ if (!enabled) this.reset(true);
472
+ }
473
+ );
474
+
475
+ if (this.options.plugins) {
476
+ this.options.plugins.forEach((p) => this.use(p));
477
+ }
478
+
479
+ this.init();
480
+ }
481
+
482
+ /**
483
+ * Retrieves a registered plugin instance by its unique name.
484
+ */
485
+ public getPlugin(name: string): SupermousePlugin | undefined {
486
+ return this.plugins.find((p) => p.name === name);
487
+ }
488
+
489
+ /**
490
+ * Returns whether the cursor system is currently enabled (processing input).
491
+ */
492
+ public get isEnabled(): boolean {
493
+ return this.input.isEnabled;
494
+ }
495
+
496
+ /**
497
+ * Enables a specific plugin by name.
498
+ * Triggers the `onEnable` lifecycle hook of the plugin.
499
+ */
500
+ public enablePlugin(name: string): void {
501
+ const plugin = this.getPlugin(name);
502
+ if (plugin && plugin.isEnabled === false) {
503
+ plugin.isEnabled = true;
504
+ plugin.onEnable?.(this);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Disables a specific plugin by name.
510
+ * Triggers the `onDisable` lifecycle hook.
511
+ */
512
+ public disablePlugin(name: string): void {
513
+ const plugin = this.getPlugin(name);
514
+ if (plugin && plugin.isEnabled !== false) {
515
+ plugin.isEnabled = false;
516
+ plugin.onDisable?.(this);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Toggles the enabled state of a plugin.
522
+ */
523
+ public togglePlugin(name: string): void {
524
+ const plugin = this.getPlugin(name);
525
+ if (plugin) {
526
+ if (plugin.isEnabled === false) this.enablePlugin(name);
527
+ else this.disablePlugin(name);
528
+ }
529
+ }
530
+
531
+ public registerHoverTarget(selector: string): void {
532
+ if (!this.hoverSelectors.has(selector)) {
533
+ this.hoverSelectors.add(selector);
534
+ this.stage.addSelector(selector);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * The fixed container element where plugins should append their DOM nodes.
540
+ */
541
+ public get container(): HTMLDivElement {
542
+ return this.stage.element;
543
+ }
544
+
545
+ /**
546
+ * Sets the native cursor visibility.
547
+ *
548
+ * @param mode
549
+ */
550
+ public setNativeCursor(mode: "hide" | "show" | "auto"): void {
551
+ this.state.forcedCursor = mode === "auto" ? null : mode === "hide" ? "none" : "auto";
552
+ }
553
+
554
+ private init(): void {
555
+ if (this.options.autoStart) {
556
+ this.startLoop();
557
+ }
558
+ }
559
+
560
+ public enable(): void {
561
+ this.input.isEnabled = true;
562
+ this.stage.setNativeCursor("none");
563
+ }
564
+ public disable(): void {
565
+ this.input.isEnabled = false;
566
+ this.stage.setNativeCursor("auto");
567
+ this.reset(true);
568
+ }
569
+
570
+ /**
571
+ * Registers a new plugin.
572
+ *
573
+ * @param plugin - The plugin object to install.
574
+ */
575
+ public use(plugin: SupermousePlugin): this {
576
+ const exists = this.plugins.some((p) => p.name === plugin.name);
577
+
578
+ if (exists) {
579
+ console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
580
+ return this;
581
+ }
582
+
583
+ if (plugin.isEnabled === undefined) {
584
+ plugin.isEnabled = true;
585
+ }
586
+
587
+ try {
588
+ plugin.install?.(this);
589
+ } catch (e) {
590
+ console.error(`[Supermouse] Failed to install plugin '${plugin.name}'.`, e);
591
+ return this;
592
+ }
593
+
594
+ this.plugins.push(plugin);
595
+ this.plugins.sort((a, b) => (a.priority || 0) - (b.priority || 0));
596
+
597
+ return this;
598
+ }
599
+
600
+ private reset(hard = false): void {
601
+ this.state.pointer = { ...OFFSCREEN };
602
+ this.state.target = { ...OFFSCREEN };
603
+ this.state.smooth = { ...OFFSCREEN };
604
+ this.state.velocity = { x: 0, y: 0 };
605
+ this.state.angle = 0;
606
+ if (hard) {
607
+ this.state.hasReceivedInput = false;
608
+ this.state.shape = null;
609
+ this.state.interaction = {};
610
+ }
611
+ }
612
+
613
+ private startLoop(): void {
614
+ if (this.isRunning) return;
615
+ this.isRunning = true;
616
+
617
+ this.lastTime = performance.now();
618
+ this.tick(this.lastTime);
619
+ }
620
+
621
+ /**
622
+ * Starts the animation loop. This is automatically called if `autoStart` is true.
623
+ * Plugins can call this method to resume the loop if it has been stopped.
624
+ */
625
+ public start(): void {
626
+ this.startLoop();
627
+ }
628
+
629
+ /**
630
+ * Manually steps the animation loop.
631
+ *
632
+ * @param time Current timestamp in milliseconds.
633
+ */
634
+ public step(time: number): void {
635
+ this.tick(time);
636
+ }
637
+
638
+ private runPluginSafe(plugin: SupermousePlugin, deltaTime: number): void {
639
+ if (plugin.isEnabled === false) return;
640
+ try {
641
+ plugin.update?.(this, deltaTime);
642
+ } catch (e) {
643
+ console.error(`[Supermouse] Plugin '${plugin.name}' crashed and has been disabled.`, e);
644
+
645
+ // Remove from active array immediately so it doesn't iterate again
646
+ const index = this.plugins.indexOf(plugin);
647
+ if (index > -1) {
648
+ this.plugins.splice(index, 1);
649
+ }
650
+
651
+ plugin.isEnabled = false;
652
+
653
+ // Attempt cleanup
654
+ try {
655
+ plugin.destroy?.(this);
656
+ plugin.onDisable?.(this);
657
+ } catch (err) {
658
+ console.error(`[Supermouse] Failed to cleanup crashed plugin '${plugin.name}'.`, err);
659
+ }
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Runs on every animation frame.
665
+ */
666
+ private tick = (time: number): void => {
667
+ const dtMs = time - this.lastTime;
668
+ const dt = Math.min(dtMs / 1000, 0.1);
669
+ this.lastTime = time;
670
+
671
+ if (this.state.hoverTarget && !this.state.hoverTarget.isConnected) {
672
+ this.input.clearHover();
673
+ }
674
+
675
+ const shouldShowStage =
676
+ this.input.isEnabled && !this.state.isNative && this.state.hasReceivedInput;
677
+ this.stage.setVisibility(shouldShowStage);
678
+
679
+ if (this.input.isEnabled && this.options.hideCursor) {
680
+ let targetState: "none" | "auto" = "auto";
681
+ if (this.state.forcedCursor !== null) {
682
+ targetState = this.state.forcedCursor;
683
+ } else {
684
+ const showNative = this.state.isNative || !this.state.hasReceivedInput;
685
+ targetState = showNative ? "auto" : "none";
686
+ }
687
+ this.stage.setNativeCursor(targetState);
688
+ }
689
+
690
+ if (this.input.isEnabled && this.state.hasReceivedInput) {
691
+ this.state.target.x = this.state.pointer.x;
692
+ this.state.target.y = this.state.pointer.y;
693
+ } else {
694
+ this.state.target = { ...OFFSCREEN };
695
+ }
696
+
697
+ for (let i = 0; i < this.plugins.length; i++) {
698
+ this.runPluginSafe(this.plugins[i], dtMs);
699
+ }
700
+
701
+ if (this.input.isEnabled) {
702
+ const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness!) * 2;
703
+
704
+ this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
705
+ this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, factor, dt);
706
+
707
+ this.state.velocity.x = this.state.target.x - this.state.smooth.x;
708
+ this.state.velocity.y = this.state.target.y - this.state.smooth.y;
709
+ const { x: vx, y: vy } = this.state.velocity;
710
+ if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
711
+ this.state.angle = Math.atan2(vy, vx) * (180 / Math.PI);
712
+ }
713
+ }
714
+
715
+ if (this.isRunning) {
716
+ this.rafId = requestAnimationFrame(this.tick);
717
+ }
718
+ };
719
+
720
+ /**
721
+ * Destroys the instance.
722
+ */
723
+ public destroy(): void {
724
+ this.isRunning = false;
725
+ cancelAnimationFrame(this.rafId);
726
+ this.input.destroy();
727
+ this.stage.destroy();
728
+ this.plugins.forEach((p) => p.destroy?.(this));
729
+ this.plugins = [];
730
+ }
731
+ }