fancy-ui-svelte 0.4.0 → 0.5.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/README.md +18 -4
- package/dist/fancy-ui/animated-beam/README.md +0 -21
- package/dist/fancy-ui/confetti/Confetti.svelte +3 -3
- package/dist/fancy-ui/confetti/Confetti.svelte.d.ts +1 -1
- package/dist/fancy-ui/confetti/README.md +1 -1
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +240 -37
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +11 -1
- package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +69 -9
- package/dist/fancy-ui/fluid-cursor/README.md +25 -0
- package/dist/fancy-ui/fluid-cursor/index.d.ts +4 -0
- package/dist/fancy-ui/fluid-cursor/index.js +4 -0
- package/dist/fancy-ui/index.d.ts +2 -0
- package/dist/fancy-ui/index.js +2 -0
- package/dist/fancy-ui/number-ticker/NumberTicker.svelte +1 -4
- package/dist/fancy-ui/registry.js +1473 -0
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +1 -1
- package/dist/fancy-ui/tailwind.css +2 -2
- package/dist/types.d.ts +35 -0
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -119,21 +119,35 @@ Beautiful animation and UI components for **Svelte 5**.
|
|
|
119
119
|
**Install via npm:**
|
|
120
120
|
|
|
121
121
|
```bash
|
|
122
|
-
npm install fancy-ui
|
|
122
|
+
npm install fancy-ui-svelte
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
Then import any component:
|
|
126
126
|
|
|
127
127
|
```ts
|
|
128
|
-
import { Marquee, BorderBeam, Confetti } from 'fancy-ui';
|
|
128
|
+
import { Marquee, BorderBeam, Confetti } from 'fancy-ui-svelte';
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
Add the Tailwind integration to your app's CSS (e.g. `src/app.css`):
|
|
132
132
|
|
|
133
133
|
```css
|
|
134
|
-
@import "
|
|
134
|
+
@import "tailwindcss";
|
|
135
|
+
@import "fancy-ui-svelte/tailwind.css";
|
|
135
136
|
```
|
|
136
137
|
|
|
138
|
+
This single import tells Tailwind v4 to scan the library's components so all utility classes are generated automatically. No manual `@source` path needed.
|
|
139
|
+
|
|
140
|
+
<details>
|
|
141
|
+
<summary>Alternative: manual @source (without the CSS import)</summary>
|
|
142
|
+
|
|
143
|
+
```css
|
|
144
|
+
@import "tailwindcss";
|
|
145
|
+
@source "../node_modules/fancy-ui-svelte/dist";
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The path is relative to your CSS file location.
|
|
149
|
+
</details>
|
|
150
|
+
|
|
137
151
|
**Or browse and copy a component:**
|
|
138
152
|
1. Find the component you need in the [live demo](https://fancy-ui.rama.app)
|
|
139
153
|
2. Copy the source from `src/lib/fancy-ui/[component-name]/`
|
|
@@ -12,27 +12,6 @@ Creates animated SVG beams that connect two elements with smooth gradients and c
|
|
|
12
12
|
- Reversible animation direction
|
|
13
13
|
- Position offsets for fine-tuning
|
|
14
14
|
|
|
15
|
-
## Implementation Notes
|
|
16
|
-
|
|
17
|
-
### Vue → Svelte Conversions
|
|
18
|
-
- `ref()` → `$state()`
|
|
19
|
-
- `computed()` → `$derived()`
|
|
20
|
-
- `watchEffect()` → `$effect()` (via `onMount`)
|
|
21
|
-
- Template refs → `bind:this`
|
|
22
|
-
- Props handling with destructuring defaults
|
|
23
|
-
|
|
24
|
-
### Key Differences
|
|
25
|
-
1. **Element References**: Svelte uses `bind:this` instead of Vue's `ref` template attribute
|
|
26
|
-
2. **Lifecycle**: Setup logic moved to `onMount` with cleanup return
|
|
27
|
-
3. **Derived Values**: Functions returning derived values (x1, x2, y1, y2) instead of computed refs
|
|
28
|
-
4. **Conditional Rendering**: Used `{#if}` blocks to ensure refs are defined before rendering beams
|
|
29
|
-
|
|
30
|
-
### Implementation Details
|
|
31
|
-
- ResizeObserver for responsive path updates
|
|
32
|
-
- SVG path calculation using quadratic Bezier curves (Q command)
|
|
33
|
-
- Direction detection for vertical/horizontal beams
|
|
34
|
-
- Unique gradient IDs to prevent conflicts with multiple instances
|
|
35
|
-
|
|
36
15
|
## Demo
|
|
37
16
|
See `/demo/animated-beam` for usage examples.
|
|
38
17
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
interface Props {
|
|
12
12
|
options?: ConfettiOptions;
|
|
13
13
|
globalOptions?: ConfettiGlobalOptions;
|
|
14
|
-
|
|
14
|
+
manualStart?: boolean;
|
|
15
15
|
class?: string;
|
|
16
16
|
children?: Snippet;
|
|
17
17
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
let {
|
|
20
20
|
options = {},
|
|
21
21
|
globalOptions = {},
|
|
22
|
-
|
|
22
|
+
manualStart = false,
|
|
23
23
|
class: className = "",
|
|
24
24
|
children,
|
|
25
25
|
}: Props = $props();
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
resize: true,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
if (!
|
|
42
|
+
if (!manualStart) {
|
|
43
43
|
fire();
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -11,7 +11,7 @@ Canvas wrapper that creates a confetti instance.
|
|
|
11
11
|
|------|------|---------|-------------|
|
|
12
12
|
| `options` | `ConfettiOptions` | `{}` | Default confetti options |
|
|
13
13
|
| `globalOptions` | `ConfettiGlobalOptions` | `{}` | Canvas creation options |
|
|
14
|
-
| `
|
|
14
|
+
| `manualStart` | `boolean` | `false` | Skip auto-fire on mount |
|
|
15
15
|
| `class` | `string` | `''` | Canvas CSS classes |
|
|
16
16
|
|
|
17
17
|
### ConfettiButton
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { cn } from "../../utils.js";
|
|
3
3
|
import { onMount } from "svelte";
|
|
4
4
|
|
|
5
|
+
// Module-level singleton registry
|
|
6
|
+
let activeInstance: (() => void) | null = null;
|
|
7
|
+
|
|
5
8
|
interface ColorRGB {
|
|
6
9
|
r: number;
|
|
7
10
|
g: number;
|
|
@@ -21,9 +24,19 @@
|
|
|
21
24
|
splatForce?: number;
|
|
22
25
|
shading?: boolean;
|
|
23
26
|
colorUpdateSpeed?: number;
|
|
24
|
-
backColor?: ColorRGB;
|
|
27
|
+
backColor?: ColorRGB | string;
|
|
25
28
|
transparent?: boolean;
|
|
29
|
+
fluidColor?: string;
|
|
30
|
+
fluidColors?: string[];
|
|
31
|
+
colorIntensity?: number;
|
|
26
32
|
class?: string;
|
|
33
|
+
autoSplat?: boolean;
|
|
34
|
+
autoSplatInterval?: number;
|
|
35
|
+
interactive?: boolean;
|
|
36
|
+
pauseWhenHidden?: boolean;
|
|
37
|
+
splatOnMount?: boolean;
|
|
38
|
+
allowMultiple?: boolean;
|
|
39
|
+
contained?: boolean;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
let {
|
|
@@ -41,9 +54,39 @@
|
|
|
41
54
|
colorUpdateSpeed = 10,
|
|
42
55
|
backColor = { r: 0.5, g: 0, b: 0 },
|
|
43
56
|
transparent = true,
|
|
57
|
+
fluidColor,
|
|
58
|
+
fluidColors,
|
|
59
|
+
colorIntensity = 0.15,
|
|
44
60
|
class: className = "",
|
|
61
|
+
autoSplat = false,
|
|
62
|
+
autoSplatInterval = 1500,
|
|
63
|
+
interactive = true,
|
|
64
|
+
pauseWhenHidden = true,
|
|
65
|
+
splatOnMount = false,
|
|
66
|
+
allowMultiple = false,
|
|
67
|
+
contained = true,
|
|
45
68
|
}: Props = $props();
|
|
46
69
|
|
|
70
|
+
function hexToRgb(hex: string): ColorRGB {
|
|
71
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
72
|
+
if (!result) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[FluidCursor] Invalid hex color: "${hex}". Expected format: "#rrggbb". Falling back to white.`
|
|
75
|
+
);
|
|
76
|
+
return { r: 1, g: 1, b: 1 };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
r: parseInt(result[1], 16) / 255,
|
|
80
|
+
g: parseInt(result[2], 16) / 255,
|
|
81
|
+
b: parseInt(result[3], 16) / 255,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
|
|
86
|
+
|
|
87
|
+
const resolvedBackColor: ColorRGB =
|
|
88
|
+
typeof backColor === "string" ? hexToRgb(backColor) : backColor;
|
|
89
|
+
|
|
47
90
|
let canvasRef: HTMLCanvasElement;
|
|
48
91
|
|
|
49
92
|
onMount(() => {
|
|
@@ -94,7 +137,7 @@
|
|
|
94
137
|
SHADING: shading,
|
|
95
138
|
COLOR_UPDATE_SPEED: colorUpdateSpeed,
|
|
96
139
|
PAUSED: false,
|
|
97
|
-
BACK_COLOR:
|
|
140
|
+
BACK_COLOR: resolvedBackColor,
|
|
98
141
|
TRANSPARENT: transparent,
|
|
99
142
|
};
|
|
100
143
|
|
|
@@ -930,15 +973,72 @@
|
|
|
930
973
|
return Math.floor(input * pixelRatio);
|
|
931
974
|
}
|
|
932
975
|
|
|
976
|
+
let canvasRectCache: DOMRect | null = null;
|
|
977
|
+
function updateCanvasRectCache() {
|
|
978
|
+
if (contained && canvas) canvasRectCache = canvas.getBoundingClientRect();
|
|
979
|
+
}
|
|
980
|
+
if (contained) {
|
|
981
|
+
window.addEventListener("resize", updateCanvasRectCache, { passive: true });
|
|
982
|
+
window.addEventListener("scroll", updateCanvasRectCache, { passive: true });
|
|
983
|
+
updateCanvasRectCache();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function getCanvasPos(clientX: number, clientY: number): { x: number; y: number } {
|
|
987
|
+
if (contained) {
|
|
988
|
+
if (!canvasRectCache) updateCanvasRectCache();
|
|
989
|
+
const rect = canvasRectCache!;
|
|
990
|
+
return {
|
|
991
|
+
x: scaleByPixelRatio(clientX - rect.left),
|
|
992
|
+
y: scaleByPixelRatio(clientY - rect.top),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
return { x: scaleByPixelRatio(clientX), y: scaleByPixelRatio(clientY) };
|
|
996
|
+
}
|
|
997
|
+
|
|
933
998
|
// Simulation Setup
|
|
934
999
|
updateKeywords();
|
|
935
1000
|
initFramebuffers();
|
|
936
1001
|
|
|
937
1002
|
let lastUpdateTime = Date.now();
|
|
938
1003
|
let colorUpdateTimer = 0.0;
|
|
1004
|
+
let colorIndex = 0;
|
|
939
1005
|
let animationFrameId: number;
|
|
940
1006
|
|
|
1007
|
+
// Cached pre-scaled colors to avoid re-parsing hex on every splat
|
|
1008
|
+
let cachedFluidColorHex: string | undefined;
|
|
1009
|
+
let cachedFluidColor: ColorRGB | null = null;
|
|
1010
|
+
let cachedFluidColorsRef: string[] | undefined;
|
|
1011
|
+
let cachedFluidColorsScaled: ColorRGB[] = [];
|
|
1012
|
+
|
|
1013
|
+
function getScaledColor(hex: string): ColorRGB {
|
|
1014
|
+
const { r, g, b } = hexToRgb(hex);
|
|
1015
|
+
return {
|
|
1016
|
+
r: r * clampedColorIntensity,
|
|
1017
|
+
g: g * clampedColorIntensity,
|
|
1018
|
+
b: b * clampedColorIntensity,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function getCachedFluidColor(hex: string): ColorRGB {
|
|
1023
|
+
if (cachedFluidColor === null || cachedFluidColorHex !== hex) {
|
|
1024
|
+
cachedFluidColorHex = hex;
|
|
1025
|
+
cachedFluidColor = getScaledColor(hex);
|
|
1026
|
+
}
|
|
1027
|
+
return cachedFluidColor;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function getCachedFluidColors(colors: string[]): ColorRGB[] {
|
|
1031
|
+
if (cachedFluidColorsRef !== colors) {
|
|
1032
|
+
cachedFluidColorsRef = colors;
|
|
1033
|
+
cachedFluidColorsScaled = colors.map(getScaledColor);
|
|
1034
|
+
}
|
|
1035
|
+
return cachedFluidColorsScaled;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
let isVisible = true;
|
|
1039
|
+
|
|
941
1040
|
function updateFrame() {
|
|
1041
|
+
if (!isVisible) return;
|
|
942
1042
|
const dt = calcDeltaTime();
|
|
943
1043
|
if (resizeCanvas()) initFramebuffers();
|
|
944
1044
|
updateColors(dt);
|
|
@@ -1189,6 +1289,36 @@
|
|
|
1189
1289
|
dye.swap();
|
|
1190
1290
|
}
|
|
1191
1291
|
|
|
1292
|
+
function multipleSplats(steps: number) {
|
|
1293
|
+
// Simulate a cursor sweep along a random arc — each step on its own frame
|
|
1294
|
+
// so the velocity field has time to evolve between injections.
|
|
1295
|
+
const angle = Math.random() * Math.PI * 2;
|
|
1296
|
+
const cx = 0.25 + Math.random() * 0.5;
|
|
1297
|
+
const cy = 0.25 + Math.random() * 0.5;
|
|
1298
|
+
const arcSpan = Math.PI * (0.8 + Math.random() * 0.8);
|
|
1299
|
+
let i = 0;
|
|
1300
|
+
|
|
1301
|
+
function step() {
|
|
1302
|
+
if (i >= steps) return;
|
|
1303
|
+
const t = i / Math.max(1, steps - 1);
|
|
1304
|
+
const a = angle + t * arcSpan;
|
|
1305
|
+
const r = 0.15 + 0.05 * Math.sin(t * Math.PI);
|
|
1306
|
+
const x = cx + Math.cos(a) * r;
|
|
1307
|
+
const y = cy + Math.sin(a) * r;
|
|
1308
|
+
const color = generateColor();
|
|
1309
|
+
splat(
|
|
1310
|
+
x,
|
|
1311
|
+
y,
|
|
1312
|
+
-Math.sin(a) * config.SPLAT_FORCE * 0.5,
|
|
1313
|
+
Math.cos(a) * config.SPLAT_FORCE * 0.5,
|
|
1314
|
+
color
|
|
1315
|
+
);
|
|
1316
|
+
i++;
|
|
1317
|
+
if (i < steps) requestAnimationFrame(step);
|
|
1318
|
+
}
|
|
1319
|
+
requestAnimationFrame(step);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1192
1322
|
function correctRadius(radius: number) {
|
|
1193
1323
|
const aspectRatio = canvas.width / canvas.height;
|
|
1194
1324
|
if (aspectRatio > 1) radius *= aspectRatio;
|
|
@@ -1232,10 +1362,19 @@
|
|
|
1232
1362
|
}
|
|
1233
1363
|
|
|
1234
1364
|
function generateColor(): ColorRGB {
|
|
1365
|
+
if (fluidColor) {
|
|
1366
|
+
return getCachedFluidColor(fluidColor);
|
|
1367
|
+
}
|
|
1368
|
+
if (fluidColors && fluidColors.length > 0) {
|
|
1369
|
+
const colors = getCachedFluidColors(fluidColors);
|
|
1370
|
+
const idx = colorIndex % colors.length;
|
|
1371
|
+
colorIndex++;
|
|
1372
|
+
return colors[idx];
|
|
1373
|
+
}
|
|
1235
1374
|
const c = HSVtoRGB(Math.random(), 1.0, 1.0);
|
|
1236
|
-
c.r *=
|
|
1237
|
-
c.g *=
|
|
1238
|
-
c.b *=
|
|
1375
|
+
c.r *= clampedColorIntensity;
|
|
1376
|
+
c.g *= clampedColorIntensity;
|
|
1377
|
+
c.b *= clampedColorIntensity;
|
|
1239
1378
|
return c;
|
|
1240
1379
|
}
|
|
1241
1380
|
|
|
@@ -1293,37 +1432,30 @@
|
|
|
1293
1432
|
// Event Listeners
|
|
1294
1433
|
function handleMouseDown(e: MouseEvent) {
|
|
1295
1434
|
const pointer = pointers[0];
|
|
1296
|
-
const posX =
|
|
1297
|
-
const posY = scaleByPixelRatio(e.clientY);
|
|
1435
|
+
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
1298
1436
|
updatePointerDownData(pointer, -1, posX, posY);
|
|
1299
1437
|
clickSplat(pointer);
|
|
1300
1438
|
}
|
|
1301
1439
|
|
|
1302
1440
|
function handleFirstMouseMove(e: MouseEvent) {
|
|
1303
1441
|
const pointer = pointers[0];
|
|
1304
|
-
const posX =
|
|
1305
|
-
const posY = scaleByPixelRatio(e.clientY);
|
|
1442
|
+
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
1306
1443
|
const color = generateColor();
|
|
1307
|
-
updateFrame();
|
|
1308
1444
|
updatePointerMoveData(pointer, posX, posY, color);
|
|
1309
1445
|
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
1310
1446
|
}
|
|
1311
1447
|
|
|
1312
1448
|
function handleMouseMove(e: MouseEvent) {
|
|
1313
1449
|
const pointer = pointers[0];
|
|
1314
|
-
const posX =
|
|
1315
|
-
|
|
1316
|
-
const color = pointer.color;
|
|
1317
|
-
updatePointerMoveData(pointer, posX, posY, color);
|
|
1450
|
+
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
1451
|
+
updatePointerMoveData(pointer, posX, posY, pointer.color);
|
|
1318
1452
|
}
|
|
1319
1453
|
|
|
1320
1454
|
function handleFirstTouchStart(e: TouchEvent) {
|
|
1321
1455
|
const touches = e.targetTouches;
|
|
1322
1456
|
const pointer = pointers[0];
|
|
1323
1457
|
for (let i = 0; i < touches.length; i++) {
|
|
1324
|
-
const posX =
|
|
1325
|
-
const posY = scaleByPixelRatio(touches[i].clientY);
|
|
1326
|
-
updateFrame();
|
|
1458
|
+
const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
|
|
1327
1459
|
updatePointerDownData(pointer, touches[i].identifier, posX, posY);
|
|
1328
1460
|
}
|
|
1329
1461
|
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
@@ -1333,8 +1465,7 @@
|
|
|
1333
1465
|
const touches = e.targetTouches;
|
|
1334
1466
|
const pointer = pointers[0];
|
|
1335
1467
|
for (let i = 0; i < touches.length; i++) {
|
|
1336
|
-
const posX =
|
|
1337
|
-
const posY = scaleByPixelRatio(touches[i].clientY);
|
|
1468
|
+
const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
|
|
1338
1469
|
updatePointerDownData(pointer, touches[i].identifier, posX, posY);
|
|
1339
1470
|
}
|
|
1340
1471
|
}
|
|
@@ -1343,36 +1474,108 @@
|
|
|
1343
1474
|
const touches = e.targetTouches;
|
|
1344
1475
|
const pointer = pointers[0];
|
|
1345
1476
|
for (let i = 0; i < touches.length; i++) {
|
|
1346
|
-
const posX =
|
|
1347
|
-
const posY = scaleByPixelRatio(touches[i].clientY);
|
|
1477
|
+
const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
|
|
1348
1478
|
updatePointerMoveData(pointer, posX, posY, pointer.color);
|
|
1349
1479
|
}
|
|
1350
1480
|
}
|
|
1351
1481
|
|
|
1352
1482
|
// Add event listeners
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1483
|
+
if (interactive) {
|
|
1484
|
+
window.addEventListener("mousedown", handleMouseDown);
|
|
1485
|
+
document.body.addEventListener("mousemove", handleFirstMouseMove);
|
|
1486
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
1487
|
+
document.body.addEventListener("touchstart", handleFirstTouchStart);
|
|
1488
|
+
window.addEventListener("touchstart", handleTouchStart, false);
|
|
1489
|
+
window.addEventListener("touchmove", handleTouchMove, false);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Pause animation when scrolled out of view
|
|
1493
|
+
let observer: IntersectionObserver | null = null;
|
|
1494
|
+
if (pauseWhenHidden) {
|
|
1495
|
+
observer = new IntersectionObserver(
|
|
1496
|
+
([entry]) => {
|
|
1497
|
+
const wasVisible = isVisible;
|
|
1498
|
+
isVisible = entry.isIntersecting;
|
|
1499
|
+
if (isVisible && !wasVisible) {
|
|
1500
|
+
lastUpdateTime = Date.now();
|
|
1501
|
+
animationFrameId = requestAnimationFrame(updateFrame);
|
|
1502
|
+
}
|
|
1503
|
+
},
|
|
1504
|
+
{ threshold: 0 }
|
|
1505
|
+
);
|
|
1506
|
+
observer.observe(canvas);
|
|
1507
|
+
}
|
|
1359
1508
|
|
|
1360
1509
|
// Start animation
|
|
1361
1510
|
updateFrame();
|
|
1511
|
+
if (splatOnMount) multipleSplats(Math.floor(Math.random() * 6) + 10);
|
|
1512
|
+
|
|
1513
|
+
// Auto-splat timer
|
|
1514
|
+
let autoSplatTimer: ReturnType<typeof setInterval> | null = null;
|
|
1515
|
+
if (autoSplat) {
|
|
1516
|
+
autoSplatTimer = setInterval(() => {
|
|
1517
|
+
const color = generateColor();
|
|
1518
|
+
splat(
|
|
1519
|
+
Math.random(),
|
|
1520
|
+
Math.random(),
|
|
1521
|
+
(Math.random() - 0.5) * 200,
|
|
1522
|
+
(Math.random() - 0.5) * 200,
|
|
1523
|
+
color
|
|
1524
|
+
);
|
|
1525
|
+
}, autoSplatInterval);
|
|
1526
|
+
}
|
|
1362
1527
|
|
|
1363
1528
|
// Cleanup
|
|
1364
|
-
|
|
1529
|
+
function cleanup() {
|
|
1365
1530
|
cancelAnimationFrame(animationFrameId);
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1531
|
+
if (observer) observer.disconnect();
|
|
1532
|
+
if (autoSplatTimer) clearInterval(autoSplatTimer);
|
|
1533
|
+
if (interactive) {
|
|
1534
|
+
window.removeEventListener("mousedown", handleMouseDown);
|
|
1535
|
+
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
1536
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
1537
|
+
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
1538
|
+
window.removeEventListener("touchstart", handleTouchStart);
|
|
1539
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
1540
|
+
}
|
|
1541
|
+
if (contained) {
|
|
1542
|
+
window.removeEventListener("resize", updateCanvasRectCache);
|
|
1543
|
+
window.removeEventListener("scroll", updateCanvasRectCache);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
if (!allowMultiple) {
|
|
1548
|
+
if (activeInstance) {
|
|
1549
|
+
if (import.meta.env.DEV) {
|
|
1550
|
+
console.warn(
|
|
1551
|
+
"[FluidCursor] Destroying previous instance. Only one instance is allowed by default. Use `allowMultiple={true}` to opt out of singleton behavior."
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
activeInstance();
|
|
1555
|
+
}
|
|
1556
|
+
activeInstance = cleanup;
|
|
1557
|
+
return () => {
|
|
1558
|
+
cleanup();
|
|
1559
|
+
if (activeInstance === cleanup) {
|
|
1560
|
+
activeInstance = null;
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
return cleanup;
|
|
1373
1566
|
});
|
|
1374
1567
|
</script>
|
|
1375
1568
|
|
|
1376
|
-
<div
|
|
1377
|
-
|
|
1569
|
+
<div
|
|
1570
|
+
class={cn(
|
|
1571
|
+
contained
|
|
1572
|
+
? "pointer-events-none absolute inset-0 h-full w-full"
|
|
1573
|
+
: "pointer-events-none fixed top-0 left-0 z-50 size-full",
|
|
1574
|
+
className
|
|
1575
|
+
)}
|
|
1576
|
+
>
|
|
1577
|
+
<canvas
|
|
1578
|
+
bind:this={canvasRef}
|
|
1579
|
+
class={contained ? "block h-full w-full" : "block h-screen w-screen"}
|
|
1580
|
+
></canvas>
|
|
1378
1581
|
</div>
|
|
@@ -16,9 +16,19 @@ interface Props {
|
|
|
16
16
|
splatForce?: number;
|
|
17
17
|
shading?: boolean;
|
|
18
18
|
colorUpdateSpeed?: number;
|
|
19
|
-
backColor?: ColorRGB;
|
|
19
|
+
backColor?: ColorRGB | string;
|
|
20
20
|
transparent?: boolean;
|
|
21
|
+
fluidColor?: string;
|
|
22
|
+
fluidColors?: string[];
|
|
23
|
+
colorIntensity?: number;
|
|
21
24
|
class?: string;
|
|
25
|
+
autoSplat?: boolean;
|
|
26
|
+
autoSplatInterval?: number;
|
|
27
|
+
interactive?: boolean;
|
|
28
|
+
pauseWhenHidden?: boolean;
|
|
29
|
+
splatOnMount?: boolean;
|
|
30
|
+
allowMultiple?: boolean;
|
|
31
|
+
contained?: boolean;
|
|
22
32
|
}
|
|
23
33
|
declare const FluidCursor: import("svelte").Component<Props, {}, "">;
|
|
24
34
|
type FluidCursor = ReturnType<typeof FluidCursor>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { render, cleanup } from "@testing-library/svelte";
|
|
2
|
-
import { afterEach, describe, it, expect } from "vitest";
|
|
2
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
3
3
|
import FluidCursor from "./FluidCursor.svelte";
|
|
4
4
|
describe("FluidCursor", () => {
|
|
5
5
|
afterEach(cleanup);
|
|
@@ -13,24 +13,27 @@ describe("FluidCursor", () => {
|
|
|
13
13
|
const canvas = container.querySelector("canvas");
|
|
14
14
|
expect(canvas).toBeInTheDocument();
|
|
15
15
|
});
|
|
16
|
-
it('canvas has id="fluid"', () => {
|
|
17
|
-
const { container } = render(FluidCursor);
|
|
18
|
-
const canvas = container.querySelector("canvas#fluid");
|
|
19
|
-
expect(canvas).toBeInTheDocument();
|
|
20
|
-
});
|
|
21
16
|
it("container has pointer-events-none class", () => {
|
|
22
17
|
const { container } = render(FluidCursor);
|
|
23
18
|
const div = container.firstElementChild;
|
|
24
19
|
expect(div?.className).toContain("pointer-events-none");
|
|
25
20
|
});
|
|
26
|
-
it("
|
|
21
|
+
it("defaults to contained mode (absolute positioning)", () => {
|
|
27
22
|
const { container } = render(FluidCursor);
|
|
28
23
|
const div = container.firstElementChild;
|
|
29
|
-
expect(div?.className).toContain("
|
|
24
|
+
expect(div?.className).toContain("absolute");
|
|
25
|
+
expect(div?.className).toContain("inset-0");
|
|
30
26
|
});
|
|
31
|
-
it("container
|
|
27
|
+
it("canvas fills container in contained mode", () => {
|
|
32
28
|
const { container } = render(FluidCursor);
|
|
29
|
+
const canvas = container.querySelector("canvas");
|
|
30
|
+
expect(canvas?.className).toContain("h-full");
|
|
31
|
+
expect(canvas?.className).toContain("w-full");
|
|
32
|
+
});
|
|
33
|
+
it("uses fixed positioning when contained={false}", () => {
|
|
34
|
+
const { container } = render(FluidCursor, { props: { contained: false } });
|
|
33
35
|
const div = container.firstElementChild;
|
|
36
|
+
expect(div?.className).toContain("fixed");
|
|
34
37
|
expect(div?.className).toContain("z-50");
|
|
35
38
|
});
|
|
36
39
|
it("applies custom class names", () => {
|
|
@@ -38,4 +41,61 @@ describe("FluidCursor", () => {
|
|
|
38
41
|
const div = container.firstElementChild;
|
|
39
42
|
expect(div?.className).toContain("my-fluid");
|
|
40
43
|
});
|
|
44
|
+
describe("interaction props", () => {
|
|
45
|
+
it("interactive={false} does not register mouse event listeners", () => {
|
|
46
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
47
|
+
render(FluidCursor, { props: { interactive: false } });
|
|
48
|
+
const mousedownCalls = addSpy.mock.calls.filter(([event]) => event === "mousedown");
|
|
49
|
+
expect(mousedownCalls).toHaveLength(0);
|
|
50
|
+
const mousemoveCalls = addSpy.mock.calls.filter(([event]) => event === "mousemove");
|
|
51
|
+
expect(mousemoveCalls).toHaveLength(0);
|
|
52
|
+
addSpy.mockRestore();
|
|
53
|
+
});
|
|
54
|
+
it("mounts without error with autoSplat={true} and splatOnMount={true}", () => {
|
|
55
|
+
const { container } = render(FluidCursor, {
|
|
56
|
+
props: { autoSplat: true, splatOnMount: true },
|
|
57
|
+
});
|
|
58
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
it("pauseWhenHidden={false} does not instantiate IntersectionObserver", () => {
|
|
61
|
+
const observeSpy = vi.fn();
|
|
62
|
+
const mockObserver = vi.fn(() => ({ observe: observeSpy, disconnect: vi.fn() }));
|
|
63
|
+
vi.stubGlobal("IntersectionObserver", mockObserver);
|
|
64
|
+
render(FluidCursor, { props: { pauseWhenHidden: false } });
|
|
65
|
+
expect(mockObserver).not.toHaveBeenCalled();
|
|
66
|
+
vi.unstubAllGlobals();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("color props", () => {
|
|
70
|
+
it("renders without error when fluidColor is provided", () => {
|
|
71
|
+
const { container } = render(FluidCursor, { props: { fluidColor: "#00ffcc" } });
|
|
72
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
it("renders without error when fluidColors is provided", () => {
|
|
75
|
+
const { container } = render(FluidCursor, {
|
|
76
|
+
props: { fluidColors: ["#ff0080", "#00ffcc", "#7700ff"] },
|
|
77
|
+
});
|
|
78
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
it("renders without error when fluidColor and fluidColors are both provided (fluidColor takes priority)", () => {
|
|
81
|
+
const { container } = render(FluidCursor, {
|
|
82
|
+
props: { fluidColor: "#ff0000", fluidColors: ["#00ffcc", "#7700ff"] },
|
|
83
|
+
});
|
|
84
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
it("renders without error when backColor is a hex string", () => {
|
|
87
|
+
const { container } = render(FluidCursor, { props: { backColor: "#1a1a2e" } });
|
|
88
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
it("warns and falls back when an invalid hex string is passed to backColor", () => {
|
|
91
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
92
|
+
render(FluidCursor, { props: { backColor: "not-a-color" } });
|
|
93
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[FluidCursor] Invalid hex color"));
|
|
94
|
+
warn.mockRestore();
|
|
95
|
+
});
|
|
96
|
+
it("renders without error when colorIntensity is out of [0,1] range (clamped internally)", () => {
|
|
97
|
+
const { container } = render(FluidCursor, { props: { colorIntensity: 5 } });
|
|
98
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
41
101
|
});
|
|
@@ -9,6 +9,7 @@ A WebGL-based fluid simulation that follows the cursor, creating beautiful flowi
|
|
|
9
9
|
- Configurable simulation parameters
|
|
10
10
|
- Transparent background support
|
|
11
11
|
- Automatic canvas resizing
|
|
12
|
+
- Controllable fluid colors via hex strings (fixed color, palette cycling, or random)
|
|
12
13
|
|
|
13
14
|
## Implementation Notes
|
|
14
15
|
|
|
@@ -27,6 +28,30 @@ The component implements a full Navier-Stokes fluid simulation with:
|
|
|
27
28
|
- Advection for both velocity and dye
|
|
28
29
|
- Gradient subtraction for incompressible flow
|
|
29
30
|
|
|
31
|
+
## Color Props
|
|
32
|
+
|
|
33
|
+
| Prop | Type | Default | Description |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| `fluidColor` | `string` (hex) | — | Fixed fluid color. Disables random cycling. |
|
|
36
|
+
| `fluidColors` | `string[]` (hex) | — | Palette to cycle through on each splat. |
|
|
37
|
+
| `colorIntensity` | `number` | `0.15` | Intensity multiplier applied to fluid colors (0–1). |
|
|
38
|
+
| `backColor` | `{ r, g, b }` or `string` (hex) | `{ r: 0.5, g: 0, b: 0 }` | Background color — accepts RGB object or hex string. |
|
|
39
|
+
|
|
40
|
+
**Priority**: `fluidColor` > `fluidColors` > random HSV
|
|
41
|
+
|
|
42
|
+
### Examples
|
|
43
|
+
|
|
44
|
+
```svelte
|
|
45
|
+
<!-- Fixed teal fluid -->
|
|
46
|
+
<FluidCursor fluidColor="#00ffcc" />
|
|
47
|
+
|
|
48
|
+
<!-- Cycling palette -->
|
|
49
|
+
<FluidCursor fluidColors={["#ff0080", "#00ffcc", "#7700ff"]} colorIntensity={0.3} />
|
|
50
|
+
|
|
51
|
+
<!-- Hex background -->
|
|
52
|
+
<FluidCursor backColor="#1a1a2e" />
|
|
53
|
+
```
|
|
54
|
+
|
|
30
55
|
### Event Handling
|
|
31
56
|
|
|
32
57
|
- Window-level mouse/touch events for full-screen tracking
|