@supermousejs/core 2.1.0 → 2.3.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 +68 -51
- package/dist/index.d.ts +45 -19
- package/dist/index.mjs +178 -151
- package/dist/index.umd.js +2 -2
- package/package.json +1 -1
- package/src/Supermouse.ts +731 -339
- package/src/__tests__/setup.ts +13 -0
- package/src/__tests__/supermouse.test.ts +19 -0
- package/src/types.ts +27 -12
- package/tsconfig.json +9 -9
- package/vitest.config.ts +10 -0
- package/src/systems/Input.ts +0 -231
- package/src/systems/Stage.ts +0 -126
- package/src/systems/index.ts +0 -2
- package/src/utils/math.ts +0 -11
package/src/Supermouse.ts
CHANGED
|
@@ -1,339 +1,731 @@
|
|
|
1
|
-
declare const __VERSION__: string;
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* This
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
93
|
-
this.state
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
this.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.state.
|
|
221
|
-
this.state.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.
|
|
227
|
-
this.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
this.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
this.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
1
|
+
declare const __VERSION__: string | undefined;
|
|
2
|
+
const VERSION: string = typeof __VERSION__ !== "undefined" ? __VERSION__ : "0.0.0";
|
|
3
|
+
|
|
4
|
+
import type { MouseState, SupermouseOptions, SupermousePlugin } from "./types";
|
|
5
|
+
|
|
6
|
+
function lerp(start: number, end: number, factor: number): number {
|
|
7
|
+
return start + (end - start) * factor;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function damp(a: number, b: number, lambda: number, dt: number): number {
|
|
11
|
+
return lerp(a, b, 1 - Math.exp(-lambda * dt));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Input.ts
|
|
16
|
+
*
|
|
17
|
+
* This class listens to browser events and mutates the shared `MouseState` object.
|
|
18
|
+
*
|
|
19
|
+
* @internal This is an internal system class instantiated by `Supermouse`.
|
|
20
|
+
*/
|
|
21
|
+
export class Input {
|
|
22
|
+
private mediaQueryList?: MediaQueryList;
|
|
23
|
+
private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
|
|
24
|
+
private motionQuery?: MediaQueryList;
|
|
25
|
+
private dataPrefix: string;
|
|
26
|
+
private normalizedDataPrefix: string;
|
|
27
|
+
private ignoreAttribute: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Master switch for input processing.
|
|
31
|
+
* Toggled by `Supermouse.enable()`/`disable()` or automatically by device capability checks.
|
|
32
|
+
*/
|
|
33
|
+
public isEnabled: boolean = true;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private state: MouseState,
|
|
37
|
+
private options: SupermouseOptions,
|
|
38
|
+
private getHoverSelector: () => string,
|
|
39
|
+
private onEnableChange: (enabled: boolean) => void
|
|
40
|
+
) {
|
|
41
|
+
this.dataPrefix = this.options.dataPrefix ?? "supermouse";
|
|
42
|
+
this.normalizedDataPrefix = this.dataPrefix.toLowerCase();
|
|
43
|
+
this.ignoreAttribute = `data-${this.dataPrefix}-ignore`;
|
|
44
|
+
|
|
45
|
+
this.checkDeviceCapability();
|
|
46
|
+
this.checkMotionPreference();
|
|
47
|
+
this.bindEvents();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private abortController = new AbortController();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Automatically disables the custom cursor on devices without fine pointer control.
|
|
54
|
+
*/
|
|
55
|
+
private checkDeviceCapability(): void {
|
|
56
|
+
if (!this.options.autoDisableOnMobile) return;
|
|
57
|
+
|
|
58
|
+
this.mediaQueryList = window.matchMedia("(pointer: fine)");
|
|
59
|
+
this.updateEnabledState(this.mediaQueryList.matches);
|
|
60
|
+
|
|
61
|
+
this.mediaQueryHandler = (e: MediaQueryListEvent) => {
|
|
62
|
+
this.updateEnabledState(e.matches);
|
|
63
|
+
};
|
|
64
|
+
this.mediaQueryList.addEventListener("change", this.mediaQueryHandler, {
|
|
65
|
+
signal: this.abortController.signal
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
private checkMotionPreference(): void {
|
|
74
|
+
this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
75
|
+
this.state.reducedMotion = this.motionQuery.matches;
|
|
76
|
+
|
|
77
|
+
this.motionQuery.addEventListener(
|
|
78
|
+
"change",
|
|
79
|
+
(e) => {
|
|
80
|
+
this.state.reducedMotion = e.matches;
|
|
81
|
+
},
|
|
82
|
+
{ signal: this.abortController.signal }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private updateEnabledState(enabled: boolean): void {
|
|
87
|
+
this.isEnabled = enabled;
|
|
88
|
+
this.onEnableChange(enabled);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private parseDOMInteraction(element: HTMLElement): void {
|
|
92
|
+
if (this.options.resolveInteraction) {
|
|
93
|
+
this.state.interaction = this.options.resolveInteraction(element) || {};
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const data: Record<string, string | boolean> = {};
|
|
97
|
+
if (this.options.rules) {
|
|
98
|
+
for (const [sel, rules] of Object.entries(this.options.rules)) {
|
|
99
|
+
if (element.matches(sel)) Object.assign(data, rules);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const pre = this.normalizedDataPrefix,
|
|
103
|
+
len = this.dataPrefix.length;
|
|
104
|
+
for (const key in element.dataset) {
|
|
105
|
+
if (!key.toLowerCase().startsWith(pre)) continue;
|
|
106
|
+
const prop = key.slice(len);
|
|
107
|
+
if (!prop) continue;
|
|
108
|
+
const val = element.dataset[key];
|
|
109
|
+
data[prop[0].toLowerCase() + prop.slice(1)] = val === "" ? true : val!;
|
|
110
|
+
}
|
|
111
|
+
this.state.interaction = data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private handleMove(e: PointerEvent): void {
|
|
115
|
+
if (!this.isEnabled) return;
|
|
116
|
+
|
|
117
|
+
if (this.options.autoDisableOnMobile && e.pointerType === "touch" && !this.options.enableTouch)
|
|
118
|
+
return;
|
|
119
|
+
|
|
120
|
+
let x = e.clientX;
|
|
121
|
+
let y = e.clientY;
|
|
122
|
+
|
|
123
|
+
if (this.options.container && this.options.container !== document.body) {
|
|
124
|
+
const rect = this.options.container.getBoundingClientRect();
|
|
125
|
+
x -= rect.left;
|
|
126
|
+
y -= rect.top;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.state.pointer.x = x;
|
|
130
|
+
this.state.pointer.y = y;
|
|
131
|
+
|
|
132
|
+
if (!this.state.hasReceivedInput) {
|
|
133
|
+
this.state.hasReceivedInput = true;
|
|
134
|
+
this.state.target.x = this.state.smooth.x = x;
|
|
135
|
+
this.state.target.y = this.state.smooth.y = y;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleDown(): void {
|
|
140
|
+
if (this.isEnabled) this.state.isDown = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private handleUp(): void {
|
|
144
|
+
if (this.isEnabled) this.state.isDown = false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private handleMouseOver(e: MouseEvent): void {
|
|
148
|
+
if (!this.isEnabled) return;
|
|
149
|
+
const target = e.target as HTMLElement;
|
|
150
|
+
|
|
151
|
+
if (target.closest(`[${this.ignoreAttribute}]`)) {
|
|
152
|
+
this.state.isNative = true;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const selector = this.getHoverSelector();
|
|
157
|
+
const hoverable = target.closest(selector);
|
|
158
|
+
|
|
159
|
+
if (hoverable) {
|
|
160
|
+
this.state.isHover = true;
|
|
161
|
+
this.state.hoverTarget = hoverable as HTMLElement;
|
|
162
|
+
this.parseDOMInteraction(this.state.hoverTarget);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const strategy = this.options.ignoreOnNative;
|
|
166
|
+
|
|
167
|
+
if (strategy && strategy !== null) {
|
|
168
|
+
const checkTags = strategy === "auto" || strategy === "tag";
|
|
169
|
+
const checkCSS = strategy === "auto" || strategy === "css";
|
|
170
|
+
let isNative = false;
|
|
171
|
+
|
|
172
|
+
if (checkTags) {
|
|
173
|
+
const tag = target.localName;
|
|
174
|
+
if (tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable) {
|
|
175
|
+
isNative = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!isNative && checkCSS) {
|
|
180
|
+
const style = window.getComputedStyle(target).cursor;
|
|
181
|
+
const supermouseAllowed = ["default", "auto", "pointer", "none", "inherit"];
|
|
182
|
+
if (!supermouseAllowed.includes(style)) {
|
|
183
|
+
isNative = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (isNative) {
|
|
188
|
+
this.state.isNative = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private handleMouseOut(e: MouseEvent): void {
|
|
194
|
+
if (!this.isEnabled) return;
|
|
195
|
+
const target = e.target as HTMLElement;
|
|
196
|
+
|
|
197
|
+
if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
|
|
198
|
+
if (!e.relatedTarget || !this.state.hoverTarget?.contains(e.relatedTarget as Node)) {
|
|
199
|
+
this.state.isHover = false;
|
|
200
|
+
this.state.hoverTarget = null;
|
|
201
|
+
this.state.interaction = {};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (this.state.isNative) {
|
|
206
|
+
this.state.isNative = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private handleWindowLeave(): void {
|
|
211
|
+
if (this.options.hideOnLeave) {
|
|
212
|
+
this.state.hasReceivedInput = false;
|
|
213
|
+
this.state.pointer = { ...OFFSCREEN };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public clearHover(): void {
|
|
218
|
+
this.state.isHover = false;
|
|
219
|
+
this.state.hoverTarget = null;
|
|
220
|
+
this.state.isNative = false;
|
|
221
|
+
this.state.interaction = {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private bindEvents(): void {
|
|
225
|
+
const { signal } = this.abortController;
|
|
226
|
+
window.addEventListener("pointermove", this.handleMove.bind(this), { passive: true, signal });
|
|
227
|
+
window.addEventListener("pointerdown", this.handleDown.bind(this), { passive: true, signal });
|
|
228
|
+
window.addEventListener("pointerup", this.handleUp.bind(this), { signal });
|
|
229
|
+
|
|
230
|
+
document.addEventListener("mouseover", this.handleMouseOver.bind(this), { signal });
|
|
231
|
+
document.addEventListener("mouseout", this.handleMouseOut.bind(this), { signal });
|
|
232
|
+
document.addEventListener("mouseleave", this.handleWindowLeave.bind(this), { signal });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
public destroy(): void {
|
|
236
|
+
this.abortController.abort();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let stageCount = 0;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Stage.ts
|
|
244
|
+
*
|
|
245
|
+
* This class manages the DOM container for the custom cursor and handles native cursor visibility.
|
|
246
|
+
* It is instantiated by the `Supermouse` class and is not intended for direct use by plugins.
|
|
247
|
+
*
|
|
248
|
+
* @internal
|
|
249
|
+
*/
|
|
250
|
+
export class Stage {
|
|
251
|
+
/** The container element appended to the document. */
|
|
252
|
+
public readonly element: HTMLDivElement;
|
|
253
|
+
private styleTag: HTMLStyleElement;
|
|
254
|
+
private id: string;
|
|
255
|
+
private scopeClass: string;
|
|
256
|
+
|
|
257
|
+
private currentCursorState: "none" | "auto" | "" | null = null;
|
|
258
|
+
private originalContainerPosition: string = "";
|
|
259
|
+
private originalContainerCursor: string = "";
|
|
260
|
+
private selectors: Set<string> = new Set([
|
|
261
|
+
"a",
|
|
262
|
+
"button",
|
|
263
|
+
"input",
|
|
264
|
+
"textarea",
|
|
265
|
+
"select",
|
|
266
|
+
'[role="button"]',
|
|
267
|
+
"[tabindex]"
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
constructor(
|
|
271
|
+
private container: HTMLElement = document.body,
|
|
272
|
+
private hideNativeCursor: boolean
|
|
273
|
+
) {
|
|
274
|
+
if (!container || !(container instanceof HTMLElement)) {
|
|
275
|
+
throw new Error(`[Supermouse] Invalid container: ${container}. Must be an HTMLElement.`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const instanceId = stageCount++;
|
|
279
|
+
this.id = `supermouse-style-${instanceId}`;
|
|
280
|
+
this.scopeClass = `supermouse-scope-${instanceId}`;
|
|
281
|
+
|
|
282
|
+
const isBody = container === document.body;
|
|
283
|
+
|
|
284
|
+
this.element = document.createElement("div");
|
|
285
|
+
Object.assign(this.element.style, {
|
|
286
|
+
position: isBody ? "fixed" : "absolute",
|
|
287
|
+
inset: "0px",
|
|
288
|
+
pointerEvents: "none",
|
|
289
|
+
zIndex: "9999",
|
|
290
|
+
opacity: "1",
|
|
291
|
+
transition: "opacity 0.15s ease"
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (!isBody) {
|
|
295
|
+
const computed = window.getComputedStyle(container);
|
|
296
|
+
this.originalContainerPosition = computed.position;
|
|
297
|
+
if (computed.position === "static") {
|
|
298
|
+
container.style.position = "relative";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.originalContainerCursor = container.style.cursor;
|
|
303
|
+
|
|
304
|
+
container.appendChild(this.element);
|
|
305
|
+
|
|
306
|
+
this.styleTag = document.createElement("style");
|
|
307
|
+
this.styleTag.id = this.id;
|
|
308
|
+
document.head.appendChild(this.styleTag);
|
|
309
|
+
|
|
310
|
+
this.container.classList.add(this.scopeClass);
|
|
311
|
+
|
|
312
|
+
if (this.hideNativeCursor) {
|
|
313
|
+
this.setNativeCursor("none");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
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
|
+
*/
|
|
322
|
+
public addSelector(selector: string): void {
|
|
323
|
+
this.selectors.add(selector);
|
|
324
|
+
if (this.hideNativeCursor) {
|
|
325
|
+
this.updateCursorCSS();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
public setVisibility(visible: boolean): void {
|
|
330
|
+
this.element.style.opacity = visible ? "1" : "0";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Toggles the visibility of the native cursor via CSS injection.
|
|
335
|
+
* @param type 'none' to hide, 'auto' to show.
|
|
336
|
+
*/
|
|
337
|
+
public setNativeCursor(type: "none" | "auto"): void {
|
|
338
|
+
if (!this.hideNativeCursor && type === "none") return;
|
|
339
|
+
|
|
340
|
+
if (type === this.currentCursorState) return;
|
|
341
|
+
this.currentCursorState = type;
|
|
342
|
+
|
|
343
|
+
if (type === "none") {
|
|
344
|
+
this.container.style.cursor = "none";
|
|
345
|
+
this.updateCursorCSS();
|
|
346
|
+
} else {
|
|
347
|
+
this.container.style.cursor = "";
|
|
348
|
+
this.styleTag.innerText = "";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private updateCursorCSS(): void {
|
|
353
|
+
const rawSelectors = Array.from(this.selectors);
|
|
354
|
+
if (rawSelectors.length === 0) {
|
|
355
|
+
this.styleTag.innerText = "";
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const scopedSelectors = rawSelectors.map((s) => `.${this.scopeClass} ${s}`).join(", ");
|
|
360
|
+
|
|
361
|
+
this.styleTag.innerText = `
|
|
362
|
+
${scopedSelectors} {
|
|
363
|
+
cursor: none !important;
|
|
364
|
+
}
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public destroy(): void {
|
|
369
|
+
this.element.remove();
|
|
370
|
+
this.styleTag.remove();
|
|
371
|
+
|
|
372
|
+
this.container.style.cursor = this.originalContainerCursor;
|
|
373
|
+
this.container.classList.remove(this.scopeClass);
|
|
374
|
+
|
|
375
|
+
if (this.container !== document.body && this.originalContainerPosition === "static") {
|
|
376
|
+
this.container.style.position = "";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const OFFSCREEN = { x: -100, y: -100 } as const;
|
|
382
|
+
export const DEFAULT_HOVER_SELECTORS = [
|
|
383
|
+
"a",
|
|
384
|
+
"button",
|
|
385
|
+
"input",
|
|
386
|
+
"textarea",
|
|
387
|
+
"[data-hover]",
|
|
388
|
+
"[data-cursor]"
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Supermouse Runtime Loop
|
|
393
|
+
*
|
|
394
|
+
* This class orchestrates the application state, manages the animation loop,
|
|
395
|
+
* and coordinates data flow between the internal systems, and the plugins.
|
|
396
|
+
*
|
|
397
|
+
* @default
|
|
398
|
+
*/
|
|
399
|
+
export class Supermouse {
|
|
400
|
+
public static readonly version: string = VERSION;
|
|
401
|
+
public readonly version: string = VERSION;
|
|
402
|
+
|
|
403
|
+
state: MouseState;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Configuration options.
|
|
407
|
+
*/
|
|
408
|
+
options: SupermouseOptions;
|
|
409
|
+
|
|
410
|
+
private plugins: SupermousePlugin[] = [];
|
|
411
|
+
private stage: Stage;
|
|
412
|
+
private input: Input;
|
|
413
|
+
|
|
414
|
+
private rafId: number = 0;
|
|
415
|
+
private lastTime: number = 0;
|
|
416
|
+
private isRunning: boolean = false;
|
|
417
|
+
|
|
418
|
+
private hoverSelectors: Set<string>;
|
|
419
|
+
|
|
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
|
+
constructor(options: SupermouseOptions = {}) {
|
|
427
|
+
this.options = {
|
|
428
|
+
smoothness: 0.15,
|
|
429
|
+
enableTouch: false,
|
|
430
|
+
autoDisableOnMobile: true,
|
|
431
|
+
ignoreOnNative: "auto",
|
|
432
|
+
hideCursor: true,
|
|
433
|
+
hideOnLeave: true,
|
|
434
|
+
autoStart: true,
|
|
435
|
+
container: document.body,
|
|
436
|
+
dataPrefix: "supermouse",
|
|
437
|
+
...options
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
this.state = {
|
|
441
|
+
pointer: { x: -100, y: -100 },
|
|
442
|
+
target: { x: -100, y: -100 },
|
|
443
|
+
smooth: { x: -100, y: -100 },
|
|
444
|
+
velocity: { x: 0, y: 0 },
|
|
445
|
+
angle: 0,
|
|
446
|
+
isDown: false,
|
|
447
|
+
isHover: false,
|
|
448
|
+
isNative: false,
|
|
449
|
+
forcedCursor: null,
|
|
450
|
+
hoverTarget: null,
|
|
451
|
+
reducedMotion: false,
|
|
452
|
+
hasReceivedInput: false,
|
|
453
|
+
shape: null,
|
|
454
|
+
interaction: {}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (this.options.hoverSelectors) {
|
|
458
|
+
this.hoverSelectors = new Set(this.options.hoverSelectors);
|
|
459
|
+
} else {
|
|
460
|
+
this.hoverSelectors = new Set(DEFAULT_HOVER_SELECTORS);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.stage = new Stage(this.options.container, !!this.options.hideCursor);
|
|
464
|
+
this.hoverSelectors.forEach((s) => this.stage.addSelector(s));
|
|
465
|
+
|
|
466
|
+
this.input = new Input(
|
|
467
|
+
this.state,
|
|
468
|
+
this.options,
|
|
469
|
+
() => Array.from(this.hoverSelectors).join(", "),
|
|
470
|
+
(enabled) => {
|
|
471
|
+
if (!enabled) this.reset(true);
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
if (this.options.plugins) {
|
|
476
|
+
this.options.plugins.forEach((p) => this.use(p));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
this.init();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Retrieves a registered plugin instance by its unique name.
|
|
484
|
+
*/
|
|
485
|
+
public getPlugin(name: string): SupermousePlugin | undefined {
|
|
486
|
+
return this.plugins.find((p) => p.name === name);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Returns whether the cursor system is currently enabled (processing input).
|
|
491
|
+
*/
|
|
492
|
+
public get isEnabled(): boolean {
|
|
493
|
+
return this.input.isEnabled;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Enables a specific plugin by name.
|
|
498
|
+
* Triggers the `onEnable` lifecycle hook of the plugin.
|
|
499
|
+
*/
|
|
500
|
+
public enablePlugin(name: string): void {
|
|
501
|
+
const plugin = this.getPlugin(name);
|
|
502
|
+
if (plugin && plugin.isEnabled === false) {
|
|
503
|
+
plugin.isEnabled = true;
|
|
504
|
+
plugin.onEnable?.(this);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Disables a specific plugin by name.
|
|
510
|
+
* Triggers the `onDisable` lifecycle hook.
|
|
511
|
+
*/
|
|
512
|
+
public disablePlugin(name: string): void {
|
|
513
|
+
const plugin = this.getPlugin(name);
|
|
514
|
+
if (plugin && plugin.isEnabled !== false) {
|
|
515
|
+
plugin.isEnabled = false;
|
|
516
|
+
plugin.onDisable?.(this);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Toggles the enabled state of a plugin.
|
|
522
|
+
*/
|
|
523
|
+
public togglePlugin(name: string): void {
|
|
524
|
+
const plugin = this.getPlugin(name);
|
|
525
|
+
if (plugin) {
|
|
526
|
+
if (plugin.isEnabled === false) this.enablePlugin(name);
|
|
527
|
+
else this.disablePlugin(name);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
public registerHoverTarget(selector: string): void {
|
|
532
|
+
if (!this.hoverSelectors.has(selector)) {
|
|
533
|
+
this.hoverSelectors.add(selector);
|
|
534
|
+
this.stage.addSelector(selector);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The fixed container element where plugins should append their DOM nodes.
|
|
540
|
+
*/
|
|
541
|
+
public get container(): HTMLDivElement {
|
|
542
|
+
return this.stage.element;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Sets the native cursor visibility.
|
|
547
|
+
*
|
|
548
|
+
* @param mode
|
|
549
|
+
*/
|
|
550
|
+
public setNativeCursor(mode: "hide" | "show" | "auto"): void {
|
|
551
|
+
this.state.forcedCursor = mode === "auto" ? null : mode === "hide" ? "none" : "auto";
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private init(): void {
|
|
555
|
+
if (this.options.autoStart) {
|
|
556
|
+
this.startLoop();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
public enable(): void {
|
|
561
|
+
this.input.isEnabled = true;
|
|
562
|
+
this.stage.setNativeCursor("none");
|
|
563
|
+
}
|
|
564
|
+
public disable(): void {
|
|
565
|
+
this.input.isEnabled = false;
|
|
566
|
+
this.stage.setNativeCursor("auto");
|
|
567
|
+
this.reset(true);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Registers a new plugin.
|
|
572
|
+
*
|
|
573
|
+
* @param plugin - The plugin object to install.
|
|
574
|
+
*/
|
|
575
|
+
public use(plugin: SupermousePlugin): this {
|
|
576
|
+
const exists = this.plugins.some((p) => p.name === plugin.name);
|
|
577
|
+
|
|
578
|
+
if (exists) {
|
|
579
|
+
console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
|
|
580
|
+
return this;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (plugin.isEnabled === undefined) {
|
|
584
|
+
plugin.isEnabled = true;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
plugin.install?.(this);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
console.error(`[Supermouse] Failed to install plugin '${plugin.name}'.`, e);
|
|
591
|
+
return this;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
this.plugins.push(plugin);
|
|
595
|
+
this.plugins.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
596
|
+
|
|
597
|
+
return this;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private reset(hard = false): void {
|
|
601
|
+
this.state.pointer = { ...OFFSCREEN };
|
|
602
|
+
this.state.target = { ...OFFSCREEN };
|
|
603
|
+
this.state.smooth = { ...OFFSCREEN };
|
|
604
|
+
this.state.velocity = { x: 0, y: 0 };
|
|
605
|
+
this.state.angle = 0;
|
|
606
|
+
if (hard) {
|
|
607
|
+
this.state.hasReceivedInput = false;
|
|
608
|
+
this.state.shape = null;
|
|
609
|
+
this.state.interaction = {};
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private startLoop(): void {
|
|
614
|
+
if (this.isRunning) return;
|
|
615
|
+
this.isRunning = true;
|
|
616
|
+
|
|
617
|
+
this.lastTime = performance.now();
|
|
618
|
+
this.tick(this.lastTime);
|
|
619
|
+
}
|
|
620
|
+
|
|
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
|
+
*/
|
|
625
|
+
public start(): void {
|
|
626
|
+
this.startLoop();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Manually steps the animation loop.
|
|
631
|
+
*
|
|
632
|
+
* @param time Current timestamp in milliseconds.
|
|
633
|
+
*/
|
|
634
|
+
public step(time: number): void {
|
|
635
|
+
this.tick(time);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private runPluginSafe(plugin: SupermousePlugin, deltaTime: number): void {
|
|
639
|
+
if (plugin.isEnabled === false) return;
|
|
640
|
+
try {
|
|
641
|
+
plugin.update?.(this, deltaTime);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
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
|
+
plugin.isEnabled = false;
|
|
652
|
+
|
|
653
|
+
// Attempt cleanup
|
|
654
|
+
try {
|
|
655
|
+
plugin.destroy?.(this);
|
|
656
|
+
plugin.onDisable?.(this);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
console.error(`[Supermouse] Failed to cleanup crashed plugin '${plugin.name}'.`, err);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Runs on every animation frame.
|
|
665
|
+
*/
|
|
666
|
+
private tick = (time: number): void => {
|
|
667
|
+
const dtMs = time - this.lastTime;
|
|
668
|
+
const dt = Math.min(dtMs / 1000, 0.1);
|
|
669
|
+
this.lastTime = time;
|
|
670
|
+
|
|
671
|
+
if (this.state.hoverTarget && !this.state.hoverTarget.isConnected) {
|
|
672
|
+
this.input.clearHover();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const shouldShowStage =
|
|
676
|
+
this.input.isEnabled && !this.state.isNative && this.state.hasReceivedInput;
|
|
677
|
+
this.stage.setVisibility(shouldShowStage);
|
|
678
|
+
|
|
679
|
+
if (this.input.isEnabled && this.options.hideCursor) {
|
|
680
|
+
let targetState: "none" | "auto" = "auto";
|
|
681
|
+
if (this.state.forcedCursor !== null) {
|
|
682
|
+
targetState = this.state.forcedCursor;
|
|
683
|
+
} else {
|
|
684
|
+
const showNative = this.state.isNative || !this.state.hasReceivedInput;
|
|
685
|
+
targetState = showNative ? "auto" : "none";
|
|
686
|
+
}
|
|
687
|
+
this.stage.setNativeCursor(targetState);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (this.input.isEnabled && this.state.hasReceivedInput) {
|
|
691
|
+
this.state.target.x = this.state.pointer.x;
|
|
692
|
+
this.state.target.y = this.state.pointer.y;
|
|
693
|
+
} else {
|
|
694
|
+
this.state.target = { ...OFFSCREEN };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
for (let i = 0; i < this.plugins.length; i++) {
|
|
698
|
+
this.runPluginSafe(this.plugins[i], dtMs);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (this.input.isEnabled) {
|
|
702
|
+
const factor = this.state.reducedMotion ? 1000 : (1 / this.options.smoothness!) * 2;
|
|
703
|
+
|
|
704
|
+
this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, factor, dt);
|
|
705
|
+
this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, factor, dt);
|
|
706
|
+
|
|
707
|
+
this.state.velocity.x = this.state.target.x - this.state.smooth.x;
|
|
708
|
+
this.state.velocity.y = this.state.target.y - this.state.smooth.y;
|
|
709
|
+
const { x: vx, y: vy } = this.state.velocity;
|
|
710
|
+
if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
|
|
711
|
+
this.state.angle = Math.atan2(vy, vx) * (180 / Math.PI);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (this.isRunning) {
|
|
716
|
+
this.rafId = requestAnimationFrame(this.tick);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Destroys the instance.
|
|
722
|
+
*/
|
|
723
|
+
public destroy(): void {
|
|
724
|
+
this.isRunning = false;
|
|
725
|
+
cancelAnimationFrame(this.rafId);
|
|
726
|
+
this.input.destroy();
|
|
727
|
+
this.stage.destroy();
|
|
728
|
+
this.plugins.forEach((p) => p.destroy?.(this));
|
|
729
|
+
this.plugins = [];
|
|
730
|
+
}
|
|
731
|
+
}
|