@supermousejs/core 2.3.0 → 2.4.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,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,282 @@ 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;
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
+ }
116
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;
158
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
+ }
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
+ }
207
331
  }
208
- }
209
332
 
210
- private handleWindowLeave(): void {
333
+ // Clear current target when pointer leaves it
334
+ if (target === this.currentTarget) {
335
+ this.currentTarget = null;
336
+ this.lastParsedTarget = null;
337
+ this.matchedRules = [];
338
+ }
339
+ };
340
+
341
+ private handleWindowLeave = (): void => {
211
342
  if (this.options.hideOnLeave) {
212
343
  this.state.hasReceivedInput = false;
213
344
  this.state.pointer = { ...OFFSCREEN };
214
345
  }
215
- }
346
+ };
216
347
 
217
348
  public clearHover(): void {
218
349
  this.state.isHover = false;
219
350
  this.state.hoverTarget = null;
220
351
  this.state.isNative = false;
352
+ this.nativeTarget = null;
221
353
  this.state.interaction = {};
354
+ this.currentTarget = null;
355
+ this.lastParsedTarget = null;
356
+ this.matchedRules = [];
357
+ }
358
+
359
+ /** Returns the raw element currently under the pointer. */
360
+ public getCurrentTarget(): HTMLElement | null {
361
+ return this.currentTarget;
222
362
  }
223
363
 
224
364
  private bindEvents(): void {
225
365
  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 });
366
+ window.addEventListener("pointermove", this.handleMove, { passive: true, signal });
367
+ window.addEventListener("pointerdown", this.handleDown, { passive: true, signal });
368
+ window.addEventListener("pointerup", this.handleUp, { signal });
229
369
 
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 });
370
+ const isBody = !this.options.container || this.options.container === document.body;
371
+ const hoverRoot = isBody ? document : this.options.container!;
372
+ hoverRoot.addEventListener("mouseover", this.handleMouseOver, { signal });
373
+ hoverRoot.addEventListener("mouseout", this.handleMouseOut, { signal });
374
+ document.addEventListener("mouseleave", this.handleWindowLeave, { signal });
233
375
  }
234
376
 
235
377
  public destroy(): void {
236
378
  this.abortController.abort();
379
+ this.resizeObserver?.disconnect();
237
380
  }
238
381
  }
239
382
 
240
383
  let stageCount = 0;
241
384
 
242
385
  /**
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.
386
+ * Owns the stage container and manages native-cursor suppression via injected styles.
387
+ * The stylesheet is rebuilt only when selectors change, not per frame.
247
388
  *
248
- * @internal
389
+ * @internal Instantiated by `Supermouse`.
249
390
  */
