@supermousejs/core 2.3.0 → 2.4.1

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,37 +1,85 @@
1
1
  declare const __VERSION__: string | undefined;
2
2
  const VERSION: string = typeof __VERSION__ !== "undefined" ? __VERSION__ : "0.0.0";
3
3
 
4
- import type { MouseState, SupermouseOptions, SupermousePlugin } from "./types";
4
+ import type { MouseState, SupermouseOptions, SupermousePlugin, RuleDefinition } from "./types";
5
5
 
6
+ /** Standard linear interpolation */
6
7
  function lerp(start: number, end: number, factor: number): number {
7
8
  return start + (end - start) * factor;
8
9
  }
9
10
 
11
+ /**
12
+ * Framerate-independent exponential smoothing by Freya Holmér.
13
+ * @param lambda Response rate.
14
+ * @param dt Delta time in seconds.
15
+ *
16
+ * https://www.youtube.com/watch?v=LSNQuFEDOyQ
17
+ */
10
18
  function damp(a: number, b: number, lambda: number, dt: number): number {
11
19
  return lerp(a, b, 1 - Math.exp(-lambda * dt));
12
20
  }
13
21
 
22
+ /** Off-screen park position before input arrives or after pointer leaves viewport. */
23
+ const OFFSCREEN = { x: -100, y: -100 } as const;
24
+
25
+ /** HTML tags that always warrant native cursor fallback. */
26
+ const NATIVE_TAGS = new Set(["input", "textarea", "select"]);
27
+
14
28
  /**
15
- * Input.ts
16
- *
17
- * This class listens to browser events and mutates the shared `MouseState` object.
29
+ * Computed `cursor` values treated as "author didn't explicitly choose one."
30
+ * Anything else wins over custom cursor.
31
+ */
32
+ const SUPERMOUSE_CURSORS = new Set([
33
+ "default",
34
+ "auto",
35
+ "pointer",
36
+ "none",
37
+ "inherit",
38
+ "grab",
39
+ "grabbing"
40
+ ]);
41
+
42
+ /** Default selectors that trigger `state.isHover`. Override with `hoverSelectors`. */
43
+ export const DEFAULT_HOVER_SELECTORS = [
44
+ "a",
45
+ "button",
46
+ "input",
47
+ "textarea",
48
+ "[data-hover]",
49
+ "[data-cursor]"
50
+ ];
51
+
52
+ /**
53
+ * Owns all browser-event listening and is the only class allowed to write
54
+ * to these `MouseState` fields: `pointer`, `isDown`, `isHover`, `isNative`,
55
+ * `hoverTarget`, `interaction`, `reducedMotion`.
18
56
  *
19
- * @internal This is an internal system class instantiated by `Supermouse`.
57
+ * @internal Instantiated by `Supermouse`.
20
58
  */
