next-sketch 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Reinan Br.
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,212 @@
1
+ <div align='center'>
2
+
3
+ <h1>next-sketch</h1>
4
+
5
+ [![npm version](https://img.shields.io/npm/v/next-sketch.svg)](https://www.npmjs.com/package/next-sketch)
6
+ [![npm downloads](https://img.shields.io/npm/dm/next-sketch.svg)](https://www.npmjs.com/package/next-sketch)
7
+ [![CI](https://github.com/reinanbr/next-sketch/actions/workflows/ci.yml/badge.svg)](https://github.com/reinanbr/next-sketch/actions/workflows/ci.yml)
8
+ [![node](https://img.shields.io/node/v/next-sketch.svg)](https://www.npmjs.com/package/next-sketch)
9
+ [![types](https://img.shields.io/npm/types/next-sketch.svg)](https://www.npmjs.com/package/next-sketch)
10
+ [![license](https://img.shields.io/npm/l/next-sketch.svg)](LICENSE)
11
+
12
+ <p>p5.js-style canvas sketches for React and Next.js — a headless hook plus a drop-in <code>&lt;CanvasSketch /&gt;</code> component. DPR-aware resizing, a requestAnimationFrame loop with <code>dt</code>/<code>time</code>/<code>frame</code>, normalized pointer/keyboard input, and full SSR safety, all in one dependency-free package.</p>
13
+
14
+ </div>
15
+
16
+ <hr>
17
+
18
+ ## Table of contents
19
+
20
+ - [Why](#why)
21
+ - [Installation](#installation)
22
+ - [Quickstart](#quickstart)
23
+ - [API](#api)
24
+ - [`useCanvasSketch(options)`](#usecanvassketchoptions)
25
+ - [`<CanvasSketch />`](#canvassketch-)
26
+ - [`useThreeSketch(options)`](#usethreesketchoptions)
27
+ - [`<ThreeSketch />`](#threesketch-)
28
+ - [WebGL context budget](#webgl-context-budget)
29
+ - [`useInViewport(ref, options)`](#useinviewportref-options)
30
+ - [Utilities](#utilities)
31
+ - [Next.js / SSR](#nextjs--ssr)
32
+ - [License](#license)
33
+
34
+ <hr>
35
+
36
+ ## Why
37
+
38
+ Every hand-rolled canvas animation in React ends up rewriting the same plumbing: a
39
+ `canvasRef`, a `devicePixelRatio`-aware resize handler, a `requestAnimationFrame` loop with
40
+ manual `dt` bookkeeping, pointer coordinates translated through `getBoundingClientRect()`, and
41
+ cleanup on unmount. `next-sketch` extracts that plumbing once so you can focus on `setup`/`draw`,
42
+ the way you would in [p5.js](https://p5js.org) — but as an idiomatic React hook/component instead
43
+ of a global-mode sketch.
44
+
45
+ ## Installation
46
+
47
+ ```sh
48
+ npm install next-sketch
49
+ ```
50
+
51
+ Requires React 18+. Works in any React app; the SSR-safety and `next/dynamic` friendliness make
52
+ it a natural fit for Next.js specifically.
53
+
54
+ ## Quickstart
55
+
56
+ ```tsx
57
+ import { CanvasSketch, randRange } from 'next-sketch';
58
+
59
+ export default function Sketch() {
60
+ return (
61
+ <CanvasSketch
62
+ style={{ width: '100%', height: 400 }}
63
+ setup={({ ctx, width, height }) => {
64
+ // called once on mount, and again on every resize
65
+ }}
66
+ draw={({ ctx, width, height, dt, time, frame }) => {
67
+ ctx.fillStyle = '#0f172a';
68
+ ctx.fillRect(0, 0, width, height);
69
+ ctx.fillStyle = '#6366f1';
70
+ ctx.beginPath();
71
+ ctx.arc(width / 2 + Math.sin(time) * 100, height / 2, 20, 0, Math.PI * 2);
72
+ ctx.fill();
73
+ }}
74
+ />
75
+ );
76
+ }
77
+ ```
78
+
79
+ See [`examples/bouncing-particles.tsx`](examples/bouncing-particles.tsx) for a fuller example
80
+ with particles, input, and a play/pause control.
81
+
82
+ ## API
83
+
84
+ ### `useCanvasSketch(options)`
85
+
86
+ The headless engine. Returns a `canvasRef` to attach to your own `<canvas>`, plus imperative
87
+ controls:
88
+
89
+ ```ts
90
+ const { canvasRef, start, stop, reset, isRunning, running } = useCanvasSketch({
91
+ setup?: (info: SetupInfo) => void,
92
+ draw?: (info: DrawInfo) => void,
93
+ onPointerDown?: (info: PointerInfo) => void,
94
+ onPointerMove?: (info: PointerInfo) => void,
95
+ onPointerUp?: (info: PointerInfo) => void,
96
+ onKeyDown?: (event: KeyboardEvent) => void,
97
+ onKeyUp?: (event: KeyboardEvent) => void,
98
+ autoStart?: boolean, // default true
99
+ });
100
+ ```
101
+
102
+ - `SetupInfo` — `{ ctx, canvas, width, height }` (`width`/`height` in CSS pixels, already
103
+ DPR-normalized — draw as if `devicePixelRatio` were 1).
104
+ - `DrawInfo` — `SetupInfo` plus `{ dt, time, frame }`: seconds since the last frame, seconds
105
+ since `start()`, and a frame counter. Both reset to zero on `reset()`.
106
+ - `PointerInfo` — `{ x, y, event }`, `x`/`y` already localized to the canvas in CSS pixels.
107
+
108
+ ### `<CanvasSketch />`
109
+
110
+ A thin `<canvas>` wrapper around the hook. Accepts the same options as props, plus `className`/
111
+ `style`. Pass a `ref` to get a `CanvasSketchHandle` (`start`/`stop`/`reset`/`isRunning`) for
112
+ building play/pause/reset UI without touching the hook directly.
113
+
114
+ ### `useThreeSketch(options)`
115
+
116
+ The same engine as `useCanvasSketch`, but backed by [Three.js](https://threejs.org) instead of a
117
+ 2D context. `setup`/`draw` get `{ scene, camera, renderer, canvas, width, height }` (plus
118
+ `dt`/`time`/`frame` in `draw`) instead of `ctx`; `renderer.render(scene, camera)` happens for you
119
+ right after `draw` runs. Requires `three` as a peer dependency (`npm install three`) — it's not
120
+ bundled, so sketches that only use the 2D engine pay nothing for it.
121
+
122
+ ```tsx
123
+ import { ThreeSketch } from 'next-sketch';
124
+ import * as THREE from 'three';
125
+
126
+ <ThreeSketch
127
+ style={{ width: '100%', height: 400 }}
128
+ setup={({ scene }) => {
129
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
130
+ const mesh = new THREE.Mesh(
131
+ new THREE.IcosahedronGeometry(1.5, 1),
132
+ new THREE.MeshStandardMaterial({ color: '#6366f1' }),
133
+ );
134
+ scene.add(mesh);
135
+ }}
136
+ draw={({ scene, dt }) => {
137
+ scene.children[1].rotation.y += dt;
138
+ }}
139
+ />
140
+ ```
141
+
142
+ Unlike 2D canvas contexts, WebGL contexts are a scarce per-process browser resource — see
143
+ [WebGL context budget](#webgl-context-budget) below, which `useThreeSketch` respects by default.
144
+
145
+ On unmount, `useThreeSketch` disposes every geometry/material in the scene graph and calls
146
+ `renderer.dispose()` + `renderer.forceContextLoss()`, so the context is actually freed rather than
147
+ left for the garbage collector to get to eventually.
148
+
149
+ ### `<ThreeSketch />`
150
+
151
+ A thin `<canvas>` wrapper around `useThreeSketch`, mirroring `<CanvasSketch />`: same
152
+ `ref`-based `start`/`stop`/`reset`/`isRunning` handle, plus `className`/`style`.
153
+
154
+ ### WebGL context budget
155
+
156
+ A page rendering several live 3D sketches at once (e.g. a gallery of animated previews) can hit
157
+ the browser's per-process WebGL context limit — Chrome allows around 16, Safari's ceiling is
158
+ often much lower. Past that limit, browsers silently evict the *oldest* context with no error;
159
+ the canvas just goes blank. `useThreeSketch` guards against this automatically: each instance
160
+ waits for a slot in a shared, module-level budget (default: 4 concurrent) before creating its
161
+ renderer, and releases its slot on unmount.
162
+
163
+ ```ts
164
+ import { configureWebglBudget, getWebglBudget } from 'next-sketch';
165
+
166
+ configureWebglBudget(6); // raise/lower the app-wide cap
167
+ getWebglBudget(); // -> { active: 2, max: 6 }
168
+ ```
169
+
170
+ Opt a specific sketch out with `respectBudget: false` (e.g. for the one full, interactive
171
+ simulation on a detail page, as opposed to a grid of decorative previews).
172
+
173
+ ### `useInViewport(ref, options)`
174
+
175
+ ```ts
176
+ const wrapperRef = useRef<HTMLDivElement>(null);
177
+ const inView = useInViewport(wrapperRef, { rootMargin: '200px', once: false });
178
+ ```
179
+
180
+ Tracks whether an element is inside the viewport via `IntersectionObserver`, so you can mount an
181
+ expensive sketch only while it's actually visible. With `once: true` (the default-ish choice for
182
+ cheap 2D sketches) it stays `true` forever after the first intersection — no restart cost on
183
+ scroll. With `once: false` it flips back to `false` when the element scrolls back out, which is
184
+ what lets a `<ThreeSketch />` actually unmount and free its WebGL context budget slot in a long
185
+ gallery page instead of accumulating contexts forever.
186
+
187
+ ### Utilities
188
+
189
+ Small helpers factored out of the same duplicated math every canvas sketch ends up writing:
190
+
191
+ ```ts
192
+ import { clamp, randRange, lerpColor } from 'next-sketch';
193
+
194
+ clamp(value, min, max); // -> number
195
+ randRange(min, max); // -> number
196
+ lerpColor([59,130,246], [239,68,68], t); // -> "rgb(r,g,b)"
197
+ ```
198
+
199
+ ## Next.js / SSR
200
+
201
+ `useCanvasSketch`/`CanvasSketch` never touch `window`/`document` outside of effects, so they're
202
+ safe to import in a Server Component tree. If you still prefer to opt a sketch fully out of SSR
203
+ (e.g. it depends on `window` inside your own `draw` callback), wrap it the usual way:
204
+
205
+ ```tsx
206
+ import dynamic from 'next/dynamic';
207
+ const Sketch = dynamic(() => import('./Sketch'), { ssr: false });
208
+ ```
209
+
210
+ ## License
211
+
212
+ MIT © [Reinan Br.](LICENSE)
@@ -0,0 +1,11 @@
1
+ import type { CanvasSketchHandle, CanvasSketchOptions } from './types';
2
+ export interface CanvasSketchProps extends CanvasSketchOptions {
3
+ className?: string;
4
+ style?: React.CSSProperties;
5
+ }
6
+ /**
7
+ * Drop-in `<canvas>` wired to useCanvasSketch. Pass `ref` to get imperative
8
+ * start()/stop()/reset()/isRunning() controls for building play/pause UI.
9
+ */
10
+ export declare const CanvasSketch: import("react").ForwardRefExoticComponent<CanvasSketchProps & import("react").RefAttributes<CanvasSketchHandle>>;
11
+ //# sourceMappingURL=CanvasSketch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CanvasSketch.d.ts","sourceRoot":"","sources":["../src/CanvasSketch.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAEvE,MAAM,WAAW,iBAAkB,SAAQ,mBAAmB;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CAC7B;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,kHAQxB,CAAC"}
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.CanvasSketch = void 0;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const useCanvasSketch_1 = require("./useCanvasSketch");
8
+ /**
9
+ * Drop-in `<canvas>` wired to useCanvasSketch. Pass `ref` to get imperative
10
+ * start()/stop()/reset()/isRunning() controls for building play/pause UI.
11
+ */
12
+ exports.CanvasSketch = (0, react_1.forwardRef)(function CanvasSketch({ className, style, ...options }, ref) {
13
+ const { canvasRef, start, stop, reset, isRunning } = (0, useCanvasSketch_1.useCanvasSketch)(options);
14
+ (0, react_1.useImperativeHandle)(ref, () => ({ start, stop, reset, isRunning }), [start, stop, reset, isRunning]);
15
+ return (0, jsx_runtime_1.jsx)("canvas", { ref: canvasRef, className: className, style: style });
16
+ });
@@ -0,0 +1,11 @@
1
+ import type { ThreeSketchHandle, ThreeSketchOptions } from './types';
2
+ export interface ThreeSketchProps extends ThreeSketchOptions {
3
+ className?: string;
4
+ style?: React.CSSProperties;
5
+ }
6
+ /**
7
+ * Drop-in `<canvas>` wired to useThreeSketch. Pass `ref` to get imperative
8
+ * start()/stop()/reset()/isRunning() controls for building play/pause UI.
9
+ */
10
+ export declare const ThreeSketch: import("react").ForwardRefExoticComponent<ThreeSketchProps & import("react").RefAttributes<ThreeSketchHandle>>;
11
+ //# sourceMappingURL=ThreeSketch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThreeSketch.d.ts","sourceRoot":"","sources":["../src/ThreeSketch.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAErE,MAAM,WAAW,gBAAiB,SAAQ,kBAAkB;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CAC7B;AAED;;;GAGG;AACH,eAAO,MAAM,WAAW,gHAQvB,CAAC"}
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ThreeSketch = void 0;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const useThreeSketch_1 = require("./useThreeSketch");
8
+ /**
9
+ * Drop-in `<canvas>` wired to useThreeSketch. Pass `ref` to get imperative
10
+ * start()/stop()/reset()/isRunning() controls for building play/pause UI.
11
+ */
12
+ exports.ThreeSketch = (0, react_1.forwardRef)(function ThreeSketch({ className, style, ...options }, ref) {
13
+ const { canvasRef, start, stop, reset, isRunning } = (0, useThreeSketch_1.useThreeSketch)(options);
14
+ (0, react_1.useImperativeHandle)(ref, () => ({ start, stop, reset, isRunning }), [start, stop, reset, isRunning]);
15
+ return (0, jsx_runtime_1.jsx)("canvas", { ref: canvasRef, className: className, style: style });
16
+ });
@@ -0,0 +1,15 @@
1
+ export { useCanvasSketch } from './useCanvasSketch';
2
+ export type { UseCanvasSketchResult } from './useCanvasSketch';
3
+ export { CanvasSketch } from './CanvasSketch';
4
+ export type { CanvasSketchProps } from './CanvasSketch';
5
+ export { useThreeSketch } from './useThreeSketch';
6
+ export type { UseThreeSketchResult } from './useThreeSketch';
7
+ export { ThreeSketch } from './ThreeSketch';
8
+ export type { ThreeSketchProps } from './ThreeSketch';
9
+ export { useInViewport } from './useInViewport';
10
+ export type { UseInViewportOptions } from './useInViewport';
11
+ export { configureWebglBudget, getWebglBudget } from './webglBudget';
12
+ export { clamp, randRange, lerpColor } from './utils';
13
+ export type { RGB } from './utils';
14
+ export type { SetupInfo, DrawInfo, PointerInfo, CanvasSketchOptions, CanvasSketchHandle, ThreeSetupInfo, ThreeDrawInfo, ThreeSketchOptions, ThreeSketchHandle, } from './types';
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACrE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACtD,YAAY,EAAE,GAAG,EAAE,MAAM,SAAS,CAAC;AACnC,YAAY,EACV,SAAS,EACT,QAAQ,EACR,WAAW,EACX,mBAAmB,EACnB,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lerpColor = exports.randRange = exports.clamp = exports.getWebglBudget = exports.configureWebglBudget = exports.useInViewport = exports.ThreeSketch = exports.useThreeSketch = exports.CanvasSketch = exports.useCanvasSketch = void 0;
4
+ var useCanvasSketch_1 = require("./useCanvasSketch");
5
+ Object.defineProperty(exports, "useCanvasSketch", { enumerable: true, get: function () { return useCanvasSketch_1.useCanvasSketch; } });
6
+ var CanvasSketch_1 = require("./CanvasSketch");
7
+ Object.defineProperty(exports, "CanvasSketch", { enumerable: true, get: function () { return CanvasSketch_1.CanvasSketch; } });
8
+ var useThreeSketch_1 = require("./useThreeSketch");
9
+ Object.defineProperty(exports, "useThreeSketch", { enumerable: true, get: function () { return useThreeSketch_1.useThreeSketch; } });
10
+ var ThreeSketch_1 = require("./ThreeSketch");
11
+ Object.defineProperty(exports, "ThreeSketch", { enumerable: true, get: function () { return ThreeSketch_1.ThreeSketch; } });
12
+ var useInViewport_1 = require("./useInViewport");
13
+ Object.defineProperty(exports, "useInViewport", { enumerable: true, get: function () { return useInViewport_1.useInViewport; } });
14
+ var webglBudget_1 = require("./webglBudget");
15
+ Object.defineProperty(exports, "configureWebglBudget", { enumerable: true, get: function () { return webglBudget_1.configureWebglBudget; } });
16
+ Object.defineProperty(exports, "getWebglBudget", { enumerable: true, get: function () { return webglBudget_1.getWebglBudget; } });
17
+ var utils_1 = require("./utils");
18
+ Object.defineProperty(exports, "clamp", { enumerable: true, get: function () { return utils_1.clamp; } });
19
+ Object.defineProperty(exports, "randRange", { enumerable: true, get: function () { return utils_1.randRange; } });
20
+ Object.defineProperty(exports, "lerpColor", { enumerable: true, get: function () { return utils_1.lerpColor; } });
@@ -0,0 +1,93 @@
1
+ export interface SetupInfo {
2
+ ctx: CanvasRenderingContext2D;
3
+ canvas: HTMLCanvasElement;
4
+ /** CSS pixels — already DPR-normalized, matches what you'd draw with. */
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export interface DrawInfo extends SetupInfo {
9
+ /** Seconds elapsed since the previous frame. */
10
+ dt: number;
11
+ /** Seconds elapsed since start() was called (resets on reset()). */
12
+ time: number;
13
+ /** Frame counter, resets on reset(). */
14
+ frame: number;
15
+ }
16
+ export interface PointerInfo {
17
+ /** Pointer position in CSS pixels, local to the canvas. */
18
+ x: number;
19
+ y: number;
20
+ event: PointerEvent;
21
+ }
22
+ export interface CanvasSketchOptions {
23
+ /** Called once after the canvas is mounted/sized, and again after every resize. */
24
+ setup?: (info: SetupInfo) => void;
25
+ /** Called every animation frame while running. */
26
+ draw?: (info: DrawInfo) => void;
27
+ onPointerDown?: (info: PointerInfo) => void;
28
+ onPointerMove?: (info: PointerInfo) => void;
29
+ onPointerUp?: (info: PointerInfo) => void;
30
+ onKeyDown?: (event: KeyboardEvent) => void;
31
+ onKeyUp?: (event: KeyboardEvent) => void;
32
+ /** Start the render loop automatically on mount. Default: true. */
33
+ autoStart?: boolean;
34
+ }
35
+ export interface CanvasSketchHandle {
36
+ start: () => void;
37
+ stop: () => void;
38
+ /** Stops the loop and resets time/frame back to zero (does not clear the canvas). */
39
+ reset: () => void;
40
+ isRunning: () => boolean;
41
+ }
42
+ export interface ThreeSetupInfo {
43
+ scene: import('three').Scene;
44
+ camera: import('three').PerspectiveCamera;
45
+ renderer: import('three').WebGLRenderer;
46
+ canvas: HTMLCanvasElement;
47
+ /** CSS pixels. */
48
+ width: number;
49
+ height: number;
50
+ }
51
+ export interface ThreeDrawInfo extends ThreeSetupInfo {
52
+ /** Seconds elapsed since the previous frame. */
53
+ dt: number;
54
+ /** Seconds elapsed since start() was called (resets on reset()). */
55
+ time: number;
56
+ /** Frame counter, resets on reset(). */
57
+ frame: number;
58
+ }
59
+ export interface ThreeSketchOptions {
60
+ /** Called once after scene/camera/renderer are created and sized, and again after every resize. */
61
+ setup?: (info: ThreeSetupInfo) => void;
62
+ /** Called every animation frame while running, right before renderer.render(scene, camera). */
63
+ draw?: (info: ThreeDrawInfo) => void;
64
+ onPointerDown?: (info: PointerInfo) => void;
65
+ onPointerMove?: (info: PointerInfo) => void;
66
+ onPointerUp?: (info: PointerInfo) => void;
67
+ onKeyDown?: (event: KeyboardEvent) => void;
68
+ onKeyUp?: (event: KeyboardEvent) => void;
69
+ /** Start the render loop automatically on mount. Default: true. */
70
+ autoStart?: boolean;
71
+ /** Default PerspectiveCamera field of view, in degrees. Default: 50. */
72
+ fov?: number;
73
+ near?: number;
74
+ far?: number;
75
+ /** Forwarded to `new THREE.WebGLRenderer(...)`. `canvas` is always set for you. */
76
+ rendererOptions?: Omit<import('three').WebGLRendererParameters, 'canvas'>;
77
+ /**
78
+ * Wait for a slot in the global WebGL context budget (see
79
+ * `configureWebglBudget`) before creating the renderer. Default: true.
80
+ * Browsers silently evict the oldest WebGL context once a per-process
81
+ * limit is hit (Safari's ceiling is notably lower than Chrome's), so a
82
+ * page rendering several live 3D previews at once needs this to avoid
83
+ * contexts going blank with no error.
84
+ */
85
+ respectBudget?: boolean;
86
+ }
87
+ export interface ThreeSketchHandle {
88
+ start: () => void;
89
+ stop: () => void;
90
+ reset: () => void;
91
+ isRunning: () => boolean;
92
+ }
93
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,17 @@
1
+ import type { CanvasSketchOptions } from './types';
2
+ export interface UseCanvasSketchResult {
3
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
4
+ start: () => void;
5
+ stop: () => void;
6
+ reset: () => void;
7
+ isRunning: () => boolean;
8
+ /** Re-renders when start()/stop() are called — handy for a play/pause button label. */
9
+ running: boolean;
10
+ }
11
+ /**
12
+ * Headless canvas render-loop engine: DPR-aware resize, requestAnimationFrame loop with
13
+ * dt/time/frame, and pointer/keyboard input normalized to canvas-local CSS-pixel coordinates.
14
+ * All DOM/window access happens inside effects, so it's safe to use during SSR.
15
+ */
16
+ export declare function useCanvasSketch(options?: CanvasSketchOptions): UseCanvasSketchResult;
17
+ //# sourceMappingURL=useCanvasSketch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useCanvasSketch.d.ts","sourceRoot":"","sources":["../src/useCanvasSketch.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAe,MAAM,SAAS,CAAC;AAEhE,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IACrD,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,uFAAuF;IACvF,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,mBAAwB,GAAG,qBAAqB,CAkHxF"}
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.useCanvasSketch = useCanvasSketch;
5
+ const react_1 = require("react");
6
+ /**
7
+ * Headless canvas render-loop engine: DPR-aware resize, requestAnimationFrame loop with
8
+ * dt/time/frame, and pointer/keyboard input normalized to canvas-local CSS-pixel coordinates.
9
+ * All DOM/window access happens inside effects, so it's safe to use during SSR.
10
+ */
11
+ function useCanvasSketch(options = {}) {
12
+ const canvasRef = (0, react_1.useRef)(null);
13
+ const ctxRef = (0, react_1.useRef)(null);
14
+ const optionsRef = (0, react_1.useRef)(options);
15
+ optionsRef.current = options;
16
+ const rafRef = (0, react_1.useRef)(0);
17
+ const runningRef = (0, react_1.useRef)(false);
18
+ const lastTsRef = (0, react_1.useRef)(null);
19
+ const timeRef = (0, react_1.useRef)(0);
20
+ const frameRef = (0, react_1.useRef)(0);
21
+ const [running, setRunning] = (0, react_1.useState)(false);
22
+ const loop = (0, react_1.useCallback)((ts) => {
23
+ const canvas = canvasRef.current;
24
+ const ctx = ctxRef.current;
25
+ if (!canvas || !ctx)
26
+ return;
27
+ if (lastTsRef.current === null)
28
+ lastTsRef.current = ts;
29
+ const dt = (ts - lastTsRef.current) / 1000;
30
+ lastTsRef.current = ts;
31
+ timeRef.current += dt;
32
+ frameRef.current += 1;
33
+ const dpr = window.devicePixelRatio || 1;
34
+ const width = canvas.width / dpr;
35
+ const height = canvas.height / dpr;
36
+ optionsRef.current.draw?.({
37
+ ctx, canvas, width, height,
38
+ dt, time: timeRef.current, frame: frameRef.current,
39
+ });
40
+ rafRef.current = requestAnimationFrame(loop);
41
+ }, []);
42
+ const start = (0, react_1.useCallback)(() => {
43
+ if (runningRef.current)
44
+ return;
45
+ runningRef.current = true;
46
+ setRunning(true);
47
+ lastTsRef.current = null;
48
+ rafRef.current = requestAnimationFrame(loop);
49
+ }, [loop]);
50
+ const stop = (0, react_1.useCallback)(() => {
51
+ runningRef.current = false;
52
+ setRunning(false);
53
+ cancelAnimationFrame(rafRef.current);
54
+ }, []);
55
+ const reset = (0, react_1.useCallback)(() => {
56
+ stop();
57
+ timeRef.current = 0;
58
+ frameRef.current = 0;
59
+ lastTsRef.current = null;
60
+ }, [stop]);
61
+ const isRunning = (0, react_1.useCallback)(() => runningRef.current, []);
62
+ (0, react_1.useEffect)(() => {
63
+ const canvas = canvasRef.current;
64
+ if (!canvas)
65
+ return;
66
+ const ctx = canvas.getContext('2d');
67
+ if (!ctx)
68
+ return;
69
+ ctxRef.current = ctx;
70
+ const resize = () => {
71
+ const rect = canvas.getBoundingClientRect();
72
+ const dpr = window.devicePixelRatio || 1;
73
+ const width = Math.max(1, Math.round(rect.width));
74
+ const height = Math.max(1, Math.round(rect.height));
75
+ canvas.width = width * dpr;
76
+ canvas.height = height * dpr;
77
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
78
+ optionsRef.current.setup?.({ ctx, canvas, width, height });
79
+ };
80
+ resize();
81
+ const ro = new ResizeObserver(resize);
82
+ ro.observe(canvas);
83
+ const toLocal = (e) => {
84
+ const rect = canvas.getBoundingClientRect();
85
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top, event: e };
86
+ };
87
+ const onPointerDown = (e) => optionsRef.current.onPointerDown?.(toLocal(e));
88
+ const onPointerMove = (e) => optionsRef.current.onPointerMove?.(toLocal(e));
89
+ const onPointerUp = (e) => optionsRef.current.onPointerUp?.(toLocal(e));
90
+ canvas.addEventListener('pointerdown', onPointerDown);
91
+ canvas.addEventListener('pointermove', onPointerMove);
92
+ canvas.addEventListener('pointerup', onPointerUp);
93
+ const onKeyDown = (e) => optionsRef.current.onKeyDown?.(e);
94
+ const onKeyUp = (e) => optionsRef.current.onKeyUp?.(e);
95
+ window.addEventListener('keydown', onKeyDown);
96
+ window.addEventListener('keyup', onKeyUp);
97
+ if (optionsRef.current.autoStart ?? true)
98
+ start();
99
+ return () => {
100
+ ro.disconnect();
101
+ canvas.removeEventListener('pointerdown', onPointerDown);
102
+ canvas.removeEventListener('pointermove', onPointerMove);
103
+ canvas.removeEventListener('pointerup', onPointerUp);
104
+ window.removeEventListener('keydown', onKeyDown);
105
+ window.removeEventListener('keyup', onKeyUp);
106
+ cancelAnimationFrame(rafRef.current);
107
+ runningRef.current = false;
108
+ };
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, []);
111
+ return { canvasRef, start, stop, reset, isRunning, running };
112
+ }
@@ -0,0 +1,19 @@
1
+ export interface UseInViewportOptions {
2
+ /** Forwarded to IntersectionObserver. Default: '200px'. */
3
+ rootMargin?: string;
4
+ /**
5
+ * Once true, stays true forever after the first intersection (old
6
+ * "mount once" behavior — cheap sketches that shouldn't restart on
7
+ * every scroll in/out). Once false, tracks intersection continuously,
8
+ * which is what lets a WebGL sketch actually unmount and free its
9
+ * context budget slot when scrolled away. Default: false.
10
+ */
11
+ once?: boolean;
12
+ }
13
+ /**
14
+ * Tracks whether `ref`'s element is inside the viewport (+ rootMargin).
15
+ * Used to lazily mount expensive sketches (canvas/WebGL) only while
16
+ * they're actually visible.
17
+ */
18
+ export declare function useInViewport(ref: React.RefObject<Element | null>, { rootMargin, once }?: UseInViewportOptions): boolean;
19
+ //# sourceMappingURL=useInViewport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useInViewport.d.ts","sourceRoot":"","sources":["../src/useInViewport.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC,EACpC,EAAE,UAAoB,EAAE,IAAY,EAAE,GAAE,oBAAyB,GAChE,OAAO,CAyBT"}
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.useInViewport = useInViewport;
5
+ const react_1 = require("react");
6
+ /**
7
+ * Tracks whether `ref`'s element is inside the viewport (+ rootMargin).
8
+ * Used to lazily mount expensive sketches (canvas/WebGL) only while
9
+ * they're actually visible.
10
+ */
11
+ function useInViewport(ref, { rootMargin = '200px', once = false } = {}) {
12
+ const [inView, setInView] = (0, react_1.useState)(false);
13
+ (0, react_1.useEffect)(() => {
14
+ const el = ref.current;
15
+ if (!el)
16
+ return undefined;
17
+ const obs = new IntersectionObserver((entries) => {
18
+ const isIntersecting = !!entries[0]?.isIntersecting;
19
+ if (isIntersecting) {
20
+ setInView(true);
21
+ if (once)
22
+ obs.disconnect();
23
+ }
24
+ else if (!once) {
25
+ setInView(false);
26
+ }
27
+ }, { rootMargin });
28
+ obs.observe(el);
29
+ return () => obs.disconnect();
30
+ // eslint-disable-next-line react-hooks/exhaustive-deps
31
+ }, [ref.current, rootMargin, once]);
32
+ return inView;
33
+ }
@@ -0,0 +1,25 @@
1
+ import type { ThreeSketchOptions } from './types';
2
+ export interface UseThreeSketchResult {
3
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
4
+ start: () => void;
5
+ stop: () => void;
6
+ reset: () => void;
7
+ isRunning: () => boolean;
8
+ /** Re-renders when start()/stop() are called — handy for a play/pause button label. */
9
+ running: boolean;
10
+ }
11
+ /**
12
+ * Headless Three.js render-loop engine: same shape as useCanvasSketch
13
+ * (DPR-aware resize, requestAnimationFrame loop with dt/time/frame,
14
+ * normalized pointer/keyboard input), but hands your setup/draw callbacks
15
+ * a `scene`/`camera`/`renderer` instead of a 2D `ctx`.
16
+ *
17
+ * Unlike useCanvasSketch, WebGL contexts are a scarce per-process browser
18
+ * resource, so this hook (a) waits for a slot in the shared context budget
19
+ * before creating the renderer (see `configureWebglBudget`), and (b) fully
20
+ * disposes the scene graph and forces context loss on unmount, so a page
21
+ * that mounts/unmounts many of these (e.g. a gallery of live previews)
22
+ * doesn't leak contexts.
23
+ */
24
+ export declare function useThreeSketch(options?: ThreeSketchOptions): UseThreeSketchResult;
25
+ //# sourceMappingURL=useThreeSketch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useThreeSketch.d.ts","sourceRoot":"","sources":["../src/useThreeSketch.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,SAAS,CAAC;AAG/D,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IACrD,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,uFAAuF;IACvF,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,OAAO,GAAE,kBAAuB,GAAG,oBAAoB,CAgLrF"}
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ 'use client';
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.useThreeSketch = useThreeSketch;
38
+ const react_1 = require("react");
39
+ const THREE = __importStar(require("three"));
40
+ const webglBudget_1 = require("./webglBudget");
41
+ /**
42
+ * Headless Three.js render-loop engine: same shape as useCanvasSketch
43
+ * (DPR-aware resize, requestAnimationFrame loop with dt/time/frame,
44
+ * normalized pointer/keyboard input), but hands your setup/draw callbacks
45
+ * a `scene`/`camera`/`renderer` instead of a 2D `ctx`.
46
+ *
47
+ * Unlike useCanvasSketch, WebGL contexts are a scarce per-process browser
48
+ * resource, so this hook (a) waits for a slot in the shared context budget
49
+ * before creating the renderer (see `configureWebglBudget`), and (b) fully
50
+ * disposes the scene graph and forces context loss on unmount, so a page
51
+ * that mounts/unmounts many of these (e.g. a gallery of live previews)
52
+ * doesn't leak contexts.
53
+ */
54
+ function useThreeSketch(options = {}) {
55
+ const canvasRef = (0, react_1.useRef)(null);
56
+ const optionsRef = (0, react_1.useRef)(options);
57
+ optionsRef.current = options;
58
+ const sceneRef = (0, react_1.useRef)(null);
59
+ const cameraRef = (0, react_1.useRef)(null);
60
+ const rendererRef = (0, react_1.useRef)(null);
61
+ const rafRef = (0, react_1.useRef)(0);
62
+ const runningRef = (0, react_1.useRef)(false);
63
+ const lastTsRef = (0, react_1.useRef)(null);
64
+ const timeRef = (0, react_1.useRef)(0);
65
+ const frameRef = (0, react_1.useRef)(0);
66
+ const [running, setRunning] = (0, react_1.useState)(false);
67
+ const [hasSlot, setHasSlot] = (0, react_1.useState)(false);
68
+ const loop = (0, react_1.useCallback)((ts) => {
69
+ const canvas = canvasRef.current;
70
+ const scene = sceneRef.current;
71
+ const camera = cameraRef.current;
72
+ const renderer = rendererRef.current;
73
+ if (!canvas || !scene || !camera || !renderer)
74
+ return;
75
+ if (lastTsRef.current === null)
76
+ lastTsRef.current = ts;
77
+ const dt = (ts - lastTsRef.current) / 1000;
78
+ lastTsRef.current = ts;
79
+ timeRef.current += dt;
80
+ frameRef.current += 1;
81
+ const width = canvas.clientWidth || 1;
82
+ const height = canvas.clientHeight || 1;
83
+ optionsRef.current.draw?.({
84
+ scene, camera, renderer, canvas, width, height,
85
+ dt, time: timeRef.current, frame: frameRef.current,
86
+ });
87
+ renderer.render(scene, camera);
88
+ rafRef.current = requestAnimationFrame(loop);
89
+ }, []);
90
+ const start = (0, react_1.useCallback)(() => {
91
+ if (runningRef.current)
92
+ return;
93
+ runningRef.current = true;
94
+ setRunning(true);
95
+ lastTsRef.current = null;
96
+ rafRef.current = requestAnimationFrame(loop);
97
+ }, [loop]);
98
+ const stop = (0, react_1.useCallback)(() => {
99
+ runningRef.current = false;
100
+ setRunning(false);
101
+ cancelAnimationFrame(rafRef.current);
102
+ }, []);
103
+ const reset = (0, react_1.useCallback)(() => {
104
+ stop();
105
+ timeRef.current = 0;
106
+ frameRef.current = 0;
107
+ lastTsRef.current = null;
108
+ }, [stop]);
109
+ const isRunning = (0, react_1.useCallback)(() => runningRef.current, []);
110
+ // Reserva um slot no orçamento global de contextos WebGL antes de criar
111
+ // qualquer coisa. Se não conseguir na hora, fica em espera e tenta de
112
+ // novo quando um slot se abrir (ver webglBudget.ts).
113
+ (0, react_1.useEffect)(() => {
114
+ if (!(optionsRef.current.respectBudget ?? true)) {
115
+ setHasSlot(true);
116
+ return undefined;
117
+ }
118
+ let granted = false;
119
+ const onGrant = () => { granted = true; setHasSlot(true); };
120
+ if ((0, webglBudget_1.acquireWebglSlot)(onGrant)) {
121
+ granted = true;
122
+ setHasSlot(true);
123
+ }
124
+ return () => {
125
+ if (granted)
126
+ (0, webglBudget_1.releaseWebglSlot)();
127
+ else
128
+ (0, webglBudget_1.cancelWebglSlotRequest)(onGrant);
129
+ };
130
+ }, []);
131
+ (0, react_1.useEffect)(() => {
132
+ if (!hasSlot)
133
+ return undefined;
134
+ const canvas = canvasRef.current;
135
+ if (!canvas)
136
+ return undefined;
137
+ const renderer = new THREE.WebGLRenderer({
138
+ canvas,
139
+ antialias: true,
140
+ alpha: false,
141
+ ...optionsRef.current.rendererOptions,
142
+ });
143
+ const scene = new THREE.Scene();
144
+ const camera = new THREE.PerspectiveCamera(optionsRef.current.fov ?? 50, 1, optionsRef.current.near ?? 0.1, optionsRef.current.far ?? 1000);
145
+ camera.position.z = 5;
146
+ sceneRef.current = scene;
147
+ cameraRef.current = camera;
148
+ rendererRef.current = renderer;
149
+ const resize = () => {
150
+ const rect = canvas.getBoundingClientRect();
151
+ const width = Math.max(1, Math.round(rect.width));
152
+ const height = Math.max(1, Math.round(rect.height));
153
+ renderer.setPixelRatio(window.devicePixelRatio || 1);
154
+ renderer.setSize(width, height, false);
155
+ camera.aspect = width / height;
156
+ camera.updateProjectionMatrix();
157
+ optionsRef.current.setup?.({ scene, camera, renderer, canvas, width, height });
158
+ };
159
+ resize();
160
+ const ro = new ResizeObserver(resize);
161
+ ro.observe(canvas);
162
+ const toLocal = (e) => {
163
+ const rect = canvas.getBoundingClientRect();
164
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top, event: e };
165
+ };
166
+ const onPointerDown = (e) => optionsRef.current.onPointerDown?.(toLocal(e));
167
+ const onPointerMove = (e) => optionsRef.current.onPointerMove?.(toLocal(e));
168
+ const onPointerUp = (e) => optionsRef.current.onPointerUp?.(toLocal(e));
169
+ canvas.addEventListener('pointerdown', onPointerDown);
170
+ canvas.addEventListener('pointermove', onPointerMove);
171
+ canvas.addEventListener('pointerup', onPointerUp);
172
+ const onKeyDown = (e) => optionsRef.current.onKeyDown?.(e);
173
+ const onKeyUp = (e) => optionsRef.current.onKeyUp?.(e);
174
+ window.addEventListener('keydown', onKeyDown);
175
+ window.addEventListener('keyup', onKeyUp);
176
+ if (optionsRef.current.autoStart ?? true)
177
+ start();
178
+ return () => {
179
+ ro.disconnect();
180
+ canvas.removeEventListener('pointerdown', onPointerDown);
181
+ canvas.removeEventListener('pointermove', onPointerMove);
182
+ canvas.removeEventListener('pointerup', onPointerUp);
183
+ window.removeEventListener('keydown', onKeyDown);
184
+ window.removeEventListener('keyup', onKeyUp);
185
+ cancelAnimationFrame(rafRef.current);
186
+ runningRef.current = false;
187
+ // Libera geometria/material/textura e força a perda do contexto —
188
+ // sem isso o contexto WebGL fica pendurado até o garbage collector
189
+ // decidir coletar o WebGLRenderer, o que pode nunca acontecer a
190
+ // tempo em uma galeria que monta/desmonta várias prévias.
191
+ scene.traverse((obj) => {
192
+ const mesh = obj;
193
+ mesh.geometry?.dispose?.();
194
+ const material = mesh.material;
195
+ if (Array.isArray(material))
196
+ material.forEach((m) => m.dispose());
197
+ else
198
+ material?.dispose?.();
199
+ });
200
+ renderer.dispose();
201
+ renderer.forceContextLoss();
202
+ sceneRef.current = null;
203
+ cameraRef.current = null;
204
+ rendererRef.current = null;
205
+ };
206
+ // eslint-disable-next-line react-hooks/exhaustive-deps
207
+ }, [hasSlot]);
208
+ return { canvasRef, start, stop, reset, isRunning, running };
209
+ }
@@ -0,0 +1,6 @@
1
+ export declare function clamp(value: number, min: number, max: number): number;
2
+ export declare function randRange(min: number, max: number): number;
3
+ export type RGB = [number, number, number];
4
+ /** Linearly interpolates between two RGB colors and returns an `rgb(...)` string. */
5
+ export declare function lerpColor(a: RGB, b: RGB, t: number): string;
6
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAErE;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAE3C,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAM3D"}
package/dist/utils.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clamp = clamp;
4
+ exports.randRange = randRange;
5
+ exports.lerpColor = lerpColor;
6
+ function clamp(value, min, max) {
7
+ return Math.min(max, Math.max(min, value));
8
+ }
9
+ function randRange(min, max) {
10
+ return min + Math.random() * (max - min);
11
+ }
12
+ /** Linearly interpolates between two RGB colors and returns an `rgb(...)` string. */
13
+ function lerpColor(a, b, t) {
14
+ const tt = clamp(t, 0, 1);
15
+ const r = Math.round(a[0] + (b[0] - a[0]) * tt);
16
+ const g = Math.round(a[1] + (b[1] - a[1]) * tt);
17
+ const bl = Math.round(a[2] + (b[2] - a[2]) * tt);
18
+ return `rgb(${r},${g},${bl})`;
19
+ }
@@ -0,0 +1,25 @@
1
+ type Listener = () => void;
2
+ /**
3
+ * Ajusta o número máximo de contextos WebGL simultâneos que useThreeSketch
4
+ * respeita. Browsers descartam o contexto mais antigo silenciosamente ao
5
+ * estourar o teto do processo (Safari costuma ser bem mais restritivo que
6
+ * Chrome), então o padrão (4) é conservador para caber várias prévias na
7
+ * mesma página sem depender do limite exato de cada navegador.
8
+ */
9
+ export declare function configureWebglBudget(max: number): void;
10
+ export declare function getWebglBudget(): {
11
+ active: number;
12
+ max: number;
13
+ };
14
+ /**
15
+ * Reserva um slot no orçamento global. Retorna true se conseguiu na hora
16
+ * (o chamador deve chamar releaseWebglSlot() quando terminar). Se não
17
+ * houver slot livre, registra `onGrant` para ser chamado assim que um se
18
+ * abrir — o chamador decide o que fazer nesse momento (tipicamente tentar
19
+ * adquirir de novo).
20
+ */
21
+ export declare function acquireWebglSlot(onGrant?: Listener): boolean;
22
+ export declare function cancelWebglSlotRequest(onGrant: Listener): void;
23
+ export declare function releaseWebglSlot(): void;
24
+ export {};
25
+ //# sourceMappingURL=webglBudget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webglBudget.d.ts","sourceRoot":"","sources":["../src/webglBudget.ts"],"names":[],"mappings":"AAAA,KAAK,QAAQ,GAAG,MAAM,IAAI,CAAC;AAkB3B;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAGtD;AAED,wBAAgB,cAAc,IAAI;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAEhE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,OAAO,CAO5D;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,QAAQ,GAAG,IAAI,CAE9D;AAED,wBAAgB,gBAAgB,IAAI,IAAI,CAGvC"}
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.configureWebglBudget = configureWebglBudget;
4
+ exports.getWebglBudget = getWebglBudget;
5
+ exports.acquireWebglSlot = acquireWebglSlot;
6
+ exports.cancelWebglSlotRequest = cancelWebglSlotRequest;
7
+ exports.releaseWebglSlot = releaseWebglSlot;
8
+ let maxConcurrent = 4;
9
+ let active = 0;
10
+ const waiters = new Set();
11
+ function notifyWaiters() {
12
+ while (active < maxConcurrent && waiters.size > 0) {
13
+ const listener = waiters.values().next().value;
14
+ waiters.delete(listener);
15
+ // Reserve the slot before calling out — the waiter is being granted
16
+ // exactly the capacity acquireWebglSlot() would have given it
17
+ // synchronously, so it must count against the budget the same way.
18
+ active += 1;
19
+ listener();
20
+ }
21
+ }
22
+ /**
23
+ * Ajusta o número máximo de contextos WebGL simultâneos que useThreeSketch
24
+ * respeita. Browsers descartam o contexto mais antigo silenciosamente ao
25
+ * estourar o teto do processo (Safari costuma ser bem mais restritivo que
26
+ * Chrome), então o padrão (4) é conservador para caber várias prévias na
27
+ * mesma página sem depender do limite exato de cada navegador.
28
+ */
29
+ function configureWebglBudget(max) {
30
+ maxConcurrent = Math.max(1, max);
31
+ notifyWaiters();
32
+ }
33
+ function getWebglBudget() {
34
+ return { active, max: maxConcurrent };
35
+ }
36
+ /**
37
+ * Reserva um slot no orçamento global. Retorna true se conseguiu na hora
38
+ * (o chamador deve chamar releaseWebglSlot() quando terminar). Se não
39
+ * houver slot livre, registra `onGrant` para ser chamado assim que um se
40
+ * abrir — o chamador decide o que fazer nesse momento (tipicamente tentar
41
+ * adquirir de novo).
42
+ */
43
+ function acquireWebglSlot(onGrant) {
44
+ if (active < maxConcurrent) {
45
+ active += 1;
46
+ return true;
47
+ }
48
+ if (onGrant)
49
+ waiters.add(onGrant);
50
+ return false;
51
+ }
52
+ function cancelWebglSlotRequest(onGrant) {
53
+ waiters.delete(onGrant);
54
+ }
55
+ function releaseWebglSlot() {
56
+ active = Math.max(0, active - 1);
57
+ notifyWaiters();
58
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "next-sketch",
3
+ "version": "0.2.0",
4
+ "description": "p5.js-style canvas sketches for React/Next.js — headless hooks plus drop-in <CanvasSketch>/<ThreeSketch> components, SSR-safe and DPR-aware, with an optional Three.js engine and a shared WebGL context budget.",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "require": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "jest",
17
+ "test:watch": "jest --watch",
18
+ "test:coverage": "jest --coverage",
19
+ "prepublishOnly": "npm run build",
20
+ "lint": "tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "react",
24
+ "nextjs",
25
+ "canvas",
26
+ "sketch",
27
+ "p5js",
28
+ "animation",
29
+ "simulation",
30
+ "creative-coding",
31
+ "three",
32
+ "threejs",
33
+ "webgl",
34
+ "3d"
35
+ ],
36
+ "author": "ReinanBr",
37
+ "license": "MIT",
38
+ "homepage": "https://github.com/reinanbr/next-sketch#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/reinanbr/next-sketch/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "peerDependencies": {
46
+ "react": ">=18",
47
+ "react-dom": ">=18",
48
+ "three": ">=0.150.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "three": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@testing-library/react": "^16.0.0",
57
+ "@types/jest": "^30.0.0",
58
+ "@types/node": "^22.0.0",
59
+ "@types/react": "^19.0.0",
60
+ "@types/react-dom": "^19.0.0",
61
+ "@types/three": "^0.185.1",
62
+ "jest": "^30.0.0",
63
+ "jest-environment-jsdom": "^30.0.0",
64
+ "react": "^19.0.0",
65
+ "react-dom": "^19.0.0",
66
+ "three": "^0.185.1",
67
+ "ts-jest": "^29.1.0",
68
+ "typescript": "^5.8.2"
69
+ },
70
+ "files": [
71
+ "dist",
72
+ "README.md",
73
+ "LICENSE"
74
+ ],
75
+ "repository": {
76
+ "type": "git",
77
+ "url": "https://github.com/reinanbr/next-sketch"
78
+ }
79
+ }