@supermousejs/core 2.0.5 → 2.1.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/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export * from './Supermouse';
2
- export * from './types';
1
+ export * from "./Supermouse";
2
+ export * from "./types";
@@ -1,262 +1,231 @@
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
- }
1
+ import type { MouseState, SupermouseOptions } from "../types";
2
+
3
+ /**
4
+ * This class listens to browser events and mutates the shared `MouseState` object.
5
+ *
6
+ * @internal This is an internal system class instantiated by `Supermouse`.
7
+ */
8
+ export class Input {
9
+ private mediaQueryList?: MediaQueryList;
10
+ private mediaQueryHandler?: (e: MediaQueryListEvent) => void;
11
+ private motionQuery?: MediaQueryList;
12
+
13
+ /**
14
+ * Master switch for input processing.
15
+ * Toggled by `Supermouse.enable()`/`disable()` or automatically by device capability checks.
16
+ */
17
+ public isEnabled: boolean = true;
18
+
19
+ constructor(
20
+ private state: MouseState,
21
+ private options: SupermouseOptions,
22
+ private getHoverSelector: () => string,
23
+ private onEnableChange: (enabled: boolean) => void
24
+ ) {
25
+ this.checkDeviceCapability();
26
+ this.checkMotionPreference();
27
+ this.bindEvents();
28
+ }
29
+
30
+ /**
31
+ * Automatically disables the custom cursor on devices without fine pointer control.
32
+ * Relies on `matchMedia('(pointer: fine)')`.
33
+ */
34
+ private checkDeviceCapability() {
35
+ if (!this.options.autoDisableOnMobile) return;
36
+
37
+ this.mediaQueryList = window.matchMedia("(pointer: fine)");
38
+ this.updateEnabledState(this.mediaQueryList.matches);
39
+
40
+ this.mediaQueryHandler = (e: MediaQueryListEvent) => {
41
+ this.updateEnabledState(e.matches);
42
+ };
43
+ this.mediaQueryList.addEventListener("change", this.mediaQueryHandler);
44
+ }
45
+
46
+ /**
47
+ * Checks for `prefers-reduced-motion`.
48
+ * If true, the core physics engine will switch to instant snapping (high damping) to avoid motion sickness.
49
+ */
50
+ private checkMotionPreference() {
51
+ this.motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
52
+ this.state.reducedMotion = this.motionQuery.matches;
53
+
54
+ this.motionQuery.addEventListener("change", (e) => {
55
+ this.state.reducedMotion = e.matches;
56
+ });
57
+ }
58
+
59
+ private updateEnabledState(enabled: boolean) {
60
+ this.isEnabled = enabled;
61
+ this.onEnableChange(enabled);
62
+ }
63
+
64
+ private parseDOMInteraction(element: HTMLElement) {
65
+ if (this.options.resolveInteraction) {
66
+ this.state.interaction = this.options.resolveInteraction(element);
67
+ return;
68
+ }
69
+
70
+ const data: Record<string, string | boolean> = {};
71
+
72
+ if (this.options.rules) {
73
+ for (const [selector, rules] of Object.entries(this.options.rules)) {
74
+ if (element.matches(selector)) {
75
+ Object.assign(data, rules);
76
+ }
77
+ }
78
+ }
79
+
80
+ const dataset = element.dataset;
81
+ for (const key in dataset) {
82
+ if (key.startsWith("supermouse")) {
83
+ const prop = key.slice(10);
84
+ if (prop) {
85
+ const cleanKey = prop.charAt(0).toLowerCase() + prop.slice(1);
86
+ const val = dataset[key];
87
+ if (val !== undefined) {
88
+ data[cleanKey] = val === "" ? true : val;
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ this.state.interaction = data;
95
+ }
96
+
97
+ private handleMove(e: PointerEvent) {
98
+ if (!this.isEnabled) return;
99
+
100
+ if (this.options.autoDisableOnMobile && e.pointerType === "touch") return;
101
+
102
+ let x = e.clientX;
103
+ let y = e.clientY;
104
+
105
+ if (this.options.container && this.options.container !== document.body) {
106
+ const rect = this.options.container.getBoundingClientRect();
107
+ x -= rect.left;
108
+ y -= rect.top;
109
+ }
110
+
111
+ this.state.pointer.x = x;
112
+ this.state.pointer.y = y;
113
+
114
+ if (!this.state.hasReceivedInput) {
115
+ this.state.hasReceivedInput = true;
116
+ this.state.target.x = this.state.smooth.x = x;
117
+ this.state.target.y = this.state.smooth.y = y;
118
+ }
119
+ }
120
+
121
+ private handleDown(): void {
122
+ if (this.isEnabled) this.state.isDown = true;
123
+ }
124
+
125
+ private handleUp(): void {
126
+ if (this.isEnabled) this.state.isDown = false;
127
+ }
128
+
129
+ private handleMouseOver(e: MouseEvent) {
130
+ if (!this.isEnabled) return;
131
+ const target = e.target as HTMLElement;
132
+
133
+ if (target.closest("[data-supermouse-ignore]")) {
134
+ this.state.isNative = true;
135
+ return;
136
+ }
137
+
138
+ const selector = this.getHoverSelector();
139
+ const hoverable = target.closest(selector);
140
+
141
+ if (hoverable) {
142
+ this.state.isHover = true;
143
+ this.state.hoverTarget = hoverable as HTMLElement;
144
+ this.parseDOMInteraction(this.state.hoverTarget);
145
+ }
146
+
147
+ const strategy = this.options.ignoreOnNative;
148
+
149
+ if (strategy) {
150
+ const checkTags = strategy === true || strategy === "auto" || strategy === "tag";
151
+ const checkCSS = strategy === true || strategy === "auto" || strategy === "css";
152
+ let isNative = false;
153
+
154
+ if (checkTags) {
155
+ const tag = target.localName;
156
+ if (tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable) {
157
+ isNative = true;
158
+ }
159
+ }
160
+
161
+ if (!isNative && checkCSS) {
162
+ const style = window.getComputedStyle(target).cursor;
163
+ const supermouseAllowed = ["default", "auto", "pointer", "none", "inherit"];
164
+ if (!supermouseAllowed.includes(style)) {
165
+ isNative = true;
166
+ }
167
+ }
168
+
169
+ if (isNative) {
170
+ this.state.isNative = true;
171
+ }
172
+ }
173
+ }
174
+
175
+ private handleMouseOut(e: MouseEvent) {
176
+ if (!this.isEnabled) return;
177
+ const target = e.target as HTMLElement;
178
+
179
+ if (target === this.state.hoverTarget || target.contains(this.state.hoverTarget)) {
180
+ if (!e.relatedTarget || !this.state.hoverTarget?.contains(e.relatedTarget as Node)) {
181
+ this.state.isHover = false;
182
+ this.state.hoverTarget = null;
183
+ this.state.interaction = {};
184
+ }
185
+ }
186
+
187
+ if (this.state.isNative) {
188
+ this.state.isNative = false;
189
+ }
190
+ }
191
+
192
+ private handleWindowLeave(): void {
193
+ if (this.options.hideOnLeave) {
194
+ this.state.hasReceivedInput = false;
195
+ }
196
+ }
197
+
198
+ public clearHover() {
199
+ this.state.isHover = false;
200
+ this.state.hoverTarget = null;
201
+ this.state.isNative = false;
202
+ }
203
+
204
+ private bindEvents() {
205
+ window.addEventListener("pointermove", this.handleMove.bind(this), { passive: true });
206
+ window.addEventListener("pointerdown", this.handleDown.bind(this), { passive: true });
207
+ window.addEventListener("pointerup", this.handleUp.bind(this));
208
+
209
+ document.addEventListener("mouseover", this.handleMouseOver.bind(this));
210
+ document.addEventListener("mouseout", this.handleMouseOut.bind(this));
211
+ document.addEventListener("mouseleave", this.handleWindowLeave.bind(this));
212
+ }
213
+
214
+ public destroy() {
215
+ if (this.mediaQueryList && this.mediaQueryHandler) {
216
+ this.mediaQueryList.removeEventListener("change", this.mediaQueryHandler);
217
+ }
218
+ if (this.motionQuery) {
219
+ // Modern browsers support removeEventListener on MediaQueryList
220
+ this.motionQuery.onchange = null;
221
+ }
222
+
223
+ window.removeEventListener("pointermove", this.handleMove);
224
+ window.removeEventListener("pointerdown", this.handleDown);
225
+ window.removeEventListener("pointerup", this.handleUp);
226
+
227
+ document.removeEventListener("mouseover", this.handleMouseOver);
228
+ document.removeEventListener("mouseout", this.handleMouseOut);
229
+ document.removeEventListener("mouseleave", this.handleWindowLeave);
230
+ }
231
+ }