@supermousejs/core 2.1.0 → 2.2.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,9 +1,381 @@
1
1
  declare const __VERSION__: string;
2
2
 
3
3
  import type { MouseState, SupermouseOptions, SupermousePlugin } from "./types";
4
- import { Stage, Input } from "./systems";
5
- import { damp, angle } from "./utils/math";
6
4
 
5
+ function lerp(start: number, end: number, factor: number): number {
6
+ return start + (end - start) * factor;
7
+ }
8
+
9
+ function damp(a: number, b: number, lambda: number, dt: number): number {
10
+ return lerp(a, b, 1 - Math.exp(-lambda * dt));
11
+ }
12
+
13
+ function angle(x: number, y: number): number {
14
+ return Math.atan2(y, x) * (180 / Math.PI);
15
+ }
16
+
17
+ /**
18
+ * Input.ts
19
+ *
20
+ * This class listens to browser events and mutates the shared `MouseState` object.
21
+ *
22
+ * @internal This is an internal system class instantiated by `Supermouse`.
23
+ */
24
+ export class Input {
25
+ private mediaQueryList?: MediaQueryList;
26
+ private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
27
+ private motionQuery?: MediaQueryList;
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.checkDeviceCapability();
42
+ this.checkMotionPreference();
43
+ this.bindEvents();
44
+ }
45
+
46
+ private abortController = new AbortController();
47
+
48
+ /**
49
+ * Automatically disables the custom cursor on devices without fine pointer control.
50
+ */
51
+ private checkDeviceCapability(): void {
52
+ if (!this.options.autoDisableOnMobile) return;
53
+
54
+ this.mediaQueryList = window.matchMedia("(pointer: fine)");
55
+ this.updateEnabledState(this.mediaQueryList.matches);
56
+
57
+ this.mediaQueryHandler = (e: MediaQueryListEvent) => {
58
+ this.updateEnabledState(e.matches);
59
+ };
60
+ this.mediaQueryList.addEventListener("change", this.mediaQueryHandler, {
61
+ signal: this.abortController.signal
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Checks for `prefers-reduced-motion`.
67
+ * If true, the core physics engine will switch to instant snapping (high damping) to avoid motion sickness.
68
+ */
69
+ private checkMotionPreference(): void {
70
+ this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
71
+ this.state.reducedMotion = this.motionQuery.matches;
72
+
73
+ this.motionQuery.addEventListener(
74
+ "change",
75
+ (e) => {
76
+ this.state.reducedMotion = e.matches;
77
+ },
78
+ { signal: this.abortController.signal }
79
+ );
80
+ }
81
+
82
+ private updateEnabledState(enabled: boolean): void {
83
+ this.isEnabled = enabled;
84
+ this.onEnableChange(enabled);
85
+ }
86
+
87
+ private parseDOMInteraction(element: HTMLElement): void {
88
+ if (this.options.resolveInteraction) {
89
+ this.state.interaction = this.options.resolveInteraction(element);
90
+ return;
91
+ }
92
+
93
+ const data: Record<string, string | boolean> = {};
94
+
95
+ if (this.options.rules) {
96
+ for (const [selector, rules] of Object.entries(this.options.rules)) {
97
+ if (element.matches(selector)) {
98
+ Object.assign(data, rules);
99
+ }
100
+ }
101
+ }
102
+
103
+ const dataset = element.dataset;
104
+ for (const key in dataset) {
105
+ if (key.startsWith("supermouse")) {
106
+ const prop = key.slice(10);
107
+ if (prop) {
108
+ const cleanKey = prop.charAt(0).toLowerCase() + prop.slice(1);
109
+ const val = dataset[key];
110
+ if (val !== undefined) {
111
+ data[cleanKey] = val === "" ? true : val;
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ this.state.interaction = data;
118
+ }
119
+
120
+ private handleMove(e: PointerEvent): void {
121
+ if (!this.isEnabled) return;
122
+
123
+ if (this.options.autoDisableOnMobile && e.pointerType === "touch") return;
124
+
125
+ let x = e.clientX;
126
+ let y = e.clientY;
127
+
128
+ if (this.options.container && this.options.container !== document.body) {
129
+ const rect = this.options.container.getBoundingClientRect();
130
+ x -= rect.left;
131
+ y -= rect.top;
132
+ }
133
+
134
+ this.state.pointer.x = x;
135
+ this.state.pointer.y = y;
136
+
137
+ if (!this.state.hasReceivedInput) {
138
+ this.state.hasReceivedInput = true;
139
+ this.state.target.x = this.state.smooth.x = x;
140
+ this.state.target.y = this.state.smooth.y = y;
141
+ }
142
+ }
143
+
144
+ private handleDown(): void {
145
+ if (this.isEnabled) this.state.isDown = true;
146
+ }
147
+
148
+ private handleUp(): void {
149
+ if (this.isEnabled) this.state.isDown = false;
150
+ }
151
+
152
+ private handleMouseOver(e: MouseEvent): void {
153
+ if (!this.isEnabled) return;
154
+ const target = e.target as HTMLElement;
155
+
156
+ if (target.closest("[data-supermouse-ignore]")) {
157
+ this.state.isNative = true;
158
+ return;
159
+ }
160
+
161
+ const selector = this.getHoverSelector();
162
+ const hoverable = target.closest(selector);
163
+
164
+ if (hoverable) {
165
+ this.state.isHover = true;
166
+ this.state.hoverTarget = hoverable as HTMLElement;
167
+ this.parseDOMInteraction(this.state.hoverTarget);
168
+ }
169
+
170
+ const strategy = this.options.ignoreOnNative;
171
+
172
+ if (strategy) {
173
+ const checkTags = strategy === true || strategy === "auto" || strategy === "tag";
174
+ const checkCSS = strategy === true || strategy === "auto" || strategy === "css";
175
+ let isNative = false;
176
+
177
+ if (checkTags) {
178
+ const tag = target.localName;
179
+ if (tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable) {
180
+ isNative = true;
181
+ }
182
+ }
183
+
184
+ if (!isNative && checkCSS) {
185
+ const style = window.getComputedStyle(target).cursor;
186
+ const supermouseAllowed = ["default", "auto", "pointer", "none", "inherit"];
187
+ if (!supermouseAllowed.includes(style)) {
188
+ isNative = true;
189
+ }
190
+ }
191
+
192
+ if (isNative) {
193
+ this.state.isNative = true;
194
+ }
195
+ }
196
+ }
197
+
198
+ private handleMouseOut(e: MouseEvent): void {
199
+ if (!this.isEnabled) return;
200
+ const target = e.target as HTMLElement;
201
+
202
+ if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
203
+ if (!e.relatedTarget || !this.state.hoverTarget?.contains(e.relatedTarget as Node)) {
204
+ this.state.isHover = false;
205
+ this.state.hoverTarget = null;
206
+ this.state.interaction = {};
207
+ }
208
+ }
209
+
210
+ if (this.state.isNative) {
211
+ this.state.isNative = false;
212
+ }
213
+ }
214
+
215
+ private handleWindowLeave(): void {
216
+ if (this.options.hideOnLeave) {
217
+ this.state.hasReceivedInput = false;
218
+ }
219
+ }
220
+
221
+ public clearHover(): void {
222
+ this.state.isHover = false;
223
+ this.state.hoverTarget = null;
224
+ this.state.isNative = false;
225
+ }
226
+
227
+ private bindEvents(): void {
228
+ const { signal } = this.abortController;
229
+ window.addEventListener("pointermove", this.handleMove.bind(this), { passive: true, signal });
230
+ window.addEventListener("pointerdown", this.handleDown.bind(this), { passive: true, signal });
231
+ window.addEventListener("pointerup", this.handleUp.bind(this), { signal });
232
+
233
+ document.addEventListener("mouseover", this.handleMouseOver.bind(this), { signal });
234
+ document.addEventListener("mouseout", this.handleMouseOut.bind(this), { signal });
235
+ document.addEventListener("mouseleave", this.handleWindowLeave.bind(this), { signal });
236
+ }
237
+
238
+ public destroy(): void {
239
+ this.abortController.abort();
240
+ }
241
+ }
242
+
243
+ let stageCount = 0;
244
+
245
+ /**
246
+ * Stage.ts
247
+ *
248
+ * This class manages the DOM container for the custom cursor and handles native cursor visibility.
249
+ * It is instantiated by the `Supermouse` class and is not intended for direct use by plugins.
250
+ *
251
+ * @internal
252
+ */
253
+ export class Stage {
254
+ /** The container element appended to the document. */
255
+ public readonly element: HTMLDivElement;
256
+ private styleTag: HTMLStyleElement;
257
+ private id: string;
258
+ private scopeClass: string;
259
+
260
+ private currentCursorState: "none" | "auto" | "" | null = null;
261
+
262
+ private selectors: Set<string> = new Set([
263
+ "a",
264
+ "button",
265
+ "input",
266
+ "textarea",
267
+ "select",
268
+ '[role="button"]',
269
+ "[tabindex]"
270
+ ]);
271
+
272
+ constructor(
273
+ private container: HTMLElement = document.body,
274
+ private hideNativeCursor: boolean
275
+ ) {
276
+ if (!container || !(container instanceof HTMLElement)) {
277
+ throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
278
+ }
279
+
280
+ const instanceId = stageCount++;
281
+ this.id = `supermouse-style-${instanceId}`;
282
+ this.scopeClass = `supermouse-scope-${instanceId}`;
283
+
284
+ const isBody = container === document.body;
285
+
286
+ this.element = document.createElement("div");
287
+ Object.assign(this.element.style, {
288
+ position: isBody ? "fixed" : "absolute",
289
+ top: "0",
290
+ left: "0",
291
+ width: "100%",
292
+ height: "100%",
293
+ pointerEvents: "none",
294
+ zIndex: "9999",
295
+ opacity: "1",
296
+ transition: "opacity 0.15s ease"
297
+ });
298
+
299
+ if (!isBody) {
300
+ const computed = window.getComputedStyle(container);
301
+ if (computed.position === "static") {
302
+ container.style.position = "relative";
303
+ }
304
+ }
305
+
306
+ container.appendChild(this.element);
307
+
308
+ this.styleTag = document.createElement("style");
309
+ this.styleTag.id = this.id;
310
+ document.head.appendChild(this.styleTag);
311
+
312
+ this.container.classList.add(this.scopeClass);
313
+
314
+ if (this.hideNativeCursor) {
315
+ this.setNativeCursor("none");
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Adds a new CSS selector to the `selectors` set.
321
+ * Called by `Supermouse` and subsequently plugins during install to ensure
322
+ * the native cursor is hidden on their specific interactive targets.
323
+ */
324
+ public addSelector(selector: string): void {
325
+ this.selectors.add(selector);
326
+ if (this.hideNativeCursor) {
327
+ this.updateCursorCSS();
328
+ }
329
+ }
330
+
331
+ public setVisibility(visible: boolean): void {
332
+ this.element.style.opacity = visible ? "1" : "0";
333
+ }
334
+
335
+ /**
336
+ * Toggles the visibility of the native cursor via CSS injection.
337
+ * @param type 'none' to hide, 'auto' to show.
338
+ */
339
+ public setNativeCursor(type: "none" | "auto" | ""): void {
340
+ if (!this.hideNativeCursor && type === "none") return;
341
+
342
+ if (type === this.currentCursorState) return;
343
+ this.currentCursorState = type;
344
+
345
+ if (type === "none") {
346
+ this.container.style.cursor = "none";
347
+ this.updateCursorCSS();
348
+ } else {
349
+ this.container.style.cursor = "";
350
+ this.styleTag.innerText = "";
351
+ }
352
+ }
353
+
354
+ private updateCursorCSS(): void {
355
+ const rawSelectors = Array.from(this.selectors);
356
+ if (rawSelectors.length === 0) {
357
+ this.styleTag.innerText = "";
358
+ return;
359
+ }
360
+
361
+ const scopedSelectors = rawSelectors.map((s) => `.${this.scopeClass} ${s}`).join(", ");
362
+
363
+ this.styleTag.innerText = `
364
+ ${scopedSelectors} {
365
+ cursor: none !important;
366
+ }
367
+ `;
368
+ }
369
+
370
+ public destroy(): void {
371
+ this.element.remove();
372
+ this.styleTag.remove();
373
+ this.container.style.cursor = "";
374
+ this.container.classList.remove(this.scopeClass);
375
+ }
376
+ }
377
+
378
+ const OFFSCREEN = { x: -100, y: -100 } as const;
7
379
  export const DEFAULT_HOVER_SELECTORS = [
8
380
  "a",
9
381
  "button",
@@ -14,10 +386,12 @@ export const DEFAULT_HOVER_SELECTORS = [
14
386
  ];
15
387
 
16
388
  /**
17
- * Runtime Loop of Supermouse.
389
+ * Supermouse Runtime Loop
18
390
  *
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.
391
+ * This class orchestrates the application state, manages the animation loop,
392
+ * and coordinates data flow between the internal systems, and the plugins.
393
+ *
394
+ * @default
21
395
  */
22
396
  export class Supermouse {
23
397
  public static readonly version: string = __VERSION__;
@@ -108,7 +482,7 @@ export class Supermouse {
108
482
  /**
109
483
  * Retrieves a registered plugin instance by its unique name.
110
484
  */
111
- public getPlugin(name: string) {
485
+ public getPlugin(name: string): SupermousePlugin | undefined {
112
486
  return this.plugins.find((p) => p.name === name);
113
487
  }
114
488
 
@@ -123,7 +497,7 @@ export class Supermouse {
123
497
  * Enables a specific plugin by name.
124
498
  * Triggers the `onEnable` lifecycle hook of the plugin.
125
499
  */
126
- public enablePlugin(name: string) {
500
+ public enablePlugin(name: string): void {
127
501
  const plugin = this.getPlugin(name);
128
502
  if (plugin && plugin.isEnabled === false) {
129
503
  plugin.isEnabled = true;
@@ -135,7 +509,7 @@ export class Supermouse {
135
509
  * Disables a specific plugin by name.
136
510
  * Triggers the `onDisable` lifecycle hook.
137
511
  */
138
- public disablePlugin(name: string) {
512
+ public disablePlugin(name: string): void {
139
513
  const plugin = this.getPlugin(name);
140
514
  if (plugin && plugin.isEnabled !== false) {
141
515
  plugin.isEnabled = false;
@@ -146,7 +520,7 @@ export class Supermouse {
146
520
  /**
147
521
  * Toggles the enabled state of a plugin.
148
522
  */
149
- public togglePlugin(name: string) {
523
+ public togglePlugin(name: string): void {
150
524
  const plugin = this.getPlugin(name);
151
525
  if (plugin) {
152
526
  if (plugin.isEnabled === false) this.enablePlugin(name);
@@ -154,7 +528,7 @@ export class Supermouse {
154
528
  }
155
529
  }
156
530
 
157
- public registerHoverTarget(selector: string) {
531
+ public registerHoverTarget(selector: string): void {
158
532
  if (!this.hoverSelectors.has(selector)) {
159
533
  this.hoverSelectors.add(selector);
160
534
  this.stage.addSelector(selector);
@@ -173,21 +547,21 @@ export class Supermouse {
173
547
  *
174
548
  * @param type 'auto' (Show Native), 'none' (Hide Native), or null (Resume Auto-detection)
175
549
  */
176
- public setCursor(type: "auto" | "none" | null) {
550
+ public setCursor(type: "auto" | "none" | null): void {
177
551
  this.state.forcedCursor = type;
178
552
  }
179
553
 
180
- private init() {
554
+ private init(): void {
181
555
  if (this.options.autoStart) {
182
556
  this.startLoop();
183
557
  }
184
558
  }
185
559
 
186
- public enable() {
560
+ public enable(): void {
187
561
  this.input.isEnabled = true;
188
562
  this.stage.setNativeCursor("none");
189
563
  }
190
- public disable() {
564
+ public disable(): void {
191
565
  this.input.isEnabled = false;
192
566
  this.stage.setNativeCursor("auto");
193
567
  this.resetPosition();
@@ -198,7 +572,7 @@ export class Supermouse {
198
572
  *
199
573
  * @param plugin - The plugin object to install.
200
574
  */
201
- public use(plugin: SupermousePlugin) {
575
+ public use(plugin: SupermousePlugin): this {
202
576
  if (this.plugins.find((p) => p.name === plugin.name)) {
203
577
  console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
204
578
  return this;
@@ -215,19 +589,22 @@ export class Supermouse {
215
589
  return this;
216
590
  }
217
591
 
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 };
592
+ private resetCoords(): void {
593
+ this.state.pointer = { ...OFFSCREEN };
594
+ this.state.target = { ...OFFSCREEN };
595
+ this.state.smooth = { ...OFFSCREEN };
223
596
  this.state.velocity = { x: 0, y: 0 };
224
597
  this.state.angle = 0;
598
+ }
599
+
600
+ private resetPosition(): void {
601
+ this.resetCoords();
225
602
  this.state.hasReceivedInput = false;
226
603
  this.state.shape = null;
227
604
  this.state.interaction = {};
228
605
  }
229
606
 
230
- private startLoop() {
607
+ private startLoop(): void {
231
608
  if (this.isRunning) return;
232
609
  this.isRunning = true;
233
610
  this.lastTime = performance.now();
@@ -239,11 +616,11 @@ export class Supermouse {
239
616
  *
240
617
  * @param time Current timestamp in milliseconds.
241
618
  */
242
- public step(time: number) {
619
+ public step(time: number): void {
243
620
  this.tick(time);
244
621
  }
245
622
 
246
- private runPluginSafe(plugin: SupermousePlugin, deltaTime: number) {
623
+ private runPluginSafe(plugin: SupermousePlugin, deltaTime: number): void {
247
624
  if (plugin.isEnabled === false) return;
248
625
  try {
249
626
  plugin.update?.(this, deltaTime);
@@ -252,14 +629,16 @@ export class Supermouse {
252
629
  plugin.isEnabled = false;
253
630
  try {
254
631
  plugin.onDisable?.(this);
255
- } catch (err) {}
632
+ } catch (err) {
633
+ console.error(`[Supermouse] Failed to execute onDisable for plugin '${plugin.name}'.`, err);
634
+ }
256
635
  }
257
636
  }
258
637
 
259
638
  /**
260
639
  * Runs on every animation frame.
261
640
  */
262
- private tick = (time: number) => {
641
+ private tick = (time: number): void => {
263
642
  const dtMs = time - this.lastTime;
264
643
  const dt = Math.min(dtMs / 1000, 0.1);
265
644
  this.lastTime = time;
@@ -274,25 +653,27 @@ export class Supermouse {
274
653
 
275
654
  if (this.input.isEnabled && this.options.hideCursor) {
276
655
  let targetState: "none" | "auto" = "auto";
277
-
278
656
  if (this.state.forcedCursor !== null) {
279
657
  targetState = this.state.forcedCursor;
280
658
  } else {
281
659
  const showNative = this.state.isNative || !this.state.hasReceivedInput;
282
660
  targetState = showNative ? "auto" : "none";
283
661
  }
284
-
285
662
  this.stage.setNativeCursor(targetState);
286
663
  }
287
664
 
288
665
  if (this.input.isEnabled) {
289
666
  this.state.target.x = this.state.pointer.x;
290
667
  this.state.target.y = this.state.pointer.y;
668
+ } else {
669
+ this.resetCoords();
670
+ }
291
671
 
292
- for (let i = 0; i < this.plugins.length; i++) {
293
- this.runPluginSafe(this.plugins[i], dtMs);
294
- }
672
+ for (let i = 0; i < this.plugins.length; i++) {
673
+ this.runPluginSafe(this.plugins[i], dtMs);
674
+ }
295
675
 
676
+ if (this.input.isEnabled) {
296
677
  const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness!) * 2;
297
678
 
298
679
  this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
@@ -300,24 +681,12 @@ export class Supermouse {
300
681
 
301
682
  const vx = this.state.target.x - this.state.smooth.x;
302
683
  const vy = this.state.target.y - this.state.smooth.y;
303
-
304
684
  this.state.velocity.x = vx;
305
685
  this.state.velocity.y = vy;
306
686
 
307
687
  if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
308
688
  this.state.angle = angle(vx, vy);
309
689
  }
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
690
  }
322
691
 
323
692
  if (this.options.autoStart && this.isRunning) {
@@ -328,7 +697,7 @@ export class Supermouse {
328
697
  /**
329
698
  * Destroys the instance.
330
699
  */
331
- public destroy() {
700
+ public destroy(): void {
332
701
  this.isRunning = false;
333
702
  cancelAnimationFrame(this.rafId);
334
703
  this.input.destroy();
package/tsconfig.json CHANGED
@@ -1,9 +1,9 @@
1
- {
2
- "extends": "../../tsconfig.composite-lib.json",
3
- "compilerOptions": {
4
- "rootDir": "src",
5
- "outDir": "dist"
6
- },
7
- "include": ["src"],
8
- "references": []
9
- }
1
+ {
2
+ "extends": "../../tsconfig.composite-lib.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src"],
8
+ "references": []
9
+ }