250
391
  export class Stage {
251
- /** The container element appended to the document. */
252
392
  public readonly element: HTMLDivElement;
253
393
  private styleTag: HTMLStyleElement;
254
- private id: string;
255
- private scopeClass: string;
394
+ private readonly id: string;
395
+ private readonly scopeClass: string;
396
+ private readonly hideClass: string;
256
397
 
257
- private currentCursorState: "none" | "auto" | "" | null = null;
398
+ private currentCursorState: "none" | "auto" | null = null;
258
399
  private originalContainerPosition: string = "";
259
400
  private originalContainerCursor: string = "";
401
+
402
+ /** Selectors that need explicit `cursor: none !important` to override UA styles. */
260
403
  private selectors: Set<string> = new Set([
261
404
  "a",
262
405
  "button",
@@ -269,24 +412,30 @@ export class Stage {
269
412
 
270
413
  constructor(
271
414
  private container: HTMLElement = document.body,
272
- private hideNativeCursor: boolean
415
+ private zIndex: number = 9999
273
416
  ) {
274
417
  if (!container || !(container instanceof HTMLElement)) {
275
418
  throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
276
419
  }
420
+ if (!container.isConnected) {
421
+ console.warn(
422
+ "[Supermouse] container is not attached to the document — " +
423
+ "stage sizing/positioning will be wrong until it is."
424
+ );
425
+ }
277
426
 
278
427
  const instanceId = stageCount++;
279
428
  this.id = `supermouse-style-${instanceId}`;
280
429
  this.scopeClass = `supermouse-scope-${instanceId}`;
430
+ this.hideClass = `supermouse-hide-${instanceId}`;
281
431
 
282
432
  const isBody = container === document.body;
283
-
284
433
  this.element = document.createElement("div");
285
434
  Object.assign(this.element.style, {
286
435
  position: isBody ? "fixed" : "absolute",
287
436
  inset: "0px",
288
437
  pointerEvents: "none",
289
- zIndex: "9999",
438
+ zIndex: String(this.zIndex),
290
439
  opacity: "1",
291
440
  transition: "opacity 0.15s ease"
292
441
  });
@@ -294,59 +443,50 @@ export class Stage {
294
443
  if (!isBody) {
295
444
  const computed = window.getComputedStyle(container);
296
445
  this.originalContainerPosition = computed.position;
297
- if (computed.position === "static") {
298
- container.style.position = "relative";
299
- }
446
+ if (computed.position === "static") container.style.position = "relative";
300
447
  }
301
448
 
302
449
  this.originalContainerCursor = container.style.cursor;
303
-
304
450
  container.appendChild(this.element);
305
451
 
306
452
  this.styleTag = document.createElement("style");
307
453
  this.styleTag.id = this.id;
308
454
  document.head.appendChild(this.styleTag);
309
455
 
310
- this.container.classList.add(this.scopeClass);
456
+ this.container.classList.add("supermouse-scope", this.scopeClass);
457
+ this.updateCursorCSS();
458
+ }
311
459
 
312
- if (this.hideNativeCursor) {
313
- this.setNativeCursor("none");
460
+ /** Batch add selectors. Comma‑separated groups are split and scoped individually. */
461
+ public addSelectors(selectors: Iterable<string>): void {
462
+ let changed = false;
463
+ for (const selector of selectors) {
464
+ selector.split(",").forEach((s) => {
465
+ const trimmed = s.trim();
466
+ if (trimmed && !this.selectors.has(trimmed)) {
467
+ this.selectors.add(trimmed);
468
+ changed = true;
469
+ }
470
+ });
314
471
  }
472
+ if (changed) this.updateCursorCSS();
315
473
  }
316
474
 
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
- */
475
+ /** Add a single selector (or comma‑separated group). */
322
476
  public addSelector(selector: string): void {
323
- this.selectors.add(selector);
324
- if (this.hideNativeCursor) {
325
- this.updateCursorCSS();
326
- }
477
+ this.addSelectors([selector]);
327
478
  }
328
479
 
329
480
  public setVisibility(visible: boolean): void {
330
481
  this.element.style.opacity = visible ? "1" : "0";
331
482
  }
332
483
 
333
- /**
334
- * Toggles the visibility of the native cursor via CSS injection.
335
- * @param type 'none' to hide, 'auto' to show.
336
- */
484
+ /** Toggle native cursor visibility. */
337
485
  public setNativeCursor(type: "none" | "auto"): void {
338
- if (!this.hideNativeCursor && type === "none") return;
339
-
340
486
  if (type === this.currentCursorState) return;
341
487
  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
- }
488
+ this.container.classList.toggle(this.hideClass, type === "none");
489
+ this.container.style.cursor = type === "none" ? "none" : this.originalContainerCursor;
350
490
  }
351
491
 
352
492
  private updateCursorCSS(): void {
@@ -356,45 +496,59 @@ export class Stage {
356
496
  return;
357
497
  }
358
498
 
359
- const scopedSelectors = rawSelectors.map((s) => `.${this.scopeClass} ${s}`).join(", ");
499
+ const exclusion = `:not(.${this.scopeClass} .supermouse-scope):not(.${this.scopeClass} .supermouse-scope *)`;
500
+ const scopeRule = (s: string) =>
501
+ `.${this.scopeClass}.${this.hideClass} ${s}${exclusion} { cursor: none !important; }`;
502
+
503
+ const scopedRules = rawSelectors.map(scopeRule).join("\n");
504
+
505
+ const broadRule = `.${this.scopeClass}.${this.hideClass} *${exclusion} { cursor: none !important; }`;
506
+ const containerRule = `.${this.scopeClass}.${this.hideClass} { cursor: none !important; }`;
360
507
 
361
508
  this.styleTag.innerText = `
362
- ${scopedSelectors} {
363
- cursor: none !important;
364
- }
365
- `;
509
+ ${containerRule}
510
+ ${broadRule}
511
+ ${scopedRules}
512
+ ${scopeRule("label")}
513
+ ${scopeRule("select")}
514
+ ${scopeRule('input[type="range"]::-webkit-slider-thumb')}
515
+ ${scopeRule('input[type="range"]::-moz-range-thumb')}
516
+ `;
366
517
  }
367
518
 
368
519
  public destroy(): void {
369
520
  this.element.remove();
370
521
  this.styleTag.remove();
371
-
372
522
  this.container.style.cursor = this.originalContainerCursor;
373
- this.container.classList.remove(this.scopeClass);
374
-
523
+ this.container.classList.remove("supermouse-scope", this.scopeClass, this.hideClass);
375
524
  if (this.container !== document.body && this.originalContainerPosition === "static") {
376
525
  this.container.style.position = "";
377
526
  }
378
527
  }
379
528
  }
380
529
 
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
- ];
530
+ /**
531
+ * The subset of `SupermouseOptions` guaranteed to have a concrete value once
532
+ * the constructor has merged user input over the defaults.
533
+ */
534
+ type ResolvedOptions = SupermouseOptions &
535
+ Required<
536
+ Pick<
537
+ SupermouseOptions,
538
+ | "smoothness"
539
+ | "enableTouch"
540
+ | "autoDisableOnMobile"
541
+ | "cursor"
542
+ | "hideOnLeave"
543
+ | "autoStart"
544
+ | "container"
545
+ | "dataPrefix"
546
+ | "zIndex"
547
+ >
548
+ >;
390
549
 
391
550
  /**
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
551
+ * Orchestrates state, animation loop, and plugin lifecycle.
398
552
  */
399
553
  export class Supermouse {
400
554
  public static readonly version: string = VERSION;
@@ -402,51 +556,48 @@ export class Supermouse {
402
556
 
403
557
  state: MouseState;
404
558
 
405
- /**
406
- * Configuration options.
407
- */
408
- options: SupermouseOptions;
559
+ /** Configuration options, fully resolved with defaults applied. */
560
+ options: ResolvedOptions;
409
561
 
410
562
  private plugins: SupermousePlugin[] = [];
411
- private stage: Stage;
563
+ private _stage: Stage;
412
564
  private input: Input;
413
565
 
414
566
  private rafId: number = 0;
415
567
  private lastTime: number = 0;
416
568
  private isRunning: boolean = false;
569
+ private isSuspended: boolean = false;
570
+ private visibilityAbortController = new AbortController();
417
571
 
418
572
  private hoverSelectors: Set<string>;
573
+ private hoverSelectorString: string;
574
+ private crashedPlugins: SupermousePlugin[] = [];
419
575
 
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
576
  constructor(options: SupermouseOptions = {}) {
427
577
  this.options = {
428
578
  smoothness: 0.15,
429
579
  enableTouch: false,
430
580
  autoDisableOnMobile: true,
431
- ignoreOnNative: "auto",
432
- hideCursor: true,
581
+ cursor: "auto",
433
582
  hideOnLeave: true,
434
583
  autoStart: true,
435
584
  container: document.body,
436
585
  dataPrefix: "supermouse",
586
+ zIndex: 9999,
437
587
  ...options
438
- };
588
+ } as ResolvedOptions;
439
589
 
440
590
  this.state = {
441
- pointer: { x: -100, y: -100 },
442
- target: { x: -100, y: -100 },
443
- smooth: { x: -100, y: -100 },
591
+ pointer: { ...OFFSCREEN },
592
+ target: { ...OFFSCREEN },
593
+ smooth: { ...OFFSCREEN },
444
594
  velocity: { x: 0, y: 0 },
595
+ displacement: { x: 0, y: 0 },
445
596
  angle: 0,
446
597
  isDown: false,
447
598
  isHover: false,
448
599
  isNative: false,
449
- forcedCursor: null,
600
+ cursorMode: this.options.cursor,
450
601
  hoverTarget: null,
451
602
  reducedMotion: false,
452
603
  hasReceivedInput: false,
@@ -454,154 +605,184 @@ export class Supermouse {
454
605
  interaction: {}
455
606
  };
456
607
 
457
- if (this.options.hoverSelectors) {
458
- this.hoverSelectors = new Set(this.options.hoverSelectors);
459
- } else {
460
- this.hoverSelectors = new Set(DEFAULT_HOVER_SELECTORS);
461
- }
608
+ this.hoverSelectors = new Set(this.options.hoverSelectors ?? DEFAULT_HOVER_SELECTORS);
609
+ this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
462
610
 
463
- this.stage = new Stage(this.options.container, !!this.options.hideCursor);
464
- this.hoverSelectors.forEach((s) => this.stage.addSelector(s));
611
+ this._stage = new Stage(this.options.container, this.options.zIndex);
612
+ this._stage.addSelectors(this.hoverSelectors);
465
613
 
466
614
  this.input = new Input(
467
615
  this.state,
468
616
  this.options,
469
- () => Array.from(this.hoverSelectors).join(", "),
617
+ () => this.hoverSelectorString,
470
618
  (enabled) => {
471
619
  if (!enabled) this.reset(true);
472
620
  }
473
621
  );
474
622
 
475
- if (this.options.plugins) {
476
- this.options.plugins.forEach((p) => this.use(p));
477
- }
478
-
623
+ this.options.plugins?.forEach((p) => this.use(p));
624
+ this.bindVisibilityHandling();
479
625
  this.init();
480
626
  }
481
627
 
482
- /**
483
- * Retrieves a registered plugin instance by its unique name.
484
- */
628
+ /** Look up a registered plugin by name. */
485
629
  public getPlugin(name: string): SupermousePlugin | undefined {
486
630
  return this.plugins.find((p) => p.name === name);
487
631
  }
488
632
 
489
- /**
490
- * Returns whether the cursor system is currently enabled (processing input).
491
- */
633
+ /** Whether the instance is not disabled/suspended and is processing input. */
492
634
  public get isEnabled(): boolean {
493
635
  return this.input.isEnabled;
494
636
  }
495
637
 
496
- /**
497
- * Enables a specific plugin by name.
498
- * Triggers the `onEnable` lifecycle hook of the plugin.
499
- */
638
+ /** Enable a plugin by name. */
500
639
  public enablePlugin(name: string): void {
501
640
  const plugin = this.getPlugin(name);
502
641
  if (plugin && plugin.isEnabled === false) {
503
642
  plugin.isEnabled = true;
643
+ if (plugin.element) plugin.element.style.display = "";
504
644
  plugin.onEnable?.(this);
505
645
  }
506
646
  }
507
647
 
508
- /**
509
- * Disables a specific plugin by name.
510
- * Triggers the `onDisable` lifecycle hook.
511
- */
648
+ /** Disable a plugin by name and hide its element. */
512
649
  public disablePlugin(name: string): void {
513
650
  const plugin = this.getPlugin(name);
514
651
  if (plugin && plugin.isEnabled !== false) {
515
652
  plugin.isEnabled = false;
516
- plugin.onDisable?.(this);
653
+
654
+ const finishDisable = () => {
655
+ if (plugin.element) plugin.element.style.display = "none";
656
+ plugin.onDisable?.(this);
657
+ };
658
+
659
+ const result = plugin.onBeforeDisable?.(this);
660
+ if (result && typeof result.then === "function") {
661
+ void Promise.resolve(result)
662
+ .then(finishDisable)
663
+ .catch((err) => {
664
+ console.error(`[Supermouse] Plugin '${plugin.name}' onBeforeDisable threw:`, err);
665
+ finishDisable();
666
+ });
667
+ } else {
668
+ finishDisable();
669
+ }
517
670
  }
518
671
  }
519
672
 
520
- /**
521
- * Toggles the enabled state of a plugin.
522
- */
673
+ /** Toggle a plugin's enabled state by name. */
523
674
  public togglePlugin(name: string): void {
524
675
  const plugin = this.getPlugin(name);
525
- if (plugin) {
526
- if (plugin.isEnabled === false) this.enablePlugin(name);
527
- else this.disablePlugin(name);
528
- }
676
+ if (!plugin) return;
677
+ if (plugin.isEnabled === false) this.enablePlugin(name);
678
+ else this.disablePlugin(name);
529
679
  }
530
680
 
681
+ /** Add a selector to hover detection and cursor suppression. */
531
682
  public registerHoverTarget(selector: string): void {
532
683
  if (!this.hoverSelectors.has(selector)) {
533
684
  this.hoverSelectors.add(selector);
534
- this.stage.addSelector(selector);
685
+ this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
686
+ this._stage.addSelector(selector);
535
687
  }
536
688
  }
537
689
 
538
- /**
539
- * The fixed container element where plugins should append their DOM nodes.
540
- */
541
- public get container(): HTMLDivElement {
542
- return this.stage.element;
690
+ /** The DOM element the instance is scoped to. */
691
+ public get container(): HTMLElement {
692
+ return this.options.container;
543
693
  }
544
694
 
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";
695
+ /** The stage element that plugins append their visuals into. */
696
+ public get stage(): HTMLDivElement {
697
+ return this._stage.element;
698
+ }
699
+
700
+ /** Set the current cursor mode. */
701
+ public setCursor(mode: "auto" | "custom" | "native" | "both"): void {
702
+ this.state.cursorMode = mode;
552
703
  }
553
704
 
554
705
  private init(): void {
555
- if (this.options.autoStart) {
556
- this.startLoop();
557
- }
706
+ if (this.options.autoStart) this.startLoop();
558
707
  }
559
708
 
709
+ /** Re‑enable input processing and re‑apply cursor state. */
560
710
  public enable(): void {
561
711
  this.input.isEnabled = true;
562
- this.stage.setNativeCursor("none");
712
+
713
+ if (this.input.hasSeenPointer) {
714
+ this.state.target.x = this.state.smooth.x = this.state.pointer.x;
715
+ this.state.target.y = this.state.smooth.y = this.state.pointer.y;
716
+ this.resetMotion();
717
+ this.state.hasReceivedInput = true;
718
+ }
719
+
720
+ this._stage.setNativeCursor(this.resolveCursorState());
563
721
  }
722
+
723
+ /** Disable input processing and restore native cursor. */
564
724
  public disable(): void {
565
725
  this.input.isEnabled = false;
566
- this.stage.setNativeCursor("auto");
726
+ this._stage.setNativeCursor("auto");
727
+ this._stage.setVisibility(false);
567
728
  this.reset(true);
568
729
  }
569
730
 
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);
731
+ /** Temporarily yield to a scoped instance. */
732
+ public suspend(): void {
733
+ if (!this.input.isEnabled) return;
734
+ this.isSuspended = true;
735
+ this.input.isEnabled = false;
736
+ this.input.clearHover();
737
+ /** Current limitations with multi-scoped containers identified.
738
+ * setting native cursor here suppresses cursor css so aggressively
739
+ * that cursor: "both" will not work on scoped instances.
740
+ * proposed fix by v2.5+ */
741
+ // this._stage.setNativeCursor("auto");
742
+ this._stage.setVisibility(false);
743
+ }
744
+
745
+ /** Resume from `suspend()`. */
746
+ public resume(): void {
747
+ if (!this.isSuspended) return;
748
+ this.isSuspended = false;
749
+ this.input.isEnabled = true;
577
750
 
578
- if (exists) {
579
- console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
580
- return this;
751
+ if (this.state.hasReceivedInput) {
752
+ this.state.target.x = this.state.smooth.x = this.state.pointer.x;
753
+ this.state.target.y = this.state.smooth.y = this.state.pointer.y;
754
+ this.resetMotion();
581
755
  }
582
-
583
- if (plugin.isEnabled === undefined) {
584
- plugin.isEnabled = true;
756
+ // Update plugins before showing stage to avoid stale visuals.
757
+ for (let i = this.plugins.length - 1; i >= 0; i--) {
758
+ this.runPluginSafe(this.plugins[i], 0);
585
759
  }
760
+ this._stage.setVisibility(true);
761
+ }
586
762
 
763
+ /** Register a new plugin. */
764
+ public use(plugin: SupermousePlugin): this {
765
+ if (this.plugins.some((p) => p.name === plugin.name)) {
766
+ console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
767
+ return this;
768
+ }
769
+ plugin.isEnabled ??= true;
587
770
  try {
588
771
  plugin.install?.(this);
589
772
  } catch (e) {
590
773
  console.error(`[Supermouse] Failed to install plugin '${plugin.name}'.`, e);
591
774
  return this;
592
775
  }
593
-
594
776
  this.plugins.push(plugin);
595
- this.plugins.sort((a, b) => (a.priority || 0) - (b.priority || 0));
596
-
777
+ this.plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
597
778
  return this;
598
779
  }
599
780
 
781
+ /** Reset physics; optionally clear all input state. */
600
782
  private reset(hard = false): void {
601
- this.state.pointer = { ...OFFSCREEN };
602
783
  this.state.target = { ...OFFSCREEN };
603
784
  this.state.smooth = { ...OFFSCREEN };
604
- this.state.velocity = { x: 0, y: 0 };
785
+ this.resetMotion();
605
786
  this.state.angle = 0;
606
787
  if (hard) {
607
788
  this.state.hasReceivedInput = false;
@@ -613,26 +794,19 @@ export class Supermouse {
613
794
  private startLoop(): void {
614
795
  if (this.isRunning) return;
615
796
  this.isRunning = true;
616
-
797
+ if (document.hidden) return;
617
798
  this.lastTime = performance.now();
618
- this.tick(this.lastTime);
799
+ this.rafId = requestAnimationFrame(this.tick);
619
800
  }
620
801
 
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
- */
802
+ /** Start the animation loop. */
625
803
  public start(): void {
626
804
  this.startLoop();
627
805
  }
628
806
 
629
- /**
630
- * Manually steps the animation loop.
631
- *
632
- * @param time Current timestamp in milliseconds.
633
- */
807
+ /** Manually step the animation loop. */
634
808
  public step(time: number): void {
635
- this.tick(time);
809
+ this.update(time);
636
810
  }
637
811
 
638
812
  private runPluginSafe(plugin: SupermousePlugin, deltaTime: number): void {
@@ -641,91 +815,139 @@ export class Supermouse {
641
815
  plugin.update?.(this, deltaTime);
642
816
  } catch (e) {
643
817
  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
818
  plugin.isEnabled = false;
819
+ this.crashedPlugins.push(plugin);
820
+ }
821
+ }
652
822
 
653
- // Attempt cleanup
823
+ private cleanupCrashedPlugins(): void {
824
+ if (this.crashedPlugins.length === 0) return;
825
+ for (const plugin of this.crashedPlugins) {
826
+ const index = this.plugins.indexOf(plugin);
827
+ if (index > -1) this.plugins.splice(index, 1);
654
828
  try {
655
- plugin.destroy?.(this);
656
829
  plugin.onDisable?.(this);
830
+ plugin.destroy?.(this);
657
831
  } catch (err) {
658
832
  console.error(`[Supermouse] Failed to cleanup crashed plugin '${plugin.name}'.`, err);
659
833
  }
834
+ plugin.element?.remove();
660
835
  }
836
+ this.crashedPlugins = [];
661
837
  }
662
838
 
663
- /**
664
- * Runs on every animation frame.
665
- */
666
- private tick = (time: number): void => {
839
+ private resolveStageVisibility(): boolean {
840
+ if (this.state.cursorMode === "native") return false;
841
+ if (this.state.cursorMode === "both")
842
+ return this.input.isEnabled && this.state.hasReceivedInput;
843
+ if (this.state.cursorMode === "custom")
844
+ return this.input.isEnabled && this.state.hasReceivedInput;
845
+
846
+ return this.input.isEnabled && !this.state.isNative && this.state.hasReceivedInput;
847
+ }
848
+
849
+ private resolveCursorState(): "none" | "auto" {
850
+ if (!this.input.isEnabled) return "auto";
851
+
852
+ if (this.state.cursorMode === "both") return "auto";
853
+ if (this.state.cursorMode === "native") return "auto";
854
+ if (this.state.cursorMode === "custom") return "none";
855
+ return this.state.isNative || !this.state.hasReceivedInput ? "auto" : "none";
856
+ }
857
+
858
+ private resetMotion(): void {
859
+ this.state.velocity = { x: 0, y: 0 };
860
+ this.state.displacement = { x: 0, y: 0 };
861
+ }
862
+
863
+ private update(time: number): void {
667
864
  const dtMs = time - this.lastTime;
668
865
  const dt = Math.min(dtMs / 1000, 0.1);
669
866
  this.lastTime = time;
670
867
 
671
- if (this.state.hoverTarget && !this.state.hoverTarget.isConnected) {
868
+ const currentTarget = this.input.getCurrentTarget();
869
+ if (currentTarget && !currentTarget.isConnected) {
672
870
  this.input.clearHover();
871
+ } else if (currentTarget) {
872
+ this.input.parseDOMInteraction(currentTarget);
673
873
  }
674
874
 
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);
875
+ this._stage.setVisibility(this.resolveStageVisibility());
876
+ if (this.input.isEnabled) {
877
+ this._stage.setNativeCursor(this.resolveCursorState());
688
878
  }
689
879
 
690
880
  if (this.input.isEnabled && this.state.hasReceivedInput) {
691
881
  this.state.target.x = this.state.pointer.x;
692
882
  this.state.target.y = this.state.pointer.y;
693
- } else {
694
- this.state.target = { ...OFFSCREEN };
695
883
  }
696
884
 
697
885
  for (let i = 0; i < this.plugins.length; i++) {
698
886
  this.runPluginSafe(this.plugins[i], dtMs);
699
887
  }
888
+ this.cleanupCrashedPlugins();
700
889
 
701
890
  if (this.input.isEnabled) {
702
- const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness!) * 2;
891
+ const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness) * 2;
892
+
893
+ const previousX = this.state.smooth.x;
894
+ const previousY = this.state.smooth.y;
703
895
 
704
896
  this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
705
897
  this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, factor, dt);
706
898
 
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;
899
+ this.state.displacement.x = this.state.target.x - this.state.smooth.x;
900
+ this.state.displacement.y = this.state.target.y - this.state.smooth.y;
901
+
902
+ if (dt > 0) {
903
+ this.state.velocity.x = (this.state.smooth.x - previousX) / dt;
904
+ this.state.velocity.y = (this.state.smooth.y - previousY) / dt;
905
+ } else {
906
+ this.state.velocity.x = 0;
907
+ this.state.velocity.y = 0;
908
+ }
909
+
709
910
  const { x: vx, y: vy } = this.state.velocity;
710
911
  if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
711
912
  this.state.angle = Math.atan2(vy, vx) * (180 / Math.PI);
712
913
  }
713
914
  }
915
+ }
714
916
 
917
+ private tick = (time: number): void => {
918
+ this.update(time);
715
919
  if (this.isRunning) {
716
920
  this.rafId = requestAnimationFrame(this.tick);
717
921
  }
718
922
  };
719
923
 
720
- /**
721
- * Destroys the instance.
722
- */
924
+ /** Pause rAF loop when tab hidden; resume on visible. */
925
+ private bindVisibilityHandling(): void {
926
+ document.addEventListener(
927
+ "visibilitychange",
928
+ () => {
929
+ if (!this.isRunning) return;
930
+ if (document.hidden) {
931
+ cancelAnimationFrame(this.rafId);
932
+ } else {
933
+ this.lastTime = performance.now();
934
+ this.rafId = requestAnimationFrame(this.tick);
935
+ }
936
+ },
937
+ { signal: this.visibilityAbortController.signal }
938
+ );
939
+ }
940
+
941
+ /** Destroy the instance, freeing all resources. */
723
942
  public destroy(): void {
724
943
  this.isRunning = false;
725
944
  cancelAnimationFrame(this.rafId);
945
+ this.visibilityAbortController.abort();
726
946
  this.input.destroy();
727
- this.stage.destroy();
947
+ this._stage.destroy();
728
948
  this.plugins.forEach((p) => p.destroy?.(this));
729
949
  this.plugins = [];
730
950
  }
731
951
  }
952
+
953
+ export type SupermouseInstance = Supermouse;