@supermousejs/labs 2.1.1 → 2.2.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/SmartIcon.ts CHANGED
@@ -1,239 +1,200 @@
1
- import type { ValueOrGetter } from "@supermousejs/core";
2
- import { definePlugin, normalize, dom, math, Layers } from "@supermousejs/utils";
3
-
4
- export interface SmartIconMap {
5
- [key: string]: string;
6
- }
7
-
8
- export type SmartIconAnchor =
9
- | "center"
10
- | "top-left"
11
- | "top-right"
12
- | "bottom-left"
13
- | "bottom-right";
14
-
15
- export interface SmartIconOptions {
16
- name?: string;
17
- isEnabled?: boolean;
18
- icons: SmartIconMap;
19
- defaultState?: string;
20
- useSemanticTags?: boolean;
21
- transitionDuration?: number;
22
- size?: ValueOrGetter<number>;
23
- color?: ValueOrGetter<string>;
24
- offset?: [number, number];
25
- anchor?: ValueOrGetter<SmartIconAnchor>;
26
- followStrategy?: ValueOrGetter<"smooth" | "raw">;
27
- rotateWithVelocity?: ValueOrGetter<boolean>;
28
- }
29
-
30
- function resolveSemanticState(
31
- target: HTMLElement,
32
- icons: SmartIconMap
33
- ): string | null {
34
- const tag = target.tagName.toLowerCase();
35
-
36
- if (tag === "input" || tag === "textarea" || target.isContentEditable) {
37
- const type = (target as HTMLInputElement).type;
38
- if (
39
- !["button", "submit", "checkbox", "radio", "range", "color"].includes(
40
- type
41
- )
42
- ) {
43
- if (icons["text"]) return "text";
44
- } else if (icons["pointer"]) {
45
- return "pointer";
46
- }
47
- } else if (
48
- tag === "a" ||
49
- tag === "button" ||
50
- target.closest("a") ||
51
- target.closest("button")
52
- ) {
53
- if (icons["pointer"]) return "pointer";
54
- }
55
- return null;
56
- }
57
-
58
- export const SmartIcon = (options: SmartIconOptions) => {
59
- let contentWrapper: HTMLDivElement;
60
-
61
- // State
62
- let currentState = options.defaultState || "default";
63
- let targetState = options.defaultState || "default";
64
-
65
- let lastTarget: HTMLElement | null = null;
66
- let cachedSemanticState: string | null = null;
67
-
68
- let isTransitioning = false;
69
- let transitionTimer: ReturnType<typeof setTimeout>;
70
-
71
- // Rotation State
72
- let currentRotation = 0;
73
- let lastTargetRotation = 0;
74
-
75
- // Normalized Getters (Pre-calculated)
76
- const getSize = normalize(options.size, 24);
77
- const getStrategy = normalize(options.followStrategy, "smooth");
78
- const getAnchor = normalize(options.anchor, "center");
79
- const getShouldRotate = normalize(options.rotateWithVelocity, false);
80
-
81
- const useSemanticTags = options.useSemanticTags ?? true;
82
- const duration = options.transitionDuration ?? 200;
83
- const userOffX = options.offset ? options.offset[0] : 0;
84
- const userOffY = options.offset ? options.offset[1] : 0;
85
-
86
- return definePlugin<HTMLDivElement, SmartIconOptions>(
87
- {
88
- name: options.name || "smart-icon",
89
- selector: "[data-supermouse-icon]",
90
-
91
- create: () => {
92
- const el = dom.createActor("div") as HTMLDivElement;
93
- el.style.zIndex = Layers.CURSOR;
94
-
95
- contentWrapper = dom.createActor("div") as HTMLDivElement;
96
- dom.applyStyles(contentWrapper, {
97
- width: "100%",
98
- height: "100%",
99
- display: "flex",
100
- alignItems: "center",
101
- justifyContent: "center",
102
- transformOrigin: "center center",
103
- transform: "scale(1)",
104
- // Pre-baked ease. Note: If duration changes dynamically, this won't update.
105
- transition: `transform ${
106
- duration / 2
107
- }ms cubic-bezier(0.16, 1, 0.3, 1)`,
108
- });
109
-
110
- contentWrapper.innerHTML = options.icons[currentState] || "";
111
-
112
- el.appendChild(contentWrapper);
113
- return el;
114
- },
115
-
116
- styles: {
117
- color: "color",
118
- },
119
-
120
- update: (app, el) => {
121
- const icons = options.icons;
122
- const target = app.state.hoverTarget;
123
-
124
- // 1. Determine Next State
125
- let nextState = options.defaultState || "default";
126
-
127
- if (target) {
128
- // A. Update Cache if target changed (Performance Fix)
129
- if (target !== lastTarget) {
130
- lastTarget = target;
131
- cachedSemanticState = useSemanticTags
132
- ? resolveSemanticState(target, icons)
133
- : null;
134
- }
135
-
136
- // B. Resolve Logic
137
- // Check Unified Interaction State (Attribute)
138
- const attrSmartIcon = app.state.interaction?.icon; // Optional chaining safety
139
-
140
- if (attrSmartIcon && icons[attrSmartIcon]) {
141
- nextState = attrSmartIcon;
142
- } else if (cachedSemanticState) {
143
- nextState = cachedSemanticState;
144
- }
145
- } else {
146
- lastTarget = null;
147
- cachedSemanticState = null;
148
- }
149
-
150
- // 2. Handle State Transition
151
- if (nextState !== currentState && !isTransitioning) {
152
- if (
153
- icons[nextState] ||
154
- nextState === (options.defaultState || "default")
155
- ) {
156
- targetState = nextState;
157
- isTransitioning = true;
158
-
159
- // Clean up any pending timer to avoid race conditions
160
- clearTimeout(transitionTimer);
161
-
162
- // Scale Down
163
- contentWrapper.style.transform = "scale(0)";
164
-
165
- transitionTimer = setTimeout(() => {
166
- currentState = targetState;
167
- contentWrapper.innerHTML = icons[currentState] || "";
168
- contentWrapper.style.transform = "scale(1)";
169
-
170
- transitionTimer = setTimeout(() => {
171
- isTransitioning = false;
172
- }, duration / 2);
173
- }, duration / 2);
174
- }
175
- }
176
-
177
- // 3. Layout & Styling
178
- const size = getSize(app.state);
179
- dom.setStyle(el, "width", `${size}px`);
180
- dom.setStyle(el, "height", `${size}px`);
181
-
182
- // 4. Calculate Anchor
183
- let anchorX = 0;
184
- let anchorY = 0;
185
- const half = size / 2;
186
- const anchor = getAnchor(app.state);
187
-
188
- if (anchor !== "center") {
189
- if (anchor.includes("left")) anchorX = half;
190
- if (anchor.includes("right")) anchorX = -half;
191
- if (anchor.includes("top")) anchorY = half;
192
- if (anchor.includes("bottom")) anchorY = -half;
193
- }
194
-
195
- // 5. Rotation
196
- const isSemanticState =
197
- currentState === "pointer" || currentState === "text";
198
-
199
- if (
200
- getShouldRotate(app.state) &&
201
- !isSemanticState &&
202
- !app.state.reducedMotion
203
- ) {
204
- const { x: vx, y: vy } = app.state.velocity;
205
- const speed = math.dist(vx, vy);
206
-
207
- if (speed > 1) {
208
- lastTargetRotation = math.angle(vx, vy);
209
- }
210
- // Assuming math.lerpAngle handles the 360 wrap logic correctly
211
- currentRotation = math.lerpAngle(
212
- currentRotation,
213
- lastTargetRotation,
214
- 0.15
215
- );
216
- } else {
217
- currentRotation = math.lerpAngle(currentRotation, 0, 0.15);
218
- }
219
-
220
- // 6. Render
221
- const pos =
222
- getStrategy(app.state) === "raw"
223
- ? app.state.pointer
224
- : app.state.smooth;
225
- dom.setTransform(
226
- el,
227
- pos.x + userOffX + anchorX,
228
- pos.y + userOffY + anchorY,
229
- currentRotation
230
- );
231
- },
232
-
233
- cleanup() {
234
- clearTimeout(transitionTimer);
235
- },
236
- },
237
- options
238
- );
239
- };
1
+ import type { ValueOrGetter, Supermouse } from "@supermousejs/core";
2
+ import { definePlugin, normalize, dom, math, Layers } from "@supermousejs/utils";
3
+
4
+ export interface SmartIconMap {
5
+ [key: string]: string;
6
+ }
7
+
8
+ export type SmartIconAnchor = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
9
+
10
+ export interface SmartIconOptions {
11
+ name?: string;
12
+ isEnabled?: boolean;
13
+ icons: SmartIconMap;
14
+ defaultState?: string;
15
+ useSemanticTags?: boolean;
16
+ transitionDuration?: number;
17
+ size?: ValueOrGetter<number>;
18
+ color?: ValueOrGetter<string>;
19
+ offset?: [number, number];
20
+ anchor?: ValueOrGetter<SmartIconAnchor>;
21
+ followStrategy?: ValueOrGetter<"smooth" | "raw">;
22
+ rotateWithVelocity?: ValueOrGetter<boolean>;
23
+ }
24
+
25
+ function resolveSemanticState(target: HTMLElement, icons: SmartIconMap): string | null {
26
+ const tag = target.tagName.toLowerCase();
27
+
28
+ if (tag === "input" || tag === "textarea" || target.isContentEditable) {
29
+ const type = (target as HTMLInputElement).type;
30
+ if (!["button", "submit", "checkbox", "radio", "range", "color"].includes(type)) {
31
+ if (icons["text"]) return "text";
32
+ } else if (icons["pointer"]) {
33
+ return "pointer";
34
+ }
35
+ } else if (tag === "a" || tag === "button" || target.closest("a") || target.closest("button")) {
36
+ if (icons["pointer"]) return "pointer";
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export const SmartIcon = (options: SmartIconOptions) => {
42
+ let contentWrapper: HTMLDivElement;
43
+
44
+ let currentState = options.defaultState || "default";
45
+ let targetState = options.defaultState || "default";
46
+
47
+ let lastTarget: HTMLElement | null = null;
48
+ let cachedSemanticState: string | null = null;
49
+
50
+ let isTransitioning = false;
51
+ let transitionTimer: ReturnType<typeof setTimeout>;
52
+
53
+ let currentRotation = 0;
54
+ let lastTargetRotation = 0;
55
+
56
+ const getSize = normalize(options.size, 24);
57
+ const getStrategy = normalize(options.followStrategy, "smooth");
58
+ const getAnchor = normalize(options.anchor, "center");
59
+ const getShouldRotate = normalize(options.rotateWithVelocity, false);
60
+
61
+ const useSemanticTags = options.useSemanticTags ?? true;
62
+ const duration = options.transitionDuration ?? 200;
63
+ const userOffX = options.offset ? options.offset[0] : 0;
64
+ const userOffY = options.offset ? options.offset[1] : 0;
65
+
66
+ return definePlugin<HTMLDivElement, SmartIconOptions>(
67
+ {
68
+ name: options.name || "smart-icon",
69
+ selector: "[data-supermouse-icon]",
70
+
71
+ create: () => {
72
+ const el = dom.createActor("div") as HTMLDivElement;
73
+ el.style.zIndex = Layers.CURSOR;
74
+
75
+ contentWrapper = dom.createActor("div") as HTMLDivElement;
76
+ dom.applyStyles(contentWrapper, {
77
+ width: "100%",
78
+ height: "100%",
79
+ display: "flex",
80
+ alignItems: "center",
81
+ justifyContent: "center",
82
+ transformOrigin: "center center",
83
+ transform: "scale(1)",
84
+ transition: `transform ${duration / 2}ms cubic-bezier(0.16, 1, 0.3, 1)`
85
+ });
86
+
87
+ contentWrapper.innerHTML = options.icons[currentState] || "";
88
+
89
+ dom.injectStyles(
90
+ "supermouse-smart-icon-styles",
91
+ `
92
+ @keyframes sm {
93
+ 0% { transform: rotate(0deg); }
94
+ 100% { transform: rotate(360deg); }
95
+ }
96
+ .supermouse-spin {
97
+ animation: sm 1s linear infinite;
98
+ }
99
+ `
100
+ );
101
+
102
+ el.appendChild(contentWrapper);
103
+ return el;
104
+ },
105
+
106
+ styles: {
107
+ color: "color"
108
+ },
109
+
110
+ update: (app: Supermouse, el: HTMLDivElement) => {
111
+ const icons = options.icons;
112
+ const target = app.state.hoverTarget;
113
+
114
+ let nextState = options.defaultState || "default";
115
+
116
+ if (target) {
117
+ if (target !== lastTarget) {
118
+ lastTarget = target;
119
+ cachedSemanticState = useSemanticTags ? resolveSemanticState(target, icons) : null;
120
+ }
121
+
122
+ const attrSmartIcon = app.state.interaction?.icon;
123
+
124
+ if (attrSmartIcon && icons[attrSmartIcon]) {
125
+ nextState = attrSmartIcon;
126
+ } else if (cachedSemanticState) {
127
+ nextState = cachedSemanticState;
128
+ }
129
+ } else {
130
+ lastTarget = null;
131
+ cachedSemanticState = null;
132
+ }
133
+
134
+ if (nextState !== currentState && !isTransitioning) {
135
+ if (icons[nextState] || nextState === (options.defaultState || "default")) {
136
+ targetState = nextState;
137
+ isTransitioning = true;
138
+
139
+ clearTimeout(transitionTimer);
140
+
141
+ contentWrapper.style.transform = "scale(0)";
142
+
143
+ transitionTimer = setTimeout(() => {
144
+ currentState = targetState;
145
+ contentWrapper.innerHTML = icons[currentState] || "";
146
+ contentWrapper.style.transform = "scale(1)";
147
+
148
+ transitionTimer = setTimeout(() => {
149
+ isTransitioning = false;
150
+ }, duration / 2);
151
+ }, duration / 2);
152
+ }
153
+ }
154
+
155
+ const size = getSize(app.state);
156
+ dom.setStyle(el, "width", `${size}px`);
157
+ dom.setStyle(el, "height", `${size}px`);
158
+
159
+ let anchorX = 0;
160
+ let anchorY = 0;
161
+ const half = size / 2;
162
+ const anchor = getAnchor(app.state);
163
+
164
+ if (anchor !== "center") {
165
+ if (anchor.includes("left")) anchorX = half;
166
+ if (anchor.includes("right")) anchorX = -half;
167
+ if (anchor.includes("top")) anchorY = half;
168
+ if (anchor.includes("bottom")) anchorY = -half;
169
+ }
170
+
171
+ const isSemanticState = currentState === "pointer" || currentState === "text";
172
+
173
+ if (getShouldRotate(app.state) && !isSemanticState && !app.state.reducedMotion) {
174
+ const { x: vx, y: vy } = app.state.velocity;
175
+ const speed = math.dist(vx, vy);
176
+
177
+ if (speed > 1) {
178
+ lastTargetRotation = math.angle(vx, vy);
179
+ }
180
+ currentRotation = math.lerpAngle(currentRotation, lastTargetRotation, 0.15);
181
+ } else {
182
+ currentRotation = math.lerpAngle(currentRotation, 0, 0.15);
183
+ }
184
+
185
+ const pos = getStrategy(app.state) === "raw" ? app.state.pointer : app.state.smooth;
186
+ dom.setTransform(
187
+ el,
188
+ pos.x + userOffX + anchorX,
189
+ pos.y + userOffY + anchorY,
190
+ currentRotation
191
+ );
192
+ },
193
+
194
+ cleanup() {
195
+ clearTimeout(transitionTimer);
196
+ }
197
+ },
198
+ options
199
+ );
200
+ };
package/src/SmartRing.ts CHANGED
@@ -1,105 +1,105 @@
1
- import type { ValueOrGetter } from "@supermousejs/core";
2
- import { definePlugin, normalize, dom, math, effects, Layers } from "@supermousejs/utils";
3
-
4
- export interface SmartRingOptions {
5
- name?: string;
6
- isEnabled?: boolean;
7
- size?: ValueOrGetter<number>;
8
- hoverSize?: ValueOrGetter<number>;
9
- color?: ValueOrGetter<string>;
10
- fill?: ValueOrGetter<string>;
11
- borderWidth?: ValueOrGetter<number>;
12
- mixBlendMode?: string;
13
- enableSkew?: boolean;
14
- }
15
-
16
- export const SmartRing = (options: SmartRingOptions = {}) => {
17
- const getSize = normalize(options.size, 20);
18
- const getHoverSize = normalize(options.hoverSize, 40);
19
- const getColor = normalize(options.color, "#ffffff");
20
- const getBorder = normalize(options.borderWidth, 2);
21
- const getFill = normalize(options.fill, "transparent");
22
-
23
- let currentW = 20;
24
- let currentH = 20;
25
- let currentRot = 0;
26
- let currentScaleX = 1;
27
- let currentScaleY = 1;
28
-
29
- return definePlugin<HTMLDivElement, SmartRingOptions>(
30
- {
31
- name: options.name || "smart-ring",
32
- selector: "[data-supermouse-color]",
33
-
34
- create: (app) => {
35
- const el = dom.createCircle(getSize(app.state), getFill(app.state));
36
- dom.applyStyles(el, {
37
- zIndex: Layers.FOLLOWER,
38
- mixBlendMode: options.mixBlendMode || "difference",
39
- transition: "opacity 0.2s ease, border-radius 0.2s ease",
40
- borderStyle: "solid",
41
- });
42
- return el;
43
- },
44
-
45
- update: (app, el) => {
46
- const baseSize = getSize(app.state);
47
- const shape = app.state.shape;
48
-
49
- let targetW = baseSize;
50
- let targetH = baseSize;
51
- let targetRadius = "50%";
52
- let color = getColor(app.state);
53
-
54
- if (shape) {
55
- targetW = shape.width;
56
- targetH = shape.height;
57
- targetRadius = `${shape.borderRadius}px`;
58
- } else {
59
- if (app.state.isHover) {
60
- targetW = getHoverSize(app.state);
61
- targetH = getHoverSize(app.state);
62
- }
63
- if (app.state.isDown) {
64
- targetW *= 0.9;
65
- targetH *= 0.9;
66
- }
67
- }
68
-
69
- if (app.state.interaction.color) color = app.state.interaction.color;
70
-
71
- currentW = math.lerp(currentW, targetW, 0.2);
72
- currentH = math.lerp(currentH, targetH, 0.2);
73
-
74
- dom.setStyle(el, "width", `${currentW}px`);
75
- dom.setStyle(el, "height", `${currentH}px`);
76
- dom.setStyle(el, "borderRadius", targetRadius);
77
- dom.setStyle(el, "borderColor", color);
78
- dom.setStyle(el, "backgroundColor", getFill(app.state));
79
- dom.setStyle(el, "borderWidth", `${getBorder(app.state)}px`);
80
-
81
- let targetRot = 0;
82
- let targetScaleX = 1;
83
- let targetScaleY = 1;
84
-
85
- if (!shape && options.enableSkew && !app.state.reducedMotion) {
86
- const { velocity } = app.state;
87
- const dist = effects.getVelocityDistortion(velocity.x, velocity.y);
88
- targetRot = dist.rotation;
89
- targetScaleX = dist.scaleX;
90
- targetScaleY = dist.scaleY;
91
- currentRot = math.lerpAngle(currentRot, targetRot, 0.15);
92
- } else {
93
- currentRot = 0;
94
- }
95
-
96
- currentScaleX = math.lerp(currentScaleX, targetScaleX, 0.15);
97
- currentScaleY = math.lerp(currentScaleY, targetScaleY, 0.15);
98
-
99
- const { x, y } = app.state.smooth;
100
- dom.setTransform(el, x, y, currentRot, currentScaleX, currentScaleY);
101
- },
102
- },
103
- options
104
- );
105
- };
1
+ import type { ValueOrGetter, Supermouse } from "@supermousejs/core";
2
+ import { definePlugin, normalize, dom, math, effects, Layers } from "@supermousejs/utils";
3
+
4
+ export interface SmartRingOptions {
5
+ name?: string;
6
+ isEnabled?: boolean;
7
+ size?: ValueOrGetter<number>;
8
+ hoverSize?: ValueOrGetter<number>;
9
+ color?: ValueOrGetter<string>;
10
+ fill?: ValueOrGetter<string>;
11
+ borderWidth?: ValueOrGetter<number>;
12
+ mixBlendMode?: string;
13
+ enableSkew?: boolean;
14
+ }
15
+
16
+ export const SmartRing = (options: SmartRingOptions = {}) => {
17
+ const getSize = normalize(options.size, 20);
18
+ const getHoverSize = normalize(options.hoverSize, 40);
19
+ const getColor = normalize(options.color, "#ffffff");
20
+ const getBorder = normalize(options.borderWidth, 2);
21
+ const getFill = normalize(options.fill, "transparent");
22
+
23
+ let currentW = 20;
24
+ let currentH = 20;
25
+ let currentRot = 0;
26
+ let currentScaleX = 1;
27
+ let currentScaleY = 1;
28
+
29
+ return definePlugin<HTMLDivElement, SmartRingOptions>(
30
+ {
31
+ name: options.name || "smart-ring",
32
+ selector: "[data-supermouse-color]",
33
+
34
+ create: (app: Supermouse) => {
35
+ const el = dom.createCircle(getSize(app.state), getFill(app.state));
36
+ dom.applyStyles(el, {
37
+ zIndex: Layers.FOLLOWER,
38
+ mixBlendMode: options.mixBlendMode || "difference",
39
+ transition: "opacity 0.2s ease, border-radius 0.2s ease",
40
+ borderStyle: "solid"
41
+ });
42
+ return el;
43
+ },
44
+
45
+ update: (app: Supermouse, el: HTMLDivElement) => {
46
+ const baseSize = getSize(app.state);
47
+ const shape = app.state.shape;
48
+
49
+ let targetW = baseSize;
50
+ let targetH = baseSize;
51
+ let targetRadius = "50%";
52
+ let color = getColor(app.state);
53
+
54
+ if (shape) {
55
+ targetW = shape.width;
56
+ targetH = shape.height;
57
+ targetRadius = `${shape.borderRadius}px`;
58
+ } else {
59
+ if (app.state.isHover) {
60
+ targetW = getHoverSize(app.state);
61
+ targetH = getHoverSize(app.state);
62
+ }
63
+ if (app.state.isDown) {
64
+ targetW *= 0.9;
65
+ targetH *= 0.9;
66
+ }
67
+ }
68
+
69
+ if (app.state.interaction.color) color = app.state.interaction.color;
70
+
71
+ currentW = math.lerp(currentW, targetW, 0.2);
72
+ currentH = math.lerp(currentH, targetH, 0.2);
73
+
74
+ dom.setStyle(el, "width", `${currentW}px`);
75
+ dom.setStyle(el, "height", `${currentH}px`);
76
+ dom.setStyle(el, "borderRadius", targetRadius);
77
+ dom.setStyle(el, "borderColor", color);
78
+ dom.setStyle(el, "backgroundColor", getFill(app.state));
79
+ dom.setStyle(el, "borderWidth", `${getBorder(app.state)}px`);
80
+
81
+ let targetRot = 0;
82
+ let targetScaleX = 1;
83
+ let targetScaleY = 1;
84
+
85
+ if (!shape && options.enableSkew && !app.state.reducedMotion) {
86
+ const { velocity } = app.state;
87
+ const dist = effects.getVelocityDistortion(velocity.x, velocity.y);
88
+ targetRot = dist.rotation;
89
+ targetScaleX = dist.scaleX;
90
+ targetScaleY = dist.scaleY;
91
+ currentRot = math.lerpAngle(currentRot, targetRot, 0.15);
92
+ } else {
93
+ currentRot = 0;
94
+ }
95
+
96
+ currentScaleX = math.lerp(currentScaleX, targetScaleX, 0.15);
97
+ currentScaleY = math.lerp(currentScaleY, targetScaleY, 0.15);
98
+
99
+ const { x, y } = app.state.smooth;
100
+ dom.setTransform(el, x, y, currentRot, currentScaleX, currentScaleY);
101
+ }
102
+ },
103
+ options
104
+ );
105
+ };