fancy-ui-svelte 0.8.0 → 0.9.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 +107 -89
- package/dist/fancy-ui/animated-testimonials/README.md +44 -0
- package/dist/fancy-ui/animated-tooltip/README.md +40 -0
- package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
- package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
- package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
- package/dist/fancy-ui/border-beam/README.md +37 -0
- package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
- package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
- package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
- package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
- package/dist/fancy-ui/displacement-text/README.md +39 -0
- package/dist/fancy-ui/dock/README.md +1 -1
- package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
- package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +130 -9
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +9 -1
- package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +19 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +32 -0
- package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +11 -4
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +113 -34
- package/dist/fancy-ui/line-hover-link/README.md +37 -0
- package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
- package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
- package/dist/fancy-ui/marquee/README.md +57 -0
- package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
- package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
- package/dist/fancy-ui/matrix-rain/README.md +42 -0
- package/dist/fancy-ui/noise-reveal/README.md +52 -0
- package/dist/fancy-ui/rainbow-button/README.md +36 -0
- package/dist/fancy-ui/registry.js +20 -20
- package/dist/fancy-ui/ripple-button/README.md +36 -0
- package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
- package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
- package/dist/fancy-ui/terminal-text/README.md +48 -0
- package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
- package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
- package/package.json +15 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { render, cleanup, fireEvent } from "@testing-library/svelte";
|
|
2
|
+
import { createRawSnippet } from "svelte";
|
|
3
|
+
import { afterEach, describe, it, expect } from "vitest";
|
|
4
|
+
import CardContainer from "./CardContainer.svelte";
|
|
5
|
+
import CardBody from "./CardBody.svelte";
|
|
6
|
+
import CardItem from "./CardItem.svelte";
|
|
7
|
+
function textSnippet(html) {
|
|
8
|
+
return createRawSnippet(() => ({ render: () => html }));
|
|
9
|
+
}
|
|
10
|
+
describe("CardContainer", () => {
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
it("renders the perspective wrapper and the 3d inner container", () => {
|
|
13
|
+
const { container } = render(CardContainer);
|
|
14
|
+
const outer = container.firstElementChild;
|
|
15
|
+
const inner = outer.firstElementChild;
|
|
16
|
+
expect(outer.getAttribute("style")).toContain("perspective: 1000px");
|
|
17
|
+
expect(inner.getAttribute("style")).toContain("transform-style: preserve-3d");
|
|
18
|
+
});
|
|
19
|
+
it("applies containerClass to the outer wrapper and class to the inner container", () => {
|
|
20
|
+
const { container } = render(CardContainer, {
|
|
21
|
+
props: { containerClass: "my-outer", class: "my-inner" },
|
|
22
|
+
});
|
|
23
|
+
const outer = container.firstElementChild;
|
|
24
|
+
const inner = outer.firstElementChild;
|
|
25
|
+
expect(outer.className).toContain("my-outer");
|
|
26
|
+
expect(inner.className).toContain("my-inner");
|
|
27
|
+
});
|
|
28
|
+
it("renders children content", () => {
|
|
29
|
+
const { container } = render(CardContainer, {
|
|
30
|
+
props: { children: textSnippet("<p data-testid='content'>card body</p>") },
|
|
31
|
+
});
|
|
32
|
+
expect(container.querySelector("[data-testid='content']")?.textContent).toBe("card body");
|
|
33
|
+
});
|
|
34
|
+
it("rotates the inner container on mousemove and resets on mouseleave", async () => {
|
|
35
|
+
const { container } = render(CardContainer);
|
|
36
|
+
const inner = container.firstElementChild.firstElementChild;
|
|
37
|
+
// jsdom's getBoundingClientRect() is all zeros, so rotateY = clientX / 25
|
|
38
|
+
// and rotateX = clientY / 25 for this component's handler.
|
|
39
|
+
await fireEvent.mouseMove(inner, { clientX: 50, clientY: 25 });
|
|
40
|
+
expect(inner.style.transform).toBe("rotateY(2deg) rotateX(1deg)");
|
|
41
|
+
await fireEvent.mouseLeave(inner);
|
|
42
|
+
expect(inner.style.transform).toBe("rotateY(0deg) rotateX(0deg)");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("CardBody", () => {
|
|
46
|
+
afterEach(cleanup);
|
|
47
|
+
it("renders with the fixed 3d-card dimensions", () => {
|
|
48
|
+
const { container } = render(CardBody);
|
|
49
|
+
const body = container.firstElementChild;
|
|
50
|
+
expect(body.className).toContain("h-96");
|
|
51
|
+
expect(body.className).toContain("w-96");
|
|
52
|
+
expect(body.getAttribute("style")).toContain("transform-style: preserve-3d");
|
|
53
|
+
});
|
|
54
|
+
it("applies custom class names", () => {
|
|
55
|
+
const { container } = render(CardBody, { props: { class: "my-body" } });
|
|
56
|
+
const body = container.firstElementChild;
|
|
57
|
+
expect(body.className).toContain("my-body");
|
|
58
|
+
});
|
|
59
|
+
it("renders children content", () => {
|
|
60
|
+
const { container } = render(CardBody, {
|
|
61
|
+
props: { children: textSnippet("<span data-testid='inner'>x</span>") },
|
|
62
|
+
});
|
|
63
|
+
expect(container.querySelector("[data-testid='inner']")).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("CardItem", () => {
|
|
67
|
+
afterEach(cleanup);
|
|
68
|
+
// CardItem reads its hover state from CardContainer via setContext/getContext
|
|
69
|
+
// under the "card3d:mouseEntered" key. @testing-library/svelte forwards a
|
|
70
|
+
// `context` mount option straight to Svelte's mount(), so it can be tested
|
|
71
|
+
// standalone without mounting a full CardContainer tree.
|
|
72
|
+
function withContext(mouseEntered) {
|
|
73
|
+
return new Map([["card3d:mouseEntered", () => mouseEntered]]);
|
|
74
|
+
}
|
|
75
|
+
it("sits at the zero transform when the container reports no hover", () => {
|
|
76
|
+
const { container } = render(CardItem, {
|
|
77
|
+
props: { translateX: 20, rotateZ: 10 },
|
|
78
|
+
context: withContext(false),
|
|
79
|
+
});
|
|
80
|
+
const el = container.firstElementChild;
|
|
81
|
+
expect(el.style.transform).toContain("translateX(0px)");
|
|
82
|
+
expect(el.style.transform).toContain("rotateZ(0deg)");
|
|
83
|
+
});
|
|
84
|
+
it("applies the configured translate/rotate values once the container reports hover", () => {
|
|
85
|
+
const { container } = render(CardItem, {
|
|
86
|
+
props: { translateX: 20, translateY: -10, rotateZ: 10 },
|
|
87
|
+
context: withContext(true),
|
|
88
|
+
});
|
|
89
|
+
const el = container.firstElementChild;
|
|
90
|
+
expect(el.style.transform).toContain("translateX(20px)");
|
|
91
|
+
expect(el.style.transform).toContain("translateY(-10px)");
|
|
92
|
+
expect(el.style.transform).toContain("rotateZ(10deg)");
|
|
93
|
+
});
|
|
94
|
+
it("renders the element specified by the as prop", () => {
|
|
95
|
+
const { container } = render(CardItem, {
|
|
96
|
+
props: { as: "span" },
|
|
97
|
+
context: withContext(false),
|
|
98
|
+
});
|
|
99
|
+
expect(container.firstElementChild?.tagName.toLowerCase()).toBe("span");
|
|
100
|
+
});
|
|
101
|
+
it("defaults to a div and merges custom class names", () => {
|
|
102
|
+
const { container } = render(CardItem, {
|
|
103
|
+
props: { class: "my-item" },
|
|
104
|
+
context: withContext(false),
|
|
105
|
+
});
|
|
106
|
+
const el = container.firstElementChild;
|
|
107
|
+
expect(el.tagName.toLowerCase()).toBe("div");
|
|
108
|
+
expect(el.className).toContain("my-item");
|
|
109
|
+
expect(el.className).toContain("w-fit");
|
|
110
|
+
});
|
|
111
|
+
it("renders children content", () => {
|
|
112
|
+
const { container } = render(CardItem, {
|
|
113
|
+
props: { children: textSnippet("<em data-testid='item-child'>x</em>") },
|
|
114
|
+
context: withContext(false),
|
|
115
|
+
});
|
|
116
|
+
expect(container.querySelector("[data-testid='item-child']")).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render, cleanup, fireEvent } from "@testing-library/svelte";
|
|
2
|
+
import { createRawSnippet } from "svelte";
|
|
3
|
+
import { afterEach, describe, it, expect } from "vitest";
|
|
4
|
+
import CardSpotlight from "./CardSpotlight.svelte";
|
|
5
|
+
function textSnippet(html) {
|
|
6
|
+
return createRawSnippet(() => ({ render: () => html }));
|
|
7
|
+
}
|
|
8
|
+
describe("CardSpotlight", () => {
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
it("renders the wrapper with the base card classes", () => {
|
|
11
|
+
const { container } = render(CardSpotlight);
|
|
12
|
+
const wrapper = container.firstElementChild;
|
|
13
|
+
expect(wrapper.className).toContain("rounded-xl");
|
|
14
|
+
expect(wrapper.className).toContain("overflow-hidden");
|
|
15
|
+
});
|
|
16
|
+
it("applies custom class names to the wrapper", () => {
|
|
17
|
+
const { container } = render(CardSpotlight, { props: { class: "my-spotlight" } });
|
|
18
|
+
const wrapper = container.firstElementChild;
|
|
19
|
+
expect(wrapper.className).toContain("my-spotlight");
|
|
20
|
+
});
|
|
21
|
+
it("applies slotClass to the content wrapper", () => {
|
|
22
|
+
const { container } = render(CardSpotlight, { props: { slotClass: "my-slot" } });
|
|
23
|
+
const slot = container.querySelector(".relative.z-10");
|
|
24
|
+
expect(slot.className).toContain("my-slot");
|
|
25
|
+
});
|
|
26
|
+
it("renders children content", () => {
|
|
27
|
+
const { container } = render(CardSpotlight, {
|
|
28
|
+
props: { children: textSnippet("<p data-testid='content'>hi</p>") },
|
|
29
|
+
});
|
|
30
|
+
expect(container.querySelector("[data-testid='content']")).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
// The overlay's `style="background: {backgroundStyle}; opacity: {gradientOpacity}"`
|
|
33
|
+
// attribute never materializes under this repo's vitest+jsdom setup — verified
|
|
34
|
+
// with a from-scratch minimal component reproduction, unrelated to mouse
|
|
35
|
+
// tracking or gradientSize/gradientColor: any style attribute shaped like
|
|
36
|
+
// `style="prop: {dyn}; prop2: value"` on a plain element renders with no
|
|
37
|
+
// style attribute at all (confirmed via getAttribute, .style.cssText, and
|
|
38
|
+
// the individual .style.* properties, all empty). Because vite.config.ts
|
|
39
|
+
// forces `resolve: { conditions: ['browser'] }`, tests compile the same
|
|
40
|
+
// client output real browsers get, so this is worth a maintainer checking
|
|
41
|
+
// in an actual browser rather than something to chase further here — see
|
|
42
|
+
// the gradient/opacity plumbing tests below, which stay on structure only.
|
|
43
|
+
it("accepts gradientSize/gradientColor/gradientOpacity without throwing and keeps the overlay in the DOM", () => {
|
|
44
|
+
const { container } = render(CardSpotlight, {
|
|
45
|
+
props: { gradientSize: 300, gradientColor: "#ff0000", gradientOpacity: 0.5 },
|
|
46
|
+
});
|
|
47
|
+
const overlay = container.querySelector(".pointer-events-none.absolute.inset-0");
|
|
48
|
+
expect(overlay).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
it("responds to mousemove and mouseleave without throwing", async () => {
|
|
51
|
+
const { container } = render(CardSpotlight);
|
|
52
|
+
const wrapper = container.firstElementChild;
|
|
53
|
+
await fireEvent.mouseMove(wrapper, { clientX: 40, clientY: 30 });
|
|
54
|
+
await fireEvent.mouseLeave(wrapper);
|
|
55
|
+
// The awaits above would reject if either handler threw.
|
|
56
|
+
expect(container.querySelector(".pointer-events-none.absolute.inset-0")).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# DisplacementText
|
|
2
|
+
|
|
3
|
+
Large text rendered as a Three.js plane whose surface bulges toward the viewer near the cursor, via a vertex-shader displacement driven by raycasting against an invisible hit-plane. The text itself is rasterized to a canvas texture, not drawn with real DOM glyphs.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```svelte
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { DisplacementText } from "fancy-ui-svelte";
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<DisplacementText text="Hover Me" fontSize={220} class="h-[400px] w-full" />
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```svelte
|
|
16
|
+
<!-- Fixed color instead of following light/dark mode -->
|
|
17
|
+
<DisplacementText text="Fancy" color="#ff4d4d" font="Georgia, serif" />
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
| Prop | Type | Default | Description |
|
|
23
|
+
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------ |
|
|
24
|
+
| `text` | `string` | `"Hover Me"` | Text to display |
|
|
25
|
+
| `fontSize` | `number` | `200` | Font size in pixels, used when rasterizing the text texture |
|
|
26
|
+
| `font` | `string` | `"Inter, sans-serif"` | Font family |
|
|
27
|
+
| `color` | `string` | — | Fixed text color; overrides `lightColor`/`darkColor` and disables dark-mode tracking |
|
|
28
|
+
| `lightColor` | `string` | `"#000000"` | Text color in light mode |
|
|
29
|
+
| `darkColor` | `string` | `"#ffffff"` | Text color in dark mode |
|
|
30
|
+
| `class` | `string` | `""` | Additional CSS classes on the root |
|
|
31
|
+
|
|
32
|
+
## Implementation notes
|
|
33
|
+
|
|
34
|
+
- The whole Three.js scene (renderer, camera, geometry, material) is created and torn down inside a single `$effect` that depends on `text`, `fontSize`, `font`, `color`, `lightColor`, and `darkColor` — any of those changing disposes the old scene and builds a new one from scratch rather than updating it incrementally. This is simple but not cheap; avoid binding these props to something that changes every frame.
|
|
35
|
+
- Displacement comes from a plane geometry (100×100 segments) whose vertex shader offsets `z` based on distance from a `uDisplacement` uniform, updated every `pointermove` by raycasting against a large (500×500), fully transparent hit-plane placed in the same scene — the visible text plane itself is not the raycast target.
|
|
36
|
+
- Text is rasterized once per texture rebuild via a 2048×2048 `<canvas>` with `ctx.fillText`, then uploaded as a `THREE.CanvasTexture`.
|
|
37
|
+
- When `color` is not set, a `MutationObserver` watches `document.documentElement`'s `class` attribute (the app's dark-mode toggle) and regenerates the text texture — disposing the old one — whenever the resolved color actually changes. This observer is not created at all when `color` is set.
|
|
38
|
+
- Cleanup on effect teardown: removes the `pointermove` and `resize` listeners, cancels the animation frame, disconnects the `MutationObserver`, removes the renderer's canvas from the DOM, and disposes the renderer, geometry, material, and texture.
|
|
39
|
+
- No `prefers-reduced-motion` handling — the render loop and pointer tracking run regardless of user preference.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render, cleanup } from "@testing-library/svelte";
|
|
2
|
+
import { afterEach, beforeEach, describe, it, expect } from "vitest";
|
|
3
|
+
import EditorialEngine from "./EditorialEngine.svelte";
|
|
4
|
+
// The real engine measures text via @chenglou/pretext, which needs a canvas
|
|
5
|
+
// 2d context or OffscreenCanvas — neither exists under jsdom
|
|
6
|
+
// (HTMLCanvasElement.prototype.getContext is mocked to return null in
|
|
7
|
+
// src/test-setup.ts). Stubbing `document.fonts.ready` with a promise that
|
|
8
|
+
// never resolves keeps the component pinned on its pre-engine fallback
|
|
9
|
+
// branch (the only branch that's meaningfully testable under jsdom) instead
|
|
10
|
+
// of letting the effect's `.then()` handler call into pretext and throw.
|
|
11
|
+
function stubNeverReadyFonts() {
|
|
12
|
+
Object.defineProperty(document, "fonts", {
|
|
13
|
+
configurable: true,
|
|
14
|
+
writable: true,
|
|
15
|
+
value: { ready: new Promise(() => { }) },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
describe("EditorialEngine", () => {
|
|
19
|
+
beforeEach(stubNeverReadyFonts);
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
it("mounts and renders the stage without throwing", () => {
|
|
22
|
+
const { container } = render(EditorialEngine);
|
|
23
|
+
expect(container.querySelector(".ee-stage")).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
it("applies custom class names to the stage", () => {
|
|
26
|
+
const { container } = render(EditorialEngine, { props: { class: "my-stage" } });
|
|
27
|
+
const stage = container.querySelector(".ee-stage");
|
|
28
|
+
expect(stage.className).toContain("my-stage");
|
|
29
|
+
});
|
|
30
|
+
it("sets the font-family style from the fontFamily prop", () => {
|
|
31
|
+
const { container } = render(EditorialEngine, { props: { fontFamily: "Georgia, serif" } });
|
|
32
|
+
const stage = container.querySelector(".ee-stage");
|
|
33
|
+
expect(stage.style.fontFamily).toContain("Georgia");
|
|
34
|
+
});
|
|
35
|
+
it("renders the pre-engine fallback with the headline and body before the engine is ready", () => {
|
|
36
|
+
const { container } = render(EditorialEngine, {
|
|
37
|
+
props: { headline: "HELLO WORLD", body: "Some body copy." },
|
|
38
|
+
});
|
|
39
|
+
const fallback = container.querySelector(".ee-fallback");
|
|
40
|
+
expect(fallback).toBeTruthy();
|
|
41
|
+
expect(fallback?.querySelector("h2")?.textContent).toBe("HELLO WORLD");
|
|
42
|
+
expect(fallback?.querySelector("p")?.textContent).toBe("Some body copy.");
|
|
43
|
+
});
|
|
44
|
+
it("does not carry the ready class while pretext has not measured yet", () => {
|
|
45
|
+
const { container } = render(EditorialEngine);
|
|
46
|
+
const stage = container.querySelector(".ee-stage");
|
|
47
|
+
expect(stage.classList.contains("ready")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
it("unmounts cleanly", () => {
|
|
50
|
+
const { container, unmount } = render(EditorialEngine);
|
|
51
|
+
expect(() => unmount()).not.toThrow();
|
|
52
|
+
expect(container.querySelector(".ee-stage")).toBeFalsy();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import {
|
|
5
5
|
type ColorRGB,
|
|
6
6
|
type Pointer,
|
|
7
|
+
type FluidCursorHandle,
|
|
8
|
+
type FluidRenderLevel,
|
|
7
9
|
pointerPrototype,
|
|
8
10
|
hexToRgb,
|
|
9
11
|
HSVtoRGB,
|
|
@@ -46,6 +48,14 @@
|
|
|
46
48
|
contained?: boolean;
|
|
47
49
|
hdr?: boolean;
|
|
48
50
|
hdrBoost?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Called once the fluid engine is live, with an imperative handle to
|
|
53
|
+
* drive the simulation programmatically (trace a path via
|
|
54
|
+
* `moveTo`/`penUp`, fire a one-off `burst`) and read back the actual
|
|
55
|
+
* `renderLevel`. Pair with `interactive={false}` to drive it entirely
|
|
56
|
+
* without a real cursor.
|
|
57
|
+
*/
|
|
58
|
+
onReady?: (handle: FluidCursorHandle) => void;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
let {
|
|
@@ -76,6 +86,7 @@
|
|
|
76
86
|
contained = true,
|
|
77
87
|
hdr = false,
|
|
78
88
|
hdrBoost = 1.5,
|
|
89
|
+
onReady,
|
|
79
90
|
}: Props = $props();
|
|
80
91
|
|
|
81
92
|
const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
|
|
@@ -157,6 +168,31 @@
|
|
|
157
168
|
return cleanup;
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
// Inert handle for environments where no renderer came up at all, so a
|
|
172
|
+
// consumer waiting on onReady can tell "unsupported" from "still starting".
|
|
173
|
+
const noRendererHandle: FluidCursorHandle = {
|
|
174
|
+
moveTo() {},
|
|
175
|
+
penUp() {},
|
|
176
|
+
burst() {},
|
|
177
|
+
renderLevel: "none",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Hand the handle to the consumer. Guarded because the WebGPU path invokes
|
|
181
|
+
// this inside its startup promise chain: an exception from the callback would
|
|
182
|
+
// otherwise be caught as a WebGPU setup failure, retrying WebGL on a canvas
|
|
183
|
+
// already claimed by WebGPU and dropping the registered cleanup. Re-thrown
|
|
184
|
+
// asynchronously so it still surfaces as an unhandled error.
|
|
185
|
+
function notifyReady(handle: FluidCursorHandle) {
|
|
186
|
+
if (!onReady) return;
|
|
187
|
+
try {
|
|
188
|
+
onReady(handle);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
queueMicrotask(() => {
|
|
191
|
+
throw error;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
160
196
|
onMount(() => {
|
|
161
197
|
const canvas = canvasRef;
|
|
162
198
|
if (!canvas) return;
|
|
@@ -209,23 +245,26 @@
|
|
|
209
245
|
splatOnMount,
|
|
210
246
|
generateColor,
|
|
211
247
|
})
|
|
212
|
-
.then((
|
|
248
|
+
.then((handle) => {
|
|
213
249
|
if (disposed) {
|
|
214
|
-
cleanup
|
|
250
|
+
handle?.cleanup();
|
|
215
251
|
return;
|
|
216
252
|
}
|
|
217
253
|
// A newer singleton instance mounted while we were starting up:
|
|
218
254
|
// registering now would destroy it (newest-mount-wins would
|
|
219
255
|
// invert). Discard this stale completion instead.
|
|
220
256
|
if (!allowMultiple && mountToken !== mountCounter) {
|
|
221
|
-
cleanup
|
|
257
|
+
handle?.cleanup();
|
|
222
258
|
return;
|
|
223
259
|
}
|
|
224
|
-
if (!
|
|
260
|
+
if (!handle) {
|
|
261
|
+
// WebGPU unavailable: the WebGL fallback surfaces its own
|
|
262
|
+
// handle through onReady.
|
|
225
263
|
activeCleanup = startWebGl() ?? null;
|
|
226
264
|
return;
|
|
227
265
|
}
|
|
228
|
-
activeCleanup = registerInstance(cleanup);
|
|
266
|
+
activeCleanup = registerInstance(handle.cleanup);
|
|
267
|
+
notifyReady(handle);
|
|
229
268
|
})
|
|
230
269
|
.catch((error) => {
|
|
231
270
|
// Unexpected failure while wiring the WebGPU path: fall back
|
|
@@ -245,9 +284,24 @@
|
|
|
245
284
|
|
|
246
285
|
// WebGL engine path (default, and fallback when WebGPU is missing).
|
|
247
286
|
function startWebGl(): (() => void) | undefined {
|
|
287
|
+
// Wide-gamut probe result, surfaced through the handle's renderLevel.
|
|
288
|
+
let renderLevel: FluidRenderLevel = "webgl-sdr";
|
|
289
|
+
// Dedicated synthetic pointer driven programmatically through the
|
|
290
|
+
// handle. It lives alongside the mouse pointer in the same list, so
|
|
291
|
+
// applyInputs splats both and the two inputs coexist. Only created
|
|
292
|
+
// when a consumer asked for the handle: an unused extra pointer would
|
|
293
|
+
// still consume the shared fluidColors sequence in updateColors.
|
|
294
|
+
const autopilotPointer = onReady ? pointerPrototype() : null;
|
|
295
|
+
if (autopilotPointer) pointers.push(autopilotPointer);
|
|
296
|
+
let penWasUp = true;
|
|
297
|
+
|
|
248
298
|
// Get WebGL context
|
|
249
299
|
const context = getWebGLContext(canvas);
|
|
250
|
-
if (!context.gl || !context.ext)
|
|
300
|
+
if (!context.gl || !context.ext) {
|
|
301
|
+
// No renderer at all (WebGPU already failed if it was tried).
|
|
302
|
+
notifyReady(noRendererHandle);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
251
305
|
|
|
252
306
|
// These are guaranteed non-null after the check above
|
|
253
307
|
const gl = context.gl;
|
|
@@ -282,15 +336,24 @@
|
|
|
282
336
|
|
|
283
337
|
// HDR fallback: wide-gamut backbuffer when the WebGPU engine is
|
|
284
338
|
// unavailable. Colors are reinterpreted in P3 (more saturated).
|
|
339
|
+
// The property is read back because assigning an unsupported color
|
|
340
|
+
// space silently leaves the buffer in sRGB.
|
|
285
341
|
if (hdr && "drawingBufferColorSpace" in gl) {
|
|
286
342
|
try {
|
|
287
343
|
gl.drawingBufferColorSpace = "display-p3";
|
|
288
|
-
if (
|
|
289
|
-
|
|
344
|
+
if (gl.drawingBufferColorSpace === "display-p3") {
|
|
345
|
+
renderLevel = "webgl-p3";
|
|
290
346
|
}
|
|
291
347
|
} catch {
|
|
292
348
|
// Unsupported color space: buffer stays sRGB.
|
|
293
349
|
}
|
|
350
|
+
if (import.meta.env.DEV) {
|
|
351
|
+
console.info(
|
|
352
|
+
renderLevel === "webgl-p3"
|
|
353
|
+
? "[FluidCursor] HDR level: webgl-p3 (wide gamut fallback)"
|
|
354
|
+
: "[FluidCursor] HDR level: webgl-sdr (display-p3 drawing buffer rejected)"
|
|
355
|
+
);
|
|
356
|
+
}
|
|
294
357
|
}
|
|
295
358
|
|
|
296
359
|
const isWebGL2 = "drawBuffers" in gl;
|
|
@@ -1157,6 +1220,10 @@
|
|
|
1157
1220
|
if (colorUpdateTimer >= 1) {
|
|
1158
1221
|
colorUpdateTimer = wrap(colorUpdateTimer, 0, 1);
|
|
1159
1222
|
pointers.forEach((p) => {
|
|
1223
|
+
// The synthetic pointer's color belongs to the handle: pulling
|
|
1224
|
+
// from the generator here would advance the shared fluidColors
|
|
1225
|
+
// index twice per update and overwrite explicit stroke colors.
|
|
1226
|
+
if (p === autopilotPointer) return;
|
|
1160
1227
|
p.color = generateColor();
|
|
1161
1228
|
});
|
|
1162
1229
|
}
|
|
@@ -1467,6 +1534,49 @@
|
|
|
1467
1534
|
return delta;
|
|
1468
1535
|
}
|
|
1469
1536
|
|
|
1537
|
+
// Programmatic drive surface (exposed through onReady).
|
|
1538
|
+
//
|
|
1539
|
+
// x,y in [0,1], top-left origin. `updatePointerMoveData` applies
|
|
1540
|
+
// `texcoordY = 1 - posY / height`, so feeding it a top-left pixel Y
|
|
1541
|
+
// yields the same path a real mouse event's clientY takes. This is the
|
|
1542
|
+
// only Y flip on the WebGL path.
|
|
1543
|
+
function autopilotMoveTo(x: number, y: number, color?: ColorRGB) {
|
|
1544
|
+
if (!autopilotPointer) return;
|
|
1545
|
+
const posX = x * canvas.width;
|
|
1546
|
+
const posY = y * canvas.height;
|
|
1547
|
+
if (penWasUp) {
|
|
1548
|
+
// First move of a new stroke: reposition only (mirrors
|
|
1549
|
+
// updatePointerDownData) so the jump from the previous stroke's
|
|
1550
|
+
// end never draws a connecting streak.
|
|
1551
|
+
penWasUp = false;
|
|
1552
|
+
autopilotPointer.down = true;
|
|
1553
|
+
autopilotPointer.moved = false;
|
|
1554
|
+
autopilotPointer.texcoordX = posX / canvas.width;
|
|
1555
|
+
autopilotPointer.texcoordY = 1 - posY / canvas.height;
|
|
1556
|
+
autopilotPointer.prevTexcoordX = autopilotPointer.texcoordX;
|
|
1557
|
+
autopilotPointer.prevTexcoordY = autopilotPointer.texcoordY;
|
|
1558
|
+
autopilotPointer.deltaX = 0;
|
|
1559
|
+
autopilotPointer.deltaY = 0;
|
|
1560
|
+
// A stroke with no explicit color takes a fresh generated one, as
|
|
1561
|
+
// a real pointer-down does: the prototype's black would inject
|
|
1562
|
+
// invisible dye.
|
|
1563
|
+
autopilotPointer.color = color ?? generateColor();
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
updatePointerMoveData(autopilotPointer, posX, posY, color ?? autopilotPointer.color);
|
|
1567
|
+
}
|
|
1568
|
+
function autopilotPenUp() {
|
|
1569
|
+
// `moved` is deliberately left alone: applyInputs consumes and clears
|
|
1570
|
+
// it on the next frame, so clearing it here would swallow the final
|
|
1571
|
+
// segment of a stroke finished synchronously before that frame.
|
|
1572
|
+
penWasUp = true;
|
|
1573
|
+
if (autopilotPointer) autopilotPointer.down = false;
|
|
1574
|
+
}
|
|
1575
|
+
// Top-left origin in, bottom-up texcoords out: flip y and negate dy.
|
|
1576
|
+
function autopilotBurst(x: number, y: number, dx: number, dy: number, color: ColorRGB) {
|
|
1577
|
+
splat(x, 1 - y, dx, -dy, color);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1470
1580
|
// Event Listeners
|
|
1471
1581
|
function handleMouseDown(e: MouseEvent) {
|
|
1472
1582
|
const pointer = pointers[0];
|
|
@@ -1582,7 +1692,18 @@
|
|
|
1582
1692
|
}
|
|
1583
1693
|
}
|
|
1584
1694
|
|
|
1585
|
-
|
|
1695
|
+
// Register before notifying: a throwing callback must not cost us the
|
|
1696
|
+
// cleanup registration (listeners, rAF loop and GL resources would
|
|
1697
|
+
// otherwise outlive the component).
|
|
1698
|
+
const dispose = registerInstance(cleanup);
|
|
1699
|
+
notifyReady({
|
|
1700
|
+
moveTo: autopilotMoveTo,
|
|
1701
|
+
penUp: autopilotPenUp,
|
|
1702
|
+
burst: autopilotBurst,
|
|
1703
|
+
renderLevel,
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
return dispose;
|
|
1586
1707
|
}
|
|
1587
1708
|
});
|
|
1588
1709
|
</script>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ColorRGB } from "./fluid-shared.js";
|
|
1
|
+
import { type ColorRGB, type FluidCursorHandle } from "./fluid-shared.js";
|
|
2
2
|
interface Props {
|
|
3
3
|
simResolution?: number;
|
|
4
4
|
dyeResolution?: number;
|
|
@@ -27,6 +27,14 @@ interface Props {
|
|
|
27
27
|
contained?: boolean;
|
|
28
28
|
hdr?: boolean;
|
|
29
29
|
hdrBoost?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Called once the fluid engine is live, with an imperative handle to
|
|
32
|
+
* drive the simulation programmatically (trace a path via
|
|
33
|
+
* `moveTo`/`penUp`, fire a one-off `burst`) and read back the actual
|
|
34
|
+
* `renderLevel`. Pair with `interactive={false}` to drive it entirely
|
|
35
|
+
* without a real cursor.
|
|
36
|
+
*/
|
|
37
|
+
onReady?: (handle: FluidCursorHandle) => void;
|
|
30
38
|
}
|
|
31
39
|
declare const FluidCursor: import("svelte").Component<Props, {}, "">;
|
|
32
40
|
type FluidCursor = ReturnType<typeof FluidCursor>;
|
|
@@ -127,4 +127,23 @@ describe("FluidCursor", () => {
|
|
|
127
127
|
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
|
+
describe("onReady handle", () => {
|
|
131
|
+
// jsdom provides no WebGL context, so this exercises the no-renderer path.
|
|
132
|
+
it('reports renderLevel "none" when no renderer is available', () => {
|
|
133
|
+
const onReady = vi.fn();
|
|
134
|
+
render(FluidCursor, { props: { onReady } });
|
|
135
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(onReady.mock.calls[0][0].renderLevel).toBe("none");
|
|
137
|
+
});
|
|
138
|
+
it("hands over an inert handle that is safe to call", () => {
|
|
139
|
+
const onReady = vi.fn();
|
|
140
|
+
render(FluidCursor, { props: { onReady } });
|
|
141
|
+
const handle = onReady.mock.calls[0][0];
|
|
142
|
+
expect(() => {
|
|
143
|
+
handle.moveTo(0.5, 0.5);
|
|
144
|
+
handle.penUp();
|
|
145
|
+
handle.burst(0.5, 0.5, 10, 10, { r: 1, g: 0, b: 0 });
|
|
146
|
+
}).not.toThrow();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
130
149
|
});
|
|
@@ -38,3 +38,35 @@ export declare function correctRadius(radius: number, width: number, height: num
|
|
|
38
38
|
export declare function scaleRadiusForContainer(radius: number, containerHeight: number, viewportHeight: number): number;
|
|
39
39
|
export declare function correctDeltaX(delta: number, width: number, height: number): number;
|
|
40
40
|
export declare function correctDeltaY(delta: number, width: number, height: number): number;
|
|
41
|
+
/**
|
|
42
|
+
* How the fluid is actually being rendered, surfaced as data so callers can
|
|
43
|
+
* react (e.g. tell true HDR output apart from a clamped fallback):
|
|
44
|
+
* - "webgpu-hdr": WebGPU with extended tone mapping active AND a display
|
|
45
|
+
* reporting `(dynamic-range: high)` — true HDR output.
|
|
46
|
+
* - "webgpu-sdr": WebGPU float16 + P3, but the browser clamped tone mapping or
|
|
47
|
+
* the output display is SDR.
|
|
48
|
+
* - "webgl-p3": WebGL fallback with a display-p3 drawing buffer.
|
|
49
|
+
* - "webgl-sdr": WebGL fallback, plain sRGB.
|
|
50
|
+
* - "none": no GPU rendering available at all; the handle is inert.
|
|
51
|
+
*
|
|
52
|
+
* Sampled once when the handle is created: it does not update if the window
|
|
53
|
+
* later moves to a display with a different dynamic range.
|
|
54
|
+
*/
|
|
55
|
+
export type FluidRenderLevel = "webgpu-hdr" | "webgpu-sdr" | "webgl-p3" | "webgl-sdr" | "none";
|
|
56
|
+
/**
|
|
57
|
+
* Imperative control surface handed to `FluidCursor`'s `onReady` callback so a
|
|
58
|
+
* parent can drive the simulation programmatically (trace a path, fire a
|
|
59
|
+
* one-off burst) and read back which render path actually engaged. Coordinates
|
|
60
|
+
* are in [0,1] with the ORIGIN AT THE TOP-LEFT (CSS convention); velocity is
|
|
61
|
+
* derived from consecutive `moveTo` calls, so smooth paths feel like a real
|
|
62
|
+
* cursor. Any engine-specific Y flip is internal.
|
|
63
|
+
*/
|
|
64
|
+
export interface FluidCursorHandle {
|
|
65
|
+
/** Drive the synthetic pointer like a mouse. The next `moveTo` after `penUp` repositions without drawing. */
|
|
66
|
+
moveTo(x: number, y: number, color?: ColorRGB): void;
|
|
67
|
+
/** Lift the pen so the next `moveTo` repositions without producing a connecting streak. */
|
|
68
|
+
penUp(): void;
|
|
69
|
+
/** One-off impulse (click-like burst). Same coord convention; dx/dy in engine velocity units. */
|
|
70
|
+
burst(x: number, y: number, dx: number, dy: number, color: ColorRGB): void;
|
|
71
|
+
readonly renderLevel: FluidRenderLevel;
|
|
72
|
+
}
|
|
@@ -3,3 +3,4 @@ export { default as FluidCursor } from "./FluidCursor.svelte";
|
|
|
3
3
|
* @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.
|
|
4
4
|
*/
|
|
5
5
|
export { default as FluidCursorAdvanced } from "./FluidCursor.svelte";
|
|
6
|
+
export type { FluidCursorHandle, FluidRenderLevel } from "./fluid-shared.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ColorRGB } from "./fluid-shared.js";
|
|
1
|
+
import { type ColorRGB, type FluidCursorHandle } from "./fluid-shared.js";
|
|
2
2
|
export interface WebGpuFluidOptions {
|
|
3
3
|
simResolution: number;
|
|
4
4
|
dyeResolution: number;
|
|
@@ -23,7 +23,14 @@ export interface WebGpuFluidOptions {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
|
|
26
|
-
* visibility pause + auto splats. Resolves with a
|
|
27
|
-
* `null` when WebGPU is unavailable
|
|
26
|
+
* visibility pause + auto splats. Resolves with a {@link FluidCursorHandle}
|
|
27
|
+
* (augmented with `cleanup`), or with `null` when WebGPU is unavailable
|
|
28
|
+
* (caller falls back to the WebGL path).
|
|
29
|
+
*
|
|
30
|
+
* Handle coordinates are top-left origin in [0,1]; WebGPU texcoords already
|
|
31
|
+
* use a top-origin convention, so no Y flip happens here (the WebGL path owns
|
|
32
|
+
* its own flip).
|
|
28
33
|
*/
|
|
29
|
-
export declare function startWebGpuFluid(canvas: HTMLCanvasElement, opts: WebGpuFluidOptions): Promise<(
|
|
34
|
+
export declare function startWebGpuFluid(canvas: HTMLCanvasElement, opts: WebGpuFluidOptions): Promise<(FluidCursorHandle & {
|
|
35
|
+
cleanup(): void;
|
|
36
|
+
}) | null>;
|