@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/CHANGELOG.md +40 -0
- package/README.md +314 -10
- package/dist/index.d.ts +106 -139
- package/dist/index.mjs +316 -227
- package/dist/index.umd.js +10 -5
- package/package.json +4 -3
- package/src/Supermouse.ts +524 -295
- package/src/__tests__/core.spec.ts +291 -0
- package/src/__tests__/cursor-mode.spec.ts +109 -0
- package/src/__tests__/input.spec.ts +261 -0
- package/src/__tests__/integration.spec.ts +121 -0
- package/src/__tests__/lifecycle.spec.ts +199 -0
- package/src/__tests__/nested-instance.spec.ts +58 -0
- package/src/__tests__/plugin.spec.ts +130 -0
- package/src/__tests__/setup.ts +14 -6
- package/src/__tests__/stage.spec.ts +235 -0
- package/src/__tests__/suspend-resume.spec.ts +50 -0
- package/src/types.ts +165 -183
- package/vitest.config.ts +1 -1
- package/src/__tests__/supermouse.test.ts +0 -19
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
x -=
|
|
126
|
-
y -=
|
|
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:
|
|
263
|
+
private handleMouseOver = (e: Event): void => {
|
|
148
264
|
if (!this.isEnabled) return;
|
|
149
265
|
const target = e.target as HTMLElement;
|
|
150
266
|
|
|
151
|
-
if (
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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:
|
|
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 (!
|
|
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.
|
|
206
|
-
this.
|
|
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
|
-
|
|
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
|
|
227
|
-
window.addEventListener("pointerdown", this.handleDown
|
|
228
|
-
window.addEventListener("pointerup", this.handleUp
|
|
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
|
-
|
|
231
|
-
document
|
|
232
|
-
|
|
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
|
-
*
|
|
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" |
|
|
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
|
|
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:
|
|
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
|
-
|
|
313
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
407
|
-
*/
|
|
408
|
-
options: SupermouseOptions;
|
|
571
|
+
/** Configuration options, fully resolved with defaults applied. */
|
|
572
|
+
options: ResolvedOptions;
|
|
409
573
|
|
|
410
574
|
private plugins: SupermousePlugin[] = [];
|
|
411
|
-
private
|
|
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
|
-
|
|
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: {
|
|
442
|
-
target: {
|
|
443
|
-
smooth: {
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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.
|
|
464
|
-
this.
|
|
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
|
-
() =>
|
|
629
|
+
() => this.hoverSelectorString,
|
|
470
630
|
(enabled) => {
|
|
471
631
|
if (!enabled) this.reset(true);
|
|
472
632
|
}
|
|
473
633
|
);
|
|
474
634
|
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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.
|
|
697
|
+
this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
|
|
698
|
+
this._stage.addSelector(selector);
|
|
535
699
|
}
|
|
536
700
|
}
|
|
537
701
|
|
|
538
|
-
/**
|
|
539
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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.
|
|
738
|
+
this._stage.setNativeCursor("auto");
|
|
739
|
+
this._stage.setVisibility(false);
|
|
567
740
|
this.reset(true);
|
|
568
741
|
}
|
|
569
742
|
|
|
570
|
-
/**
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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 (
|
|
584
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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.
|
|
708
|
-
this.state.
|
|
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
|
-
|
|
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.
|
|
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;
|