@supermousejs/utils 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/src/doctor.ts CHANGED
@@ -1,47 +1,300 @@
1
- /**
2
- * A diagnostic utility to find common CSS conflicts that cause cursor glitches.
3
- */
4
- export function doctor() {
5
- console.group("🐭 Supermouse Doctor");
1
+ export interface DoctorIssue {
2
+ severity: "error" | "warn" | "info";
3
+ code: string;
4
+ message: string;
5
+ hint?: string;
6
+ }
7
+
8
+ interface PluginLike {
9
+ readonly name?: string;
10
+ readonly priority?: number;
11
+ readonly element?: HTMLElement;
12
+ readonly update?: (...args: any[]) => void;
13
+ readonly [key: string]: unknown;
14
+ }
15
+
16
+ interface AppLike {
17
+ readonly plugins?: readonly PluginLike[];
18
+ readonly options?: Readonly<Record<string, unknown>>;
19
+ readonly state?: {
20
+ readonly cursorMode?: "auto" | "custom" | "native" | "both";
21
+ readonly hasReceivedInput?: boolean;
22
+ };
23
+ readonly input?: {
24
+ readonly isEnabled?: boolean;
25
+ };
26
+ }
27
+
28
+ const BRAND = "[supermouse] doctor";
29
+
30
+ const S = {
31
+ brand:
32
+ "color: #ffffff; background: #42b883; padding: 2px 4px; border-radius: 2px; font-weight: 700;",
33
+ error:
34
+ "color: #ffffff; background: #f43f5e; padding: 2px 4px; border-radius: 2px; font-weight: 700;",
35
+ warn: "color: #1f2937; background: #f59e0b; padding: 2px 4px; border-radius: 2px; font-weight: 700;",
36
+ info: "color: #ffffff; background: #3b82f6; padding: 2px 4px; border-radius: 2px; font-weight: 700;",
37
+ dim: "color: #6b7280;",
38
+ code: "color: #a1a1aa; font-weight: 500;",
39
+ hint: "color: #10b981; font-weight: 600;"
40
+ } as const;
41
+
42
+ export function doctor(app?: any): void {
43
+ const issues = app ? audit(app) : scanDom();
44
+ printReport(issues);
45
+ }
46
+
47
+ function audit(app: any): readonly DoctorIssue[] {
48
+ const plugins = app.plugins ?? [];
49
+ const opts = app.options ?? {};
50
+
51
+ return [
52
+ ...checkPluginPriorities(plugins),
53
+ ...checkStatesPriority(plugins),
54
+ ...checkGhostElements(plugins),
55
+ ...checkMultiInstance(opts),
56
+ ...checkContainerPosition(opts),
57
+ ...checkCursorMode(opts, app.state),
58
+ ...scanDom(app)
59
+ ];
60
+ }
61
+
62
+ function isVisualPlugin(plugin: PluginLike): boolean {
63
+ if (plugin.element !== undefined) return true;
64
+ if (typeof plugin.update === "function" && plugin.update.length >= 3) return true;
65
+ return false;
66
+ }
67
+
68
+ function isLogicPlugin(plugin: PluginLike): boolean {
69
+ if (plugin.element !== undefined) return false;
70
+ if (typeof plugin.update === "function" && plugin.update.length === 2) return true;
71
+ return false;
72
+ }
73
+
74
+ function checkPluginPriorities(plugins: readonly PluginLike[]): readonly DoctorIssue[] {
75
+ const out: DoctorIssue[] = [];
76
+
77
+ for (const p of plugins) {
78
+ const name = p.name ?? "unknown";
79
+ const pri = p.priority ?? 0;
80
+
81
+ if (isLogicPlugin(p) && pri >= 0) {
82
+ out.push({
83
+ severity: "warn",
84
+ code: "PRIORITY_LOGIC",
85
+ message: `Plugin "${name}" has priority ${pri} but appears to be a logic plugin (should run before physics).`,
86
+ hint: `Set priority to a negative value (e.g. -10) on "${name}"`
87
+ });
88
+ }
89
+
90
+ if (isVisualPlugin(p) && pri < 0) {
91
+ out.push({
92
+ severity: "warn",
93
+ code: "PRIORITY_VISUAL",
94
+ message: `Plugin "${name}" has priority ${pri} but appears to be a visual plugin (should run after physics).`,
95
+ hint: "Remove priority or use a positive value"
96
+ });
97
+ }
98
+ }
99
+
100
+ return out;
101
+ }
102
+
103
+ function checkStatesPriority(plugins: readonly PluginLike[]): readonly DoctorIssue[] {
104
+ const states = plugins.find((p) => p.name === "states");
105
+ if (!states) return [];
106
+
107
+ const pri = states.priority ?? 0;
108
+ if (pri >= 0) {
109
+ return [
110
+ {
111
+ severity: "error",
112
+ code: "STATES_PRIORITY",
113
+ message: `States plugin has priority ${pri}. It must run before all visual plugins.`,
114
+ hint: "Set priority: -999 on the States plugin"
115
+ }
116
+ ];
117
+ }
118
+
119
+ return [];
120
+ }
121
+
122
+ function checkGhostElements(plugins: readonly PluginLike[]): readonly DoctorIssue[] {
123
+ const out: DoctorIssue[] = [];
124
+
125
+ for (const p of plugins) {
126
+ const name = p.name ?? "unknown";
127
+ if (isVisualPlugin(p) && !p.element) {
128
+ out.push({
129
+ severity: "warn",
130
+ code: "MISSING_ELEMENT",
131
+ message: `Plugin "${name}" has no 'element' property — disablePlugin() will not hide its DOM.`,
132
+ hint: "Assign plugin.element = el in install(), or use definePlugin()"
133
+ });
134
+ }
135
+ }
136
+
137
+ return out;
138
+ }
139
+
140
+ function checkMultiInstance(opts: Readonly<Record<string, unknown>>): readonly DoctorIssue[] {
141
+ const scopes = document.querySelectorAll('[class*="supermouse-scope-"]');
142
+ if (scopes.length <= 1) return [];
143
+ if (opts.container !== document.body) return [];
144
+
145
+ return [
146
+ {
147
+ severity: "warn",
148
+ code: "MULTI_INSTANCE",
149
+ message: `Detected ${scopes.length} Supermouse instances. The body instance may conflict with scoped ones.`,
150
+ hint: "Use suspend() / resume() or scope instances to different containers"
151
+ }
152
+ ];
153
+ }
154
+
155
+ function checkContainerPosition(opts: Readonly<Record<string, unknown>>): readonly DoctorIssue[] {
156
+ const container = opts.container as HTMLElement | undefined;
157
+ if (!container || container === document.body) return [];
158
+
159
+ const position = window.getComputedStyle(container).position;
160
+ if (position !== "static") return [];
161
+
162
+ return [
163
+ {
164
+ severity: "info",
165
+ code: "CONTAINER_POSITION",
166
+ message: "Container has position:static. Supermouse will mutate it to relative.",
167
+ hint: "Set position: relative in your CSS to avoid the mutation"
168
+ }
169
+ ];
170
+ }
171
+
172
+ function checkCursorMode(
173
+ opts: Readonly<Record<string, unknown>>,
174
+ state?: AppLike["state"]
175
+ ): readonly DoctorIssue[] {
176
+ const cursor = opts.cursor;
177
+ if (cursor === undefined) return [];
178
+
179
+ const validModes = ["auto", "custom", "native", "both"];
180
+ if (typeof cursor === "string" && !validModes.includes(cursor)) {
181
+ return [
182
+ {
183
+ severity: "error",
184
+ code: "INVALID_CURSOR_MODE",
185
+ message: `Invalid cursor mode "${cursor}". Expected one of: ${validModes.join(", ")}.`,
186
+ hint: `Set cursor to one of: ${validModes.join(", ")}`
187
+ }
188
+ ];
189
+ }
190
+
191
+ if (cursor === "both" && opts.container !== document.body && state?.cursorMode === "both") {
192
+ return [
193
+ {
194
+ severity: "warn",
195
+ code: "NESTED_BOTH_MODE",
196
+ message:
197
+ "Scoped instance uses cursor: 'both'. In nested scopes, the native cursor may be hidden by the outer instance's cursor suppression.",
198
+ hint: "Offset the custom cursor when cursorMode === 'both' or consider a single-instance scope architecture"
199
+ }
200
+ ];
201
+ }
202
+
203
+ return [];
204
+ }
205
+
206
+ function scanDom(app?: AppLike): readonly DoctorIssue[] {
207
+ const out: DoctorIssue[] = [];
6
208
 
7
- const hasInitialized = Array.from(document.querySelectorAll("style")).some((s) =>
209
+ const isInitialized = Array.from(document.querySelectorAll("style")).some((s) =>
8
210
  s.id.startsWith("supermouse-style-")
9
211
  );
10
212
 
11
- if (!hasInitialized) {
12
- console.warn("[x] Supermouse has not been initialized yet.");
13
- console.warn(" Please call doctor() after `new Supermouse()` has run.");
14
- console.groupEnd();
15
- return;
213
+ if (!isInitialized) {
214
+ return [
215
+ {
216
+ severity: "error",
217
+ code: "NOT_INITIALIZED",
218
+ message: "Supermouse has not been initialized yet.",
219
+ hint: "Call doctor() after new Supermouse() has run"
220
+ }
221
+ ];
16
222
  }
17
223
 
18
- const issues: { el: Element; reason: string }[] = [];
19
-
20
- const inlineCursor = document.querySelectorAll('[style*="cursor"]');
21
- inlineCursor.forEach((el) => {
22
- if (el.getAttribute("style")?.includes("cursor: none")) return;
224
+ document.querySelectorAll('[style*="cursor"]').forEach((el) => {
225
+ const style = el.getAttribute("style") ?? "";
226
+ if (style.includes("cursor: none")) return;
227
+ if (!style.includes("cursor")) return;
23
228
 
24
- issues.push({
25
- el,
26
- reason: 'Element has inline "cursor" style. Remove it and let Supermouse handle state.'
229
+ out.push({
230
+ severity: "warn",
231
+ code: "INLINE_CURSOR",
232
+ message: "Element has an inline cursor style that may override Supermouse.",
233
+ hint: "Remove inline cursor styles"
27
234
  });
28
235
  });
29
236
 
30
- if (document.body.style.cursor !== "none") {
31
- console.warn(
32
- '[Global] document.body.style.cursor is not "none". Ensure { hideCursor: true } is passed to Supermouse.'
33
- );
237
+ const bodyInstance = document.querySelector(".supermouse-scope-0");
238
+ if (bodyInstance) {
239
+ const hasHideClass = bodyInstance.classList.contains("supermouse-hide-0");
240
+ const cursorMode = app?.state?.cursorMode ?? "auto";
241
+ const hasReceivedInput = app?.state?.hasReceivedInput ?? true;
242
+ const inputEnabled = app?.input?.isEnabled ?? true;
243
+
244
+ if (cursorMode === "custom" && inputEnabled && hasReceivedInput && !hasHideClass) {
245
+ out.push({
246
+ severity: "warn",
247
+ code: "BODY_CURSOR_LEAK",
248
+ message:
249
+ "Body instance has cursor: 'custom' but native cursor suppression class is not applied.",
250
+ hint: "Check that the animation loop is running and setCursor('custom') was called"
251
+ });
252
+ }
253
+ }
254
+
255
+ return out;
256
+ }
257
+
258
+ function printReport(issues: readonly DoctorIssue[]): void {
259
+ if (issues.length === 0) {
260
+ console.log(`%c${BRAND}%c No issues found`, S.brand, S.dim);
261
+ return;
34
262
  }
35
263
 
36
- if (issues.length > 0) {
37
- console.warn(`Found ${issues.length} potential conflicts:`);
38
- issues.forEach((i) => console.warn(`[${i.reason}]`, i.el));
39
- console.info(
40
- 'Tip: Avoid setting "cursor: pointer" manually. Use plugins or `rules` config instead.'
41
- );
42
- } else {
43
- console.log("[ok] No obvious inline-style conflicts found.");
264
+ const summary = [
265
+ formatCount(issues, "error", "error"),
266
+ formatCount(issues, "warn", "warning"),
267
+ formatCount(issues, "info", "note")
268
+ ]
269
+ .filter((s): s is string => s !== null)
270
+ .join(" · ");
271
+
272
+ console.log(`%c${BRAND}%c ${summary}`, S.brand, S.dim);
273
+
274
+ for (const issue of issues) {
275
+ printIssue(issue);
44
276
  }
277
+ }
278
+
279
+ function formatCount(
280
+ issues: readonly DoctorIssue[],
281
+ severity: DoctorIssue["severity"],
282
+ label: string
283
+ ): string | null {
284
+ const n = issues.filter((i) => i.severity === severity).length;
285
+ return n > 0 ? `${n} ${label}${n > 1 ? "s" : ""}` : null;
286
+ }
287
+
288
+ function printIssue(issue: DoctorIssue): void {
289
+ console.log(
290
+ `%c${issue.severity.toUpperCase()}%c %c${issue.code}%c ${issue.message}`,
291
+ S[issue.severity],
292
+ S.dim,
293
+ S.code,
294
+ "color: inherit;"
295
+ );
45
296
 
46
- console.groupEnd();
297
+ if (issue.hint) {
298
+ console.log(` %c→%c ${issue.hint}`, S.hint, S.dim);
299
+ }
47
300
  }
package/src/dom.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * @param id A unique identifier for this style block
6
6
  * @param css A string of CSS rules to inject.
7
7
  */
8
- export const injectStyles = (id: string, css: string) => {
8
+ export const injectStyles = (id: string, css: string): void => {
9
9
  if (typeof document === "undefined") return;
10
10
  if (document.getElementById(id)) return;
11
11
 
@@ -15,16 +15,31 @@ export const injectStyles = (id: string, css: string) => {
15
15
  document.head.appendChild(style);
16
16
  };
17
17
 
18
- // WeakMap to store previous styles for elements to prevent DOM thrashing
19
- const styleCache = new WeakMap<HTMLElement, Record<string, string | number>>();
18
+ const styleCache = new WeakMap<HTMLElement | SVGElement, Record<string, string | number>>();
19
+
20
+ type CSSProperties = {
21
+ [K in keyof CSSStyleDeclaration as CSSStyleDeclaration[K] extends (...args: any) => any
22
+ ? never
23
+ : K]?: string | number;
24
+ };
25
+
26
+ function camelToKebab(str: string): string {
27
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
28
+ }
20
29
 
21
30
  /**
22
- * Smart Style Setter (Batch).
23
- * Only writes to the DOM if the value has actually changed.
24
- * @param el The element to style
25
- * @param styles An object of CSS properties and values
31
+ * Applies CSS properties to an element. Only touches the DOM when a value
32
+ * has actually changed.
33
+ *
34
+ * @example
35
+ * css(el, {
36
+ * width: `${size}px`,
37
+ * height: `${size}px`,
38
+ * opacity: state.isHover ? 1 : 0,
39
+ * backgroundColor: state.interaction.color || "#000",
40
+ * });
26
41
  */
27
- export function applyStyles(el: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
42
+ export function css(el: HTMLElement | SVGElement, styles: CSSProperties): void {
28
43
  if (typeof document === "undefined" || !el) return;
29
44
 
30
45
  let cache = styleCache.get(el);
@@ -33,28 +48,32 @@ export function applyStyles(el: HTMLElement, styles: Partial<CSSStyleDeclaration
33
48
  styleCache.set(el, cache);
34
49
  }
35
50
 
36
- for (const prop in styles) {
37
- const value = (styles as any)[prop];
38
- if (cache[prop] !== value) {
39
- (el.style as any)[prop] = value;
40
- cache[prop] = value;
51
+ for (const [prop, value] of Object.entries(styles)) {
52
+ if (value === undefined) continue;
53
+ const stringValue = String(value);
54
+ const kebabProp = camelToKebab(prop);
55
+ if (cache[kebabProp] !== stringValue) {
56
+ el.style.setProperty(kebabProp, stringValue);
57
+ cache[kebabProp] = stringValue;
41
58
  }
42
59
  }
43
60
  }
44
61
 
45
62
  /**
46
- * Smart Style Setter (Single).
47
- * Proxies to applyStyles for consistency.
48
- * @param el The element to style
49
- * @param property The CSS property to set
50
- * @param value The value to set for the property
63
+ * @deprecated Use `dom.css()` instead. This function will be removed in future versions.
64
+ */
65
+ export function setStyle(el: HTMLElement | SVGElement, prop: string, value: string | number): void {
66
+ css(el, { [prop]: value });
67
+ }
68
+
69
+ /**
70
+ * @deprecated Use `dom.css()` instead. This function will be removed in future versions.
51
71
  */
52
- export function setStyle(
53
- el: HTMLElement,
54
- property: keyof CSSStyleDeclaration,
55
- value: string | number
56
- ) {
57
- applyStyles(el, { [property]: value } as any);
72
+ export function applyStyles(
73
+ el: HTMLElement | SVGElement,
74
+ styles: Record<string, string | number>
75
+ ): void {
76
+ css(el, styles);
58
77
  }
59
78
 
60
79
  /**
@@ -71,7 +90,7 @@ export function setStyle(
71
90
  * @param skewY Skew Y (deg) - Default 0
72
91
  */
73
92
  export function setTransform(
74
- el: HTMLElement,
93
+ el: HTMLElement | SVGElement,
75
94
  x: number,
76
95
  y: number,
77
96
  rotation: number = 0,
@@ -79,16 +98,19 @@ export function setTransform(
79
98
  scaleY: number = 1,
80
99
  skewX: number = 0,
81
100
  skewY: number = 0
82
- ) {
101
+ ): void {
83
102
  const transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%) rotate(${rotation}deg) skew(${skewX}deg, ${skewY}deg) scale(${scaleX}, ${scaleY})`;
84
103
 
85
- setStyle(el, "transform", transform);
104
+ css(el, { transform });
86
105
  }
87
106
 
88
107
  /**
89
108
  * Calculates the bounding rectangle of an element relative to a container.
90
109
  */
91
- export function projectRect(element: HTMLElement, container: HTMLElement = document.body): DOMRect {
110
+ export function projectRect(
111
+ element: HTMLElement | SVGSVGElement,
112
+ container: HTMLElement | SVGSVGElement = document.body
113
+ ): DOMRect {
92
114
  const rect = element.getBoundingClientRect();
93
115
 
94
116
  if (container !== document.body) {
@@ -108,9 +130,9 @@ export function projectRect(element: HTMLElement, container: HTMLElement = docum
108
130
  *
109
131
  * @param tagName The HTML tag to create (default: 'div')
110
132
  */
111
- export function createActor(tagName: string = "div"): HTMLElement {
133
+ export function createActor(tagName: string = "div"): HTMLElement | SVGSVGElement {
112
134
  const el = document.createElement(tagName);
113
- applyStyles(el, {
135
+ css(el, {
114
136
  position: "absolute",
115
137
  top: "0",
116
138
  left: "0",
@@ -127,7 +149,7 @@ export function createActor(tagName: string = "div"): HTMLElement {
127
149
  */
128
150
  export function createCircle(size: number, color: string): HTMLDivElement {
129
151
  const el = createActor("div") as HTMLDivElement;
130
- applyStyles(el, {
152
+ css(el, {
131
153
  width: `${size}px`,
132
154
  height: `${size}px`,
133
155
  borderRadius: "50%",
package/src/index.ts CHANGED
@@ -1,10 +1,15 @@
1
- import * as math from "./math";
2
- import * as dom from "./dom";
3
- import * as effects from "./effects";
4
-
5
- export { math, dom, effects };
6
- export * from "./layers";
7
- export * from "./css";
8
- export * from "./plugin";
9
- export * from "./options";
10
- export * from "./doctor";
1
+ export * from "./math";
2
+ export * from "./dom";
3
+ export * from "./effects";
4
+ export * from "./options";
5
+ export * from "./doctor";
6
+ export * from "./plugin";
7
+ export * from "./svg";
8
+ export * from "./css";
9
+
10
+ import * as math from "./math";
11
+ import * as dom from "./dom";
12
+ import * as effects from "./effects";
13
+ import * as svg from "./svg";
14
+
15
+ export { math, dom, effects, svg };
package/src/math.ts CHANGED
@@ -58,3 +58,7 @@ export function dist(x1: number, y1: number, x2: number = 0, y2: number = 0): nu
58
58
  export function angle(x: number, y: number): number {
59
59
  return Math.atan2(y, x) * (180 / Math.PI);
60
60
  }
61
+
62
+ export function circumference(r: number): number {
63
+ return 2 * Math.PI * r;
64
+ }
package/src/options.ts CHANGED
@@ -1,22 +1,65 @@
1
- import type { MouseState, ValueOrGetter } from "@supermousejs/core";
2
-
3
- /**
4
- * Returns a function that always resolves the option value.
5
- * Eliminates 'typeof' checks inside the render loop by normalizing
6
- * static values into getter functions during initialization.
7
- *
8
- * @param option The option passed by the user
9
- * @param defaultValue Fallback value
10
- */
11
- export function normalize<T>(
12
- option: ValueOrGetter<T> | undefined,
13
- defaultValue: T
14
- ): (state: MouseState) => T {
15
- if (option === undefined) {
16
- return () => defaultValue;
17
- }
18
- if (typeof option === "function") {
19
- return option as (state: MouseState) => T;
20
- }
21
- return () => option;
22
- }
1
+ import type { MouseState, ValueOrGetter } from "@supermousejs/core";
2
+
3
+ /**
4
+ * Returns a function that always resolves the option value.
5
+ * Eliminates 'typeof' checks inside the render loop by normalizing
6
+ * static values into getter functions during initialization.
7
+ *
8
+ * @param option The option passed by the user
9
+ * @param defaultValue Fallback value
10
+ * @returns A function that returns the resolved value of the option
11
+ */
12
+ export function normalize<T>(
13
+ option: ValueOrGetter<T> | undefined,
14
+ defaultValue: T
15
+ ): (state: MouseState) => T {
16
+ if (option === undefined) {
17
+ return () => defaultValue;
18
+ }
19
+ if (typeof option === "function") {
20
+ return option as (state: MouseState) => T;
21
+ }
22
+ return () => option;
23
+ }
24
+
25
+ /**
26
+ * Normalizes multiple options in one call.
27
+ *
28
+ * @example
29
+ * const cfg = normalizeAll(options, {
30
+ * size: 20,
31
+ * color: "#fff",
32
+ * opacity: 1,
33
+ * });
34
+ *
35
+ * // In update():
36
+ * const size = cfg.size(app.state);
37
+ *
38
+ * @param defaults Default values for the options
39
+ * @param options User-provided options to normalize
40
+ * @returns An object with normalized getter functions for each option
41
+ */
42
+ export function normalizeAll<T extends Record<string, unknown>>(
43
+ options: Partial<{ [K in keyof T]: ValueOrGetter<T[K]> }>,
44
+ defaults: T
45
+ ): { [K in keyof T]: (state: MouseState) => T[K] } {
46
+ const result = {} as { [K in keyof T]: (state: MouseState) => T[K] };
47
+
48
+ for (const key of Object.keys(defaults) as Array<keyof T>) {
49
+ result[key] = normalize(
50
+ options[key] as ValueOrGetter<T[typeof key]> | undefined,
51
+ defaults[key]
52
+ );
53
+ }
54
+
55
+ return result;
56
+ }
57
+
58
+ /**
59
+ * Checks if the current device has a fine pointer (e.g., mouse) or a coarse pointer (e.g., touch).
60
+ * This is useful for conditionally enabling or disabling cursor effects based on the input device.
61
+ *
62
+ * @returns True if the device has a fine pointer, false otherwise.
63
+ */
64
+ export const hasFinePointer = (): boolean =>
65
+ typeof window !== "undefined" && window.matchMedia("(pointer: fine)").matches;