@supermousejs/core 2.0.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 +7 -0
- package/LICENSE.md +21 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.mjs +453 -0
- package/dist/index.umd.js +5 -0
- package/package.json +23 -0
- package/src/Supermouse.ts +405 -0
- package/src/index.ts +2 -0
- package/src/systems/Input.ts +262 -0
- package/src/systems/Stage.ts +156 -0
- package/src/systems/index.ts +2 -0
- package/src/types.ts +169 -0
- package/src/utils/math.ts +20 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +32 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
declare const __VERSION__: string;
|
|
2
|
+
|
|
3
|
+
import { MouseState, SupermouseOptions, SupermousePlugin } from './types';
|
|
4
|
+
import { Stage, Input } from './systems';
|
|
5
|
+
import { damp, angle } from './utils/math';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_HOVER_SELECTORS = [
|
|
8
|
+
'a', 'button', 'input', 'textarea', '[data-hover]', '[data-cursor]'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Central Conductor & Runtime Loop of Supermouse.
|
|
13
|
+
*
|
|
14
|
+
* This class orchestrates the application state, manages the animation loop (`requestAnimationFrame`),
|
|
15
|
+
* and coordinates data flow between the Input system, the Stage system, and the Plugins.
|
|
16
|
+
*
|
|
17
|
+
* ## Architecture
|
|
18
|
+
* 1. **Input System**: Captures raw events and writes to `state.pointer`.
|
|
19
|
+
* 2. **Logic Plugins**: (Priority < 0) Read `pointer`, modify `state.target` (e.g. Magnetic, Stick).
|
|
20
|
+
* 3. **Physics**: Core interpolates `state.smooth` towards `state.target`.
|
|
21
|
+
* 4. **Visual Plugins**: (Priority >= 0) Read `state.smooth`, update DOM transforms.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const app = new Supermouse({ smoothness: 0.15 });
|
|
26
|
+
* app.use(Dot({ color: 'red' }));
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class Supermouse {
|
|
30
|
+
/** The current version of Supermouse.js */
|
|
31
|
+
public static readonly version: string = __VERSION__;
|
|
32
|
+
public readonly version: string = __VERSION__;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Single Source of Truth.
|
|
36
|
+
*
|
|
37
|
+
* This object is shared by reference. `Input` writes to it; `Supermouse` physics reads/writes to it;
|
|
38
|
+
* Plugins read/write to it.
|
|
39
|
+
*/
|
|
40
|
+
state: MouseState;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Configuration options.
|
|
44
|
+
*/
|
|
45
|
+
options: SupermouseOptions;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Registry of active plugins.
|
|
49
|
+
* @internal Use `use()`, `enablePlugin()`, or `disablePlugin()` to interact with this.
|
|
50
|
+
*/
|
|
51
|
+
private plugins: SupermousePlugin[] = [];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Stage System responsible for the DOM container and CSS injection.
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
private stage: Stage;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Input System responsible for event listeners.
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
private input: Input;
|
|
64
|
+
|
|
65
|
+
private rafId: number = 0;
|
|
66
|
+
private lastTime: number = 0;
|
|
67
|
+
private isRunning: boolean = false;
|
|
68
|
+
|
|
69
|
+
private hoverSelectors: Set<string>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a new Supermouse instance.
|
|
73
|
+
*
|
|
74
|
+
* @param options - Global configuration options.
|
|
75
|
+
* @throws Will throw if running in a non-browser environment (window/document undefined).
|
|
76
|
+
*/
|
|
77
|
+
constructor(options: SupermouseOptions = {}) {
|
|
78
|
+
this.options = {
|
|
79
|
+
smoothness: 0.15,
|
|
80
|
+
enableTouch: false,
|
|
81
|
+
autoDisableOnMobile: true,
|
|
82
|
+
ignoreOnNative: 'auto',
|
|
83
|
+
hideCursor: true,
|
|
84
|
+
hideOnLeave: true,
|
|
85
|
+
autoStart: true,
|
|
86
|
+
container: document.body,
|
|
87
|
+
...options
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Ensure container is valid (fallback to body if null/undefined passed explicitly)
|
|
91
|
+
if (!this.options.container) {
|
|
92
|
+
this.options.container = document.body;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.state = {
|
|
96
|
+
pointer: { x: -100, y: -100 },
|
|
97
|
+
target: { x: -100, y: -100 },
|
|
98
|
+
smooth: { x: -100, y: -100 },
|
|
99
|
+
velocity: { x: 0, y: 0 },
|
|
100
|
+
angle: 0,
|
|
101
|
+
isDown: false,
|
|
102
|
+
isHover: false,
|
|
103
|
+
isNative: false,
|
|
104
|
+
forcedCursor: null,
|
|
105
|
+
hoverTarget: null,
|
|
106
|
+
reducedMotion: false,
|
|
107
|
+
hasReceivedInput: false,
|
|
108
|
+
shape: null,
|
|
109
|
+
interaction: {}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Initialize Selectors
|
|
113
|
+
if (this.options.hoverSelectors) {
|
|
114
|
+
this.hoverSelectors = new Set(this.options.hoverSelectors);
|
|
115
|
+
} else {
|
|
116
|
+
this.hoverSelectors = new Set(DEFAULT_HOVER_SELECTORS);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.stage = new Stage(this.options.container, !!this.options.hideCursor);
|
|
120
|
+
this.hoverSelectors.forEach(s => this.stage.addSelector(s));
|
|
121
|
+
|
|
122
|
+
// Pass state by reference to Input. Input will mutate this state directly.
|
|
123
|
+
this.input = new Input(
|
|
124
|
+
this.state,
|
|
125
|
+
this.options,
|
|
126
|
+
() => Array.from(this.hoverSelectors).join(', '),
|
|
127
|
+
(enabled) => { if (!enabled) this.resetPosition(); }
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (this.options.plugins) {
|
|
131
|
+
this.options.plugins.forEach(p => this.use(p));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.init();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Retrieves a registered plugin instance by its unique name.
|
|
139
|
+
*/
|
|
140
|
+
public getPlugin(name: string) {
|
|
141
|
+
return this.plugins.find(p => p.name === name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns whether the cursor system is currently enabled (processing input).
|
|
146
|
+
*/
|
|
147
|
+
public get isEnabled(): boolean {
|
|
148
|
+
return this.input.isEnabled;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Enables a specific plugin by name.
|
|
153
|
+
* Triggers the `onEnable` lifecycle hook of the plugin.
|
|
154
|
+
*/
|
|
155
|
+
public enablePlugin(name: string) {
|
|
156
|
+
const plugin = this.getPlugin(name);
|
|
157
|
+
if (plugin && plugin.isEnabled === false) {
|
|
158
|
+
plugin.isEnabled = true;
|
|
159
|
+
plugin.onEnable?.(this);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Disables a specific plugin by name.
|
|
165
|
+
* Triggers the `onDisable` lifecycle hook.
|
|
166
|
+
*/
|
|
167
|
+
public disablePlugin(name: string) {
|
|
168
|
+
const plugin = this.getPlugin(name);
|
|
169
|
+
if (plugin && plugin.isEnabled !== false) {
|
|
170
|
+
plugin.isEnabled = false;
|
|
171
|
+
plugin.onDisable?.(this);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Toggles the enabled state of a plugin.
|
|
177
|
+
*/
|
|
178
|
+
public togglePlugin(name: string) {
|
|
179
|
+
const plugin = this.getPlugin(name);
|
|
180
|
+
if (plugin) {
|
|
181
|
+
if (plugin.isEnabled === false) this.enablePlugin(name);
|
|
182
|
+
else this.disablePlugin(name);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Registers a CSS selector as an "Interactive Target".
|
|
188
|
+
*
|
|
189
|
+
* When the mouse hovers over an element matching this selector:
|
|
190
|
+
* 1. `state.isHover` becomes `true`.
|
|
191
|
+
* 2. `state.hoverTarget` is set to the element.
|
|
192
|
+
* 3. The `Stage` system injects CSS to hide the native cursor for this element (if `hideCursor: true`).
|
|
193
|
+
*
|
|
194
|
+
* @param selector - A valid CSS selector string (e.g., `.my-button`, `[data-trigger]`).
|
|
195
|
+
*/
|
|
196
|
+
public registerHoverTarget(selector: string) {
|
|
197
|
+
if (!this.hoverSelectors.has(selector)) {
|
|
198
|
+
this.hoverSelectors.add(selector);
|
|
199
|
+
this.stage.addSelector(selector);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The fixed container element where plugins should append their DOM nodes.
|
|
205
|
+
*/
|
|
206
|
+
public get container(): HTMLDivElement { return this.stage.element; }
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Manually override the native cursor visibility.
|
|
210
|
+
* Useful for drag-and-drop operations, modals, or special UI states.
|
|
211
|
+
*
|
|
212
|
+
* @param type 'auto' (Show Native), 'none' (Hide Native), or null (Resume Auto-detection)
|
|
213
|
+
*/
|
|
214
|
+
public setCursor(type: 'auto' | 'none' | null) {
|
|
215
|
+
this.state.forcedCursor = type;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private init() {
|
|
219
|
+
if (this.options.autoStart) {
|
|
220
|
+
this.startLoop();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Starts the update loop and enables input listeners.
|
|
226
|
+
* Hides the native cursor if configured.
|
|
227
|
+
*/
|
|
228
|
+
public enable() { this.input.isEnabled = true; this.stage.setNativeCursor('none'); }
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Stops the update loop, disables listeners, and restores the native cursor.
|
|
232
|
+
* Resets internal state positions to off-screen.
|
|
233
|
+
*/
|
|
234
|
+
public disable() { this.input.isEnabled = false; this.stage.setNativeCursor('auto'); this.resetPosition(); }
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Registers a new plugin.
|
|
238
|
+
*
|
|
239
|
+
* @remarks
|
|
240
|
+
* Plugins are sorted by `priority` immediately after registration.
|
|
241
|
+
* - **Negative Priority (< 0)**: Logic plugins (run before physics).
|
|
242
|
+
* - **Positive Priority (>= 0)**: Visual plugins (run after physics).
|
|
243
|
+
*
|
|
244
|
+
* @param plugin - The plugin object to install.
|
|
245
|
+
*/
|
|
246
|
+
public use(plugin: SupermousePlugin) {
|
|
247
|
+
if (this.plugins.find(p => p.name === plugin.name)) {
|
|
248
|
+
console.warn(`[Supermouse] Plugin "${plugin.name}" already installed.`);
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (plugin.isEnabled === undefined) {
|
|
253
|
+
plugin.isEnabled = true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.plugins.push(plugin);
|
|
257
|
+
this.plugins.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
258
|
+
|
|
259
|
+
plugin.install?.(this);
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private resetPosition() {
|
|
264
|
+
const off = { x: -100, y: -100 };
|
|
265
|
+
this.state.pointer = { ...off };
|
|
266
|
+
this.state.target = { ...off };
|
|
267
|
+
this.state.smooth = { ...off };
|
|
268
|
+
this.state.velocity = { x: 0, y: 0 };
|
|
269
|
+
this.state.angle = 0;
|
|
270
|
+
this.state.hasReceivedInput = false;
|
|
271
|
+
this.state.shape = null;
|
|
272
|
+
this.state.interaction = {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private startLoop() {
|
|
276
|
+
if (this.isRunning) return;
|
|
277
|
+
this.isRunning = true;
|
|
278
|
+
this.lastTime = performance.now();
|
|
279
|
+
this.tick(this.lastTime);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Manually steps the animation loop.
|
|
284
|
+
* Useful when integrating with external game loops (e.g., Three.js, PixiJS) where
|
|
285
|
+
* you want to disable the internal RAF and drive `Supermouse` from your own ticker.
|
|
286
|
+
*
|
|
287
|
+
* @param time Current timestamp in milliseconds.
|
|
288
|
+
*/
|
|
289
|
+
public step(time: number) {
|
|
290
|
+
this.tick(time);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private runPluginSafe(plugin: SupermousePlugin, deltaTime: number) {
|
|
294
|
+
if (plugin.isEnabled === false) return;
|
|
295
|
+
try {
|
|
296
|
+
plugin.update?.(this, deltaTime);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error(`[Supermouse] Plugin '${plugin.name}' crashed and has been disabled.`, e);
|
|
299
|
+
plugin.isEnabled = false;
|
|
300
|
+
plugin.onDisable?.(this);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The Heartbeat.
|
|
306
|
+
* Runs on every animation frame.
|
|
307
|
+
*/
|
|
308
|
+
private tick = (time: number) => {
|
|
309
|
+
// 1. Calculate Delta Time (in seconds)
|
|
310
|
+
// We cap dt at 0.1s (100ms) to prevent massive jumps if the tab is inactive for a while.
|
|
311
|
+
const dtMs = time - this.lastTime;
|
|
312
|
+
const dt = Math.min(dtMs / 1000, 0.1);
|
|
313
|
+
this.lastTime = time;
|
|
314
|
+
|
|
315
|
+
// 2. Visibility Logic
|
|
316
|
+
// Stage is visible only if input is active, it's not a native-cursor override situation,
|
|
317
|
+
// and we have actually received mouse coordinates.
|
|
318
|
+
const shouldShowStage = this.input.isEnabled && !this.state.isNative && this.state.hasReceivedInput;
|
|
319
|
+
this.stage.setVisibility(shouldShowStage);
|
|
320
|
+
|
|
321
|
+
// 3. Native Cursor Hiding Logic
|
|
322
|
+
if (this.input.isEnabled && this.options.hideCursor) {
|
|
323
|
+
let targetState: 'none' | 'auto' = 'auto';
|
|
324
|
+
|
|
325
|
+
// PRIORITY 1: Manual Override (User/Plugin says so)
|
|
326
|
+
if (this.state.forcedCursor !== null) {
|
|
327
|
+
targetState = this.state.forcedCursor;
|
|
328
|
+
}
|
|
329
|
+
// PRIORITY 2: Auto-Detection (Default behavior)
|
|
330
|
+
else {
|
|
331
|
+
// Show native if we are in a "Native Zone" (Input) OR if we haven't moved mouse yet
|
|
332
|
+
const showNative = this.state.isNative || !this.state.hasReceivedInput;
|
|
333
|
+
targetState = showNative ? 'auto' : 'none';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.stage.setNativeCursor(targetState);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (this.input.isEnabled) {
|
|
340
|
+
// 4. Sync Target
|
|
341
|
+
// By default, target = raw pointer. Logic plugins may override this in the next step.
|
|
342
|
+
this.state.target.x = this.state.pointer.x;
|
|
343
|
+
this.state.target.y = this.state.pointer.y;
|
|
344
|
+
|
|
345
|
+
// 5. Run Plugins
|
|
346
|
+
// This iterates through the priority-sorted list.
|
|
347
|
+
// - Logic Plugins (Magnetic) run first and modify this.state.target
|
|
348
|
+
// - Visual Plugins (Dot, Ring) run last and read this.state.smooth
|
|
349
|
+
for (let i = 0; i < this.plugins.length; i++) {
|
|
350
|
+
this.runPluginSafe(this.plugins[i], dtMs);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 6. Physics Integration (Damping)
|
|
354
|
+
// Convert abstract smoothness (0-1) to damping factor (1-50 approx)
|
|
355
|
+
const userSmooth = this.options.smoothness!;
|
|
356
|
+
// Map 0.15 (floaty) -> ~10, 0.5 (snappy) -> ~25
|
|
357
|
+
const dampFactor = this.state.reducedMotion ? 1000 : (1 / userSmooth) * 2;
|
|
358
|
+
|
|
359
|
+
this.state.smooth.x = damp(this.state.smooth.x, this.state.target.x, dampFactor, dt);
|
|
360
|
+
this.state.smooth.y = damp(this.state.smooth.y, this.state.target.y, dampFactor, dt);
|
|
361
|
+
|
|
362
|
+
const vx = this.state.target.x - this.state.smooth.x;
|
|
363
|
+
const vy = this.state.target.y - this.state.smooth.y;
|
|
364
|
+
|
|
365
|
+
this.state.velocity.x = vx;
|
|
366
|
+
this.state.velocity.y = vy;
|
|
367
|
+
|
|
368
|
+
// Only update angle if moving significantly (prevents jitter at rest)
|
|
369
|
+
if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1) {
|
|
370
|
+
this.state.angle = angle(vx, vy);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
} else {
|
|
374
|
+
// Inactive State: Force positions off-screen
|
|
375
|
+
this.state.smooth.x = -100;
|
|
376
|
+
this.state.smooth.y = -100;
|
|
377
|
+
this.state.pointer.x = -100;
|
|
378
|
+
this.state.pointer.y = -100;
|
|
379
|
+
this.state.velocity.x = 0;
|
|
380
|
+
this.state.velocity.y = 0;
|
|
381
|
+
|
|
382
|
+
// Still run plugins (e.g. for exit animations)
|
|
383
|
+
for (let i = 0; i < this.plugins.length; i++) {
|
|
384
|
+
this.runPluginSafe(this.plugins[i], dtMs);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (this.options.autoStart && this.isRunning) {
|
|
389
|
+
this.rafId = requestAnimationFrame(this.tick);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Destroys the instance.
|
|
395
|
+
* Stops the loop, removes all DOM elements, removes all event listeners, and calls destroy on all plugins.
|
|
396
|
+
*/
|
|
397
|
+
public destroy() {
|
|
398
|
+
this.isRunning = false;
|
|
399
|
+
cancelAnimationFrame(this.rafId);
|
|
400
|
+
this.input.destroy();
|
|
401
|
+
this.stage.destroy();
|
|
402
|
+
this.plugins.forEach(p => p.destroy?.(this));
|
|
403
|
+
this.plugins = [];
|
|
404
|
+
}
|
|
405
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
|
|
2
|
+
import { MouseState, SupermouseOptions, InteractionState } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Sensor / State Writer.
|
|
6
|
+
*
|
|
7
|
+
* This class listens to browser events (pointermove, pointerdown, hover) and mutates the shared `MouseState` object.
|
|
8
|
+
* It acts as the "Producer" of data for the Supermouse system.
|
|
9
|
+
*
|
|
10
|
+
* @internal This is an internal system class instantiated by `Supermouse`.
|
|
11
|
+
*/
|
|
12
|
+
export class Input {
|
|
13
|
+
private mediaQueryList?: MediaQueryList;
|
|
14
|
+
private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
|
|
15
|
+
private motionQuery?: MediaQueryList;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Master switch for input processing.
|
|
19
|
+
* Toggled by `Supermouse.enable()`/`disable()` or automatically by device capability checks.
|
|
20
|
+
*/
|
|
21
|
+
public isEnabled: boolean = true;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Performance Optimization:
|
|
25
|
+
* We cache the resolved InteractionState for every element we encounter in a WeakMap.
|
|
26
|
+
*/
|
|
27
|
+
private interactionCache = new WeakMap<HTMLElement, InteractionState>();
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private state: MouseState,
|
|
31
|
+
private options: SupermouseOptions,
|
|
32
|
+
private getHoverSelector: () => string,
|
|
33
|
+
private onEnableChange: (enabled: boolean) => void
|
|
34
|
+
) {
|
|
35
|
+
this.checkDeviceCapability();
|
|
36
|
+
this.checkMotionPreference();
|
|
37
|
+
this.bindEvents();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Automatically disables the custom cursor on devices without fine pointer control (e.g. phones/tablets).
|
|
42
|
+
* Relies on `matchMedia('(pointer: fine)')`.
|
|
43
|
+
*/
|
|
44
|
+
private checkDeviceCapability() {
|
|
45
|
+
if (!this.options.autoDisableOnMobile) return;
|
|
46
|
+
|
|
47
|
+
this.mediaQueryList = window.matchMedia('(pointer: fine)');
|
|
48
|
+
this.updateEnabledState(this.mediaQueryList.matches);
|
|
49
|
+
|
|
50
|
+
this.mediaQueryHandler = (e: MediaQueryListEvent) => {
|
|
51
|
+
this.updateEnabledState(e.matches);
|
|
52
|
+
};
|
|
53
|
+
this.mediaQueryList.addEventListener('change', this.mediaQueryHandler);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks for `prefers-reduced-motion`.
|
|
58
|
+
* If true, the core physics engine will switch to instant snapping (high damping) to avoid motion sickness.
|
|
59
|
+
*/
|
|
60
|
+
private checkMotionPreference() {
|
|
61
|
+
this.motionQuery = window.matchMedia('(prefer-reduced-motion: reduce)');
|
|
62
|
+
this.state.reducedMotion = this.motionQuery.matches;
|
|
63
|
+
|
|
64
|
+
this.motionQuery.addEventListener('change', e => {
|
|
65
|
+
this.state.reducedMotion = e.matches;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private updateEnabledState(enabled: boolean) {
|
|
70
|
+
this.isEnabled = enabled;
|
|
71
|
+
this.onEnableChange(enabled);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Interaction Parsing ---
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scrapes the DOM element for metadata to populate `state.interaction`.
|
|
78
|
+
*
|
|
79
|
+
* **Strategy:**
|
|
80
|
+
* 1. Check WeakMap cache.
|
|
81
|
+
* 2. Apply config-based `rules`.
|
|
82
|
+
* 3. Scrape `dataset` (supermouse*).
|
|
83
|
+
* 4. Cache result.
|
|
84
|
+
*/
|
|
85
|
+
private parseDOMInteraction(element: HTMLElement) {
|
|
86
|
+
// 0. Custom Strategy override
|
|
87
|
+
if (this.options.resolveInteraction) {
|
|
88
|
+
this.state.interaction = this.options.resolveInteraction(element);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 1. Check Cache
|
|
93
|
+
if (this.interactionCache.has(element)) {
|
|
94
|
+
this.state.interaction = this.interactionCache.get(element)!;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data: Record<string, any> = {};
|
|
99
|
+
|
|
100
|
+
// 2. Semantic Rules (Config-based)
|
|
101
|
+
if (this.options.rules) {
|
|
102
|
+
for (const [selector, rules] of Object.entries(this.options.rules)) {
|
|
103
|
+
if (element.matches(selector)) {
|
|
104
|
+
Object.assign(data, rules);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 3. Dataset Scraping (Fast Native)
|
|
110
|
+
// Converts data-supermouse-my-val -> myVal
|
|
111
|
+
const dataset = element.dataset;
|
|
112
|
+
for (const key in dataset) {
|
|
113
|
+
if (key.startsWith('supermouse')) {
|
|
114
|
+
// 'supermouseColor' -> 'color'
|
|
115
|
+
// 'supermouseStick' -> 'stick'
|
|
116
|
+
const prop = key.slice(10);
|
|
117
|
+
if (prop) {
|
|
118
|
+
const cleanKey = prop.charAt(0).toLowerCase() + prop.slice(1);
|
|
119
|
+
const val = dataset[key];
|
|
120
|
+
// Treat empty string (bool attribute) as true
|
|
121
|
+
data[cleanKey] = val === '' ? true : val;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. Cache and Set
|
|
127
|
+
this.interactionCache.set(element, data);
|
|
128
|
+
this.state.interaction = data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Handlers ---
|
|
132
|
+
|
|
133
|
+
// Unified Pointer Event Handler
|
|
134
|
+
private handleMove = (e: PointerEvent) => {
|
|
135
|
+
if (!this.isEnabled) return;
|
|
136
|
+
|
|
137
|
+
// Ignore non-mouse inputs if not on touch device (e.g. pen hovering but not touching)
|
|
138
|
+
// unless we strictly want to track everything. PointerType check is robust.
|
|
139
|
+
if (this.options.autoDisableOnMobile && e.pointerType === 'touch') return;
|
|
140
|
+
|
|
141
|
+
let x = e.clientX;
|
|
142
|
+
let y = e.clientY;
|
|
143
|
+
|
|
144
|
+
// Handle Custom Container Coordinates
|
|
145
|
+
if (this.options.container && this.options.container !== document.body) {
|
|
146
|
+
const rect = this.options.container.getBoundingClientRect();
|
|
147
|
+
x -= rect.left;
|
|
148
|
+
y -= rect.top;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.state.pointer.x = x;
|
|
152
|
+
this.state.pointer.y = y;
|
|
153
|
+
|
|
154
|
+
if (!this.state.hasReceivedInput) {
|
|
155
|
+
this.state.hasReceivedInput = true;
|
|
156
|
+
this.state.target.x = this.state.smooth.x = x;
|
|
157
|
+
this.state.target.y = this.state.smooth.y = y;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
private handleDown = () => { if (this.isEnabled) this.state.isDown = true; };
|
|
162
|
+
private handleUp = () => { if (this.isEnabled) this.state.isDown = false; };
|
|
163
|
+
|
|
164
|
+
private handleMouseOver = (e: MouseEvent) => {
|
|
165
|
+
if (!this.isEnabled) return;
|
|
166
|
+
const target = e.target as HTMLElement;
|
|
167
|
+
|
|
168
|
+
// 0. THE VETO: Explicit Ignore
|
|
169
|
+
if (target.closest('[data-supermouse-ignore]')) {
|
|
170
|
+
this.state.isNative = true;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 1. Dynamic Hover Check
|
|
175
|
+
const selector = this.getHoverSelector();
|
|
176
|
+
const hoverable = target.closest(selector);
|
|
177
|
+
|
|
178
|
+
if (hoverable) {
|
|
179
|
+
this.state.isHover = true;
|
|
180
|
+
this.state.hoverTarget = hoverable as HTMLElement;
|
|
181
|
+
this.parseDOMInteraction(this.state.hoverTarget);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 2. Semantic Native Cursor Check (Configurable Strategy)
|
|
185
|
+
const strategy = this.options.ignoreOnNative;
|
|
186
|
+
|
|
187
|
+
if (strategy) {
|
|
188
|
+
const checkTags = strategy === true || strategy === 'auto' || strategy === 'tag';
|
|
189
|
+
const checkCSS = strategy === true || strategy === 'auto' || strategy === 'css';
|
|
190
|
+
let isNative = false;
|
|
191
|
+
|
|
192
|
+
// A. Tag Check (Fast, O(1))
|
|
193
|
+
if (checkTags) {
|
|
194
|
+
// Check localName for speed (always lowercase)
|
|
195
|
+
const tag = target.localName;
|
|
196
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable) {
|
|
197
|
+
isNative = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// B. CSS Check (Slow, causes Layout Reflow)
|
|
202
|
+
// Only run if not already detected and strategy allows it.
|
|
203
|
+
if (!isNative && checkCSS) {
|
|
204
|
+
const style = window.getComputedStyle(target).cursor;
|
|
205
|
+
const supermouseAllowed = ['default', 'auto', 'pointer', 'none', 'inherit'];
|
|
206
|
+
if (!supermouseAllowed.includes(style)) {
|
|
207
|
+
isNative = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (isNative) {
|
|
212
|
+
this.state.isNative = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
private handleMouseOut = (e: MouseEvent) => {
|
|
218
|
+
if (!this.isEnabled) return;
|
|
219
|
+
const target = e.target as HTMLElement;
|
|
220
|
+
|
|
221
|
+
if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
|
|
222
|
+
if (!e.relatedTarget || !(this.state.hoverTarget?.contains(e.relatedTarget as Node))) {
|
|
223
|
+
this.state.isHover = false;
|
|
224
|
+
this.state.hoverTarget = null;
|
|
225
|
+
this.state.interaction = {};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.state.isNative) {
|
|
230
|
+
this.state.isNative = false;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
private handleWindowLeave = () => {
|
|
235
|
+
if (this.options.hideOnLeave) {
|
|
236
|
+
this.state.hasReceivedInput = false;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
private bindEvents() {
|
|
241
|
+
window.addEventListener('pointermove', this.handleMove, { passive: true });
|
|
242
|
+
window.addEventListener('pointerdown', this.handleDown, { passive: true });
|
|
243
|
+
window.addEventListener('pointerup', this.handleUp);
|
|
244
|
+
|
|
245
|
+
document.addEventListener('mouseover', this.handleMouseOver);
|
|
246
|
+
document.addEventListener('mouseout', this.handleMouseOut);
|
|
247
|
+
document.addEventListener('mouseleave', this.handleWindowLeave);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
public destroy() {
|
|
251
|
+
if (this.mediaQueryList && this.mediaQueryHandler) {
|
|
252
|
+
this.mediaQueryList.removeEventListener('change', this.mediaQueryHandler);
|
|
253
|
+
}
|
|
254
|
+
window.removeEventListener('pointermove', this.handleMove);
|
|
255
|
+
window.removeEventListener('pointerdown', this.handleDown);
|
|
256
|
+
window.removeEventListener('pointerup', this.handleUp);
|
|
257
|
+
|
|
258
|
+
document.removeEventListener('mouseover', this.handleMouseOver);
|
|
259
|
+
document.removeEventListener('mouseout', this.handleMouseOut);
|
|
260
|
+
document.removeEventListener('mouseleave', this.handleWindowLeave);
|
|
261
|
+
}
|
|
262
|
+
}
|