@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/CHANGELOG.md +33 -0
- package/dist/index.d.ts +106 -139
- package/dist/index.mjs +309 -227
- package/dist/index.umd.js +10 -5
- package/package.json +4 -3
- package/src/Supermouse.ts +517 -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 +51 -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,282 @@ 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;
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
+
}
|
|
207
331
|
}
|
|
208
|
-
}
|
|
209
332
|
|
|
210
|
-
|
|
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
|
|
227
|
-
window.addEventListener("pointerdown", this.handleDown
|
|
228
|
-
window.addEventListener("pointerup", this.handleUp
|
|
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
|
-
|
|
231
|
-
document
|
|
232
|
-
|
|
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
|
-
*
|
|
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" |
|
|
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
|
|
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:
|
|
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
|
-
|
|
313
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
407
|
-
*/
|
|
408
|
-
options: SupermouseOptions;
|
|
559
|
+
/** Configuration options, fully resolved with defaults applied. */
|
|
560
|
+
options: ResolvedOptions;
|
|
409
561
|
|
|
410
562
|
private plugins: SupermousePlugin[] = [];
|
|
411
|
-
private
|
|
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
|
-
|
|
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: {
|
|
442
|
-
target: {
|
|
443
|
-
smooth: {
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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.
|
|
464
|
-
this.
|
|
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
|
-
() =>
|
|
617
|
+
() => this.hoverSelectorString,
|
|
470
618
|
(enabled) => {
|
|
471
619
|
if (!enabled) this.reset(true);
|
|
472
620
|
}
|
|
473
621
|
);
|
|
474
622
|
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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.
|
|
685
|
+
this.hoverSelectorString = Array.from(this.hoverSelectors).join(", ");
|
|
686
|
+
this._stage.addSelector(selector);
|
|
535
687
|
}
|
|
536
688
|
}
|
|
537
689
|
|
|
538
|
-
/**
|
|
539
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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.
|
|
726
|
+
this._stage.setNativeCursor("auto");
|
|
727
|
+
this._stage.setVisibility(false);
|
|
567
728
|
this.reset(true);
|
|
568
729
|
}
|
|
569
730
|
|
|
570
|
-
/**
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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 (
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
|
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.
|
|
708
|
-
this.state.
|
|
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
|
-
|
|
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.
|
|
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;
|