@vysmo/transitions-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/Transition.d.ts +47 -0
- package/dist/Transition.d.ts.map +1 -0
- package/dist/Transition.js +113 -0
- package/dist/Transition.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/resolve-source.d.ts +17 -0
- package/dist/resolve-source.d.ts.map +1 -0
- package/dist/resolve-source.js +22 -0
- package/dist/resolve-source.js.map +1 -0
- package/dist/use-transition-runner.d.ts +16 -0
- package/dist/use-transition-runner.d.ts.map +1 -0
- package/dist/use-transition-runner.js +31 -0
- package/dist/use-transition-runner.js.map +1 -0
- package/package.json +55 -0
- package/src/Transition.tsx +167 -0
- package/src/__tests__/Transition.test.tsx +143 -0
- package/src/__tests__/ssr.test.ts +21 -0
- package/src/index.ts +3 -0
- package/src/resolve-source.ts +36 -0
- package/src/use-transition-runner.ts +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maesto LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# @vysmo/transitions-react
|
|
2
|
+
|
|
3
|
+
React bindings for [`@vysmo/transitions`](https://www.npmjs.com/package/@vysmo/transitions). One `<Transition>` component, one hook (`useTransitionRunner`), zero opinion on how you drive progress — controlled or self-driving, your call.
|
|
4
|
+
|
|
5
|
+
[Live demos + parameter playground](https://vysmo.com/transitions) · [Source](https://github.com/vysmodev)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @vysmo/transitions @vysmo/transitions-react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`react` ≥ 18 is a peer dependency.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { Transition } from "@vysmo/transitions-react";
|
|
19
|
+
import { paintBleed } from "@vysmo/transitions";
|
|
20
|
+
|
|
21
|
+
export function Hero() {
|
|
22
|
+
return (
|
|
23
|
+
<Transition
|
|
24
|
+
transition={paintBleed}
|
|
25
|
+
from="/images/a.jpg"
|
|
26
|
+
to="/images/b.jpg"
|
|
27
|
+
duration={1200}
|
|
28
|
+
style={{ width: "100%", aspectRatio: "16 / 9" }}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's the whole minimum. The component mounts a `<canvas>`, creates a `Runner`, resolves the URLs into decoded `Image`s, and runs an autoplay from `progress=0` to `progress=1` over `duration` ms. On unmount the runner is disposed.
|
|
35
|
+
|
|
36
|
+
## Controlled progress
|
|
37
|
+
|
|
38
|
+
Pass a `progress` prop — autoplay is bypassed and the component renders exactly at the value you supply. Drive it from anything: scroll progress, a scrubber, a tween library, your own state.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// @no-check
|
|
42
|
+
import { Transition } from "@vysmo/transitions-react";
|
|
43
|
+
import { paintBleed } from "@vysmo/transitions";
|
|
44
|
+
import { useScrollProgress } from "./somewhere";
|
|
45
|
+
|
|
46
|
+
export function ScrollHero() {
|
|
47
|
+
const progress = useScrollProgress(); // 0 → 1 as the section passes
|
|
48
|
+
return (
|
|
49
|
+
<Transition
|
|
50
|
+
transition={paintBleed}
|
|
51
|
+
from="/a.jpg"
|
|
52
|
+
to="/b.jpg"
|
|
53
|
+
progress={progress}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Props
|
|
60
|
+
|
|
61
|
+
| Prop | Type | Default | Notes |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| `transition` | `Transition` | — | Any transition exported by `@vysmo/transitions`. |
|
|
64
|
+
| `from` | `Source` | — | URL string, `HTMLImageElement`, canvas, video, or `ImageBitmap`. |
|
|
65
|
+
| `to` | `Source` | — | Same shape as `from`. |
|
|
66
|
+
| `progress` | `number` | — | Controlled `[0,1]`. When set, autoplay is bypassed. |
|
|
67
|
+
| `duration` | `number` | `1000` | Autoplay duration ms. Used only when `progress` is omitted. |
|
|
68
|
+
| `playing` | `boolean` | `true` | Whether autoplay is running. Used only when `progress` is omitted. |
|
|
69
|
+
| `loop` | `boolean` | `false` | Loop autoplay. |
|
|
70
|
+
| `ease` | `(t: number) => number` | linear | Easing applied to autoplay. Pair with [`@vysmo/easings`](https://www.npmjs.com/package/@vysmo/easings) for named curves. |
|
|
71
|
+
| `params` | `UniformParams` | — | Override shader uniform defaults (e.g. `{ blur: 0.05 }`). |
|
|
72
|
+
| `onComplete` | `() => void` | — | Fires when a non-loop autoplay reaches `progress=1`. |
|
|
73
|
+
| `className` | `string` | — | Forwarded to the `<canvas>`. |
|
|
74
|
+
| `style` | `CSSProperties` | — | Forwarded to the `<canvas>`. |
|
|
75
|
+
|
|
76
|
+
## Sizing
|
|
77
|
+
|
|
78
|
+
The component renders a `<canvas>` and syncs its internal pixel dimensions to its CSS box via `ResizeObserver`, with `devicePixelRatio` applied. Style the canvas via `className` / `style` and the runner adapts.
|
|
79
|
+
|
|
80
|
+
## Hook (advanced)
|
|
81
|
+
|
|
82
|
+
If you need direct `Runner` access — e.g. to compose multiple renders per frame, or to drive a transition off a non-React render loop — use `useTransitionRunner`:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
import { useRef } from "react";
|
|
86
|
+
import { useTransitionRunner } from "@vysmo/transitions-react";
|
|
87
|
+
import { paintBleed } from "@vysmo/transitions";
|
|
88
|
+
|
|
89
|
+
function CustomDriver({ from, to, progress }) {
|
|
90
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
91
|
+
const runner = useTransitionRunner(canvasRef);
|
|
92
|
+
|
|
93
|
+
if (runner) {
|
|
94
|
+
runner.render(paintBleed, { from, to, progress });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return <canvas ref={canvasRef} />;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The component is the right call for ~95% of cases; reach for the hook when the props don't expose what you need.
|
|
102
|
+
|
|
103
|
+
## SSR
|
|
104
|
+
|
|
105
|
+
The wrapper is SSR-safe: `useEffect` bodies don't run on the server, and the module body itself doesn't touch `window` / `document`. Server renders an empty `<canvas>`; client mounts the runner.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
2
|
+
import { type Transition as TransitionType, type UniformParams } from "@vysmo/transitions";
|
|
3
|
+
import { type Source } from "./resolve-source.js";
|
|
4
|
+
export interface TransitionProps {
|
|
5
|
+
/** Any transition exported by `@vysmo/transitions`. */
|
|
6
|
+
transition: TransitionType<UniformParams>;
|
|
7
|
+
/** First image. URL string, `HTMLImageElement`, canvas, video, or `ImageBitmap`. */
|
|
8
|
+
from: Source;
|
|
9
|
+
/** Second image. Same accepted shapes as `from`. */
|
|
10
|
+
to: Source;
|
|
11
|
+
/**
|
|
12
|
+
* Controlled progress in `[0, 1]`. When set, the component renders at
|
|
13
|
+
* exactly this progress and the autoplay loop is bypassed — drive it
|
|
14
|
+
* yourself (scroll progress, scrubber, animation library, etc.).
|
|
15
|
+
*/
|
|
16
|
+
progress?: number;
|
|
17
|
+
/** Autoplay duration in ms. Used only when `progress` is omitted. Default `1000`. */
|
|
18
|
+
duration?: number;
|
|
19
|
+
/** Whether internal autoplay is running. Used only when `progress` is omitted. Default `true`. */
|
|
20
|
+
playing?: boolean;
|
|
21
|
+
/** Loop autoplay. Default `false`. */
|
|
22
|
+
loop?: boolean;
|
|
23
|
+
/** Easing function applied during autoplay. Default linear. */
|
|
24
|
+
ease?: (t: number) => number;
|
|
25
|
+
/** Override shader uniform defaults. */
|
|
26
|
+
params?: UniformParams;
|
|
27
|
+
/** Fires when a non-loop autoplay reaches `progress=1`. */
|
|
28
|
+
onComplete?: () => void;
|
|
29
|
+
/** Forwarded to the canvas element. */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Forwarded to the canvas element. */
|
|
32
|
+
style?: CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* React wrapper around `@vysmo/transitions`'s `Runner`. Renders a
|
|
36
|
+
* `<canvas>` and drives a transition between two images — either
|
|
37
|
+
* controlled by a `progress` prop or self-driving via `duration` /
|
|
38
|
+
* `playing` / `loop` / `ease` for the common "play once on mount"
|
|
39
|
+
* case.
|
|
40
|
+
*
|
|
41
|
+
* The component creates one runner on mount and disposes it on
|
|
42
|
+
* unmount; sources are resolved (URL strings → decoded `Image`s) on
|
|
43
|
+
* `from` / `to` change. Canvas size syncs to its CSS box via
|
|
44
|
+
* `ResizeObserver`, with DPR applied so output stays sharp on retina.
|
|
45
|
+
*/
|
|
46
|
+
export declare function Transition({ transition, from, to, progress, duration, playing, loop, ease, params, onComplete, className, style, }: TransitionProps): ReactElement;
|
|
47
|
+
//# sourceMappingURL=Transition.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Transition.d.ts","sourceRoot":"","sources":["../src/Transition.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACzD,OAAO,EAEL,KAAK,UAAU,IAAI,cAAc,EACjC,KAAK,aAAa,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,uDAAuD;IACvD,UAAU,EAAE,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1C,oFAAoF;IACpF,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,EAAE,EAAE,MAAM,CAAC;IACX;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sCAAsC;IACtC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+DAA+D;IAC/D,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7B,wCAAwC;IACxC,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,uCAAuC;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uCAAuC;IACvC,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,EACzB,UAAU,EACV,IAAI,EACJ,EAAE,EACF,QAAQ,EACR,QAAe,EACf,OAAc,EACd,IAAY,EACZ,IAAI,EACJ,MAAM,EACN,UAAU,EACV,SAAS,EACT,KAAK,GACN,EAAE,eAAe,GAAG,YAAY,CAkGhC"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Runner, } from "@vysmo/transitions";
|
|
5
|
+
import { resolveSource } from "./resolve-source.js";
|
|
6
|
+
/**
|
|
7
|
+
* React wrapper around `@vysmo/transitions`'s `Runner`. Renders a
|
|
8
|
+
* `<canvas>` and drives a transition between two images — either
|
|
9
|
+
* controlled by a `progress` prop or self-driving via `duration` /
|
|
10
|
+
* `playing` / `loop` / `ease` for the common "play once on mount"
|
|
11
|
+
* case.
|
|
12
|
+
*
|
|
13
|
+
* The component creates one runner on mount and disposes it on
|
|
14
|
+
* unmount; sources are resolved (URL strings → decoded `Image`s) on
|
|
15
|
+
* `from` / `to` change. Canvas size syncs to its CSS box via
|
|
16
|
+
* `ResizeObserver`, with DPR applied so output stays sharp on retina.
|
|
17
|
+
*/
|
|
18
|
+
export function Transition({ transition, from, to, progress, duration = 1000, playing = true, loop = false, ease, params, onComplete, className, style, }) {
|
|
19
|
+
const canvasRef = useRef(null);
|
|
20
|
+
const runnerRef = useRef(null);
|
|
21
|
+
const [sources, setSources] = useState(null);
|
|
22
|
+
// Stash callable / object props in refs so the autoplay effect's
|
|
23
|
+
// dependency list can stay narrow — callers passing inline literals
|
|
24
|
+
// for `params` / `onComplete` / `ease` should not restart autoplay.
|
|
25
|
+
const paramsRef = useRef(params);
|
|
26
|
+
paramsRef.current = params;
|
|
27
|
+
const onCompleteRef = useRef(onComplete);
|
|
28
|
+
onCompleteRef.current = onComplete;
|
|
29
|
+
const easeRef = useRef(ease);
|
|
30
|
+
easeRef.current = ease;
|
|
31
|
+
// Mount: create runner + resize observer; dispose on unmount.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const canvas = canvasRef.current;
|
|
34
|
+
if (!canvas)
|
|
35
|
+
return;
|
|
36
|
+
const syncSize = () => {
|
|
37
|
+
const rect = canvas.getBoundingClientRect();
|
|
38
|
+
const dpr = window.devicePixelRatio || 1;
|
|
39
|
+
const w = Math.max(1, Math.round(rect.width * dpr));
|
|
40
|
+
const h = Math.max(1, Math.round(rect.height * dpr));
|
|
41
|
+
if (canvas.width !== w)
|
|
42
|
+
canvas.width = w;
|
|
43
|
+
if (canvas.height !== h)
|
|
44
|
+
canvas.height = h;
|
|
45
|
+
};
|
|
46
|
+
syncSize();
|
|
47
|
+
const runner = new Runner({ canvas });
|
|
48
|
+
runnerRef.current = runner;
|
|
49
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncSize) : null;
|
|
50
|
+
ro?.observe(canvas);
|
|
51
|
+
return () => {
|
|
52
|
+
ro?.disconnect();
|
|
53
|
+
runner.dispose();
|
|
54
|
+
runnerRef.current = null;
|
|
55
|
+
};
|
|
56
|
+
}, []);
|
|
57
|
+
// Resolve sources whenever `from` / `to` change.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
Promise.all([resolveSource(from), resolveSource(to)]).then(([f, t]) => {
|
|
61
|
+
if (!cancelled)
|
|
62
|
+
setSources({ from: f, to: t });
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [from, to]);
|
|
68
|
+
// Render: controlled (one render per progress change) or autoplay (rAF loop).
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const runner = runnerRef.current;
|
|
71
|
+
if (!runner || !sources)
|
|
72
|
+
return;
|
|
73
|
+
const renderArgs = paramsRef.current
|
|
74
|
+
? { from: sources.from, to: sources.to, params: paramsRef.current }
|
|
75
|
+
: { from: sources.from, to: sources.to };
|
|
76
|
+
if (progress !== undefined) {
|
|
77
|
+
runner.render(transition, { ...renderArgs, progress });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!playing)
|
|
81
|
+
return;
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
let raf = 0;
|
|
84
|
+
let start = 0;
|
|
85
|
+
const tick = (now) => {
|
|
86
|
+
if (cancelled)
|
|
87
|
+
return;
|
|
88
|
+
if (start === 0)
|
|
89
|
+
start = now;
|
|
90
|
+
const t = Math.min(1, (now - start) / duration);
|
|
91
|
+
const easedT = easeRef.current ? easeRef.current(t) : t;
|
|
92
|
+
runner.render(transition, { ...renderArgs, progress: easedT });
|
|
93
|
+
if (t < 1) {
|
|
94
|
+
raf = requestAnimationFrame(tick);
|
|
95
|
+
}
|
|
96
|
+
else if (loop) {
|
|
97
|
+
start = 0;
|
|
98
|
+
raf = requestAnimationFrame(tick);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
onCompleteRef.current?.();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
raf = requestAnimationFrame(tick);
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
if (raf)
|
|
108
|
+
cancelAnimationFrame(raf);
|
|
109
|
+
};
|
|
110
|
+
}, [sources, transition, progress, playing, loop, duration]);
|
|
111
|
+
return _jsx("canvas", { ref: canvasRef, className: className, style: style });
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=Transition.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Transition.js","sourceRoot":"","sources":["../src/Transition.tsx"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAwB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,aAAa,EAAoC,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TextureSource } from "@vysmo/transitions";
|
|
2
|
+
/**
|
|
3
|
+
* What the React wrapper accepts as a `from` / `to`. Strings are
|
|
4
|
+
* convenience for `<img src=…>`-style URLs; everything else passes
|
|
5
|
+
* straight through to the runner.
|
|
6
|
+
*/
|
|
7
|
+
export type Source = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap;
|
|
8
|
+
export type ResolvedSource = Exclude<Source, string>;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a `Source` to a paint-ready `TextureSource`. Strings load via
|
|
11
|
+
* a fresh `Image` with `crossOrigin="anonymous"` and `decode()` so the
|
|
12
|
+
* transition can render on the next frame without showing a blank
|
|
13
|
+
* canvas. Non-string sources await `decode()` if applicable, then
|
|
14
|
+
* pass through.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveSource(source: Source): Promise<TextureSource>;
|
|
17
|
+
//# sourceMappingURL=resolve-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-source.d.ts","sourceRoot":"","sources":["../src/resolve-source.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;GAIG;AACH,MAAM,MAAM,MAAM,GACd,MAAM,GACN,gBAAgB,GAChB,iBAAiB,GACjB,gBAAgB,GAChB,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAErD;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAY1E"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a `Source` to a paint-ready `TextureSource`. Strings load via
|
|
3
|
+
* a fresh `Image` with `crossOrigin="anonymous"` and `decode()` so the
|
|
4
|
+
* transition can render on the next frame without showing a blank
|
|
5
|
+
* canvas. Non-string sources await `decode()` if applicable, then
|
|
6
|
+
* pass through.
|
|
7
|
+
*/
|
|
8
|
+
export async function resolveSource(source) {
|
|
9
|
+
if (typeof source === "string") {
|
|
10
|
+
const img = new Image();
|
|
11
|
+
img.crossOrigin = "anonymous";
|
|
12
|
+
img.src = source;
|
|
13
|
+
if ("decode" in img)
|
|
14
|
+
await img.decode();
|
|
15
|
+
return img;
|
|
16
|
+
}
|
|
17
|
+
if (source instanceof HTMLImageElement && !source.complete) {
|
|
18
|
+
await source.decode();
|
|
19
|
+
}
|
|
20
|
+
return source;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=resolve-source.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-source.js","sourceRoot":"","sources":["../src/resolve-source.ts"],"names":[],"mappings":"AAgBA;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAc;IAChD,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,GAAG,CAAC,WAAW,GAAG,WAAW,CAAC;QAC9B,GAAG,CAAC,GAAG,GAAG,MAAM,CAAC;QACjB,IAAI,QAAQ,IAAI,GAAG;YAAE,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC;QACxC,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,MAAM,YAAY,gBAAgB,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC3D,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC;IACxB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import { Runner } from "@vysmo/transitions";
|
|
3
|
+
/**
|
|
4
|
+
* Low-level escape hatch for callers who want the `<Transition>`
|
|
5
|
+
* component's lifecycle but their own render loop. Pass a ref to a
|
|
6
|
+
* canvas you mount yourself; the hook constructs a `Runner` once the
|
|
7
|
+
* canvas is in the DOM and disposes it on unmount, returning the
|
|
8
|
+
* runner instance (or `null` while it's still mounting).
|
|
9
|
+
*
|
|
10
|
+
* The component built on this is `<Transition>` — reach for the hook
|
|
11
|
+
* only when you're integrating with a non-React render loop or need
|
|
12
|
+
* direct `runner.render()` access for multi-pass / displacement cases
|
|
13
|
+
* the props don't expose yet.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useTransitionRunner(canvasRef: RefObject<HTMLCanvasElement | null>): Runner | null;
|
|
16
|
+
//# sourceMappingURL=use-transition-runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-transition-runner.d.ts","sourceRoot":"","sources":["../src/use-transition-runner.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,GAC7C,MAAM,GAAG,IAAI,CAef"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Runner } from "@vysmo/transitions";
|
|
4
|
+
/**
|
|
5
|
+
* Low-level escape hatch for callers who want the `<Transition>`
|
|
6
|
+
* component's lifecycle but their own render loop. Pass a ref to a
|
|
7
|
+
* canvas you mount yourself; the hook constructs a `Runner` once the
|
|
8
|
+
* canvas is in the DOM and disposes it on unmount, returning the
|
|
9
|
+
* runner instance (or `null` while it's still mounting).
|
|
10
|
+
*
|
|
11
|
+
* The component built on this is `<Transition>` — reach for the hook
|
|
12
|
+
* only when you're integrating with a non-React render loop or need
|
|
13
|
+
* direct `runner.render()` access for multi-pass / displacement cases
|
|
14
|
+
* the props don't expose yet.
|
|
15
|
+
*/
|
|
16
|
+
export function useTransitionRunner(canvasRef) {
|
|
17
|
+
const [runner, setRunner] = useState(null);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const canvas = canvasRef.current;
|
|
20
|
+
if (!canvas)
|
|
21
|
+
return;
|
|
22
|
+
const r = new Runner({ canvas });
|
|
23
|
+
setRunner(r);
|
|
24
|
+
return () => {
|
|
25
|
+
r.dispose();
|
|
26
|
+
setRunner(null);
|
|
27
|
+
};
|
|
28
|
+
}, [canvasRef]);
|
|
29
|
+
return runner;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=use-transition-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-transition-runner.js","sourceRoot":"","sources":["../src/use-transition-runner.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE5C,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CACjC,SAA8C;IAE9C,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IAE1D,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAC;QACjC,IAAI,CAAC,MAAM;YAAE,OAAO;QACpB,MAAM,CAAC,GAAG,IAAI,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;QACjC,SAAS,CAAC,CAAC,CAAC,CAAC;QACb,OAAO,GAAG,EAAE;YACV,CAAC,CAAC,OAAO,EAAE,CAAC;YACZ,SAAS,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC,CAAC;IACJ,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC;IAEhB,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vysmo/transitions-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React bindings for @vysmo/transitions — a <Transition> component that renders any of the 60 WebGL transition shaders between two images, with controlled progress or self-driving autoplay.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"react",
|
|
8
|
+
"webgl",
|
|
9
|
+
"webgl2",
|
|
10
|
+
"transitions",
|
|
11
|
+
"shader",
|
|
12
|
+
"animation",
|
|
13
|
+
"vysmo"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"module": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=18",
|
|
34
|
+
"@vysmo/transitions": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "^19.2.14",
|
|
38
|
+
"@types/react-dom": "^19.2.3",
|
|
39
|
+
"@vitest/browser": "^3.2.4",
|
|
40
|
+
"playwright": "^1.59.1",
|
|
41
|
+
"react": "^19.2.5",
|
|
42
|
+
"react-dom": "^19.2.5",
|
|
43
|
+
"typescript": "^5.6.3",
|
|
44
|
+
"vitest": "^3.2.4",
|
|
45
|
+
"@vysmo/transitions": "0.1.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.json",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
50
|
+
"test": "vitest run && vitest run --config vitest.ssr.config.ts",
|
|
51
|
+
"test:browser": "vitest run",
|
|
52
|
+
"test:ssr": "vitest run --config vitest.ssr.config.ts",
|
|
53
|
+
"test:watch": "vitest"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
5
|
+
import {
|
|
6
|
+
Runner,
|
|
7
|
+
type Transition as TransitionType,
|
|
8
|
+
type UniformParams,
|
|
9
|
+
type TextureSource,
|
|
10
|
+
} from "@vysmo/transitions";
|
|
11
|
+
import { resolveSource, type Source } from "./resolve-source.js";
|
|
12
|
+
|
|
13
|
+
export interface TransitionProps {
|
|
14
|
+
/** Any transition exported by `@vysmo/transitions`. */
|
|
15
|
+
transition: TransitionType<UniformParams>;
|
|
16
|
+
/** First image. URL string, `HTMLImageElement`, canvas, video, or `ImageBitmap`. */
|
|
17
|
+
from: Source;
|
|
18
|
+
/** Second image. Same accepted shapes as `from`. */
|
|
19
|
+
to: Source;
|
|
20
|
+
/**
|
|
21
|
+
* Controlled progress in `[0, 1]`. When set, the component renders at
|
|
22
|
+
* exactly this progress and the autoplay loop is bypassed — drive it
|
|
23
|
+
* yourself (scroll progress, scrubber, animation library, etc.).
|
|
24
|
+
*/
|
|
25
|
+
progress?: number;
|
|
26
|
+
/** Autoplay duration in ms. Used only when `progress` is omitted. Default `1000`. */
|
|
27
|
+
duration?: number;
|
|
28
|
+
/** Whether internal autoplay is running. Used only when `progress` is omitted. Default `true`. */
|
|
29
|
+
playing?: boolean;
|
|
30
|
+
/** Loop autoplay. Default `false`. */
|
|
31
|
+
loop?: boolean;
|
|
32
|
+
/** Easing function applied during autoplay. Default linear. */
|
|
33
|
+
ease?: (t: number) => number;
|
|
34
|
+
/** Override shader uniform defaults. */
|
|
35
|
+
params?: UniformParams;
|
|
36
|
+
/** Fires when a non-loop autoplay reaches `progress=1`. */
|
|
37
|
+
onComplete?: () => void;
|
|
38
|
+
/** Forwarded to the canvas element. */
|
|
39
|
+
className?: string;
|
|
40
|
+
/** Forwarded to the canvas element. */
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* React wrapper around `@vysmo/transitions`'s `Runner`. Renders a
|
|
46
|
+
* `<canvas>` and drives a transition between two images — either
|
|
47
|
+
* controlled by a `progress` prop or self-driving via `duration` /
|
|
48
|
+
* `playing` / `loop` / `ease` for the common "play once on mount"
|
|
49
|
+
* case.
|
|
50
|
+
*
|
|
51
|
+
* The component creates one runner on mount and disposes it on
|
|
52
|
+
* unmount; sources are resolved (URL strings → decoded `Image`s) on
|
|
53
|
+
* `from` / `to` change. Canvas size syncs to its CSS box via
|
|
54
|
+
* `ResizeObserver`, with DPR applied so output stays sharp on retina.
|
|
55
|
+
*/
|
|
56
|
+
export function Transition({
|
|
57
|
+
transition,
|
|
58
|
+
from,
|
|
59
|
+
to,
|
|
60
|
+
progress,
|
|
61
|
+
duration = 1000,
|
|
62
|
+
playing = true,
|
|
63
|
+
loop = false,
|
|
64
|
+
ease,
|
|
65
|
+
params,
|
|
66
|
+
onComplete,
|
|
67
|
+
className,
|
|
68
|
+
style,
|
|
69
|
+
}: TransitionProps): ReactElement {
|
|
70
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
71
|
+
const runnerRef = useRef<Runner | null>(null);
|
|
72
|
+
const [sources, setSources] = useState<{ from: TextureSource; to: TextureSource } | null>(null);
|
|
73
|
+
|
|
74
|
+
// Stash callable / object props in refs so the autoplay effect's
|
|
75
|
+
// dependency list can stay narrow — callers passing inline literals
|
|
76
|
+
// for `params` / `onComplete` / `ease` should not restart autoplay.
|
|
77
|
+
const paramsRef = useRef(params);
|
|
78
|
+
paramsRef.current = params;
|
|
79
|
+
const onCompleteRef = useRef(onComplete);
|
|
80
|
+
onCompleteRef.current = onComplete;
|
|
81
|
+
const easeRef = useRef(ease);
|
|
82
|
+
easeRef.current = ease;
|
|
83
|
+
|
|
84
|
+
// Mount: create runner + resize observer; dispose on unmount.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const canvas = canvasRef.current;
|
|
87
|
+
if (!canvas) return;
|
|
88
|
+
|
|
89
|
+
const syncSize = () => {
|
|
90
|
+
const rect = canvas.getBoundingClientRect();
|
|
91
|
+
const dpr = window.devicePixelRatio || 1;
|
|
92
|
+
const w = Math.max(1, Math.round(rect.width * dpr));
|
|
93
|
+
const h = Math.max(1, Math.round(rect.height * dpr));
|
|
94
|
+
if (canvas.width !== w) canvas.width = w;
|
|
95
|
+
if (canvas.height !== h) canvas.height = h;
|
|
96
|
+
};
|
|
97
|
+
syncSize();
|
|
98
|
+
|
|
99
|
+
const runner = new Runner({ canvas });
|
|
100
|
+
runnerRef.current = runner;
|
|
101
|
+
|
|
102
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncSize) : null;
|
|
103
|
+
ro?.observe(canvas);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
ro?.disconnect();
|
|
107
|
+
runner.dispose();
|
|
108
|
+
runnerRef.current = null;
|
|
109
|
+
};
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Resolve sources whenever `from` / `to` change.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
let cancelled = false;
|
|
115
|
+
Promise.all([resolveSource(from), resolveSource(to)]).then(([f, t]) => {
|
|
116
|
+
if (!cancelled) setSources({ from: f, to: t });
|
|
117
|
+
});
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
};
|
|
121
|
+
}, [from, to]);
|
|
122
|
+
|
|
123
|
+
// Render: controlled (one render per progress change) or autoplay (rAF loop).
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const runner = runnerRef.current;
|
|
126
|
+
if (!runner || !sources) return;
|
|
127
|
+
|
|
128
|
+
const renderArgs = paramsRef.current
|
|
129
|
+
? { from: sources.from, to: sources.to, params: paramsRef.current }
|
|
130
|
+
: { from: sources.from, to: sources.to };
|
|
131
|
+
|
|
132
|
+
if (progress !== undefined) {
|
|
133
|
+
runner.render(transition, { ...renderArgs, progress });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!playing) return;
|
|
138
|
+
|
|
139
|
+
let cancelled = false;
|
|
140
|
+
let raf = 0;
|
|
141
|
+
let start = 0;
|
|
142
|
+
|
|
143
|
+
const tick = (now: number) => {
|
|
144
|
+
if (cancelled) return;
|
|
145
|
+
if (start === 0) start = now;
|
|
146
|
+
const t = Math.min(1, (now - start) / duration);
|
|
147
|
+
const easedT = easeRef.current ? easeRef.current(t) : t;
|
|
148
|
+
runner.render(transition, { ...renderArgs, progress: easedT });
|
|
149
|
+
if (t < 1) {
|
|
150
|
+
raf = requestAnimationFrame(tick);
|
|
151
|
+
} else if (loop) {
|
|
152
|
+
start = 0;
|
|
153
|
+
raf = requestAnimationFrame(tick);
|
|
154
|
+
} else {
|
|
155
|
+
onCompleteRef.current?.();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
raf = requestAnimationFrame(tick);
|
|
159
|
+
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
if (raf) cancelAnimationFrame(raf);
|
|
163
|
+
};
|
|
164
|
+
}, [sources, transition, progress, playing, loop, duration]);
|
|
165
|
+
|
|
166
|
+
return <canvas ref={canvasRef} className={className} style={style} />;
|
|
167
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { dissolve } from "@vysmo/transitions";
|
|
5
|
+
import { Transition } from "../Transition.js";
|
|
6
|
+
|
|
7
|
+
// React 19's `act` only short-circuits its scheduler when this flag is
|
|
8
|
+
// set; otherwise it logs a noisy "not configured to support act"
|
|
9
|
+
// warning even when the wrapping does happen correctly.
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
function makeSolid(r: number, g: number, b: number, size = 32): HTMLCanvasElement {
|
|
13
|
+
const canvas = document.createElement("canvas");
|
|
14
|
+
canvas.width = size;
|
|
15
|
+
canvas.height = size;
|
|
16
|
+
const ctx = canvas.getContext("2d");
|
|
17
|
+
if (!ctx) throw new Error("2D context unavailable");
|
|
18
|
+
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
|
19
|
+
ctx.fillRect(0, 0, size, size);
|
|
20
|
+
return canvas;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function flush(): Promise<void> {
|
|
24
|
+
return new Promise((r) => setTimeout(r, 0));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("<Transition>", () => {
|
|
28
|
+
let container: HTMLDivElement;
|
|
29
|
+
let root: Root;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
container = document.createElement("div");
|
|
33
|
+
container.style.width = "128px";
|
|
34
|
+
container.style.height = "128px";
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
root = createRoot(container);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await act(async () => {
|
|
41
|
+
root.unmount();
|
|
42
|
+
});
|
|
43
|
+
container.remove();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders a canvas element", async () => {
|
|
47
|
+
const a = makeSolid(255, 0, 0);
|
|
48
|
+
const b = makeSolid(0, 0, 255);
|
|
49
|
+
await act(async () => {
|
|
50
|
+
root.render(<Transition transition={dissolve} from={a} to={b} progress={0} />);
|
|
51
|
+
});
|
|
52
|
+
expect(container.querySelector("canvas")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("at progress=0 renders the from-image (red)", async () => {
|
|
56
|
+
const a = makeSolid(255, 0, 0);
|
|
57
|
+
const b = makeSolid(0, 0, 255);
|
|
58
|
+
await act(async () => {
|
|
59
|
+
root.render(<Transition transition={dissolve} from={a} to={b} progress={0} />);
|
|
60
|
+
});
|
|
61
|
+
await flush();
|
|
62
|
+
// Re-render to ensure render effect runs after sources resolve.
|
|
63
|
+
await act(async () => {
|
|
64
|
+
root.render(<Transition transition={dissolve} from={a} to={b} progress={0} />);
|
|
65
|
+
});
|
|
66
|
+
const canvas = container.querySelector("canvas")!;
|
|
67
|
+
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
|
|
68
|
+
// We're not asserting pixels here — the runner doesn't preserve the
|
|
69
|
+
// drawing buffer by default (and the wrapper doesn't expose that
|
|
70
|
+
// option yet). The fact that the component mounted, resolved the
|
|
71
|
+
// sources, and drove a render without throwing is the contract.
|
|
72
|
+
expect(gl).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("forwards className and style to the canvas", async () => {
|
|
76
|
+
const a = makeSolid(255, 0, 0);
|
|
77
|
+
const b = makeSolid(0, 0, 255);
|
|
78
|
+
await act(async () => {
|
|
79
|
+
root.render(
|
|
80
|
+
<Transition
|
|
81
|
+
transition={dissolve}
|
|
82
|
+
from={a}
|
|
83
|
+
to={b}
|
|
84
|
+
progress={0.5}
|
|
85
|
+
className="test-class"
|
|
86
|
+
style={{ borderRadius: "12px" }}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
const canvas = container.querySelector("canvas")!;
|
|
91
|
+
expect(canvas.className).toBe("test-class");
|
|
92
|
+
expect(canvas.style.borderRadius).toBe("12px");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("resolves a string URL to an image (data URL path)", async () => {
|
|
96
|
+
// 1×1 transparent PNG.
|
|
97
|
+
const url =
|
|
98
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
|
99
|
+
await act(async () => {
|
|
100
|
+
root.render(<Transition transition={dissolve} from={url} to={url} progress={0} />);
|
|
101
|
+
});
|
|
102
|
+
// Wait for image decode.
|
|
103
|
+
await flush();
|
|
104
|
+
await flush();
|
|
105
|
+
expect(container.querySelector("canvas")).toBeTruthy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("autoplays when no progress prop is given and calls onComplete", async () => {
|
|
109
|
+
const a = makeSolid(255, 0, 0);
|
|
110
|
+
const b = makeSolid(0, 0, 255);
|
|
111
|
+
let completed = false;
|
|
112
|
+
await act(async () => {
|
|
113
|
+
root.render(
|
|
114
|
+
<Transition
|
|
115
|
+
transition={dissolve}
|
|
116
|
+
from={a}
|
|
117
|
+
to={b}
|
|
118
|
+
duration={50}
|
|
119
|
+
onComplete={() => {
|
|
120
|
+
completed = true;
|
|
121
|
+
}}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
// Wait for autoplay to finish (~50ms) plus rAF.
|
|
126
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
127
|
+
expect(completed).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("disposes runner on unmount without throwing", async () => {
|
|
131
|
+
const a = makeSolid(255, 0, 0);
|
|
132
|
+
const b = makeSolid(0, 0, 255);
|
|
133
|
+
await act(async () => {
|
|
134
|
+
root.render(<Transition transition={dissolve} from={a} to={b} progress={0.5} />);
|
|
135
|
+
});
|
|
136
|
+
await act(async () => {
|
|
137
|
+
root.unmount();
|
|
138
|
+
});
|
|
139
|
+
// Re-create root for afterEach to unmount safely.
|
|
140
|
+
root = createRoot(container);
|
|
141
|
+
expect(container.querySelector("canvas")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SSR safety: the module must load in Node without DOM globals. This
|
|
5
|
+
* verifies that no top-level code touches `window` / `document` / etc
|
|
6
|
+
* — `useEffect` bodies don't run on the server, so as long as the
|
|
7
|
+
* imports + module bodies are clean, the wrapper is SSR-safe.
|
|
8
|
+
*/
|
|
9
|
+
describe("SSR safety", () => {
|
|
10
|
+
it("window is undefined in this runtime", () => {
|
|
11
|
+
expect(typeof window).toBe("undefined");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("module loads without DOM globals", async () => {
|
|
15
|
+
const mod = await import("../index.js");
|
|
16
|
+
expect(mod).toBeDefined();
|
|
17
|
+
expect(typeof mod.Transition).toBe("function");
|
|
18
|
+
expect(typeof mod.useTransitionRunner).toBe("function");
|
|
19
|
+
expect(typeof mod.resolveSource).toBe("function");
|
|
20
|
+
});
|
|
21
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TextureSource } from "@vysmo/transitions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the React wrapper accepts as a `from` / `to`. Strings are
|
|
5
|
+
* convenience for `<img src=…>`-style URLs; everything else passes
|
|
6
|
+
* straight through to the runner.
|
|
7
|
+
*/
|
|
8
|
+
export type Source =
|
|
9
|
+
| string
|
|
10
|
+
| HTMLImageElement
|
|
11
|
+
| HTMLCanvasElement
|
|
12
|
+
| HTMLVideoElement
|
|
13
|
+
| ImageBitmap;
|
|
14
|
+
|
|
15
|
+
export type ResolvedSource = Exclude<Source, string>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a `Source` to a paint-ready `TextureSource`. Strings load via
|
|
19
|
+
* a fresh `Image` with `crossOrigin="anonymous"` and `decode()` so the
|
|
20
|
+
* transition can render on the next frame without showing a blank
|
|
21
|
+
* canvas. Non-string sources await `decode()` if applicable, then
|
|
22
|
+
* pass through.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveSource(source: Source): Promise<TextureSource> {
|
|
25
|
+
if (typeof source === "string") {
|
|
26
|
+
const img = new Image();
|
|
27
|
+
img.crossOrigin = "anonymous";
|
|
28
|
+
img.src = source;
|
|
29
|
+
if ("decode" in img) await img.decode();
|
|
30
|
+
return img;
|
|
31
|
+
}
|
|
32
|
+
if (source instanceof HTMLImageElement && !source.complete) {
|
|
33
|
+
await source.decode();
|
|
34
|
+
}
|
|
35
|
+
return source;
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import type { RefObject } from "react";
|
|
5
|
+
import { Runner } from "@vysmo/transitions";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Low-level escape hatch for callers who want the `<Transition>`
|
|
9
|
+
* component's lifecycle but their own render loop. Pass a ref to a
|
|
10
|
+
* canvas you mount yourself; the hook constructs a `Runner` once the
|
|
11
|
+
* canvas is in the DOM and disposes it on unmount, returning the
|
|
12
|
+
* runner instance (or `null` while it's still mounting).
|
|
13
|
+
*
|
|
14
|
+
* The component built on this is `<Transition>` — reach for the hook
|
|
15
|
+
* only when you're integrating with a non-React render loop or need
|
|
16
|
+
* direct `runner.render()` access for multi-pass / displacement cases
|
|
17
|
+
* the props don't expose yet.
|
|
18
|
+
*/
|
|
19
|
+
export function useTransitionRunner(
|
|
20
|
+
canvasRef: RefObject<HTMLCanvasElement | null>,
|
|
21
|
+
): Runner | null {
|
|
22
|
+
const [runner, setRunner] = useState<Runner | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const canvas = canvasRef.current;
|
|
26
|
+
if (!canvas) return;
|
|
27
|
+
const r = new Runner({ canvas });
|
|
28
|
+
setRunner(r);
|
|
29
|
+
return () => {
|
|
30
|
+
r.dispose();
|
|
31
|
+
setRunner(null);
|
|
32
|
+
};
|
|
33
|
+
}, [canvasRef]);
|
|
34
|
+
|
|
35
|
+
return runner;
|
|
36
|
+
}
|