Skip to content

Commit cf051d5

Browse files
feat: animation engine — useFrameSequence, useEnterAnimation, useArrowDraw; replaces all ad-hoc animation code
1 parent d40007f commit cf051d5

8 files changed

Lines changed: 356 additions & 142 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "next-easytour",
3-
"version": "0.3.0-alpha.16",
3+
"version": "0.3.0-alpha.17",
44
"description": "Generalizable interactive tutorial overlay library for React/Next.js. CSS-selector targeting, step actions, waitFor conditions, auto-scroll, highlight effects, animated transitions — all composable and declarative.",
55
"keywords": [
66
"tutorial",

src/core/animation.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"use client";
2+
3+
/**
4+
* @module core/animation
5+
*
6+
* Central animation engine for next-easytour.
7+
*
8+
* - `useFrameSequence` — state-machine-driven frame cycling
9+
* - `useEnterAnimation` — CSS enter class on mount / key change
10+
* - `useArrowDraw` — SVG stroke-dashoffset draw-on
11+
*
12+
* Design principles:
13+
* - Single timer per state (no nested setTimeout)
14+
* - All timers tracked and cleaned up on unmount/re-render
15+
* - CSS drives visuals; hooks drive timing and class names
16+
* - Trigger counter pattern for re-triggering on same step
17+
*/
18+
19+
import { useEffect, useRef, useState } from "react";
20+
21+
// ── Types ───────────────────────────────────────────────────────────────
22+
23+
export interface FrameSequenceOptions {
24+
/** Duration per frame in ms. Default 2000. */
25+
frameDuration?: number;
26+
/** CSS transition duration in ms. Default 300. */
27+
transitionDuration?: number;
28+
/** Loop after the last frame. Default false. */
29+
loop?: boolean;
30+
/** Delay before the first frame enters, in ms. Default 0. */
31+
delay?: number;
32+
/** Whether the sequence is enabled. Default true. */
33+
enabled?: boolean;
34+
/** Called when the frame index changes. */
35+
onFrameChange?: (index: number) => void;
36+
}
37+
38+
export type AnimPhase = "delay" | "entering" | "visible" | "exiting";
39+
40+
export interface FrameSequenceState<T> {
41+
current: T;
42+
index: number;
43+
phase: AnimPhase;
44+
done: boolean;
45+
}
46+
47+
// ── useFrameSequence ────────────────────────────────────────────────────
48+
49+
/**
50+
* State machine:
51+
*
52+
* (delay) → "entering" → "visible" → "exiting" → advance index →
53+
* "entering" → "visible" → "exiting" → ...
54+
*
55+
* Each phase sets ONE timer that transitions to the next phase.
56+
* Content (the returned `current`) updates when the index advances,
57+
* which happens at the exiting→entering boundary.
58+
*/
59+
export function useFrameSequence<T>(
60+
frames: T[],
61+
options: FrameSequenceOptions = {},
62+
): FrameSequenceState<T> {
63+
const {
64+
frameDuration = 2000,
65+
transitionDuration = 300,
66+
loop = false,
67+
delay = 0,
68+
enabled = true,
69+
onFrameChange,
70+
} = options;
71+
72+
const [index, setIndex] = useState(0);
73+
const [phase, setPhase] = useState<AnimPhase>(delay > 0 ? "delay" : "entering");
74+
const [done, setDone] = useState(false);
75+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
76+
const onFrameChangeRef = useRef(onFrameChange);
77+
onFrameChangeRef.current = onFrameChange;
78+
79+
// Reset when frames identity changes
80+
const framesLenRef = useRef(frames.length);
81+
useEffect(() => {
82+
if (frames.length !== framesLenRef.current) {
83+
framesLenRef.current = frames.length;
84+
setIndex(0);
85+
setPhase(delay > 0 ? "delay" : "entering");
86+
setDone(false);
87+
}
88+
}, [frames.length, delay]);
89+
90+
// ── State machine: one effect per phase ───────────────────────────
91+
useEffect(() => {
92+
if (!enabled || done) return;
93+
clearTimeout(timerRef.current);
94+
95+
switch (phase) {
96+
case "delay":
97+
timerRef.current = setTimeout(() => setPhase("entering"), delay);
98+
break;
99+
100+
case "entering":
101+
timerRef.current = setTimeout(() => setPhase("visible"), transitionDuration);
102+
break;
103+
104+
case "visible":
105+
// Single-frame or disabled: stay visible forever
106+
if (frames.length <= 1) break;
107+
timerRef.current = setTimeout(() => setPhase("exiting"), frameDuration);
108+
break;
109+
110+
case "exiting":
111+
timerRef.current = setTimeout(() => {
112+
// Advance the frame
113+
setIndex((prev) => {
114+
const next = prev + 1;
115+
if (next >= frames.length) {
116+
if (loop) {
117+
onFrameChangeRef.current?.(0);
118+
return 0;
119+
}
120+
setDone(true);
121+
return prev; // stay on last
122+
}
123+
onFrameChangeRef.current?.(next);
124+
return next;
125+
});
126+
setPhase("entering");
127+
}, transitionDuration);
128+
break;
129+
}
130+
131+
return () => clearTimeout(timerRef.current);
132+
}, [phase, enabled, done, frames.length, frameDuration, transitionDuration, loop, delay]);
133+
134+
const safeIndex = Math.min(index, Math.max(frames.length - 1, 0));
135+
136+
return {
137+
current: frames[safeIndex],
138+
index: safeIndex,
139+
phase,
140+
done,
141+
};
142+
}
143+
144+
// ── useEnterAnimation ───────────────────────────────────────────────────
145+
146+
/**
147+
* Returns the enter CSS class name while the animation is playing.
148+
* Re-triggers on every `key` change (including initial mount).
149+
* Uses a trigger counter so navigating away and back to the same
150+
* step still replays the animation.
151+
*/
152+
export function useEnterAnimation(
153+
key: string | null,
154+
enterClass: string,
155+
duration: number = 250,
156+
): string {
157+
const [trigger, setTrigger] = useState(0);
158+
const [active, setActive] = useState(true);
159+
const prevKey = useRef<string | null>(null);
160+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
161+
162+
// Trigger on key change (including first render)
163+
useEffect(() => {
164+
if (key !== prevKey.current) {
165+
prevKey.current = key;
166+
setTrigger((t) => t + 1);
167+
}
168+
}, [key]);
169+
170+
// Run animation on trigger
171+
useEffect(() => {
172+
setActive(true);
173+
clearTimeout(timerRef.current);
174+
timerRef.current = setTimeout(() => setActive(false), duration);
175+
return () => clearTimeout(timerRef.current);
176+
}, [trigger, duration]);
177+
178+
return active ? enterClass : "";
179+
}
180+
181+
// ── useArrowDraw ────────────────────────────────────────────────────────
182+
183+
/**
184+
* SVG stroke-dashoffset draw-on animation. Uses a trigger counter
185+
* so the animation replays when:
186+
* - stepId changes
187+
* - pathLength goes from 0 to positive (SVG rendered)
188+
*
189+
* The draw works by:
190+
* 1. Set dashoffset = pathLength (path hidden)
191+
* 2. Next frame: set dashoffset = 0 with CSS transition
192+
* 3. The browser interpolates → draw-on effect
193+
*/
194+
export function useArrowDraw(
195+
pathLength: number,
196+
stepId: string | null,
197+
enabled: boolean = true,
198+
duration: number = 400,
199+
): { dashArray?: number; dashOffset: number; transitioning: boolean } {
200+
const [trigger, setTrigger] = useState(0);
201+
const [offset, setOffset] = useState(0);
202+
const [transitioning, setTransitioning] = useState(false);
203+
const prevStepId = useRef(stepId);
204+
const prevPathLength = useRef(pathLength);
205+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
206+
const rafRef = useRef<number>(0);
207+
208+
// Trigger on step change
209+
useEffect(() => {
210+
if (stepId !== prevStepId.current) {
211+
prevStepId.current = stepId;
212+
setTrigger((t) => t + 1);
213+
}
214+
}, [stepId]);
215+
216+
// Trigger when pathLength goes from 0 to positive (first render)
217+
useEffect(() => {
218+
if (prevPathLength.current === 0 && pathLength > 0) {
219+
setTrigger((t) => t + 1);
220+
}
221+
prevPathLength.current = pathLength;
222+
}, [pathLength]);
223+
224+
// Run draw animation on trigger
225+
useEffect(() => {
226+
if (!enabled || pathLength <= 0) return;
227+
228+
// Step 1: set to full offset (hidden)
229+
setOffset(pathLength);
230+
setTransitioning(false);
231+
232+
// Step 2: next frame, animate to 0
233+
rafRef.current = requestAnimationFrame(() => {
234+
rafRef.current = requestAnimationFrame(() => {
235+
setTransitioning(true);
236+
setOffset(0);
237+
clearTimeout(timerRef.current);
238+
timerRef.current = setTimeout(() => setTransitioning(false), duration);
239+
});
240+
});
241+
242+
return () => {
243+
cancelAnimationFrame(rafRef.current);
244+
clearTimeout(timerRef.current);
245+
};
246+
}, [trigger, enabled, pathLength, duration]);
247+
248+
if (!enabled || pathLength <= 0) {
249+
return { dashOffset: 0, transitioning: false };
250+
}
251+
252+
return {
253+
dashArray: pathLength,
254+
dashOffset: offset,
255+
transitioning,
256+
};
257+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export { Tutorial, useTutorial } from "./core/Tutorial";
55
export { useTutorialTarget } from "./core/useTutorialTarget";
66
export { OverlayPortal } from "./core/OverlayPortal";
7+
export { useFrameSequence, useEnterAnimation, useArrowDraw } from "./core/animation";
78

89
// Overlay
910
export { Card } from "./overlay/Card";

src/overlay/Arrow.tsx

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as React from "react";
1212
import { createContext, useContext, useRef, useEffect, useState } from "react";
1313
import { useTutorial } from "../core/Tutorial";
1414
import { OverlayPortal } from "../core/OverlayPortal";
15+
import { useArrowDraw } from "../core/animation";
1516
import { useTargetRect } from "../core/useTargetRect";
1617
import {
1718
buildPath,
@@ -63,35 +64,24 @@ export function Arrow(props: ArrowProps) {
6364
const cardRect = useCardRect();
6465

6566
const arrow = step?.annotations?.arrow;
66-
// Resolve target: hook-registered ID first, then `step.selector`
6767
const targetId = step?.targets?.[0] ?? step?.selector ?? null;
6868
const targetRect = useTargetRect(targetId);
6969

70-
// Animation state: track path length for stroke-dashoffset animation
70+
// Measure path length for the draw-on animation
7171
const pathRef = useRef<SVGPathElement>(null);
7272
const [pathLength, setPathLength] = useState(0);
73-
const [animated, setAnimated] = useState(false);
74-
const prevStepId = useRef<string | null>(null);
75-
76-
useEffect(() => {
77-
if (step?.id !== prevStepId.current) {
78-
prevStepId.current = step?.id ?? null;
79-
setAnimated(false);
80-
// Trigger animation after a frame
81-
requestAnimationFrame(() => setAnimated(true));
82-
}
83-
}, [step?.id]);
8473

8574
useEffect(() => {
8675
if (pathRef.current) {
87-
try {
88-
setPathLength(pathRef.current.getTotalLength());
89-
} catch {
90-
setPathLength(500); // fallback
91-
}
76+
try { setPathLength(pathRef.current.getTotalLength()); }
77+
catch { setPathLength(500); }
9278
}
9379
});
9480

81+
// Draw-on animation via the animation engine
82+
const animEnabled = !disableAnimation && (arrow?.style?.animated !== false);
83+
const drawAnim = useArrowDraw(pathLength, step?.id ?? null, animEnabled, 400);
84+
9585
if (!step || !arrow) return null;
9686
if (!cardRect) return null;
9787
if (!targetRect) return null;
@@ -107,14 +97,15 @@ export function Arrow(props: ArrowProps) {
10797
const d = buildPath(src, tip, style);
10898

10999
const markerId = `eto-arrow-head-${step.id}`;
110-
const shouldAnimate = !disableAnimation && (arrow.style?.animated !== false) && animated;
111-
const dashStyle: React.CSSProperties | undefined = shouldAnimate && pathLength > 0
100+
101+
// Apply draw-on animation styles
102+
const pathStyle: React.CSSProperties = drawAnim.dashArray
112103
? {
113-
strokeDasharray: pathLength,
114-
strokeDashoffset: 0,
115-
animation: `eto-arrow-draw 400ms ease-out`,
104+
strokeDasharray: drawAnim.dashArray,
105+
strokeDashoffset: drawAnim.dashOffset,
106+
transition: drawAnim.transitioning ? `stroke-dashoffset 400ms ease-out` : undefined,
116107
}
117-
: undefined;
108+
: {};
118109

119110
return (
120111
<OverlayPortal>
@@ -147,9 +138,9 @@ export function Arrow(props: ArrowProps) {
147138
stroke={color}
148139
strokeWidth={style.strokeWidth}
149140
strokeLinecap="round"
150-
strokeDasharray={style.dashed ? "6 4" : dashStyle?.strokeDasharray?.toString()}
151-
strokeDashoffset={dashStyle?.strokeDashoffset}
152-
style={!style.dashed ? dashStyle : undefined}
141+
strokeDasharray={style.dashed ? "6 4" : pathStyle.strokeDasharray?.toString()}
142+
strokeDashoffset={style.dashed ? undefined : pathStyle.strokeDashoffset}
143+
style={!style.dashed ? pathStyle : undefined}
153144
opacity={opacity}
154145
markerEnd={`url(#${markerId})`}
155146
/>

src/overlay/Card.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "react";
2222
import { useTutorial } from "../core/Tutorial";
2323
import { OverlayPortal } from "../core/OverlayPortal";
24+
import { useEnterAnimation } from "../core/animation";
2425
import { useCardRectSetter } from "./Arrow";
2526
import type { Rect } from "../core/useTargetRect";
2627
import type { TutorialApi, CardVariant, BrandedCardProps, TransitionConfig } from "../types";
@@ -150,8 +151,12 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
150151
}, [disableKeyboard, step, next, prev, close]);
151152

152153
// ── Resolve entrance animation class ──────────────────────────────────
153-
const enterAnim = step?.transition?.enter ?? transitionProp?.enter ?? "fade-slide";
154-
const animClass = enterAnim === "none" ? "" : ` eto-card--${enterAnim}`;
154+
const enterAnimType = step?.transition?.enter ?? transitionProp?.enter ?? "fade-slide";
155+
const animClass = useEnterAnimation(
156+
step?.id ?? null,
157+
enterAnimType === "none" ? "" : `eto-card--${enterAnimType}`,
158+
step?.transition?.duration ?? transitionProp?.duration ?? 250,
159+
);
155160

156161
// ── Positioning style ────────────────────────────────────────────────
157162
const isAbsolute = cardPositioning === "absolute";

0 commit comments

Comments
 (0)