react-gif-timeline 0.0.1
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 +180 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +274 -0
- package/package.json +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# react-gif-timeline
|
|
2
|
+
|
|
3
|
+
Control GIF animation timelines with React state. Define anchor frames mapped to states, and the library animates between them (forward or reverse), respecting the GIF's original frame timing.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
State A → Frame 0 (paused)
|
|
7
|
+
State A→B → Animate frame 0 → frame 20, pause
|
|
8
|
+
State B→C → Animate frame 20 → frame 39, pause
|
|
9
|
+
State C→A → Animate frame 39 → frame 0 (reverse), pause
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Mid-transition interruptions are handled gracefully. If the state changes while animating, the new transition starts from the current frame.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add react-gif-timeline
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Hook
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { useGifTimeline } from "react-gif-timeline"
|
|
26
|
+
|
|
27
|
+
function WeatherWidget() {
|
|
28
|
+
const [state, setState] = useState("sunny")
|
|
29
|
+
|
|
30
|
+
const { canvasRef, currentFrame, isTransitioning } = useGifTimeline({
|
|
31
|
+
src: "/weather.gif",
|
|
32
|
+
anchors: { sunny: 0, cloudy: 19, rainy: 39 },
|
|
33
|
+
activeState: state,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
<canvas ref={canvasRef} />
|
|
39
|
+
<p>Frame {currentFrame} {isTransitioning && "(transitioning...)"}</p>
|
|
40
|
+
<button onClick={() => setState("sunny")}>Sunny</button>
|
|
41
|
+
<button onClick={() => setState("cloudy")}>Cloudy</button>
|
|
42
|
+
<button onClick={() => setState("rainy")}>Rainy</button>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Component
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { GifTimeline } from "react-gif-timeline"
|
|
52
|
+
|
|
53
|
+
function WeatherWidget() {
|
|
54
|
+
const [state, setState] = useState("sunny")
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<GifTimeline
|
|
58
|
+
src="/weather.gif"
|
|
59
|
+
anchors={{ sunny: 0, cloudy: 19, rainy: 39 }}
|
|
60
|
+
activeState={state}
|
|
61
|
+
speed={1.5}
|
|
62
|
+
renderLoading={() => <div>Loading...</div>}
|
|
63
|
+
renderError={(err) => <div>Error: {err}</div>}
|
|
64
|
+
>
|
|
65
|
+
{({ currentFrame, totalFrames, transitionProgress }) => (
|
|
66
|
+
<p>Frame {currentFrame}/{totalFrames} ({Math.round(transitionProgress * 100)}%)</p>
|
|
67
|
+
)}
|
|
68
|
+
</GifTimeline>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Anchors
|
|
74
|
+
|
|
75
|
+
Anchors map your application states to GIF frame indices (0-indexed). Two formats are supported:
|
|
76
|
+
|
|
77
|
+
### Named anchors (recommended)
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
const anchors = { idle: 0, loading: 15, done: 30 }
|
|
81
|
+
|
|
82
|
+
useGifTimeline({
|
|
83
|
+
anchors,
|
|
84
|
+
activeState: "loading", // TypeScript autocompletes keys
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Index-based anchors
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
const anchors = [0, 15, 30]
|
|
92
|
+
|
|
93
|
+
useGifTimeline({
|
|
94
|
+
anchors,
|
|
95
|
+
activeState: 1, // points to frame 15
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Named anchors provide full TypeScript inference. `activeState` is constrained to the keys you defined.
|
|
100
|
+
|
|
101
|
+
## API
|
|
102
|
+
|
|
103
|
+
### `useGifTimeline(options)`
|
|
104
|
+
|
|
105
|
+
#### Options
|
|
106
|
+
|
|
107
|
+
| Option | Type | Required | Default | Description |
|
|
108
|
+
|---|---|---|---|---|
|
|
109
|
+
| `src` | `string \| Blob` | yes | - | GIF URL or Blob |
|
|
110
|
+
| `anchors` | `number[] \| Record<string, number>` | yes | - | Frame indices mapped to states |
|
|
111
|
+
| `activeState` | `number \| string` | yes | - | Active state (index for arrays, key for records) |
|
|
112
|
+
| `speed` | `number` | no | `1` | Playback speed multiplier |
|
|
113
|
+
| `onTransitionStart` | `(fromFrame, toFrame) => void` | no | - | Called when a transition begins |
|
|
114
|
+
| `onTransitionEnd` | `(state) => void` | no | - | Called when a transition completes |
|
|
115
|
+
|
|
116
|
+
#### Return
|
|
117
|
+
|
|
118
|
+
| Field | Type | Description |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `canvasRef` | `RefObject<HTMLCanvasElement>` | Attach to a `<canvas>` element |
|
|
121
|
+
| `currentFrame` | `number` | Currently displayed frame index |
|
|
122
|
+
| `totalFrames` | `number` | Total frames in the GIF |
|
|
123
|
+
| `isLoaded` | `boolean` | GIF is loaded and parsed |
|
|
124
|
+
| `error` | `string \| null` | Error message if loading failed |
|
|
125
|
+
| `isTransitioning` | `boolean` | A transition is in progress |
|
|
126
|
+
| `transitionProgress` | `number` | 0–1 progress of current transition |
|
|
127
|
+
|
|
128
|
+
### `<GifTimeline>`
|
|
129
|
+
|
|
130
|
+
Wraps `useGifTimeline` with built-in canvas rendering and loading/error states.
|
|
131
|
+
|
|
132
|
+
Accepts all hook options as props, plus:
|
|
133
|
+
|
|
134
|
+
| Prop | Type | Description |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `className` | `string` | Applied to the canvas element |
|
|
137
|
+
| `style` | `CSSProperties` | Applied to the canvas element |
|
|
138
|
+
| `renderLoading` | `() => ReactNode` | Rendered while the GIF loads |
|
|
139
|
+
| `renderError` | `(error: string) => ReactNode` | Rendered if loading fails |
|
|
140
|
+
| `children` | `(result: GifTimelineResult) => ReactNode` | Render prop with hook return values |
|
|
141
|
+
|
|
142
|
+
## How It Works
|
|
143
|
+
|
|
144
|
+
1. **Parse**: the GIF is fetched and decoded using [gifuct-js](https://github.com/matt-way/gifuct-js)
|
|
145
|
+
2. **Pre-compose**: each frame is composited into a full image (resolving GIF delta/patch encoding), enabling instant forward and reverse playback
|
|
146
|
+
3. **Render**: frames are drawn to a canvas via `putImageData` with automatic DPR scaling for retina displays
|
|
147
|
+
4. **Animate**: when `activeState` changes, a `requestAnimationFrame` loop plays through frames using the GIF's original per-frame delays, adjusted by the `speed` multiplier
|
|
148
|
+
|
|
149
|
+
### Direction
|
|
150
|
+
|
|
151
|
+
- Target frame > current frame → plays forward
|
|
152
|
+
- Target frame < current frame → plays in reverse
|
|
153
|
+
|
|
154
|
+
### Interruptions
|
|
155
|
+
|
|
156
|
+
If `activeState` changes during a transition, the current animation is cancelled and a new one starts from whatever frame is currently displayed.
|
|
157
|
+
|
|
158
|
+
## TypeScript
|
|
159
|
+
|
|
160
|
+
The library is fully typed with generics that infer `activeState` from `anchors`:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
// anchors is Record<string, number> → activeState must be a key
|
|
164
|
+
useGifTimeline({
|
|
165
|
+
anchors: { idle: 0, active: 20 },
|
|
166
|
+
activeState: "idle", // ✅
|
|
167
|
+
activeState: "unknown", // ❌ Type error
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// anchors is number[] → activeState must be number
|
|
171
|
+
useGifTimeline({
|
|
172
|
+
anchors: [0, 20],
|
|
173
|
+
activeState: 0, // ✅
|
|
174
|
+
activeState: "idle", // ❌ Type error
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { CSSProperties, ReactNode, RefObject } from 'react';
|
|
3
|
+
|
|
4
|
+
type Anchors = number[] | Record<string, number>;
|
|
5
|
+
type ActiveState<A extends Anchors> = A extends number[] ? number : keyof A & string;
|
|
6
|
+
interface GifTimelineOptions<A extends Anchors> {
|
|
7
|
+
src: string | Blob;
|
|
8
|
+
anchors: A;
|
|
9
|
+
activeState: ActiveState<A>;
|
|
10
|
+
speed?: number;
|
|
11
|
+
onTransitionStart?: (fromFrame: number, toFrame: number) => void;
|
|
12
|
+
onTransitionEnd?: (state: ActiveState<A>) => void;
|
|
13
|
+
}
|
|
14
|
+
interface GifTimelineResult {
|
|
15
|
+
canvasRef: RefObject<HTMLCanvasElement | null>;
|
|
16
|
+
currentFrame: number;
|
|
17
|
+
totalFrames: number;
|
|
18
|
+
isLoaded: boolean;
|
|
19
|
+
error: string | null;
|
|
20
|
+
isTransitioning: boolean;
|
|
21
|
+
transitionProgress: number;
|
|
22
|
+
}
|
|
23
|
+
interface GifTimelineProps<A extends Anchors> extends GifTimelineOptions<A> {
|
|
24
|
+
className?: string;
|
|
25
|
+
style?: CSSProperties;
|
|
26
|
+
renderLoading?: () => ReactNode;
|
|
27
|
+
renderError?: (error: string) => ReactNode;
|
|
28
|
+
children?: (result: GifTimelineResult) => ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare function GifTimeline<A extends Anchors>(props: GifTimelineProps<A>): react_jsx_runtime.JSX.Element;
|
|
32
|
+
|
|
33
|
+
declare function useGifTimeline<A extends Anchors>(options: GifTimelineOptions<A>): GifTimelineResult;
|
|
34
|
+
|
|
35
|
+
export { type ActiveState, type Anchors, GifTimeline, type GifTimelineOptions, type GifTimelineProps, type GifTimelineResult, useGifTimeline };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// src/useGifTimeline.ts
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/animator.ts
|
|
5
|
+
function animate(fromFrame, toFrame, delays, speed, onFrame, onComplete) {
|
|
6
|
+
if (fromFrame === toFrame) {
|
|
7
|
+
onFrame(toFrame);
|
|
8
|
+
onComplete();
|
|
9
|
+
return { cancel: () => {
|
|
10
|
+
} };
|
|
11
|
+
}
|
|
12
|
+
const direction = toFrame > fromFrame ? 1 : -1;
|
|
13
|
+
let currentFrame = fromFrame;
|
|
14
|
+
let elapsed = 0;
|
|
15
|
+
let lastTime = 0;
|
|
16
|
+
let rafId = 0;
|
|
17
|
+
function tick(time) {
|
|
18
|
+
if (lastTime === 0) {
|
|
19
|
+
lastTime = time;
|
|
20
|
+
}
|
|
21
|
+
elapsed += time - lastTime;
|
|
22
|
+
lastTime = time;
|
|
23
|
+
while (currentFrame !== toFrame) {
|
|
24
|
+
const frameDelay = (delays[currentFrame] || 100) / speed;
|
|
25
|
+
if (elapsed < frameDelay) break;
|
|
26
|
+
elapsed -= frameDelay;
|
|
27
|
+
currentFrame += direction;
|
|
28
|
+
onFrame(currentFrame);
|
|
29
|
+
if (currentFrame === toFrame) {
|
|
30
|
+
onComplete();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
rafId = requestAnimationFrame(tick);
|
|
35
|
+
}
|
|
36
|
+
onFrame(currentFrame);
|
|
37
|
+
rafId = requestAnimationFrame(tick);
|
|
38
|
+
return {
|
|
39
|
+
cancel: () => cancelAnimationFrame(rafId)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/frameRenderer.ts
|
|
44
|
+
function initCanvas(canvas, width, height) {
|
|
45
|
+
const dpr = window.devicePixelRatio || 1;
|
|
46
|
+
canvas.width = width * dpr;
|
|
47
|
+
canvas.height = height * dpr;
|
|
48
|
+
canvas.style.width = `${width}px`;
|
|
49
|
+
canvas.style.height = `${height}px`;
|
|
50
|
+
const ctx = canvas.getContext("2d");
|
|
51
|
+
ctx.scale(dpr, dpr);
|
|
52
|
+
}
|
|
53
|
+
function renderFrame(canvas, frame, width, height) {
|
|
54
|
+
const ctx = canvas.getContext("2d");
|
|
55
|
+
const temp = document.createElement("canvas");
|
|
56
|
+
temp.width = width;
|
|
57
|
+
temp.height = height;
|
|
58
|
+
const tempCtx = temp.getContext("2d");
|
|
59
|
+
tempCtx.putImageData(frame, 0, 0);
|
|
60
|
+
ctx.clearRect(0, 0, width, height);
|
|
61
|
+
ctx.drawImage(temp, 0, 0, width, height);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/gifParser.ts
|
|
65
|
+
import { decompressFrames, parseGIF } from "gifuct-js";
|
|
66
|
+
async function parseGifSource(src) {
|
|
67
|
+
const buffer = src instanceof Blob ? await src.arrayBuffer() : await fetch(src).then((r) => r.arrayBuffer());
|
|
68
|
+
const gif = parseGIF(buffer);
|
|
69
|
+
const rawFrames = decompressFrames(gif, true);
|
|
70
|
+
if (rawFrames.length === 0) {
|
|
71
|
+
throw new Error("react-gif-timeline: GIF contains no frames");
|
|
72
|
+
}
|
|
73
|
+
const { width, height } = rawFrames[0].dims;
|
|
74
|
+
const accumulator = document.createElement("canvas");
|
|
75
|
+
accumulator.width = width;
|
|
76
|
+
accumulator.height = height;
|
|
77
|
+
const accCtx = accumulator.getContext("2d");
|
|
78
|
+
const temp = document.createElement("canvas");
|
|
79
|
+
const tempCtx = temp.getContext("2d");
|
|
80
|
+
const frames = [];
|
|
81
|
+
const delays = [];
|
|
82
|
+
for (const frame of rawFrames) {
|
|
83
|
+
const { patch, dims, disposalType, delay } = frame;
|
|
84
|
+
if (patch) {
|
|
85
|
+
temp.width = dims.width;
|
|
86
|
+
temp.height = dims.height;
|
|
87
|
+
const patchData = new ImageData(patch, dims.width, dims.height);
|
|
88
|
+
tempCtx.putImageData(patchData, 0, 0);
|
|
89
|
+
accCtx.drawImage(temp, dims.left, dims.top);
|
|
90
|
+
}
|
|
91
|
+
frames.push(accCtx.getImageData(0, 0, width, height));
|
|
92
|
+
delays.push(delay);
|
|
93
|
+
if (disposalType === 2) {
|
|
94
|
+
accCtx.clearRect(dims.left, dims.top, dims.width, dims.height);
|
|
95
|
+
}
|
|
96
|
+
if (disposalType === 3) {
|
|
97
|
+
accCtx.clearRect(dims.left, dims.top, dims.width, dims.height);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { frames, delays, width, height };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/useGifTimeline.ts
|
|
104
|
+
function resolveAnchorFrame(anchors, activeState) {
|
|
105
|
+
if (Array.isArray(anchors)) {
|
|
106
|
+
return anchors[activeState];
|
|
107
|
+
}
|
|
108
|
+
return anchors[activeState];
|
|
109
|
+
}
|
|
110
|
+
function useGifTimeline(options) {
|
|
111
|
+
const { src, anchors, activeState, speed = 1, onTransitionStart, onTransitionEnd } = options;
|
|
112
|
+
const canvasRef = useRef(null);
|
|
113
|
+
const gifDataRef = useRef(null);
|
|
114
|
+
const currentFrameRef = useRef(0);
|
|
115
|
+
const animatorRef = useRef(null);
|
|
116
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
117
|
+
const [error, setError] = useState(null);
|
|
118
|
+
const [currentFrame, setCurrentFrame] = useState(0);
|
|
119
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
120
|
+
const [transitionProgress, setTransitionProgress] = useState(0);
|
|
121
|
+
const onTransitionStartRef = useRef(onTransitionStart);
|
|
122
|
+
onTransitionStartRef.current = onTransitionStart;
|
|
123
|
+
const onTransitionEndRef = useRef(onTransitionEnd);
|
|
124
|
+
onTransitionEndRef.current = onTransitionEnd;
|
|
125
|
+
const activeStateRef = useRef(activeState);
|
|
126
|
+
activeStateRef.current = activeState;
|
|
127
|
+
const anchorsRef = useRef(anchors);
|
|
128
|
+
anchorsRef.current = anchors;
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
let cancelled = false;
|
|
131
|
+
setIsLoaded(false);
|
|
132
|
+
setError(null);
|
|
133
|
+
gifDataRef.current = null;
|
|
134
|
+
parseGifSource(src).then((data) => {
|
|
135
|
+
if (cancelled) return;
|
|
136
|
+
gifDataRef.current = data;
|
|
137
|
+
if (canvasRef.current) {
|
|
138
|
+
initCanvas(canvasRef.current, data.width, data.height);
|
|
139
|
+
}
|
|
140
|
+
const initialFrame = resolveAnchorFrame(anchorsRef.current, activeStateRef.current);
|
|
141
|
+
const clampedFrame = Math.min(Math.max(0, initialFrame), data.frames.length - 1);
|
|
142
|
+
currentFrameRef.current = clampedFrame;
|
|
143
|
+
setCurrentFrame(clampedFrame);
|
|
144
|
+
if (canvasRef.current) {
|
|
145
|
+
renderFrame(canvasRef.current, data.frames[clampedFrame], data.width, data.height);
|
|
146
|
+
}
|
|
147
|
+
setIsLoaded(true);
|
|
148
|
+
}).catch((err) => {
|
|
149
|
+
if (cancelled) return;
|
|
150
|
+
const message = err instanceof Error ? err.message : "Failed to load GIF";
|
|
151
|
+
setError(message);
|
|
152
|
+
console.error("react-gif-timeline:", message);
|
|
153
|
+
});
|
|
154
|
+
return () => {
|
|
155
|
+
cancelled = true;
|
|
156
|
+
};
|
|
157
|
+
}, [src]);
|
|
158
|
+
const prevTargetRef = useRef(null);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const data = gifDataRef.current;
|
|
161
|
+
if (!data || !isLoaded) return;
|
|
162
|
+
const targetFrame = resolveAnchorFrame(anchors, activeState);
|
|
163
|
+
if (targetFrame === void 0 || targetFrame < 0 || targetFrame >= data.frames.length) {
|
|
164
|
+
console.warn(
|
|
165
|
+
`react-gif-timeline: anchor frame ${targetFrame} is out of range (0-${data.frames.length - 1})`
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (prevTargetRef.current === targetFrame) return;
|
|
170
|
+
prevTargetRef.current = targetFrame;
|
|
171
|
+
const fromFrame = currentFrameRef.current;
|
|
172
|
+
if (fromFrame === targetFrame) return;
|
|
173
|
+
if (animatorRef.current) {
|
|
174
|
+
animatorRef.current.cancel();
|
|
175
|
+
}
|
|
176
|
+
const totalSteps = Math.abs(targetFrame - fromFrame);
|
|
177
|
+
let stepsDone = 0;
|
|
178
|
+
setIsTransitioning(true);
|
|
179
|
+
setTransitionProgress(0);
|
|
180
|
+
onTransitionStartRef.current?.(fromFrame, targetFrame);
|
|
181
|
+
animatorRef.current = animate(
|
|
182
|
+
fromFrame,
|
|
183
|
+
targetFrame,
|
|
184
|
+
data.delays,
|
|
185
|
+
speed,
|
|
186
|
+
(frameIndex) => {
|
|
187
|
+
currentFrameRef.current = frameIndex;
|
|
188
|
+
if (canvasRef.current) {
|
|
189
|
+
renderFrame(canvasRef.current, data.frames[frameIndex], data.width, data.height);
|
|
190
|
+
}
|
|
191
|
+
if (frameIndex !== fromFrame) {
|
|
192
|
+
stepsDone++;
|
|
193
|
+
setTransitionProgress(totalSteps > 0 ? stepsDone / totalSteps : 1);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
() => {
|
|
197
|
+
setCurrentFrame(targetFrame);
|
|
198
|
+
setIsTransitioning(false);
|
|
199
|
+
setTransitionProgress(1);
|
|
200
|
+
onTransitionEndRef.current?.(activeStateRef.current);
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
return () => {
|
|
204
|
+
if (animatorRef.current) {
|
|
205
|
+
animatorRef.current.cancel();
|
|
206
|
+
animatorRef.current = null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}, [activeState, anchors, speed, isLoaded]);
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
return () => {
|
|
212
|
+
if (animatorRef.current) {
|
|
213
|
+
animatorRef.current.cancel();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}, []);
|
|
217
|
+
return {
|
|
218
|
+
canvasRef,
|
|
219
|
+
currentFrame,
|
|
220
|
+
totalFrames: gifDataRef.current?.frames.length ?? 0,
|
|
221
|
+
isLoaded,
|
|
222
|
+
error,
|
|
223
|
+
isTransitioning,
|
|
224
|
+
transitionProgress
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/GifTimeline.tsx
|
|
229
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
230
|
+
function GifTimeline(props) {
|
|
231
|
+
const {
|
|
232
|
+
src,
|
|
233
|
+
anchors,
|
|
234
|
+
activeState,
|
|
235
|
+
speed,
|
|
236
|
+
onTransitionStart,
|
|
237
|
+
onTransitionEnd,
|
|
238
|
+
className,
|
|
239
|
+
style,
|
|
240
|
+
renderLoading,
|
|
241
|
+
renderError,
|
|
242
|
+
children
|
|
243
|
+
} = props;
|
|
244
|
+
const result = useGifTimeline({
|
|
245
|
+
src,
|
|
246
|
+
anchors,
|
|
247
|
+
activeState,
|
|
248
|
+
speed,
|
|
249
|
+
onTransitionStart,
|
|
250
|
+
onTransitionEnd
|
|
251
|
+
});
|
|
252
|
+
if (result.error) {
|
|
253
|
+
return /* @__PURE__ */ jsx(Fragment, { children: renderError?.(result.error) });
|
|
254
|
+
}
|
|
255
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
256
|
+
!result.isLoaded && renderLoading?.(),
|
|
257
|
+
/* @__PURE__ */ jsx(
|
|
258
|
+
"canvas",
|
|
259
|
+
{
|
|
260
|
+
ref: result.canvasRef,
|
|
261
|
+
className,
|
|
262
|
+
style: {
|
|
263
|
+
...style,
|
|
264
|
+
display: result.isLoaded ? void 0 : "none"
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
),
|
|
268
|
+
result.isLoaded && children?.(result)
|
|
269
|
+
] });
|
|
270
|
+
}
|
|
271
|
+
export {
|
|
272
|
+
GifTimeline,
|
|
273
|
+
useGifTimeline
|
|
274
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-gif-timeline",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Control GIF animation timelines with React state — map anchor frames to states and animate between them.",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"workspaces": ["demo"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "tsup --watch",
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"lint": "biome check .",
|
|
27
|
+
"prepublishOnly": "bun run build",
|
|
28
|
+
"release": "semantic-release"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"react",
|
|
32
|
+
"gif",
|
|
33
|
+
"timeline",
|
|
34
|
+
"animation",
|
|
35
|
+
"state-machine",
|
|
36
|
+
"canvas",
|
|
37
|
+
"keyframe",
|
|
38
|
+
"anchor",
|
|
39
|
+
"gif-player",
|
|
40
|
+
"gif-controller"
|
|
41
|
+
],
|
|
42
|
+
"author": {
|
|
43
|
+
"name": "Matt Silva",
|
|
44
|
+
"email": "tecoad@gmail.com",
|
|
45
|
+
"url": "https://github.com/tecoad"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/tecoad/react-gif-timeline.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/tecoad/react-gif-timeline#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/tecoad/react-gif-timeline/issues"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"gifuct-js": "^2.1.2"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@biomejs/biome": "^2.4.7",
|
|
64
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
65
|
+
"@semantic-release/github": "^12.0.6",
|
|
66
|
+
"@semantic-release/npm": "^13.1.4",
|
|
67
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
68
|
+
"@types/react": "^19.1.0",
|
|
69
|
+
"@types/react-dom": "^19.1.0",
|
|
70
|
+
"react": "^19.1.0",
|
|
71
|
+
"react-dom": "^19.1.0",
|
|
72
|
+
"semantic-release": "^25.0.3",
|
|
73
|
+
"tsup": "^8.5.1",
|
|
74
|
+
"typescript": "^5.9.3"
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"react": ">=18.0.0",
|
|
78
|
+
"react-dom": ">=18.0.0"
|
|
79
|
+
}
|
|
80
|
+
}
|