21
59
  export class Input {
22
60
  private mediaQueryList?: MediaQueryList;
23
- private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
24
61
  private motionQuery?: MediaQueryList;
25
62
  private dataPrefix: string;
26
63
  private normalizedDataPrefix: string;
27
64
  private ignoreAttribute: string;
28
-
29
- /**
30
- * Master switch for input processing.
31
- * Toggled by `Supermouse.enable()`/`disable()` or automatically by device capability checks.
32
- */
65
+ private abortController = new AbortController();
66
+ private nativeTarget: HTMLElement | null = null;
67
+ public hasSeenPointer: boolean = false;
33
68
  public isEnabled: boolean = true;
34
69
 
70
+ private containerRect: DOMRect | null = null;
71
+ private resizeObserver?: ResizeObserver;
72
+
73
+ /** Cached matched rules for the current interaction element. */
74
+ private matchedRules: Array<{ selector: string; rules: RuleDefinition }> = [];
75
+ private lastParsedTarget: HTMLElement | null = null;
76
+
77
+ /** Precomputed list of rule entries for faster iteration. */
78
+ private ruleEntries: Array<[string, RuleDefinition]>;
79
+
80
+ /** The actual element currently under the pointer (regardless of hover selectors). */
81
+ private currentTarget: HTMLElement | null = null;
82
+
35
83
  constructor(
36
84
  private state: MouseState,
37
85
  private options: SupermouseOptions,
@@ -42,38 +90,26 @@ export class Input {
42
90
  this.normalizedDataPrefix = this.dataPrefix.toLowerCase();
43
91
  this.ignoreAttribute = `data-${this.dataPrefix}-ignore`;
44
92
 
93
+ this.ruleEntries = this.options.rules ? Object.entries(this.options.rules) : [];
94
+
45
95
  this.checkDeviceCapability();
46
96
  this.checkMotionPreference();
97
+ this.setupContainerRectTracking();
47
98
  this.bindEvents();
48
99
  }
49
100
 
50
- private abortController = new AbortController();
51
-
52
- /**
53
- * Automatically disables the custom cursor on devices without fine pointer control.
54
- */
55
101
  private checkDeviceCapability(): void {
56
102
  if (!this.options.autoDisableOnMobile) return;
57
-
58
103
  this.mediaQueryList = window.matchMedia("(pointer: fine)");
59
104
  this.updateEnabledState(this.mediaQueryList.matches);
60
-
61
- this.mediaQueryHandler = (e: MediaQueryListEvent) => {
62
- this.updateEnabledState(e.matches);
63
- };
64
- this.mediaQueryList.addEventListener("change", this.mediaQueryHandler, {
105
+ this.mediaQueryList.addEventListener("change", (e) => this.updateEnabledState(e.matches), {
65
106
  signal: this.abortController.signal
66
107
  });
67
108
  }
68
109
 
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
110
  private checkMotionPreference(): void {
74
111
  this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
75
112
  this.state.reducedMotion = this.motionQuery.matches;
76
-
77
113
  this.motionQuery.addEventListener(
78
114
  "change",
79
115
  (e) => {
@@ -88,175 +124,294 @@ export class Input {
88
124
  this.onEnableChange(enabled);
89
125
  }
90
126
 
91
- private parseDOMInteraction(element: HTMLElement): void {
92
- if (this.options.resolveInteraction) {
93
- this.state.interaction = this.options.resolveInteraction(element) || {};
94
- return;
127
+ /** Caches container rect; updates on resize/scroll/ResizeObserver. */
128
+ private setupContainerRectTracking(): void {
129
+ const container = this.options.container;
130
+ if (!container || container === document.body) return;
131
+
132
+ const updateRect = (): void => {
133
+ this.containerRect = container.getBoundingClientRect();
134
+ };
135
+ updateRect();
136
+
137
+ window.addEventListener("resize", updateRect, { signal: this.abortController.signal });
138
+ window.addEventListener("scroll", updateRect, {
139
+ passive: true,
140
+ signal: this.abortController.signal
141
+ });
142
+
143
+ if (typeof ResizeObserver !== "undefined") {
144
+ this.resizeObserver = new ResizeObserver(updateRect);
145
+ this.resizeObserver.observe(container);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Evaluates rules against the given element.
151
+ * Selector matching is cached per element; only function values re-evaluated each frame.
152
+ */
153
+ public parseDOMInteraction(element: HTMLElement): void {
154
+ if (element !== this.lastParsedTarget) {
155
+ this.lastParsedTarget = element;
156
+ this.matchedRules = [];
157
+
158
+ for (const [selector, rules] of this.ruleEntries) {
159
+ if (this.matchesSelector(element, selector)) {
160
+ this.matchedRules.push({ selector, rules });
161
+ }
162
+ }
95
163
  }
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);
164
+
165
+ const data: Record<string, string | boolean | number> = {};
166
+ for (const { rules } of this.matchedRules) {
167
+ try {
168
+ const resolved = typeof rules === "function" ? rules(element) : rules;
169
+ if (!resolved || typeof resolved !== "object") continue;
170
+
171
+ for (const [key, val] of Object.entries(resolved)) {
172
+ data[key] = typeof val === "function" ? val(element) : val;
173
+ }
174
+ } catch (e) {
175
+ console.error(`[Supermouse] Rule threw during evaluation:`, e);
100
176
  }
101
177
  }
102
- const pre = this.normalizedDataPrefix,
103
- len = this.dataPrefix.length;
178
+
179
+ const pre = this.normalizedDataPrefix;
104
180
  for (const key in element.dataset) {
105
181
  if (!key.toLowerCase().startsWith(pre)) continue;
106
- const prop = key.slice(len);
182
+ const prop = key.slice(pre.length);
107
183
  if (!prop) continue;
108
184
  const val = element.dataset[key];
109
185
  data[prop[0].toLowerCase() + prop.slice(1)] = val === "" ? true : val!;
110
186
  }
187
+
111
188
  this.state.interaction = data;
112
189
  }
113
190
 
114
- private handleMove(e: PointerEvent): void {
115
- if (!this.isEnabled) return;
191
+ /**
192
+ * Optimized selector matching:
193
+ * - For simple selectors (no spaces), just call `element.matches`.
194
+ * - For complex selectors with descendant combinators, fallback to splitting.
195
+ */
196
+ private matchesSelector(element: HTMLElement, selector: string): boolean {
197
+ try {
198
+ // Fast path: simple selector
199
+ if (!/\s/.test(selector.trim())) {
200
+ return element.matches(selector);
201
+ }
202
+ } catch {
203
+ // If matches fails, fall through to ancestor-based matching
204
+ }
205
+
206
+ const parts = selector.trim().split(/\s+/);
207
+ if (parts.length < 2) return false;
116
208
 
209
+ const self = parts.pop()!;
210
+ const ancestor = parts.join(" ");
211
+ if (!self || !ancestor) return false;
212
+
213
+ try {
214
+ return element.matches(self) && !!element.closest(ancestor);
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ private isOutsideContainer(target: Node): boolean {
221
+ const { container } = this.options;
222
+ return !!container && container !== document.body && !container.contains(target);
223
+ }
224
+
225
+ private resolveComputedCursor(target: HTMLElement): string {
226
+ return window.getComputedStyle(target).cursor;
227
+ }
228
+
229
+ private handleMove = (e: PointerEvent): void => {
117
230
  if (this.options.autoDisableOnMobile && e.pointerType === "touch" && !this.options.enableTouch)
118
231
  return;
119
232
 
120
233
  let x = e.clientX;
121
234
  let y = e.clientY;
122
235
 
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;
236
+ const container = this.options.container;
237
+ if (container && this.containerRect && container !== document.body) {
238
+ x -= this.containerRect.left;
239
+ y -= this.containerRect.top;
127
240
  }
128
241
 
129
242
  this.state.pointer.x = x;
130
243
  this.state.pointer.y = y;
244
+ this.hasSeenPointer = true;
245
+
246
+ if (!this.isEnabled) return;
131
247
 
132
248
  if (!this.state.hasReceivedInput) {
133
249
  this.state.hasReceivedInput = true;
134
250
  this.state.target.x = this.state.smooth.x = x;
135
251
  this.state.target.y = this.state.smooth.y = y;
136
252
  }
137
- }
253
+ };
138
254
 
139
- private handleDown(): void {
255
+ private handleDown = (): void => {
140
256
  if (this.isEnabled) this.state.isDown = true;
141
- }
257
+ };
142
258
 
143
- private handleUp(): void {
259
+ private handleUp = (): void => {
144
260
  if (this.isEnabled) this.state.isDown = false;
145
- }
261
+ };
146
262
 
147
- private handleMouseOver(e: MouseEvent): void {
263
+ private handleMouseOver = (e: Event): void => {
148
264
  if (!this.isEnabled) return;
149
265
  const target = e.target as HTMLElement;
150
266
 
151
- if (target.closest(`[${this.ignoreAttribute}]`)) {
267
+ if (this.isOutsideContainer(target)) return;
268
+
269
+ if (this.state.cursorMode === "auto" && target.closest(`[${this.ignoreAttribute}]`)) {
270
+ this.state.isHover = false;
271
+ this.state.hoverTarget = null;
272
+ this.state.interaction = {};
273
+ this.currentTarget = null;
274
+ this.lastParsedTarget = null;
275
+ this.matchedRules = [];
276
+
152
277
  this.state.isNative = true;
278
+ this.nativeTarget = target;
153
279
  return;
154
280
  }
155
281
 
156
- const selector = this.getHoverSelector();
157
- const hoverable = target.closest(selector);
282
+ this.state.isNative = false;
283
+ this.nativeTarget = null;
284
+
285
+ this.currentTarget = target;
286
+ this.parseDOMInteraction(target);
287
+
288
+ if (this.state.cursorMode !== "auto") {
289
+ const hoverable = target.closest(this.getHoverSelector());
290
+ if (hoverable) {
291
+ this.state.isHover = true;
292
+ this.state.hoverTarget = hoverable as HTMLElement;
293
+ }
294
+ return;
295
+ }
158
296
 
297
+ const hoverable = target.closest(this.getHoverSelector());
159
298
  if (hoverable) {
160
299
  this.state.isHover = true;
161
300
  this.state.hoverTarget = hoverable as HTMLElement;
162
- this.parseDOMInteraction(this.state.hoverTarget);
163
301
  }
164
302
 
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
- }
303
+ // Built-in native detection: tags + CSS
304
+ const checkTags = NATIVE_TAGS.has(target.localName) || target.isContentEditable;
305
+ const checkCSS = !SUPERMOUSE_CURSORS.has(this.resolveComputedCursor(target));
306
+ if (checkTags || checkCSS) {
307
+ this.state.isNative = true;
308
+ this.nativeTarget = target;
190
309
  }
191
- }
310
+ };
192
311
 
193
- private handleMouseOut(e: MouseEvent): void {
312
+ private handleMouseOut = (e: Event): void => {
194
313
  if (!this.isEnabled) return;
195
314
  const target = e.target as HTMLElement;
315
+ const related = (e as MouseEvent).relatedTarget as Node | null;
316
+
317
+ if (this.isOutsideContainer(target)) return;
196
318
 
197
319
  if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
198
- if (!e.relatedTarget || !this.state.hoverTarget?.contains(e.relatedTarget as Node)) {
320
+ if (!related || !this.state.hoverTarget?.contains(related)) {
199
321
  this.state.isHover = false;
200
322
  this.state.hoverTarget = null;
201
- this.state.interaction = {};
202
323
  }
203
324
  }
204
325
 
205
- if (this.state.isNative) {
206
- this.state.isNative = false;
326
+ if (this.nativeTarget && (target === this.nativeTarget || target.contains(this.nativeTarget))) {
327
+ if (!related || !this.nativeTarget.contains(related)) {
328
+ this.state.isNative = false;
329
+ this.nativeTarget = null;
330
+ }
331
+ }
332
+
333
+ if (target === this.currentTarget) {
334
+ this.currentTarget = null;
335
+ this.lastParsedTarget = null;
336
+ this.matchedRules = [];
207
337
  }
208
- }
338
+ };
209
339
 
210
- private handleWindowLeave(): void {
340
+ /**
341
+ * Handles the case where the pointer leaves the entire document.
342
+ * This is more reliable than the `mouseleave` event on `document`.
343
+ */
344
+ private handleDocumentMouseOut = (e: MouseEvent): void => {
345
+ if (!this.isEnabled) return;
346
+ if (e.relatedTarget === null && this.options.hideOnLeave) {
347
+ this.handleWindowLeave();
348
+ }
349
+ };
350
+
351
+ private handleWindowLeave = (): void => {
211
352
  if (this.options.hideOnLeave) {
212
353
  this.state.hasReceivedInput = false;
213
354
  this.state.pointer = { ...OFFSCREEN };
214
355
  }
215
- }
356
+ };
216
357
 
217
358
  public clearHover(): void {
218
359
  this.state.isHover = false;
219
360
  this.state.hoverTarget = null;
220
361
  this.state.isNative = false;
362
+ this.nativeTarget = null;
221
363
  this.state.interaction = {};
364
+ this.currentTarget = null;
365
+ this.lastParsedTarget = null;
366
+ this.matchedRules = [];
367
+ }
368
+
369
+ /** Returns the raw element currently under the pointer. */
370
+ public getCurrentTarget(): HTMLElement | null {
371
+ return this.currentTarget;
222
372
  }
223
373
 
224
374
  private bindEvents(): void {
225
375
  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 });
376
+ window.addEventListener("pointermove", this.handleMove, { passive: true, signal });
377
+ window.addEventListener("pointerdown", this.handleDown, { passive: true, signal });
378
+ window.addEventListener("pointerup", this.handleUp, { signal });
229
379
 
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 });
380
+ const isBody = !this.options.container || this.options.container === document.body;
381
+ const hoverRoot = isBody ? document : this.options.container!;
382
+ hoverRoot.addEventListener("mouseover", this.handleMouseOver, { signal });
383
+ hoverRoot.addEventListener("mouseout", this.handleMouseOut, { signal });
384
+
385
+ // Reliable window-leave detection
386
+ document.addEventListener("mouseout", this.handleDocumentMouseOut, { signal });
233
387
  }
234
388
 
235
389
  public destroy(): void {
236
390
  this.abortController.abort();
391
+ this.resizeObserver?.disconnect();
237
392
  }
238
393
  }
239
394
 
240
395
  let stageCount = 0;
241
396
 
242
397
  /**
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.
398
+ * Owns the stage container and manages native-cursor suppression via injected styles.
399
+ * The stylesheet is rebuilt only when selectors change, not per frame.
247
400
  *
248
- * @internal
401
+ * @internal Instantiated by `Supermouse`.
249
402
  */
250
403
  export class Stage {
251
- /** The container element appended to the document. */
252
404
  public readonly element: HTMLDivElement;
253
405
  private styleTag: HTMLStyleElement;
254
- private id: string;
255
- private scopeClass: string;
406
+ private readonly id: string;
407
+ private readonly scopeClass: string;
408
+ private readonly hideClass: string;
256
409
 
257
- private currentCursorState: "none" | "auto" | "" | null = null;
410
+ private currentCursorState: "none" | "auto" | null = null;
258
411
  private originalContainerPosition: string = "";
259
412
  private originalContainerCursor: string = "";
413
+
414
+ /** Selectors that need explicit `cursor: none !important` to override UA styles. */
260
415
  private selectors: Set<string> = new Set([
261
416
  "a",
262
417
  "button",
@@ -269,24 +424,30 @@ export class Stage {
269
424
 
270
425
  constructor(
271
426
  private container: HTMLElement = document.body,
272
- private hideNativeCursor: boolean
427
+ private zIndex: number = 9999
273
428
  ) {
274
429
  if (!container || !(container instanceof HTMLElement)) {
275
430
  throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
276
431
  }
432
+ if (!container.isConnected) {
433
+ console.warn(
434
+ "[Supermouse] container is not attached to the document — " +
435
+ "stage sizing/positioning will be wrong until it is."
436
+ );
437
+ }
277
438
 
278
439
  const instanceId = stageCount++;
279
440
  this.id = `supermouse-style-${instanceId}`;
280
441
  this.scopeClass = `supermouse-scope-${instanceId}`;
442
+ this.hideClass = `supermouse-hide-${instanceId}`;
281
443
 
282
444
  const isBody = container === document.body;
283
-
284
445
  this.element = document.createElement("div");
285
446
  Object.assign(this.element.style, {
286
447
  position: isBody ? "fixed" : "absolute",
287
448
  inset: "0px",
288
449
  pointerEvents: "none",
289
- zIndex: "9999",
450
+ zIndex: String(this.zIndex),
290
451
  opacity: "1",
291
452
  transition: "opacity 0.15s ease"
292
453
  });
@@ -294,59 +455,50 @@ export class Stage {
294
455
  if (!isBody) {
295
456
  const computed = window.getComputedStyle(container);
296
457
  this.originalContainerPosition = computed.position;
297
- if (computed.position === "static") {
298
- container.style.position = "relative";
299
- }
458
+ if (computed.position === "static") container.style.position = "relative";
300
459
  }
301
460
 
302
461
  this.originalContainerCursor = container.style.cursor;
303
-
304
462
  container.appendChild(this.element);
305
463
 
306
464
  this.styleTag = document.createElement("style");
307
465
  this.styleTag.id = this.id;
308
466
  document.head.appendChild(this.styleTag);
309
467
 
310
- this.container.classList.add(this.scopeClass);
468
+ this.container.classList.add("supermouse-scope", this.scopeClass);
469
+ this.updateCursorCSS();
470
+ }
311
471
 
312
- if (this.hideNativeCursor) {
313
- this.setNativeCursor("none");
472
+ /** Batch add selectors. Comma‑separated groups are split and scoped individually. */
473
+ public addSelectors(selectors: Iterable<string>): void {
474
+ let changed = false;
475
+ for (const selector of selectors) {
476
+ selector.split(",").forEach((s) => {
477
+ const trimmed = s.trim();
478
+ if (trimmed && !this.selectors.has(trimmed)) {
479
+ this.selectors.add(trimmed);
480
+ changed = true;
481
+ }
482
+ });
314
483
  }
484
+ if (changed) this.updateCursorCSS();
315
485
  }
316
486
 
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
- */
487
+ /** Add a single selector (or comma‑separated group). */
322
488
  public addSelector(selector: string): void {
323
- this.selectors.add(selector);
324
- if (this.hideNativeCursor) {
325
- this.updateCursorCSS();
326
- }
489
+ this.addSelectors([selector]);
327
490
  }
328
491
 
329
492
  public setVisibility(visible: boolean): void {
330
493
  this.element.style.opacity = visible ? "1" : "0";
331
494
  }
332
495
 
333
- /**
334
- * Toggles the visibility of the native cursor via CSS injection.
335
- * @param type 'none' to hide, 'auto' to show.
336
- */
496
+ /** Toggle native cursor visibility. */
337
497
  public setNativeCursor(type: "none" | "auto"): void {
338
- if (!this.hideNativeCursor && type === "none") return;
339
-
340
498
  if (type === this.currentCursorState) return;
341
499
  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
- }
500
+ this.container.classList.toggle(this.hideClass, type === "none");
501
+ this.container.style.cursor = type === "none" ? "none" : this.originalContainerCursor;
350
502
  }
351
503
 
352
504
  private updateCursorCSS(): void {
@@ -356,45 +508,59 @@ export class Stage {
356
508
  return;
357
509
  }
358
510
 
359
- const scopedSelectors = rawSelectors.map((s) => `.${this.scopeClass} ${s}`).join(", ");
511
+ const exclusion = `:not(.${this.scopeClass} .supermouse-scope):not(.${this.scopeClass} .supermouse-scope *)`;
512
+ const scopeRule = (s: string) =>
513
+ `.${this.scopeClass}.${this.hideClass} ${s}${exclusion} { cursor: none !important; }`;
514
+
515
+ const scopedRules = rawSelectors.map(scopeRule).join("\n");
516
+
517
+ const broadRule = `.${this.scopeClass}.${this.hideClass} *${exclusion} { cursor: none !important; }`;
518
+ const containerRule = `.${this.scopeClass}.${this.hideClass} { cursor: none !important; }`;
360
519
 
361
520
  this.styleTag.innerText = `
362
- ${scopedSelectors} {
363
- cursor: none !important;
364
- }
365
- `;
521
+ ${containerRule}
522
+ ${broadRule}
523
+ ${scopedRules}
524
+ ${scopeRule("label")}
525
+ ${scopeRule("select")}
526
+ ${scopeRule('input[type="range"]::-webkit-slider-thumb')}
527
+ ${scopeRule('input[type="range"]::-moz-range-thumb')}
528
+ `;
366
529
  }
367
530
 
368
531
  public destroy(): void {
369
532
  this.element.remove();
370
533
  this.styleTag.remove();
371
-
372
534
  this.container.style.cursor = this.originalContainerCursor;
373
- this.container.classList.remove(this.scopeClass);
374
-
535
+ this.container.classList.remove("supermouse-scope", this.scopeClass, this.hideClass);
375
536
  if (this.container !== document.body && this.originalContainerPosition === "static") {
376
537
  this.container.style.position = "";
377
538
  }
378
539
  }
379
540
  }
380
541
 
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
- ];
542
+ /**
543
+ * The subset of `SupermouseOptions` guaranteed to have a concrete value once
544
+ * the constructor has merged user input over the defaults.
545
+ */
546
+ type ResolvedOptions = SupermouseOptions &
547
+ Required<
548
+ Pick<
549
+ SupermouseOptions,
550
+ | "smoothness"
551
+ | "enableTouch"
552
+ | "autoDisableOnMobile"
553
+ | "cursor"
554
+ | "hideOnLeave"
555
+ | "autoStart"
556
+ | "container"
557
+ | "dataPrefix"
558
+ | "zIndex"
559
+ >
560
+ >;
390
561
 
391
562
  /**
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
563
+ * Orchestrates state, animation loop, and plugin lifecycle.
398
564
  */
399
565
  export class Supermouse {
400
566
  public static readonly version: string = VERSION;
@@ -402,51 +568,48 @@ export class Supermouse {
402
568
 
403
569
  state: MouseState;
404
570
 
405
- /**
406
- * Configuration options.
407
- */
408
- options: SupermouseOptions;
571
+ /** Configuration options, fully resolved with defaults applied. */
572
+ options: ResolvedOptions;
409
573
 
410
574
  private plugins: SupermousePlugin[] = [];
411
- private stage: Stage;
575
+ private _stage: Stage;
412
576
  private input: Input;
413
577
 
414
578
  private rafId: number = 0;
415
579
  private lastTime: number = 0;
416
580
  private isRunning: boolean = false;
581
+ private isSuspended: boolean = false;
582
+ private visibilityAbortController = new AbortController();
417
583
 
418
584
  private hoverSelectors: Set<string>;
585
+ private hoverSelectorString: string;
586
+ private crashedPlugins: SupermousePlugin[] = [];
419
587
 
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
588
  constructor(options: SupermouseOptions = {}) {
427
589
  this.options = {
428
590
  smoothness: 0.15,
429
591
  enableTouch: false,
430
592
  autoDisableOnMobile: true,
431
- ignoreOnNative: "auto",
432
- hideCursor: true,
593
+ cursor: "auto",
433
594
  hideOnLeave: true,
434
595
  autoStart: true,
435
596
  container: document.body,
436
597
  dataPrefix: "supermouse",
598
+ zIndex: 9999,
437
599
  ...options
438
- };
600
+ } as ResolvedOptions;
439
601
 
440
602
  this.state = {
441
- pointer: { x: -100, y: -100 },
442
- target: { x: -100, y: -100 },
443
- smooth: { x: -100, y: -100 },
603
+ pointer: { ...OFFSCREEN },
604
+ target: { ...OFFSCREEN },
605
+ smooth: { ...OFFSCREEN },
444
606
  velocity: { x: 0, y: 0 },
607
+ displacement: { x: 0, y: 0 },
445
608
  angle: 0,
446
609
  isDown: false,
447
610
  isHover: false,
448
611
  isNative: false,
449
- forcedCursor: null,
612
+ cursorMode: this.options.cursor,
450
613
  hoverTarget: null,
451
614
  reducedMotion: false,
452
615
  hasReceivedInput: false,
@@ -454,154 +617,179 @@ export class Supermouse {
454
617
  interaction: {}
455
618
  };
456
619
 
457
- if (this.options.hoverSelectors) {
458
- this.hoverSelectors = new Set(this.options.hoverSelectors);
459
- } else {
460
- this.hoverSelectors = new Set(DEFAULT_HOVER_SELECTORS);
461
- }
620
+ this.hoverSelectors = new Set(this.options.hoverSelectors ?? DEFAULT_HOVER_SELECTORS);
621
+ this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
462
622
 
463
- this.stage = new Stage(this.options.container, !!this.options.hideCursor);
464
- this.hoverSelectors.forEach((s) => this.stage.addSelector(s));
623
+ this._stage = new Stage(this.options.container, this.options.zIndex);
624
+ this._stage.addSelectors(this.hoverSelectors);
465
625
 
466
626
  this.input = new Input(
467
627
  this.state,
468
628
  this.options,
469
- () => Array.from(this.hoverSelectors).join(", "),
629
+ () => this.hoverSelectorString,
470
630
  (enabled) => {
471
631
  if (!enabled) this.reset(true);
472
632
  }
473
633
  );
474
634
 
475
- if (this.options.plugins) {
476
- this.options.plugins.forEach((p) => this.use(p));
477
- }
478
-
635
+ this.options.plugins?.forEach((p) => this.use(p));
636
+ this.bindVisibilityHandling();
479
637
  this.init();
480
638
  }
481
639
 
482
- /**
483
- * Retrieves a registered plugin instance by its unique name.
484
- */
640
+ /** Look up a registered plugin by name. */
485
641
  public getPlugin(name: string): SupermousePlugin | undefined {
486
642
  return this.plugins.find((p) => p.name === name);
487
643
  }
488
644
 
489
- /**
490
- * Returns whether the cursor system is currently enabled (processing input).
491
- */
645
+ /** Whether the instance is not disabled/suspended and is processing input. */
492
646
  public get isEnabled(): boolean {
493
647
  return this.input.isEnabled;
494
648
  }
495
649
 
496
- /**
497
- * Enables a specific plugin by name.
498
- * Triggers the `onEnable` lifecycle hook of the plugin.
499
- */
650
+ /** Enable a plugin by name. */
500
651
  public enablePlugin(name: string): void {
501
652
  const plugin = this.getPlugin(name);
502
653
  if (plugin && plugin.isEnabled === false) {
503
654
  plugin.isEnabled = true;
655
+ if (plugin.element) plugin.element.style.display = "";
504
656
  plugin.onEnable?.(this);
505
657
  }
506
658
  }
507
659
 
508
- /**
509
- * Disables a specific plugin by name.
510
- * Triggers the `onDisable` lifecycle hook.
511
- */
660
+ /** Disable a plugin by name and hide its element. */
512
661
  public disablePlugin(name: string): void {
513
662
  const plugin = this.getPlugin(name);
514
663
  if (plugin && plugin.isEnabled !== false) {
515
664
  plugin.isEnabled = false;
516
- plugin.onDisable?.(this);
665
+
666
+ const finishDisable = () => {
667
+ if (plugin.element) plugin.element.style.display = "none";
668
+ plugin.onDisable?.(this);
669
+ };
670
+
671
+ const result = plugin.onBeforeDisable?.(this);
672
+ if (result && typeof result.then === "function") {
673
+ void Promise.resolve(result)
674
+ .then(finishDisable)
675
+ .catch((err) => {
676
+ console.error(`[Supermouse] Plugin '${plugin.name}' onBeforeDisable threw:`, err);
677
+ finishDisable();
678
+ });
679
+ } else {
680
+ finishDisable();
681
+ }
517
682
  }
518
683
  }
519
684
 
520
- /**
521
- * Toggles the enabled state of a plugin.
522
- */
685
+ /** Toggle a plugin's enabled state by name. */
523
686
  public togglePlugin(name: string): void {
524
687
  const plugin = this.getPlugin(name);
525
- if (plugin) {
526
- if (plugin.isEnabled === false) this.enablePlugin(name);
527
- else this.disablePlugin(name);
528
- }
688
+ if (!plugin) return;
689
+ if (plugin.isEnabled === false) this.enablePlugin(name);
690
+ else this.disablePlugin(name);
529
691
  }
530
692
 
693
+ /** Add a selector to hover detection and cursor suppression. */
531
694
  public registerHoverTarget(selector: string): void {
532
695
  if (!this.hoverSelectors.has(selector)) {
533
696
  this.hoverSelectors.add(selector);
534
- this.stage.addSelector(selector);
697
+ this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
698
+ this._stage.addSelector(selector);
535
699
  }
536
700
  }
537
701
 
538
- /**
539
- * The fixed container element where plugins should append their DOM nodes.
540
- */
541
- public get container(): HTMLDivElement {
542
- return this.stage.element;
702
+ /** The DOM element the instance is scoped to. */
703
+ public get container(): HTMLElement {
704
+ return this.options.container;
543
705
  }
544
706
 
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";
707
+ /** The stage element that plugins append their visuals into. */
708
+ public get stage(): HTMLDivElement {
709
+ return this._stage.element;
710
+ }
711
+
712
+ /** Set the current cursor mode. */
713
+ public setCursor(mode: "auto" | "custom" | "native" | "both"): void {
714
+ this.state.cursorMode = mode;
552
715
  }
553
716
 
554
717
  private init(): void {
555
- if (this.options.autoStart) {
556
- this.startLoop();
557
- }
718
+ if (this.options.autoStart) this.startLoop();
558
719
  }
559
720
 
721
+ /** Re‑enable input processing and re‑apply cursor state. */
560
722
  public enable(): void {
561
723
  this.input.isEnabled = true;
562
- this.stage.setNativeCursor("none");
724
+
725
+ if (this.input.hasSeenPointer) {
726
+ this.state.target.x = this.state.smooth.x = this.state.pointer.x;
727
+ this.state.target.y = this.state.smooth.y = this.state.pointer.y;
728
+ this.resetMotion();
729
+ this.state.hasReceivedInput = true;
730
+ }
731
+
732
+ this._stage.setNativeCursor(this.resolveCursorState());
563
733
  }
734
+
735
+ /** Disable input processing and restore native cursor. */
564
736
  public disable(): void {
565
737
  this.input.isEnabled = false;
566
- this.stage.setNativeCursor("auto");
738
+ this._stage.setNativeCursor("auto");
739
+ this._stage.setVisibility(false);
567
740
  this.reset(true);
568
741
  }
569
742
 
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);
743
+ /** Temporarily yield to a scoped instance. */
744
+ public suspend(): void {
745
+ if (!this.input.isEnabled) return;
746
+ this.isSuspended = true;
747
+ this.input.isEnabled = false;
748
+ this.input.clearHover();
749
+ this._stage.setVisibility(false);
750
+ }
577
751
 
578
- if (exists) {
579
- console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
580
- return this;
581
- }
752
+ /** Resume from `suspend()`. */
753
+ public resume(): void {
754
+ if (!this.isSuspended) return;
755
+ this.isSuspended = false;
756
+ this.input.isEnabled = true;
582
757
 
583
- if (plugin.isEnabled === undefined) {
584
- plugin.isEnabled = true;
758
+ if (this.state.hasReceivedInput) {
759
+ this.state.target.x = this.state.smooth.x = this.state.pointer.x;
760
+ this.state.target.y = this.state.smooth.y = this.state.pointer.y;
761
+ this.resetMotion();
762
+ }
763
+ // Update plugins before showing stage to avoid stale visuals.
764
+ for (let i = this.plugins.length - 1; i >= 0; i--) {
765
+ this.runPluginSafe(this.plugins[i], 0);
585
766
  }
767
+ this._stage.setVisibility(true);
768
+ }
586
769
 
770
+ /** Register a new plugin. */
771
+ public use(plugin: SupermousePlugin): this {
772
+ if (this.plugins.some((p) => p.name === plugin.name)) {
773
+ console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
774
+ return this;
775
+ }
776
+ plugin.isEnabled ??= true;
587
777
  try {
588
778
  plugin.install?.(this);
589
779
  } catch (e) {
590
780
  console.error(`[Supermouse] Failed to install plugin '${plugin.name}'.`, e);
591
781
  return this;
592
782
  }
593
-
594
783
  this.plugins.push(plugin);
595
- this.plugins.sort((a, b) => (a.priority || 0) - (b.priority || 0));
596
-
784
+ this.plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
597
785
  return this;
598
786
  }
599
787
 
788
+ /** Reset physics; optionally clear all input state. */
600
789
  private reset(hard = false): void {
601
- this.state.pointer = { ...OFFSCREEN };
602
790
  this.state.target = { ...OFFSCREEN };
603
791
  this.state.smooth = { ...OFFSCREEN };
604
- this.state.velocity = { x: 0, y: 0 };
792
+ this.resetMotion();
605
793
  this.state.angle = 0;
606
794
  if (hard) {
607
795
  this.state.hasReceivedInput = false;
@@ -613,26 +801,19 @@ export class Supermouse {
613
801
  private startLoop(): void {
614
802
  if (this.isRunning) return;
615
803
  this.isRunning = true;
616
-
804
+ if (document.hidden) return;
617
805
  this.lastTime = performance.now();
618
- this.tick(this.lastTime);
806
+ this.rafId = requestAnimationFrame(this.tick);
619
807
  }
620
808
 
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
- */
809
+ /** Start the animation loop. */
625
810
  public start(): void {
626
811
  this.startLoop();
627
812
  }
628
813
 
629
- /**
630
- * Manually steps the animation loop.
631
- *
632
- * @param time Current timestamp in milliseconds.
633
- */
814
+ /** Manually step the animation loop. */
634
815
  public step(time: number): void {
635
- this.tick(time);
816
+ this.update(time);
636
817
  }
637
818
 
638
819
  private runPluginSafe(plugin: SupermousePlugin, deltaTime: number): void {
@@ -641,91 +822,139 @@ export class Supermouse {
641
822
  plugin.update?.(this, deltaTime);
642
823
  } catch (e) {
643
824
  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
825
  plugin.isEnabled = false;
826
+ this.crashedPlugins.push(plugin);
827
+ }
828
+ }
652
829
 
653
- // Attempt cleanup
830
+ private cleanupCrashedPlugins(): void {
831
+ if (this.crashedPlugins.length === 0) return;
832
+ for (const plugin of this.crashedPlugins) {
833
+ const index = this.plugins.indexOf(plugin);
834
+ if (index > -1) this.plugins.splice(index, 1);
654
835
  try {
655
- plugin.destroy?.(this);
656
836
  plugin.onDisable?.(this);
837
+ plugin.destroy?.(this);
657
838
  } catch (err) {
658
839
  console.error(`[Supermouse] Failed to cleanup crashed plugin '${plugin.name}'.`, err);
659
840
  }
841
+ plugin.element?.remove();
660
842
  }
843
+ this.crashedPlugins = [];
661
844
  }
662
845
 
663
- /**
664
- * Runs on every animation frame.
665
- */
666
- private tick = (time: number): void => {
846
+ private resolveStageVisibility(): boolean {
847
+ if (this.state.cursorMode === "native") return false;
848
+ if (this.state.cursorMode === "both")
849
+ return this.input.isEnabled && this.state.hasReceivedInput;
850
+ if (this.state.cursorMode === "custom")
851
+ return this.input.isEnabled && this.state.hasReceivedInput;
852
+
853
+ return this.input.isEnabled && !this.state.isNative && this.state.hasReceivedInput;
854
+ }
855
+
856
+ private resolveCursorState(): "none" | "auto" {
857
+ if (!this.input.isEnabled) return "auto";
858
+
859
+ if (this.state.cursorMode === "both") return "auto";
860
+ if (this.state.cursorMode === "native") return "auto";
861
+ if (this.state.cursorMode === "custom") return "none";
862
+ return this.state.isNative || !this.state.hasReceivedInput ? "auto" : "none";
863
+ }
864
+
865
+ private resetMotion(): void {
866
+ this.state.velocity = { x: 0, y: 0 };
867
+ this.state.displacement = { x: 0, y: 0 };
868
+ }
869
+
870
+ private update(time: number): void {
667
871
  const dtMs = time - this.lastTime;
668
872
  const dt = Math.min(dtMs / 1000, 0.1);
669
873
  this.lastTime = time;
670
874
 
671
- if (this.state.hoverTarget && !this.state.hoverTarget.isConnected) {
875
+ const currentTarget = this.input.getCurrentTarget();
876
+ if (currentTarget && !currentTarget.isConnected) {
672
877
  this.input.clearHover();
878
+ } else if (currentTarget) {
879
+ this.input.parseDOMInteraction(currentTarget);
673
880
  }
674
881
 
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);
882
+ this._stage.setVisibility(this.resolveStageVisibility());
883
+ if (this.input.isEnabled) {
884
+ this._stage.setNativeCursor(this.resolveCursorState());
688
885
  }
689
886
 
690
887
  if (this.input.isEnabled && this.state.hasReceivedInput) {
691
888
  this.state.target.x = this.state.pointer.x;
692
889
  this.state.target.y = this.state.pointer.y;
693
- } else {
694
- this.state.target = { ...OFFSCREEN };
695
890
  }
696
891
 
697
892
  for (let i = 0; i < this.plugins.length; i++) {
698
893
  this.runPluginSafe(this.plugins[i], dtMs);
699
894
  }
895
+ this.cleanupCrashedPlugins();
700
896
 
701
897
  if (this.input.isEnabled) {
702
- const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness!) * 2;
898
+ const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness) * 2;
899
+
900
+ const previousX = this.state.smooth.x;
901
+ const previousY = this.state.smooth.y;
703
902
 
704
903
  this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
705
904
  this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, factor, dt);
706
905
 
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;
906
+ this.state.displacement.x = this.state.target.x - this.state.smooth.x;
907
+ this.state.displacement.y = this.state.target.y - this.state.smooth.y;
908
+
909
+ if (dt > 0) {
910
+ this.state.velocity.x = (this.state.smooth.x - previousX) / dt;
911
+ this.state.velocity.y = (this.state.smooth.y - previousY) / dt;
912
+ } else {
913
+ this.state.velocity.x = 0;
914
+ this.state.velocity.y = 0;
915
+ }
916
+
709
917
  const { x: vx, y: vy } = this.state.velocity;
710
918
  if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
711
919
  this.state.angle = Math.atan2(vy, vx) * (180 / Math.PI);
712
920
  }
713
921
  }
922
+ }
714
923
 
924
+ private tick = (time: number): void => {
925
+ this.update(time);
715
926
  if (this.isRunning) {
716
927
  this.rafId = requestAnimationFrame(this.tick);
717
928
  }
718
929
  };
719
930
 
720
- /**
721
- * Destroys the instance.
722
- */
931
+ /** Pause rAF loop when tab hidden; resume on visible. */
932
+ private bindVisibilityHandling(): void {
933
+ document.addEventListener(
934
+ "visibilitychange",
935
+ () => {
936
+ if (!this.isRunning) return;
937
+ if (document.hidden) {
938
+ cancelAnimationFrame(this.rafId);
939
+ } else {
940
+ this.lastTime = performance.now();
941
+ this.rafId = requestAnimationFrame(this.tick);
942
+ }
943
+ },
944
+ { signal: this.visibilityAbortController.signal }
945
+ );
946
+ }
947
+
948
+ /** Destroy the instance, freeing all resources. */
723
949
  public destroy(): void {
724
950
  this.isRunning = false;
725
951
  cancelAnimationFrame(this.rafId);
952
+ this.visibilityAbortController.abort();
726
953
  this.input.destroy();
727
- this.stage.destroy();
954
+ this._stage.destroy();
728
955
  this.plugins.forEach((p) => p.destroy?.(this));
729
956
  this.plugins = [];
730
957
  }
731
958
  }
959
+
960
+ export type SupermouseInstance = Supermouse;