@supermousejs/core 2.4.0 → 2.4.2

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 DELETED
@@ -1,953 +0,0 @@
1
- declare const __VERSION__: string | undefined;
2
- const VERSION: string = typeof __VERSION__ !== "undefined" ? __VERSION__ : "0.0.0";
3
-
4
- import type { MouseState, SupermouseOptions, SupermousePlugin, RuleDefinition } from "./types";
5
-
6
- /** Standard linear interpolation */
7
- function lerp(start: number, end: number, factor: number): number {
8
- return start + (end - start) * factor;
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
- */
18
- function damp(a: number, b: number, lambda: number, dt: number): number {
19
- return lerp(a, b, 1 - Math.exp(-lambda * dt));
20
- }
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
-
28
- /**
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`.
56
- *
57
- * @internal Instantiated by `Supermouse`.
58
- */
59
- export class Input {
60
- private mediaQueryList?: MediaQueryList;
61
- private motionQuery?: MediaQueryList;
62
- private dataPrefix: string;
63
- private normalizedDataPrefix: string;
64
- private ignoreAttribute: string;
65
- private abortController = new AbortController();
66
- private nativeTarget: HTMLElement | null = null;
67
- public hasSeenPointer: boolean = false;
68
- public isEnabled: boolean = true;
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
-
83
- constructor(
84
- private state: MouseState,
85
- private options: SupermouseOptions,
86
- private getHoverSelector: () => string,
87
- private onEnableChange: (enabled: boolean) => void
88
- ) {
89
- this.dataPrefix = this.options.dataPrefix ?? "supermouse";
90
- this.normalizedDataPrefix = this.dataPrefix.toLowerCase();
91
- this.ignoreAttribute = `data-${this.dataPrefix}-ignore`;
92
-
93
- this.ruleEntries = this.options.rules ? Object.entries(this.options.rules) : [];
94
-
95
- this.checkDeviceCapability();
96
- this.checkMotionPreference();
97
- this.setupContainerRectTracking();
98
- this.bindEvents();
99
- }
100
-
101
- private checkDeviceCapability(): void {
102
- if (!this.options.autoDisableOnMobile) return;
103
- this.mediaQueryList = window.matchMedia("(pointer: fine)");
104
- this.updateEnabledState(this.mediaQueryList.matches);
105
- this.mediaQueryList.addEventListener("change", (e) => this.updateEnabledState(e.matches), {
106
- signal: this.abortController.signal
107
- });
108
- }
109
-
110
- private checkMotionPreference(): void {
111
- this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
112
- this.state.reducedMotion = this.motionQuery.matches;
113
- this.motionQuery.addEventListener(
114
- "change",
115
- (e) => {
116
- this.state.reducedMotion = e.matches;
117
- },
118
- { signal: this.abortController.signal }
119
- );
120
- }
121
-
122
- private updateEnabledState(enabled: boolean): void {
123
- this.isEnabled = enabled;
124
- this.onEnableChange(enabled);
125
- }
126
-
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
- }
163
- }
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);
176
- }
177
- }
178
-
179
- const pre = this.normalizedDataPrefix;
180
- for (const key in element.dataset) {
181
- if (!key.toLowerCase().startsWith(pre)) continue;
182
- const prop = key.slice(pre.length);
183
- if (!prop) continue;
184
- const val = element.dataset[key];
185
- data[prop[0].toLowerCase() + prop.slice(1)] = val === "" ? true : val!;
186
- }
187
-
188
- this.state.interaction = data;
189
- }
190
-
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
- }
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 => {
230
- if (this.options.autoDisableOnMobile && e.pointerType === "touch" && !this.options.enableTouch)
231
- return;
232
-
233
- let x = e.clientX;
234
- let y = e.clientY;
235
-
236
- const container = this.options.container;
237
- if (container && this.containerRect && container !== document.body) {
238
- x -= this.containerRect.left;
239
- y -= this.containerRect.top;
240
- }
241
-
242
- this.state.pointer.x = x;
243
- this.state.pointer.y = y;
244
- this.hasSeenPointer = true;
245
-
246
- if (!this.isEnabled) return;
247
-
248
- if (!this.state.hasReceivedInput) {
249
- this.state.hasReceivedInput = true;
250
- this.state.target.x = this.state.smooth.x = x;
251
- this.state.target.y = this.state.smooth.y = y;
252
- }
253
- };
254
-
255
- private handleDown = (): void => {
256
- if (this.isEnabled) this.state.isDown = true;
257
- };
258
-
259
- private handleUp = (): void => {
260
- if (this.isEnabled) this.state.isDown = false;
261
- };
262
-
263
- private handleMouseOver = (e: Event): void => {
264
- if (!this.isEnabled) return;
265
- const target = e.target as HTMLElement;
266
-
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
-
277
- this.state.isNative = true;
278
- this.nativeTarget = target;
279
- return;
280
- }
281
-
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
- }
296
-
297
- const hoverable = target.closest(this.getHoverSelector());
298
- if (hoverable) {
299
- this.state.isHover = true;
300
- this.state.hoverTarget = hoverable as HTMLElement;
301
- }
302
-
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;
309
- }
310
- };
311
-
312
- private handleMouseOut = (e: Event): void => {
313
- if (!this.isEnabled) return;
314
- const target = e.target as HTMLElement;
315
- const related = (e as MouseEvent).relatedTarget as Node | null;
316
-
317
- if (this.isOutsideContainer(target)) return;
318
-
319
- if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
320
- if (!related || !this.state.hoverTarget?.contains(related)) {
321
- this.state.isHover = false;
322
- this.state.hoverTarget = null;
323
- }
324
- }
325
-
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
- // 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 => {
342
- if (this.options.hideOnLeave) {
343
- this.state.hasReceivedInput = false;
344
- this.state.pointer = { ...OFFSCREEN };
345
- }
346
- };
347
-
348
- public clearHover(): void {
349
- this.state.isHover = false;
350
- this.state.hoverTarget = null;
351
- this.state.isNative = false;
352
- this.nativeTarget = null;
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;
362
- }
363
-
364
- private bindEvents(): void {
365
- const { signal } = this.abortController;
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 });
369
-
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 });
375
- }
376
-
377
- public destroy(): void {
378
- this.abortController.abort();
379
- this.resizeObserver?.disconnect();
380
- }
381
- }
382
-
383
- let stageCount = 0;
384
-
385
- /**
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.
388
- *
389
- * @internal Instantiated by `Supermouse`.
390
- */
391
- export class Stage {
392
- public readonly element: HTMLDivElement;
393
- private styleTag: HTMLStyleElement;
394
- private readonly id: string;
395
- private readonly scopeClass: string;
396
- private readonly hideClass: string;
397
-
398
- private currentCursorState: "none" | "auto" | null = null;
399
- private originalContainerPosition: string = "";
400
- private originalContainerCursor: string = "";
401
-
402
- /** Selectors that need explicit `cursor: none !important` to override UA styles. */
403
- private selectors: Set<string> = new Set([
404
- "a",
405
- "button",
406
- "input",
407
- "textarea",
408
- "select",
409
- '[role="button"]',
410
- "[tabindex]"
411
- ]);
412
-
413
- constructor(
414
- private container: HTMLElement = document.body,
415
- private zIndex: number = 9999
416
- ) {
417
- if (!container || !(container instanceof HTMLElement)) {
418
- throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
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
- }
426
-
427
- const instanceId = stageCount++;
428
- this.id = `supermouse-style-${instanceId}`;
429
- this.scopeClass = `supermouse-scope-${instanceId}`;
430
- this.hideClass = `supermouse-hide-${instanceId}`;
431
-
432
- const isBody = container === document.body;
433
- this.element = document.createElement("div");
434
- Object.assign(this.element.style, {
435
- position: isBody ? "fixed" : "absolute",
436
- inset: "0px",
437
- pointerEvents: "none",
438
- zIndex: String(this.zIndex),
439
- opacity: "1",
440
- transition: "opacity 0.15s ease"
441
- });
442
-
443
- if (!isBody) {
444
- const computed = window.getComputedStyle(container);
445
- this.originalContainerPosition = computed.position;
446
- if (computed.position === "static") container.style.position = "relative";
447
- }
448
-
449
- this.originalContainerCursor = container.style.cursor;
450
- container.appendChild(this.element);
451
-
452
- this.styleTag = document.createElement("style");
453
- this.styleTag.id = this.id;
454
- document.head.appendChild(this.styleTag);
455
-
456
- this.container.classList.add("supermouse-scope", this.scopeClass);
457
- this.updateCursorCSS();
458
- }
459
-
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
- });
471
- }
472
- if (changed) this.updateCursorCSS();
473
- }
474
-
475
- /** Add a single selector (or comma‑separated group). */
476
- public addSelector(selector: string): void {
477
- this.addSelectors([selector]);
478
- }
479
-
480
- public setVisibility(visible: boolean): void {
481
- this.element.style.opacity = visible ? "1" : "0";
482
- }
483
-
484
- /** Toggle native cursor visibility. */
485
- public setNativeCursor(type: "none" | "auto"): void {
486
- if (type === this.currentCursorState) return;
487
- this.currentCursorState = type;
488
- this.container.classList.toggle(this.hideClass, type === "none");
489
- this.container.style.cursor = type === "none" ? "none" : this.originalContainerCursor;
490
- }
491
-
492
- private updateCursorCSS(): void {
493
- const rawSelectors = Array.from(this.selectors);
494
- if (rawSelectors.length === 0) {
495
- this.styleTag.innerText = "";
496
- return;
497
- }
498
-
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; }`;
507
-
508
- this.styleTag.innerText = `
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
- `;
517
- }
518
-
519
- public destroy(): void {
520
- this.element.remove();
521
- this.styleTag.remove();
522
- this.container.style.cursor = this.originalContainerCursor;
523
- this.container.classList.remove("supermouse-scope", this.scopeClass, this.hideClass);
524
- if (this.container !== document.body && this.originalContainerPosition === "static") {
525
- this.container.style.position = "";
526
- }
527
- }
528
- }
529
-
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
- >;
549
-
550
- /**
551
- * Orchestrates state, animation loop, and plugin lifecycle.
552
- */
553
- export class Supermouse {
554
- public static readonly version: string = VERSION;
555
- public readonly version: string = VERSION;
556
-
557
- state: MouseState;
558
-
559
- /** Configuration options, fully resolved with defaults applied. */
560
- options: ResolvedOptions;
561
-
562
- private plugins: SupermousePlugin[] = [];
563
- private _stage: Stage;
564
- private input: Input;
565
-
566
- private rafId: number = 0;
567
- private lastTime: number = 0;
568
- private isRunning: boolean = false;
569
- private isSuspended: boolean = false;
570
- private visibilityAbortController = new AbortController();
571
-
572
- private hoverSelectors: Set<string>;
573
- private hoverSelectorString: string;
574
- private crashedPlugins: SupermousePlugin[] = [];
575
-
576
- constructor(options: SupermouseOptions = {}) {
577
- this.options = {
578
- smoothness: 0.15,
579
- enableTouch: false,
580
- autoDisableOnMobile: true,
581
- cursor: "auto",
582
- hideOnLeave: true,
583
- autoStart: true,
584
- container: document.body,
585
- dataPrefix: "supermouse",
586
- zIndex: 9999,
587
- ...options
588
- } as ResolvedOptions;
589
-
590
- this.state = {
591
- pointer: { ...OFFSCREEN },
592
- target: { ...OFFSCREEN },
593
- smooth: { ...OFFSCREEN },
594
- velocity: { x: 0, y: 0 },
595
- displacement: { x: 0, y: 0 },
596
- angle: 0,
597
- isDown: false,
598
- isHover: false,
599
- isNative: false,
600
- cursorMode: this.options.cursor,
601
- hoverTarget: null,
602
- reducedMotion: false,
603
- hasReceivedInput: false,
604
- shape: null,
605
- interaction: {}
606
- };
607
-
608
- this.hoverSelectors = new Set(this.options.hoverSelectors ?? DEFAULT_HOVER_SELECTORS);
609
- this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
610
-
611
- this._stage = new Stage(this.options.container, this.options.zIndex);
612
- this._stage.addSelectors(this.hoverSelectors);
613
-
614
- this.input = new Input(
615
- this.state,
616
- this.options,
617
- () => this.hoverSelectorString,
618
- (enabled) => {
619
- if (!enabled) this.reset(true);
620
- }
621
- );
622
-
623
- this.options.plugins?.forEach((p) => this.use(p));
624
- this.bindVisibilityHandling();
625
- this.init();
626
- }
627
-
628
- /** Look up a registered plugin by name. */
629
- public getPlugin(name: string): SupermousePlugin | undefined {
630
- return this.plugins.find((p) => p.name === name);
631
- }
632
-
633
- /** Whether the instance is not disabled/suspended and is processing input. */
634
- public get isEnabled(): boolean {
635
- return this.input.isEnabled;
636
- }
637
-
638
- /** Enable a plugin by name. */
639
- public enablePlugin(name: string): void {
640
- const plugin = this.getPlugin(name);
641
- if (plugin && plugin.isEnabled === false) {
642
- plugin.isEnabled = true;
643
- if (plugin.element) plugin.element.style.display = "";
644
- plugin.onEnable?.(this);
645
- }
646
- }
647
-
648
- /** Disable a plugin by name and hide its element. */
649
- public disablePlugin(name: string): void {
650
- const plugin = this.getPlugin(name);
651
- if (plugin && plugin.isEnabled !== false) {
652
- plugin.isEnabled = false;
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
- }
670
- }
671
- }
672
-
673
- /** Toggle a plugin's enabled state by name. */
674
- public togglePlugin(name: string): void {
675
- const plugin = this.getPlugin(name);
676
- if (!plugin) return;
677
- if (plugin.isEnabled === false) this.enablePlugin(name);
678
- else this.disablePlugin(name);
679
- }
680
-
681
- /** Add a selector to hover detection and cursor suppression. */
682
- public registerHoverTarget(selector: string): void {
683
- if (!this.hoverSelectors.has(selector)) {
684
- this.hoverSelectors.add(selector);
685
- this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
686
- this._stage.addSelector(selector);
687
- }
688
- }
689
-
690
- /** The DOM element the instance is scoped to. */
691
- public get container(): HTMLElement {
692
- return this.options.container;
693
- }
694
-
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;
703
- }
704
-
705
- private init(): void {
706
- if (this.options.autoStart) this.startLoop();
707
- }
708
-
709
- /** Re‑enable input processing and re‑apply cursor state. */
710
- public enable(): void {
711
- this.input.isEnabled = true;
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());
721
- }
722
-
723
- /** Disable input processing and restore native cursor. */
724
- public disable(): void {
725
- this.input.isEnabled = false;
726
- this._stage.setNativeCursor("auto");
727
- this._stage.setVisibility(false);
728
- this.reset(true);
729
- }
730
-
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;
750
-
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();
755
- }
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);
759
- }
760
- this._stage.setVisibility(true);
761
- }
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;
770
- try {
771
- plugin.install?.(this);
772
- } catch (e) {
773
- console.error(`[Supermouse] Failed to install plugin '${plugin.name}'.`, e);
774
- return this;
775
- }
776
- this.plugins.push(plugin);
777
- this.plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
778
- return this;
779
- }
780
-
781
- /** Reset physics; optionally clear all input state. */
782
- private reset(hard = false): void {
783
- this.state.target = { ...OFFSCREEN };
784
- this.state.smooth = { ...OFFSCREEN };
785
- this.resetMotion();
786
- this.state.angle = 0;
787
- if (hard) {
788
- this.state.hasReceivedInput = false;
789
- this.state.shape = null;
790
- this.state.interaction = {};
791
- }
792
- }
793
-
794
- private startLoop(): void {
795
- if (this.isRunning) return;
796
- this.isRunning = true;
797
- if (document.hidden) return;
798
- this.lastTime = performance.now();
799
- this.rafId = requestAnimationFrame(this.tick);
800
- }
801
-
802
- /** Start the animation loop. */
803
- public start(): void {
804
- this.startLoop();
805
- }
806
-
807
- /** Manually step the animation loop. */
808
- public step(time: number): void {
809
- this.update(time);
810
- }
811
-
812
- private runPluginSafe(plugin: SupermousePlugin, deltaTime: number): void {
813
- if (plugin.isEnabled === false) return;
814
- try {
815
- plugin.update?.(this, deltaTime);
816
- } catch (e) {
817
- console.error(`[Supermouse] Plugin '${plugin.name}' crashed and has been disabled.`, e);
818
- plugin.isEnabled = false;
819
- this.crashedPlugins.push(plugin);
820
- }
821
- }
822
-
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);
828
- try {
829
- plugin.onDisable?.(this);
830
- plugin.destroy?.(this);
831
- } catch (err) {
832
- console.error(`[Supermouse] Failed to cleanup crashed plugin '${plugin.name}'.`, err);
833
- }
834
- plugin.element?.remove();
835
- }
836
- this.crashedPlugins = [];
837
- }
838
-
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 {
864
- const dtMs = time - this.lastTime;
865
- const dt = Math.min(dtMs / 1000, 0.1);
866
- this.lastTime = time;
867
-
868
- const currentTarget = this.input.getCurrentTarget();
869
- if (currentTarget && !currentTarget.isConnected) {
870
- this.input.clearHover();
871
- } else if (currentTarget) {
872
- this.input.parseDOMInteraction(currentTarget);
873
- }
874
-
875
- this._stage.setVisibility(this.resolveStageVisibility());
876
- if (this.input.isEnabled) {
877
- this._stage.setNativeCursor(this.resolveCursorState());
878
- }
879
-
880
- if (this.input.isEnabled && this.state.hasReceivedInput) {
881
- this.state.target.x = this.state.pointer.x;
882
- this.state.target.y = this.state.pointer.y;
883
- }
884
-
885
- for (let i = 0; i < this.plugins.length; i++) {
886
- this.runPluginSafe(this.plugins[i], dtMs);
887
- }
888
- this.cleanupCrashedPlugins();
889
-
890
- if (this.input.isEnabled) {
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;
895
-
896
- this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
897
- this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, factor, dt);
898
-
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
-
910
- const { x: vx, y: vy } = this.state.velocity;
911
- if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
912
- this.state.angle = Math.atan2(vy, vx) * (180 / Math.PI);
913
- }
914
- }
915
- }
916
-
917
- private tick = (time: number): void => {
918
- this.update(time);
919
- if (this.isRunning) {
920
- this.rafId = requestAnimationFrame(this.tick);
921
- }
922
- };
923
-
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. */
942
- public destroy(): void {
943
- this.isRunning = false;
944
- cancelAnimationFrame(this.rafId);
945
- this.visibilityAbortController.abort();
946
- this.input.destroy();
947
- this._stage.destroy();
948
- this.plugins.forEach((p) => p.destroy?.(this));
949
- this.plugins = [];
950
- }
951
- }
952
-
953
- export type SupermouseInstance = Supermouse;