diff --git a/package.json b/package.json index c300fd9..8ee0ee3 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "format": "biome format --write", "check": "biome check --write", "report": "biome check --reporter=summary", - "reset:dist": "pnpm -r --parallel exec rimraf dist .turbo", - "reset:modules": "pnpm -r --parallel exec rimraf node_modules && rimraf node_modules .turbo", + "reset:dist": "pnpm -r --parallel exec rimraf dist .turbo .source", + "reset:modules": "pnpm -r --parallel exec rimraf node_modules && rimraf node_modules .turbo .source", "reset": "pnpm reset:dist && pnpm reset:modules", "img:compress": "chmod +x ./scripts/img_compress.sh && ./scripts/img_compress.sh", "pkg:init": "pnpm test:ci && changeset", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 938394d..656b4dc 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @freestylejs/ani-core +## 1.2.0 + +### Minor Changes + +- Support raf/waapi based animation timeline using compilation. Update framework bindings. + ## 1.1.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 7a7d419..3722a43 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@freestylejs/ani-core", "author": "freestyle", - "version": "1.1.0", + "version": "1.2.0", "description": "Core functionality for the Ani animation library.", "license": "MIT", "type": "module", diff --git a/packages/core/src/API.md b/packages/core/src/API.md new file mode 100644 index 0000000..51cf346 --- /dev/null +++ b/packages/core/src/API.md @@ -0,0 +1,847 @@ +# Core Animation API + +This API provides a declarative, compositional system for building complex, high-performance animations. You define an animation's structure by creating a tree of "animation nodes." This tree is then passed to a `Timeline` controller to be played. + +The core principle is the separation of an animation's **structure** from its **values**. You can define a complex animation structure once and reuse it with different start and end values at runtime. + +A key feature of this API is its **end-to-end type safety**. The entire animation node hierarchy is generic, ensuring that all animations within a single tree operate on the same data shape. This prevents a large class of runtime errors at compile time. + +--- + +## `timeline` + +> **The main controller that runs an animation, manages its state, and controls its playback.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define the animation structure. +// TypeScript infers the data shape `G` as { opacity: number, x: number } +const myAnimation = a.sequence([ + a.ani({ to: { opacity: 1, x: 0 }, duration: 1 }), + a.ani({ to: { opacity: 1, x: 100 }, duration: 2 }), +]); + +// 2. Create a reusable timeline instance from the structure. +const myTimeline = a.timeline(myAnimation); + +// 3. Listen for updates to apply the animated values to your UI. +const unsubscribe = myTimeline.onUpdate(({ state }) => { + // `state` is strongly typed as { opacity: number, x: number } + console.log(`Opacity: ${state.opacity}, X: ${state.x}`); + // e.g., element.style.opacity = state.opacity; + // e.g., element.style.transform = `translateX(${state.x}px)`; +}); + +// 4. Play the animation from a starting state. +myTimeline.play({ from: { opacity: 0, x: 0 } }); +``` + +### Usage & Concepts + +#### Overview + +The `timeline` is the execution engine. It takes a static animation tree and brings it to life. It's responsible for calculating the animation's state at any given time, handling playback (play, pause, seek), and emitting update events. The key design principle is the separation of the animation's definition (the node tree) from its execution and values (the timeline). This makes animations reusable and dynamic. + +#### Dynamic Animations + +The `timeline.play()` method accepts a configuration object that allows you to provide dynamic values at runtime. This is the primary mechanism for creating interactive animations. + +- `keyframes`: An array of `to` values that override the ones defined in the `ani` nodes. The number of keyframes must match the number of `ani` nodes. +- `durations`: An array of `duration` values (in seconds) that override the original durations. +- `repeat`: A number indicating how many times the animation should repeat. Use `Infinity` for an endless loop. +- `delay`: An initial delay in milliseconds before the animation starts. + +```typescript +// 1. Define the structure. G is inferred as { y: number }. +const recoverAnimation = a.sequence([ + a.ani({ to: { y: -15 }, duration: 0.3 }), // Overshoot stage + a.ani({ to: { y: 0 }, duration: 0.5 }), // Settle stage +]); +const myTimeline = a.timeline(recoverAnimation); + +// 2. On user interaction, play the animation with dynamic values. +myTimeline.play({ + from: { y: 200 }, // Dynamic start position + keyframes: [ + { y: -30 }, // This becomes the 'to' for the first ani() + { y: 0 }, // This becomes the 'to' for the second ani() + ], + repeat: 2, // Repeat the whole animation twice + delay: 500, // Wait 500ms before starting +}); +``` + +#### Best Practices + +- **Do** create one timeline per independent animation. +- **Do** reuse timelines when you need to play the same animation structure with different starting values. +- **Don't** create a new timeline inside a render loop or frequently. Create it once and store it. + +### API Reference + +#### Methods + +| Method | Description | +| :--- | :--- | +| `play(config, [canBeIntercepted])` | Starts the animation with a given configuration. | +| `pause()` | Pauses the animation at its current state. | +| `resume()` | Resumes a paused animation. | +| `reset()` | Resets the animation to its initial state. | +| `seek(time)` | Jumps to a specific time in the animation (in seconds). | +| `onUpdate(cb)` | Registers a callback for state and status updates. Returns an unsubscribe function. | + +#### Type Definitions + +```typescript +// Factory Function +function timeline(rootNode: AnimationNode, clock?: AnimationClockInterface): Timeline + +// Main play configuration +interface TimelineStartingConfig { + from: AniGroup; + keyframes?: Array; + durations?: Array; + repeat?: number; // e.g., Infinity + delay?: number; // Delay in milliseconds before the animation starts + context?: Ctx; + propertyResolver?: G extends AnimePrimitive ? never : Resolver; +} + +// onUpdate callback signature +type OnUpdateCallback = (current: { + state: AniGroup; + status: 'IDLE' | 'PLAYING' | 'PAUSED' | 'ENDED'; +}) => void; + +// Timeline Interface +interface TimelineController { + play(config: TimelineStartingConfig, canBeIntercepted?: boolean): void; + pause(): void; + resume(): void; + seek(time: number): void; + reset(): void; + onUpdate(callback: OnUpdateCallback): () => void; +} +``` + +### Related Components + +- `ani` - The basic building block for animations controlled by a timeline. +- `sequence` - A composition node to be passed to a timeline. +- `parallel` - Another composition node to be passed to a timeline. + +--- + +## `webTimeline` + +> **A high-performance, browser-native timeline powered by the Web Animations API (WAAPI).** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define the animation structure. +const myAnimation = a.sequence([ + a.ani({ to: { x: 100 }, duration: 1 }), + a.ani({ to: { y: 100 }, duration: 1 }), +]); + +// 2. Create a web-native timeline. +const timeline = a.webTimeline(myAnimation); + +// 3. Play on a specific DOM element. +const element = document.getElementById("box"); +if (element) { + timeline.play(element, { + from: { x: 0, y: 0 }, + repeat: Infinity + }); +} +``` + +### Usage & Concepts + +#### Overview + +`webTimeline` compiles your declarative animation tree into native browser Keyframes and plays them using `Element.animate()`. This runs animations off the main thread (where possible), resulting in silky smooth performance even when the JavaScript main thread is busy. + +Unlike the standard `timeline`, `webTimeline` does not emit `onUpdate` events because the animation state is managed by the browser's native engine. + +#### Best Practices + +- **Do** use `webTimeline` for simple to moderately complex UI transitions that don't require per-frame JavaScript callbacks. +- **Do** use it when performance is critical (e.g., infinite loops, background animations). +- **Don't** use it if you need to sync non-CSS properties (like scroll position or canvas drawing) to the animation. Use the standard `timeline` for that. + +### API Reference + +#### Methods + +| Method | Description | +| :--- | :--- | +| `play(target, config)` | Compiles and plays the animation on the target DOM element. | +| `pause()` | Pauses the native animation. | +| `resume()` | Resumes the native animation. | +| `cancel()` | Cancels the animation and removes effects. | +| `seek(time)` | Jumps to a specific time (in seconds). | + +#### Type Definitions + +```typescript +function webTimeline(rootNode: AnimationNode): WebAniTimeline; + +interface WebAniTimelineConfig { + from: G; + repeat?: number; + delay?: number; // milliseconds +} + +interface WebAniTimeline { + play(target: Element, config: WebAniTimelineConfig): Animation | null; + pause(): void; + resume(): void; + cancel(): void; + seek(time: number): void; + get nativeAnimation(): Animation | null; +} +``` + +### Related Components + +- `timeline` - The standard JavaScript-based controller. + +--- + +## `ani` + +> **Defines a single animation segment from a start value to an end value over a duration.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define a simple animation that moves an object on the x-axis. +const moveRight = a.ani({ to: { x: 100 }, duration: 0.5 }); + +// 2. Create a timeline to control the animation. +// The data shape `{ x: number }` is inferred from the `ani` node. +const myTimeline = a.timeline(moveRight); + +// 3. Listen for updates to apply the animated values. +myTimeline.onUpdate(({ state }) => { + // `state` is strongly typed as { x: number } + console.log(`Current x value: ${state.x}`); +}); + +// 4. Play the animation from a starting state. +myTimeline.play({ from: { x: 0 } }); +// Console will log 'x' increasing from 0 to 100 over 0.5 seconds. +``` + +### Usage & Concepts + +#### Overview + +The `ani` function is the fundamental building block of the entire animation system. It represents a single, atomic "tween"—a transition from a starting state to a target `to` state over a specified `duration`. It is a "leaf" node in the animation tree, meaning it doesn't contain other animation nodes. All complex animations are built by composing these `ani` nodes inside "branch" nodes like `sequence` or `parallel`. + +#### Best Practices + +- **Do** use `ani` for any individual tween. +- **Don't** try to animate multiple, unrelated properties in a single `ani` call if they belong to different conceptual animations. Instead, use multiple `ani` nodes within a `parallel` block. + +### API Reference + +#### Parameters + +| Name | Type | Description | Default | +| :--- | :--- | :--- | :--- | +| `props` | `SegmentNodeProps` | An object containing the animation's properties. | — | +| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | + +#### Type Definitions + +```typescript +interface SegmentNodeProps { + readonly to: G; + readonly duration: number; + readonly timing?: SegmentTiming; +} + +function ani(props: SegmentNodeProps, id?: AnimationId): SegmentNode; +``` + +### Related Components + +- `timeline` - The controller required to run the animation. +- `sequence` - To play multiple `ani` nodes one after another. +- `parallel` - To play multiple `ani` nodes at the same time. + +--- + +## `delay` + +> **A node that introduces a pause in an animation sequence.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define an animation that fades in, waits, and then moves. +const myAnimation = a.sequence([ + a.ani({ to: { opacity: 1, x: 0 }, duration: 0.5 }), + a.delay(1), // 2. Wait for 1 second. + a.ani({ to: { opacity: 1, x: 100 }, duration: 1 }), +]); + +// 3. Create and play the timeline. +const myTimeline = a.timeline(myAnimation); +myTimeline.play({ from: { opacity: 0, x: 0 } }); +``` + +### Usage & Concepts + +#### Overview + +`delay` is a simple utility node that does nothing for a specified `duration`. Its primary purpose is to insert pauses within a `sequence` composition. It is a "leaf" node, similar to `ani`, but it does not affect any values. + +#### Best Practices + +- **Do** use `delay` inside a `sequence` to create a pause between two animations. +- **Don't** use `delay` inside a `parallel` block, as it will have no effect on other parallel animations. It will simply extend the total duration of the `parallel` block if it is the longest item. + +### API Reference + +#### Parameters + +| Name | Type | Description | Default | +| :--- | :--- | :--- | :--- | +| `duration` | `number` | The duration of the delay in seconds. | — | +| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | + +#### Type Definitions + +```typescript +function delay(duration: number, id?: AnimationId): SegmentNode; +``` + +### Related Components + +- `sequence` - The primary composition where `delay` is used. + +--- + +## `sequence` + +> **A composition node that plays its child animations one after another.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define two separate animation segments. +const fadeIn = a.ani({ to: { opacity: 1, x: 50 }, duration: 1 }); +const moveRight = a.ani({ to: { opacity: 1, x: 100 }, duration: 2 }); + +// 2. Create a sequence. `moveRight` will start only after `fadeIn` completes. +// The data shape `{ opacity: number, x: number }` must be consistent across all children. +const myAnimation = a.sequence([fadeIn, moveRight]); + +// 3. Create a timeline to run the sequence. +const myTimeline = a.timeline(myAnimation); + +// 4. Play the animation. Total duration will be 1 + 2 = 3 seconds. +myTimeline.play({ from: { opacity: 0, x: 50 } }); +``` + +### Usage & Concepts + +#### Overview + +`sequence` is a "branch" composition node. Its role is to organize other animation nodes (either `ani` leaves or other branches) into a linear, chronological order. The total duration of a `sequence` is the sum of the durations of all its children. + +#### Type Safety + +`sequence` enforces that all its direct children operate on the same data shape `G`. This is a core feature that prevents you from sequencing animations with incompatible data types, catching errors at compile time. + +> **Note:** The type inference for `G` is based on the *first* element in the children array. If subsequent elements have a different shape, TypeScript will raise an error. + +#### Best Practices + +- **Do** use `sequence` for multi-stage animations, like an element fading in and then moving. +- **Don't** use `sequence` if you just need two properties to animate at the same time. Use `parallel` for that. + +### API Reference + +#### Parameters + +| Name | Type | Description | Default | +| :--- | :--- | :--- | :--- | +| `children` | `readonly AnimationNode[]` | An array of animation nodes to play in order. | — | +| `timing` | `TimingFunction` | (Optional) An easing function to apply to the entire sequence's timeline. | `linear` | +| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | + +#### Type Definitions + +```typescript +function sequence[]>( + children: Children, + timing?: TimingFunction, + id?: AnimationId +): SequenceNode; +``` + +### Related Components + +- `parallel` - To run animations simultaneously instead of in order. +- `stagger` - A specialized sequence for creating cascading effects. +- `ani` - The leaf nodes that are typically placed inside a `sequence`. + +--- + +## `parallel` + +> **A composition node that plays all of its child animations at the same time.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define two animations with different durations. +const fadeIn = a.ani({ to: { opacity: 1, scale: 1.2 }, duration: 0.5 }); +const move = a.ani({ to: { x: 100, y: 50 }, duration: 0.8 }); + +// 2. Create a parallel block. Both animations will start at the same time. +const popIn = a.parallel([fadeIn, move]); + +// 3. Create a timeline. The total duration will be that of the longest child (0.8s). +const myTimeline = a.timeline(popIn); + +// 4. Play the animation. +myTimeline.play({ from: { opacity: 0, scale: 1, x: 0, y: 0 } }); +``` + +### Usage & Concepts + +#### Overview + +`parallel` is a "branch" composition node that groups animations to run concurrently. This is useful for creating effects where multiple properties change at once, like an element fading in while scaling up. The total duration of a `parallel` block is determined by the duration of its longest child animation. + +#### Type Safety + +Like `sequence`, `parallel` enforces that all children operate on a compatible data shape `G`. + +#### Best Practices + +- **Do** use `parallel` whenever you want two or more animations to happen simultaneously. +- **Don't** nest `parallel` blocks if a single one will suffice. `parallel([a, parallel([b, c])])` is the same as `parallel([a, b, c])`. + +### API Reference + +#### Parameters + +| Name | Type | Description | Default | +| :--- | :--- | :--- | :--- | +| `children` | `readonly AnimationNode[]` | An array of animation nodes to play simultaneously. | — | +| `timing` | `TimingFunction` | (Optional) An easing function to apply to the entire block's timeline. | `linear` | +| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | + +#### Type Definitions + +```typescript +function parallel[]>( + children: Children, + timing?: TimingFunction, + id?: AnimationId +): ParallelNode; +``` + +### Related Components + +- `sequence` - To run animations in order instead of simultaneously. +- `ani` - The leaf nodes that are typically placed inside a `parallel` block. + +--- + +## `stagger` + +> **A composition node that plays child animations sequentially with a fixed delay between them.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define the animation for a single list item. +const itemAnimation = a.ani({ to: { opacity: 1, y: 0 }, duration: 0.5 }); + +// 2. Create a stagger animation for three items. +// Each subsequent animation will start 0.1s after the previous one. +const listEntrance = a.stagger( + [ + itemAnimation, // for item 1 + itemAnimation, // for item 2 + itemAnimation, // for item 3 + ], + { offset: 0.1 } +); + +// 3. Create a timeline. +const myTimeline = a.timeline(listEntrance); + +// 4. Play the animation. +// Note: The `from` value applies to all three conceptual items. +myTimeline.play({ from: { opacity: 0, y: 20 } }); +``` + +### Usage & Concepts + +#### Overview + +`stagger` is a specialized composition node for creating cascading or "domino" effects, typically used for lists of elements. It runs its child animations in order, but delays the start of each subsequent animation by a fixed `offset` time. It's essentially a `sequence` with a built-in, regular delay. + +#### Best Practices + +- **Do** use `stagger` for animating lists of items into or out of view. +- **Don't** use `stagger` if you need variable delays between animations; build a custom `sequence` with `delay` nodes instead. + +### API Reference + +#### Parameters + +| Name | Type | Description | Default | +| :--- | :--- | :--- | :--- | +| `children` | `readonly AnimationNode[]` | An array of animation nodes to play. | — | +| `props` | `StaggerNodeProps` | An object containing the stagger configuration. | — | +| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | + +#### Type Definitions + +```typescript +interface StaggerNodeProps { + offset: number; + timing?: TimingFunction; +}; + +function stagger[]>( + children: Children, + props: StaggerNodeProps, + id?: AnimationId +): StaggerNode; +``` + +### Related Components + +- `sequence` - The underlying concept for `stagger`. +- `delay` - A utility node that can be used to create custom stagger effects within a `sequence`. + +--- + +## `loop` + +> **A composition node that repeats a child animation a specified number of times.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define an animation that pulses an element's scale. +const pulse = a.sequence([ + a.ani({ to: { scale: 1.2 }, duration: 0.5 }), + a.ani({ to: { scale: 1 }, duration: 0.5 }), +]); + +// 2. Create a loop to repeat the pulse animation 3 times. +const loopedPulse = a.loop(pulse, 3); + +// 3. Create and play the timeline. +const myTimeline = a.timeline(loopedPulse); +myTimeline.play({ from: { scale: 1 } }); +``` + +### Usage & Concepts + +#### Overview + +`loop` is a composition node that takes a single child animation and repeats it a set number of times. The total duration of the `loop` node is the duration of its child multiplied by the loop count. This is a convenient alternative to manually creating a long `sequence` of identical animations. + +#### Best Practices + +- **Do** use `loop` for simple, repetitive animations like pulsing or bouncing. +- **Don't** use `loop` for the main application loop. For continuous animations, use the `repeat: Infinity` option in the `timeline.play()` method, which is more efficient. + +### API Reference + +#### Parameters + +| Name | Type | Description | Default | +| :--- | :--- | :--- | :--- | +| `child` | `AnimationNode` | The animation node to repeat. | — | +| `loopCount` | `number` | The number of times to repeat the child node. | — | +| `timing` | `TimingFunction` | (Optional) An easing function to apply to the entire loop's timeline. | `linear` | +| `id` | `AnimationId` | (Optional) A unique identifier for the node. | `undefined` | + +#### Type Definitions + +```typescript +function loop( + child: AnimationNode, + loopCount: number, + timing?: TimingFunction, + id?: AnimationId +): LoopNode; +``` + +### Related Components + +- `timeline` - The `play` method's `repeat` option provides an alternative way to loop. +- `sequence` - Can be used to manually create loops, though `loop` is more concise. + +--- + +## `a.timing` + +> **A collection of functions that control the rate of change of an animation over time.** + +Timing functions, often called "easing functions," are mathematical formulas that determine an animation's acceleration and deceleration. They are the key to making animations feel natural and lifelike. You can apply a timing function to any `ani` node or to an entire composition like `sequence` or `parallel`. + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// Use a spring-like bounce effect for the animation. +const myAnimation = a.ani({ + to: { y: 100 }, + duration: 1, + timing: a.timing.spring({ m: 1, k: 100, c: 10 }), // Mass, stiffness, damping +}); + +const myTimeline = a.timeline(myAnimation); +myTimeline.play({ from: { y: 0 } }); +``` + +### Available Timing Functions + +#### `linear()` + +The default timing function. Creates an animation that proceeds at a constant speed. + +#### `bezier(opt)` + +Creates a cubic Bézier curve. This is a versatile function that can create a wide variety of easing effects, including "ease-in," "ease-out," and "ease-in-out." + +- `opt`: An object with `p2` and `p3` coordinates, e.g., `{ p2: {x: 0.42, y: 0}, p3: {x: 0.58, y: 1} }`. + +#### `spring(opt)` + +Simulates a physical spring. This is great for creating bouncy, organic animations. + +- `opt`: An object with physical properties: + - `m`: Mass (heaviness) + - `k`: Spring constant (stiffness) + - `c`: Damping constant (resistance) + +#### `dynamicSpring(opt)` + +A more advanced spring simulation that is frame-rate independent and often produces more stable results, especially for interactive animations. It uses the same options as `spring`. + +### API Reference + +```typescript +const a = { + // ... + timing: { + linear: () => LinearTimingFunction; + bezier: (opt: BezierTimingFunctionOpt) => BezierTimingFunction; + spring: (opt: SpringTimingFunctionOpt) => SpringTimingFunction; + dynamicSpring: (opt: SpringTimingFunctionOpt) => DynamicSpringTimingFunction; + } +} + +interface BezierTimingFunctionOpt { + p2: { x: number; y: number }; + p3: { x: number; y: number }; +} + +interface SpringTimingFunctionOpt { + m: number; // Mass + k: number; // Spring constant + c: number; // Damping constant +} +``` + +--- + +## `createStates` + +> **Creates a state machine to manage and transition between different animations.** + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define different animation structures for each state. +const animations = { + inactive: a.ani({ to: { scale: 1, opacity: 0.5 }, duration: 0.5 }), + active: a.ani({ to: { scale: 1.2, opacity: 1 }, duration: 0.3 }), +}; + +// 2. Create a state machine controller. +const myStates = a.createStates({ + initial: 'inactive', + initialFrom: { scale: 1, opacity: 0.5 }, + states: animations, +}); + +// 3. Listen for updates from the currently active timeline. +myStates.timeline().onUpdate(({ state }) => { + console.log(state.scale); +}); + +// 4. Transition to a new state. This will switch to the 'active' animation. +myStates.transitionTo('active'); +``` + +### Usage & Concepts + +#### Overview + +`createStates` is a high-level utility for managing complex animation logic. It allows you to define a set of named animation trees and provides a simple API to transition between them. When you call `transitionTo`, it gracefully handles switching from the current animation timeline to the new one. This is ideal for UI components with multiple visual states (e.g., hover, active, disabled) or for character animations. + +#### The Controller + +The `createStates` function returns a controller object with three main methods: +- `timeline()`: Returns the currently active timeline instance. You can use this to subscribe to updates or control playback manually. +- `transitionTo(newState, [config], [canBeIntercepted])`: Switches to a different animation state. You can optionally provide a timeline configuration object (similar to `play`) to customize the transition. +- `onTimelineChange(callback)`: Registers a callback that fires whenever the active timeline changes (i.e., after a transition). + +### API Reference + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :--- | +| `config` | `StateProps` | The state machine configuration. | + +#### Type Definitions + +```typescript +// Configuration object +interface StateProps { + initial: keyof AnimationStates; + initialFrom: AniGroup>; + states: AnimationStates; + clock?: AnimationClockInterface; +} + +// Returned controller +interface StateController { + timeline: () => GetTimeline; + transitionTo( + newState: keyof AnimationStates, + timelineConfig?: TimelineStartingConfig< + ExtractAnimationNode, + any + >, + canBeIntercepted?: boolean + ): void; + onTimelineChange( + callback: (newTimeline: GetTimeline) => void + ): () => void; +} + +function createStates( + config: StateProps +): StateController; +``` + +### Related Components + +- `timeline` - The underlying controller for each state within the machine. + +--- + +## `EventManager` + +> **A utility for managing DOM events and connecting them to an animation timeline.** + +### Example + +```typescript +import { a, EventManager } from "@freestylejs/ani-core"; + +// 1. Define an animation and timeline. +const myAnimation = a.ani({ to: { x: 500 }, duration: 1 }); +const myTimeline = a.timeline(myAnimation); + +// 2. Define the events you want to listen for. +const supportedEvents = ["mousedown", "mouseup"] as const; +const eventManager = new EventManager(supportedEvents); + +// 3. Bind the manager to a DOM element. +const myElement = document.getElementById("my-element"); +if (myElement) { + eventManager.bind(myElement); +} + +// 4. Provide a "getter" so the manager can access the timeline. +eventManager.setAnimeGetter(() => myTimeline); + +// 5. Attach event handlers. +eventManager.attach({ + onMousedown: (timeline, event) => { + // The first argument is the timeline instance from the getter. + timeline.play({ from: { x: event.clientX } }); + }, + onMouseup: (timeline, event) => { + timeline.pause(); + }, +}); + +// 6. Clean up when the component unmounts. +// eventManager.cleanupAll(); +``` + +### Usage & Concepts + +#### Overview + +`EventManager` simplifies creating interactive animations that respond to user input. It acts as a bridge between the DOM event system and the animation system. By providing a "getter" for your animation context (like a `timeline`), your event handlers receive a direct, up-to-date reference to the animation controller, allowing you to `play`, `pause`, `seek`, etc., in response to events. + +#### Best Practices + +- **Do** use `EventManager` to keep your event handling logic clean and co-located with your animation logic. +- **Do** call `cleanupAll()` when your component or element is destroyed to prevent memory leaks. +- **Don't** create multiple `EventManager` instances for the same element; one is sufficient. + +### API Reference + +#### Constructor + +| Parameter | Type | Description | +| :--- | :--- | :--- | +| `supportedEvents` | `readonly string[]` | An array of DOM event names to support (e.g., `['click', 'mousemove']`). | + +#### Methods + +| Method | Description | +| :--- | :--- | +| `bind(el)` | Binds the manager to a DOM element. | +| `setAnimeGetter(getter)` | Provides a function that returns the animation context (e.g., a timeline). | +| `add(name, listener)` | Adds a listener for a specific event. | +| `attach(handlers)` | Attaches a map of `onEventName` handlers. | +| `cleanupAll()` | Removes all registered event listeners from the element. | + +### Related Components + +- `timeline` - The typical animation context provided to the `EventManager`. \ No newline at end of file diff --git a/packages/core/src/ani/__tests__/engine.test.ts b/packages/core/src/ani/core/__tests__/engine.test.ts similarity index 98% rename from packages/core/src/ani/__tests__/engine.test.ts rename to packages/core/src/ani/core/__tests__/engine.test.ts index b248fac..68299f8 100644 --- a/packages/core/src/ani/__tests__/engine.test.ts +++ b/packages/core/src/ani/core/__tests__/engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { T, type TimingFunction } from '~/timing' -import { calculateSegmentState } from '../engine' +import { calculateSegmentState } from '../../core/engine' describe('calculateSegmentState', () => { it('should correctly interpolate a single value', () => { diff --git a/packages/core/src/ani/engine.ts b/packages/core/src/ani/core/engine.ts similarity index 75% rename from packages/core/src/ani/engine.ts rename to packages/core/src/ani/core/engine.ts index 9d9c8b6..6f1870c 100644 --- a/packages/core/src/ani/engine.ts +++ b/packages/core/src/ani/core/engine.ts @@ -1,27 +1,9 @@ +import type { + SegmentDefinition, + SegmentState, +} from '~/ani/core/interface/core_types' import type { TimingFunction } from '~/timing' import { isEndOfAnimation } from '~/utils/time' -import type { Groupable, GroupableRecord } from './timeline' - -export type AnimePrimitive = readonly number[] - -export interface SegmentDefinition { - from: AnimePrimitive - to: AnimePrimitive - duration: number - timing: SegmentTiming -} - -export type SegmentTiming = - G extends AnimePrimitive - ? readonly TimingFunction[] | TimingFunction - : G extends GroupableRecord - ? Record | TimingFunction - : never - -export interface SegmentState { - values: AnimePrimitive - isComplete: boolean -} /** * Calculates the animated values for a single segment at a specific local time. diff --git a/packages/core/src/ani/core/index.ts b/packages/core/src/ani/core/index.ts new file mode 100644 index 0000000..8faeb3e --- /dev/null +++ b/packages/core/src/ani/core/index.ts @@ -0,0 +1,2 @@ +export * from './engine' +export * from './interface' diff --git a/packages/core/src/ani/core/interface/core_types.ts b/packages/core/src/ani/core/interface/core_types.ts new file mode 100644 index 0000000..08627fc --- /dev/null +++ b/packages/core/src/ani/core/interface/core_types.ts @@ -0,0 +1,61 @@ +import type { SegmentNode } from '~/nodes' +import type { StylesheetSupportedLiteral } from '~/style' +import type { TimingFunction } from '~/timing' +import type { + Prettify, + UnionToIntersection, + WithLiteral, + WithLiteralRecord, +} from '~/utils/types' + +/** + * Animatable target values + */ +export type Groupable = AnimePrimitive | GroupableRecord + +export type GroupableRecordKey = WithLiteral + +export type GroupableRecord = WithLiteralRecord< + GroupableRecordKey, + AnimePrimitive[number] +> + +export type AniGroup = Prettify> + +export interface ExecutionSegment { + /** + * Execution segment node + */ + node: SegmentNode + /** + * Animation start time + */ + startTime: number + /** + * Animation end time + */ + endTime: number +} + +export type ExecutionPlan = Array> + +export type AnimePrimitive = readonly number[] + +export interface SegmentDefinition { + from: AnimePrimitive + to: AnimePrimitive + duration: number + timing: SegmentTiming +} + +export type SegmentTiming = + G extends AnimePrimitive + ? readonly TimingFunction[] | TimingFunction + : G extends GroupableRecord + ? Record | TimingFunction + : never + +export interface SegmentState { + values: AnimePrimitive + isComplete: boolean +} diff --git a/packages/core/src/ani/core/interface/index.ts b/packages/core/src/ani/core/interface/index.ts new file mode 100644 index 0000000..c6faea7 --- /dev/null +++ b/packages/core/src/ani/core/interface/index.ts @@ -0,0 +1,2 @@ +export * from './core_types' +export * from './timeline_interface' diff --git a/packages/core/src/ani/core/interface/timeline_interface.ts b/packages/core/src/ani/core/interface/timeline_interface.ts new file mode 100644 index 0000000..958577b --- /dev/null +++ b/packages/core/src/ani/core/interface/timeline_interface.ts @@ -0,0 +1,151 @@ +import { type AnimationNode, SegmentNode, type SegmentNodeProps } from '~/nodes' +import type { Resolver } from '~/style' +import type { AnimePrimitive, ExecutionPlan, Groupable } from './core_types' + +export interface TimelineCommonConfig { + /** + * Starting dynamic value. + */ + from: G + /** + * Animation repeat count. + */ + repeat?: number + /** + * Initial delay before animation starts (ms). + */ + delay?: number + /** + * Dynamic target overrides. Matches the order of SEGMENT nodes in the plan. + */ + keyframes?: Array + /** + * Dynamic duration overrides. Matches the order of SEGMENT nodes in the plan. + */ + durations?: Array + /** + * Custom style property resolver. + * + * @example + * ```ts + * const timeline = a.timeline(...) + * timeline.play({ + * propertyResolver: { + * 'px': (pxValue) => { key: `top`, value: `${pxValue}px` } + * } + * }) + * ``` + */ + propertyResolver?: G extends AnimePrimitive ? never : Resolver +} + +export abstract class TimelineBase { + public readonly duration: number + + protected readonly _baseExecutionPlan: ExecutionPlan + protected _currentExecutionPlan: ExecutionPlan | null = null + + constructor(protected readonly rootNode: AnimationNode) { + this.duration = rootNode.duration + this._baseExecutionPlan = this._constructExecutionPlan(rootNode) + + this.play = this.play.bind(this) + this.pause = this.pause.bind(this) + this.seek = this.seek.bind(this) + this.reset = this.reset.bind(this) + this.resume = this.resume.bind(this) + } + + /** + * flatten the AST into a linear execution plan. + */ + private _constructExecutionPlan( + rootNode: AnimationNode + ): ExecutionPlan { + const plan: ExecutionPlan = [] + rootNode.construct(plan, 0) + return plan + } + + /** + * Merges the base plan with runtime dynamic overrides. + */ + protected _resolveExecutionPlan( + keyframes?: Array, + durations?: Array + ): ExecutionPlan { + if (!keyframes && !durations) { + return [...this._baseExecutionPlan] + } + + const segmentNodes = this._baseExecutionPlan.filter( + (segment) => segment.node.type === 'SEGMENT' + ) + const segLength = segmentNodes.length + + if (keyframes && keyframes.length !== segLength) { + throw new Error( + `[Timeline] Keyframe mismatch: Expected ${segLength}, received ${keyframes.length}.` + ) + } + if (durations && durations.length !== segLength) { + throw new Error( + `[Timeline] Duration mismatch: Expected ${segLength}, received ${durations.length}.` + ) + } + + const newPlan: ExecutionPlan = [] + let keyframeIndex = 0 + + for (const segment of this._baseExecutionPlan) { + if (segment.node.type === 'SEGMENT') { + const dynamicTo = keyframes?.[keyframeIndex] + const dynamicDuration = durations?.[keyframeIndex] + + const newSegmentProps: SegmentNodeProps = { + ...segment.node.props, + ...(dynamicTo && + dynamicTo !== 'keep' && { + to: dynamicTo, + }), + ...(dynamicDuration && + dynamicDuration !== 'keep' && { + duration: dynamicDuration, + }), + } + + const newSegment = new SegmentNode( + newSegmentProps, + segment.node.id + ) + newPlan.push({ ...segment, node: newSegment }) + keyframeIndex++ + } else { + newPlan.push({ ...segment }) + } + } + + return newPlan + } + + // Abstract public members + + public abstract play(...args: unknown[]): void + /** + * Pause animation. + */ + public abstract pause(): void + /** + * Resume animation. + */ + public abstract resume(): void + /** + * Reset(cancel) animation. + */ + public abstract reset(): void + /** + * Seek to specific target time. + * @param targetTime Target seek time. + */ + public abstract seek(targetTime: number): void +} diff --git a/packages/core/src/ani/core/resolver.ts b/packages/core/src/ani/core/resolver.ts new file mode 100644 index 0000000..c1460f7 --- /dev/null +++ b/packages/core/src/ani/core/resolver.ts @@ -0,0 +1,114 @@ +import { calculateSegmentState } from '~/ani/core' +import type { + AnimePrimitive, + ExecutionPlan, + Groupable, + GroupableRecord, + SegmentDefinition, +} from '~/ani/core/interface/core_types' +import { T, TimingFunction } from '~/timing' + +export function resolveGroup(group: Groupable): { + keyMap: Map | null + values: AnimePrimitive +} { + if (Array.isArray(group)) { + return { keyMap: null, values: group } + } + const typedGroup = group as GroupableRecord + const keys = Object.keys(typedGroup) + const keyMap = new Map(keys.map((key, i) => [key, i])) + const values = keys.map((key) => typedGroup[key]!) + return { keyMap, values } +} + +export function resolveStateToGroup( + state: AnimePrimitive, + keyMap: Map | null +): Groupable { + if (!keyMap) { + return state + } + const group: Record = {} + for (const [key, index] of keyMap.entries()) { + group[key] = state[index]! + } + return group +} + +export function resolvePlanState( + plan: ExecutionPlan, + initialValues: AnimePrimitive, + keyMap: Map | null, + targetTime: number, + dt: number = 0 +): number[] { + const nextState: number[] = [...initialValues] + let stateAtLastStartTime: number[] = [...initialValues] + + for (const segment of plan) { + // Skip future + if (targetTime < segment.startTime) { + continue + } + + // Snapshot state at segment start + stateAtLastStartTime = [...nextState] + + const { keyMap: segKeyMap, values: toValues } = resolveGroup( + segment.node.props.to + ) + + const isRecordAni = keyMap !== null + + let fromValues: number[] = [] + const timings: Array = [] + const t = segment.node.props.timing + const isRecordTiming = t && !(t instanceof TimingFunction) + + if (isRecordAni) { + // Record/Object-based animation + for (const key of segKeyMap!.keys()) { + const index = keyMap!.get(key)! + fromValues.push(stateAtLastStartTime[index]!) + + if (isRecordTiming) { + timings.push((t as Record)[key]!) + } + } + } else { + // Array-based animation + fromValues = stateAtLastStartTime + } + + const localTime = targetTime - segment.startTime + const segmentDef: SegmentDefinition = { + from: fromValues, + to: toValues, + duration: segment.node.duration, + // default fallback = linear + timing: isRecordAni && isRecordTiming ? timings : (t ?? T.linear()), + } + + const result = calculateSegmentState(localTime, segmentDef, dt) + + const finalValues = result.isComplete ? toValues : result.values + + if (isRecordAni) { + let i = 0 + for (const key of segKeyMap!.keys()) { + const stateIndex = keyMap!.get(key) + if (stateIndex !== undefined && stateIndex !== -1) { + nextState[stateIndex] = finalValues[i]! + } + i++ + } + } else { + for (let i = 0; i < finalValues.length; i++) { + nextState[i] = finalValues[i]! + } + } + } + + return nextState +} diff --git a/packages/core/src/ani/index.ts b/packages/core/src/ani/index.ts index e5ce8f6..091c70f 100644 --- a/packages/core/src/ani/index.ts +++ b/packages/core/src/ani/index.ts @@ -1,38 +1,3 @@ -import { ani, delay, loop, parallel, sequence, stagger } from './nodes' -import { createStates } from './states' -import { timeline } from './timeline' - -/** - * Core ani lib - */ -export { ani, createStates, delay, loop, parallel, sequence, stagger, timeline } - -export { - AnimationDuration, - AnimationId, - AnimationNode, - AnimationNodeType, - CompositionNode, - ParallelNode, - SegmentNode, - SequenceNode, - StaggerNode, -} from './nodes' -export { - AnimationStateShape, - GetTimeline, - StateController, - StateProps, -} from './states' -export { - AniGroup, - ExecutionPlan, - ExecutionSegment, - Groupable, - GroupableRecord, - GroupableRecordKey, - OnUpdateCallback, - Timeline, - TimelineController, - TimelineStartingConfig, -} from './timeline' +export * from './core' +export * from './raf' +export * from './waapi' diff --git a/packages/core/src/ani/__tests__/loop.test.ts b/packages/core/src/ani/raf/__tests__/loop.test.ts similarity index 97% rename from packages/core/src/ani/__tests__/loop.test.ts rename to packages/core/src/ani/raf/__tests__/loop.test.ts index 46eb3e1..3c73881 100644 --- a/packages/core/src/ani/__tests__/loop.test.ts +++ b/packages/core/src/ani/raf/__tests__/loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { ani, loop, sequence } from '../nodes' +import { ani, loop, sequence } from '~/nodes' describe('LoopNode', () => { it('should calculate duration as child duration multiplied by count', () => { diff --git a/packages/core/src/ani/__tests__/nodes.test.ts b/packages/core/src/ani/raf/__tests__/nodes.test.ts similarity index 96% rename from packages/core/src/ani/__tests__/nodes.test.ts rename to packages/core/src/ani/raf/__tests__/nodes.test.ts index 8a0fd0a..7312c81 100644 --- a/packages/core/src/ani/__tests__/nodes.test.ts +++ b/packages/core/src/ani/raf/__tests__/nodes.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type { TimingFunction } from '~/timing' import { type AnimationNode, ani, @@ -7,7 +6,8 @@ import { parallel, sequence, stagger, -} from '../nodes' +} from '~/nodes' +import type { TimingFunction } from '~/timing' describe('Animation Nodes & Compositors', () => { describe('animate()', () => { @@ -106,7 +106,7 @@ describe('Animation Nodes & Compositors', () => { ani({ to: [1], duration: 1 }), // ends at 0.5 + 1 = 1.5 ani({ to: [1], duration: 2 }), // ends at 1.0 + 2 = 3.0 ] - const stag = stagger(children, { offset: 0.5 }) + const stag = stagger(children, 0.5) // Total duration = (2 * 0.5) + 2 = 3 expect(stag.duration).toBe(3) }) @@ -116,7 +116,7 @@ describe('Animation Nodes & Compositors', () => { const child2 = ani({ to: [1], duration: 2 }) const mockCompile1 = vi.spyOn(child1, 'construct') const mockCompile2 = vi.spyOn(child2, 'construct') - const stag = stagger([child1, child2], { offset: 0.5 }) + const stag = stagger([child1, child2], 0.5) const plan: any[] = [] stag.construct(plan, 0) expect(mockCompile1).toHaveBeenCalledWith(plan, 0) diff --git a/packages/core/src/ani/__tests__/timeline.test.ts b/packages/core/src/ani/raf/__tests__/timeline.test.ts similarity index 93% rename from packages/core/src/ani/__tests__/timeline.test.ts rename to packages/core/src/ani/raf/__tests__/timeline.test.ts index 49116cc..84b0aad 100644 --- a/packages/core/src/ani/__tests__/timeline.test.ts +++ b/packages/core/src/ani/raf/__tests__/timeline.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AnimationClock } from '~/loop/clock' +import { type AnimationNode, ani, parallel, sequence, stagger } from '~/nodes' import { T, type TimingFunction } from '~/timing' -import { type AnimationNode, ani, parallel, sequence, stagger } from '../nodes' -import { timeline } from '../timeline' +import { rafTimeline } from '../timeline' // A simple linear timing function for predictable tests const linear: TimingFunction = T.linear() @@ -44,7 +44,7 @@ describe('Timeline Controller', () => { ani({ to: [1], duration: 1, timing: linear }), ]), ]) - const line = timeline(root) + const line = rafTimeline(root) // 1 (sequence) + 2 (longest in parallel) = 3 expect(line.duration).toBe(3) }) @@ -54,7 +54,7 @@ describe('Timeline Controller', () => { ani({ to: [100], duration: 1, timing: linear }), ani({ to: [200], duration: 1, timing: linear }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -94,7 +94,7 @@ describe('Timeline Controller', () => { ]) // >> x = 200 / y = 100 wins - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate((a) => { onUpdate(a) @@ -125,9 +125,9 @@ describe('Timeline Controller', () => { ani({ to: [100], duration: 1, timing: linear }), // 0s -> 1s ani({ to: [200], duration: 1, timing: linear }), // 0.75s -> 1.75s ], - { offset: 0.75 } + 0.75 ) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate((arg) => { onUpdate(arg) @@ -162,7 +162,7 @@ describe('Timeline Controller', () => { it('should pause and resume correctly', () => { const root = ani({ to: [100], duration: 2, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -190,7 +190,7 @@ describe('Timeline Controller', () => { ani({ to: [100], duration: 1, timing: linear }), ani({ to: [0], duration: 1, timing: linear }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -217,7 +217,7 @@ describe('Timeline Controller', () => { it('should resume correctly after a seek', () => { const root = ani({ to: [100], duration: 2, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -242,7 +242,7 @@ describe('Timeline Controller', () => { ani({ to: { y: 200 }, duration: 0, timing: linear }), // Jumps instantly ani({ to: { y: 200 }, duration: 1, timing: linear }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate((a) => { onUpdate(a) @@ -280,7 +280,7 @@ describe('Timeline Controller', () => { ani({ to: { z: 300 }, duration: 1, timing: linear }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -308,7 +308,7 @@ describe('Timeline Controller', () => { ani({ to: { y: 100 }, duration: 1, timing: linear }), ]), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -339,14 +339,14 @@ describe('Timeline Controller', () => { }), ani({ to: { b: 20 }, duration: 0.5, timing: linear }), ], - { offset: 0.2 } + 0.2 ), parallel([ ani({ to: { a: 0, b: 0 }, duration: 1, timing: linear }), ani({ to: { b: 0 }, duration: 1, timing: linear }), ]), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -371,11 +371,11 @@ describe('Timeline Controller', () => { const root = sequence([ ani({ to: [100], duration: 1, timing: linear }), parallel([]), - stagger([], { offset: 0.1 }), + stagger([], 0.1), sequence([]), ani({ to: [200], duration: 1, timing: linear }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -396,7 +396,7 @@ describe('Timeline Controller', () => { it('should reset and play again with a different from state', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -422,7 +422,7 @@ describe('Timeline Controller', () => { it('should not have transitionTo method for non-state nodes', () => { const root = ani({ to: { x: 1 }, duration: 1 }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) // @ts-expect-error transitionTo should not exist expect(line.transitionTo).toBeUndefined() }) @@ -430,7 +430,7 @@ describe('Timeline Controller', () => { describe('`ENDED` state behavior', () => { it('should have status "ENDED" and hold the final state upon completion', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -450,7 +450,7 @@ describe('Timeline Controller', () => { it('should return the final state via getCurrentValue() after ending', () => { const root = ani({ to: { x: 50 }, duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) expect(line.getCurrentValue()).toBeNull() @@ -462,7 +462,7 @@ describe('Timeline Controller', () => { it('should allow seeking after the timeline has ended', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -482,7 +482,7 @@ describe('Timeline Controller', () => { it('should allow playing a new animation after the previous one has ended', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -515,7 +515,7 @@ describe('Timeline Controller', () => { ani({ to: { x: 100 }, duration: 1, timing: linear }), ani({ to: { x: 200 }, duration: 1, timing: linear }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -542,7 +542,7 @@ describe('Timeline Controller', () => { ani({ to: { x: 100 }, duration: 1 }), ani({ to: { x: 200 }, duration: 1 }), ]) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const playWithWrongKeyframes = () => { line.play({ @@ -552,7 +552,7 @@ describe('Timeline Controller', () => { } expect(playWithWrongKeyframes).toThrow( - 'Timeline keyframe mismatch: Expected 2 keyframes, but received 1.' + '[Timeline] Keyframe mismatch: Expected 2, received 1.' ) }) }) @@ -560,7 +560,7 @@ describe('Timeline Controller', () => { describe('Delay Behavior', () => { it('should delay the start of the animation', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -595,7 +595,7 @@ describe('Timeline Controller', () => { it('should handle a delay of zero correctly', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -608,7 +608,7 @@ describe('Timeline Controller', () => { it('should pause and resume during the delay period', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -636,7 +636,7 @@ describe('Timeline Controller', () => { it('should reset correctly during the delay period', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -657,7 +657,7 @@ describe('Timeline Controller', () => { it('should bypass the delay when seeking', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) @@ -672,7 +672,7 @@ describe('Timeline Controller', () => { it('should only apply the delay on the first run, not on repetitions', () => { const root = ani({ to: [100], duration: 1, timing: linear }) - const line = timeline(root, clock) + const line = rafTimeline(root, clock) const onUpdate = vi.fn() line.onUpdate(onUpdate) diff --git a/packages/core/src/ani/raf/index.ts b/packages/core/src/ani/raf/index.ts new file mode 100644 index 0000000..6f89d50 --- /dev/null +++ b/packages/core/src/ani/raf/index.ts @@ -0,0 +1,2 @@ +export * from './states' +export * from './timeline' diff --git a/packages/core/src/ani/states.ts b/packages/core/src/ani/raf/states.ts similarity index 76% rename from packages/core/src/ani/states.ts rename to packages/core/src/ani/raf/states.ts index 01751c6..d8da59f 100644 --- a/packages/core/src/ani/states.ts +++ b/packages/core/src/ani/raf/states.ts @@ -1,26 +1,34 @@ +import type { AniGroup, Groupable } from '~/ani/core' import type { AnimationClockInterface } from '~/loop' -import type { AnimationNode, ExtractAnimationNode } from './nodes' +import type { AnimationNode, ExtractAnimationNode } from '~/nodes' import { - type AniGroup, - type Groupable, - type Timeline, - type TimelineStartingConfig, - timeline, + type RafAniTimeline, + type RafTimelineConfig, + rafTimeline, } from './timeline' export type AnimationStateShape = Record> -export type GetTimeline = Timeline< +export type GetTimeline = RafAniTimeline< ExtractAnimationNode, any > +type TimelineChangeCallback = ( + timeline: GetTimeline +) => void + export interface StateController { /** * Get current timeline. */ timeline: () => GetTimeline + /** + * Get current state. + */ + state: () => keyof AnimationStates + /** * Transition timeline animation into another state. * @param newState transition target. @@ -30,7 +38,7 @@ export interface StateController { */ transitionTo( newState: keyof AnimationStates, - timelineConfig?: TimelineStartingConfig< + timelineConfig?: RafTimelineConfig< ExtractAnimationNode, any >, @@ -43,7 +51,7 @@ export interface StateController { * @returns unsubscribe function */ onTimelineChange( - callback: (newTimeline: GetTimeline) => void + callback: TimelineChangeCallback ): () => void } @@ -79,12 +87,12 @@ export function createStates( config: StateProps ): StateController { let State: keyof AnimationStates = config.initial - let Timeline: Timeline = timeline( + let Timeline: RafAniTimeline = rafTimeline( config.states[State]!, config.clock ) - const subs = new Set<(newTimeline: GetTimeline) => void>() + const subs = new Set>() const notify = (timeline: GetTimeline) => { for (const Sub of subs) { Sub(timeline) @@ -93,23 +101,19 @@ export function createStates( return { timeline: () => Timeline as unknown as GetTimeline, + state: () => State, onTimelineChange(callback) { subs.add(callback) return () => subs.delete(callback) }, transitionTo(newState, timelineConfig, canBeIntercepted) { - // keep current timeline - if (!config.states[newState] || State === newState) { - return - } - // new timeline const from = (timelineConfig?.from ?? // 1. config Timeline.getCurrentValue() ?? // 2. last value - config.initialFrom) as TimelineStartingConfig['from'] // 3. initial value + config.initialFrom) as RafTimelineConfig['from'] // 3. initial value State = newState - Timeline = timeline(config.states[State]!, config.clock) + Timeline = rafTimeline(config.states[State]!, config.clock) notify(Timeline as unknown as GetTimeline) Timeline.play( diff --git a/packages/core/src/ani/raf/timeline.ts b/packages/core/src/ani/raf/timeline.ts new file mode 100644 index 0000000..7e34f97 --- /dev/null +++ b/packages/core/src/ani/raf/timeline.ts @@ -0,0 +1,244 @@ +import type { AniGroup, AnimePrimitive, Groupable } from '~/ani/core' +import { TimelineBase, type TimelineCommonConfig } from '~/ani/core' +import { + type Animatable, + AnimationClock, + type AnimationClockInterface, +} from '~/loop' +import type { AnimationNode } from '~/nodes' +import { isEndOfAnimation } from '~/utils/time' +import { + resolveGroup, + resolvePlanState, + resolveStateToGroup, +} from '../core/resolver' + +export interface RafTimelineConfig + extends TimelineCommonConfig { + /** + * Custom context definition during animation cycle. + */ + context?: Ctx +} + +type TimelineStatus = 'IDLE' | 'PLAYING' | 'PAUSED' | 'ENDED' + +export type OnUpdateCallback = (current: { + /** + * Current animation state + */ + state: AniGroup + /** + * Current animation status + */ + status: TimelineStatus +}) => void + +export class RafAniTimeline + extends TimelineBase + // Raf clock + implements Animatable +{ + private readonly _clock: AnimationClockInterface + + private _masterTime = 0 + private _delay = 0 + private _status: TimelineStatus = 'IDLE' + + private _currentConfig: RafTimelineConfig | null = null + public get currentConfig() { + return this._currentConfig + } + + private _state: AnimePrimitive = [] + private _initialState: AnimePrimitive = [] + private _repeatCount: number = 0 + + private _propertyKeyMap: Map | null = null + private _onUpdateCallbacks = new Set>() + + constructor(rootNode: AnimationNode, clock?: AnimationClockInterface) { + super(rootNode) + this._clock = clock ?? AnimationClock.create() + + this.play = this.play.bind(this) + this.pause = this.pause.bind(this) + this.seek = this.seek.bind(this) + this.resume = this.resume.bind(this) + this.reset = this.reset.bind(this) + } + + public getCurrentValue(): AniGroup | null { + if (this._state.length === 0) return null + return resolveStateToGroup( + this._state, + this._propertyKeyMap + ) as AniGroup + } + + private _calculateStateAtTime( + targetTime: number, + dt: number = 0 + ): AnimePrimitive { + if (this._initialState.length === 0 || !this._currentExecutionPlan) { + return [] + } + + return resolvePlanState( + this._currentExecutionPlan, + this._initialState, + this._propertyKeyMap, // Using the class property directly + targetTime, + dt + ) + } + + private notify(): void { + for (const subscriber of this._onUpdateCallbacks) { + subscriber({ + state: resolveStateToGroup( + this._state, + this._propertyKeyMap + ) as AniGroup, + status: this._status, + }) + } + } + + /** + * @private Internal clock subscription callback. + */ + update(dt: number): void { + if (this._status !== 'PLAYING') return + + if (this._delay > 0) { + this._delay -= dt + if (this._delay < 0) { + dt = -this._delay + this._delay = 0 + } else { + return + } + } + + this._masterTime += dt + if (this._masterTime >= this.duration) this._masterTime = this.duration + + this._state = this._calculateStateAtTime(this._masterTime, dt) + this.notify() + + if (isEndOfAnimation(this._masterTime, this.duration)) { + this._repeatCount += 1 + const noRepeat = (this._currentConfig!.repeat ?? 0) === 0 + if (noRepeat) { + this._status = 'ENDED' + this._clock.unsubscribe(this) + this.notify() + } else { + this.play(this._currentConfig!) + } + } + } + + /** + * Plays animation. + * @param config {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame RequestAnimationFrame API} based config. + * @param canBeIntercepted if `true` animation can be intercepted even if already animation started. + */ + public play( + config: RafTimelineConfig, + canBeIntercepted: boolean = true + ): void { + if (this._status === 'PLAYING' && !canBeIntercepted) return + + const isRepeating = (this._currentConfig?.repeat ?? 0) >= 1 + const savedRepeatCount = isRepeating ? this._repeatCount : 0 + + this.reset(false) + this._repeatCount = savedRepeatCount + + if (isRepeating && this._repeatCount >= config.repeat!) { + this._repeatCount = 0 + return + } + + this._currentConfig = config + if (this._repeatCount === 0) { + this._delay = (this._currentConfig.delay ?? 0) * 1e-3 + } + + // >> Resolve Dynamic Plan via Base Class << + this._currentExecutionPlan = this._resolveExecutionPlan( + config.keyframes, + config.durations + ) + + const { keyMap, values } = resolveGroup(config.from) + this._propertyKeyMap = keyMap + this._state = values + this._initialState = values + + this._status = 'PLAYING' + this._clock.subscribe(this) + this.notify() + } + + public pause(): void { + this._status = 'PAUSED' + this._clock.unsubscribe(this) + } + + public resume(): void { + if (this._status !== 'PAUSED') return + this._status = 'PLAYING' + this._clock.subscribe(this) + } + + public reset(notify: boolean = true): void { + this._status = 'IDLE' + this._currentConfig = null + this._masterTime = 0 + this._delay = 0 + this._state = [] + this._initialState = [] + this._propertyKeyMap = null + this._currentExecutionPlan = null + this._clock.unsubscribe(this) + this._repeatCount = 0 + if (notify) this.notify() + } + + public seek(targetTime: number): void { + if (this._status === 'PLAYING' || this._status === 'ENDED') { + this.pause() + } + const seekTime = Math.max(0, Math.min(targetTime, this.duration)) + this._masterTime = seekTime + this._state = this._calculateStateAtTime(seekTime, 0) + this.notify() + } + + /** + * When timeline updates, subscribes on update callback. + * @param callback Subscription callback. + * @returns Unsubscribe. + */ + public onUpdate(callback: OnUpdateCallback): () => void { + this._onUpdateCallbacks.add(callback) + return () => { + this._onUpdateCallbacks.delete(callback) + } + } +} + +/** + * Create dynamic timeline. {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame RequestAnimationFrame API} based. + * @param rootNode Root animation node. + * @param clock [Custom clock] + */ +export function rafTimeline( + rootNode: AnimationNode, + clock?: AnimationClockInterface +): RafAniTimeline { + return new RafAniTimeline(rootNode, clock) +} diff --git a/packages/core/src/ani/timeline.ts b/packages/core/src/ani/timeline.ts deleted file mode 100644 index 5818c57..0000000 --- a/packages/core/src/ani/timeline.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { - type Animatable, - AnimationClock, - type AnimationClockInterface, -} from '~/loop' -import type { Resolver, StylesheetSupportedLiteral } from '~/style' -import { TimingFunction } from '~/timing' -import { isEndOfAnimation } from '~/utils/time' -import type { - Prettify, - UnionToIntersection, - WithLiteral, - WithLiteralRecord, -} from '~/utils/types' -import { - type AnimePrimitive, - calculateSegmentState, - type SegmentDefinition, -} from './engine' -import { type AnimationNode, SegmentNode, type SegmentNodeProps } from './nodes' - -/** - * Animatable target values - */ -export type Groupable = AnimePrimitive | GroupableRecord - -export type GroupableRecordKey = WithLiteral - -export type GroupableRecord = WithLiteralRecord< - GroupableRecordKey, - AnimePrimitive[number] -> - -export type AniGroup = Prettify> - -export interface ExecutionSegment { - /** - * Execution segment node - */ - node: SegmentNode - /** - * Animation start time - */ - startTime: number - /** - * Animation end time - */ - endTime: number -} - -export type ExecutionPlan = Array> - -type TimelineStatus = 'IDLE' | 'PLAYING' | 'PAUSED' | 'ENDED' -export type OnUpdateCallback = (current: { - state: AniGroup - status: TimelineStatus -}) => void - -type ShouldKeepSymbol = 'keep' -type Keyframe = Array -type Duration = Array - -export interface TimelineStartingConfig { - /** - * Starting dynamic value. - */ - from: AniGroup - /** - * Dynamic `from` values, if passed `keep` for specific index, keep original timeline config. - */ - keyframes?: Keyframe - /** - * Dynamic `duration` values, if passed `keep` for specific index, keep original timeline config. - */ - durations?: Duration - /** - * Custom context definition during animation cycle. - */ - context?: Ctx - /** - * Animation repeat count. - * - * - if `Infinity` goes infinity repeat - */ - repeat?: number - - /** - * Initial delay before animation starts. - * - * - unit = `MS` - */ - delay?: number - - /** - * Custom style property resolver. - * - * @example - * ```ts - * const timeline = a.timeline(...) - * timeline.play({ - * propertyResolver: { - * 'px': (pxValue) => { key: `top`, value: `${pxValue}px` } - * } - * }) - * ``` - */ - propertyResolver?: G extends AnimePrimitive ? never : Resolver -} - -/** - * Public timeline controller - */ -export interface TimelineController { - /** - * Play timeline - * @param config Timeline starting configuration - * @param canBeIntercepted if `true`, will play animation again even if already PLAYING. - */ - play( - config: TimelineStartingConfig, - canBeIntercepted?: boolean - ): void - /** - * Pause timeline - */ - pause(): void - /** - * Resume timeline - */ - resume(): void - /** - * Seek to target time - * @param time - */ - seek(time: number): void - /** - * Reset timeline - */ - reset(): void -} - -export class Timeline - implements TimelineController, Animatable -{ - public readonly duration: number - - private readonly _baseExecutionPlan: ExecutionPlan - private _currentExecutionPlan: ExecutionPlan | null = null - - private readonly _clock: AnimationClockInterface - - private _masterTime = 0 - - private _delay = 0 - - private _status: TimelineStatus = 'IDLE' - private _currentConfig: TimelineStartingConfig | null = null - /** - * Current animation running config. - */ - public get currentConfig() { - return this._currentConfig - } - - private _state: AnimePrimitive = [] - private _initialState: AnimePrimitive = [] - - private _repeatCount: number = 0 - - private _propertyKeyMap: Map | null = null - private _segmentStartStates = new Map() - - private _onUpdateCallbacks = new Set>() - - constructor( - /** - * Animation construction root node. - */ - protected readonly rootNode: AnimationNode, - clock?: AnimationClockInterface - ) { - this.duration = rootNode.duration - this._baseExecutionPlan = this._constructExecutionPlan(rootNode) - - // default clock - this._clock = clock ?? AnimationClock.create() - - // binding - this.play.bind(this) - this.pause.bind(this) - this.seek.bind(this) - this.resume.bind(this) - this.reset.bind(this) - } - - /** - * Resolves a Group (like {x, y}) into keys and values. - */ - private _resolveGroup(group: G): { - keyMap: Map | null - values: AnimePrimitive - } { - if (Array.isArray(group)) { - return { keyMap: null, values: group } - } - const keyMap = new Map(Object.keys(group).map((key, i) => [key, i])) - const values = Object.values(group) as AnimePrimitive - return { keyMap, values } - } - - /** - * Resolves the internal state (a number array) back into Group. - */ - private _resolveStateToGroup(state: AnimePrimitive): AniGroup { - if (!this._propertyKeyMap) { - return state as unknown as AniGroup - } - const group = {} as Record - - let i = 0 - for (const key of this._propertyKeyMap.keys()) { - group[key] = state[i]! - i++ - } - return group as AniGroup - } - - /** - * Compile animation execution plan - */ - private _constructExecutionPlan( - rootNode: AnimationNode - ): ExecutionPlan { - const plan: ExecutionPlan = [] - rootNode.construct(plan, 0) - return plan - } - - /** - * Calculates the exact state of the animation at point. - */ - private _calculateStateAtTime( - targetTime: number, - dt: number = 0 - ): AnimePrimitive { - if (this._initialState.length === 0 || !this._currentExecutionPlan) { - return [] - } - - const nextState: Array = [...this._initialState] - let stateAtLastStartTime: Array = [...this._initialState] - - for (const segment of this._currentExecutionPlan) { - // Only for activated animation segment - if (targetTime < segment.startTime) { - continue - } - - if (!segment.node.props.timing) { - throw new Error( - `[Timeline] timing should be provided. Please specify timing using a.timing.(...). Check target segment: ${JSON.stringify(segment, null, 2)}.`, - { cause: segment } - ) - } - - // Use the state before this segment for "from" calculation - stateAtLastStartTime = [...nextState] - - const { keyMap, values: toValues } = this._resolveGroup( - segment.node.props.to - ) - const isRecordAni: boolean = - this._propertyKeyMap !== null && keyMap !== null - - // From value calculations - let fromValues: Array = [] - const timings: Array = [] - const t = segment.node.props.timing - const isRecordTiming: boolean = t && !(t instanceof TimingFunction) - - if (isRecordAni) { - for (const key of keyMap!.keys()) { - const index = this._propertyKeyMap!.get(key)! - fromValues.push(stateAtLastStartTime[index]!) - if (isRecordTiming) { - timings.push( - (t as Record)[key]! - ) - } - } - } else { - fromValues = stateAtLastStartTime - } - - let finalAnimeValues: AnimePrimitive = [] - - const localTime = targetTime - segment.startTime - - const segmentDef: SegmentDefinition = { - from: fromValues, - to: toValues, - duration: segment.node.duration, - timing: isRecordAni && isRecordTiming ? timings : t, - } - - const segmentState = calculateSegmentState( - localTime, - segmentDef, - dt - ) - - if (segmentState.isComplete) { - finalAnimeValues = toValues // End target - } else { - finalAnimeValues = segmentState.values // Calculated current target - } - - // Update next state - if (isRecordAni) { - // Record ani - let i = 0 - for (const key of keyMap!.keys()) { - const stateIndex = this._propertyKeyMap!.get(key)! - if (stateIndex === -1) { - continue - } - nextState[stateIndex] = finalAnimeValues[i]! - i++ - } - } else { - // Array ani - for (let i = 0; i < finalAnimeValues.length; i++) { - nextState[i] = finalAnimeValues[i]! - } - } - } - - return nextState - } - - private _resolveExecutionPlan( - keyframes?: Keyframe, - durations?: Duration - ): ExecutionPlan { - if (!keyframes && !durations) { - return [...this._baseExecutionPlan] - } - - const segmentNodes = this._baseExecutionPlan.filter( - (segment) => segment.node.type === 'SEGMENT' - ) - const segLength = segmentNodes.length - - if (keyframes && keyframes.length !== segLength) { - throw new Error( - `Timeline keyframe mismatch: Expected ${segLength} keyframes, but received ${keyframes.length}.` - ) - } - if (durations && durations.length !== segLength) { - throw new Error( - `Timeline keyframe mismatch: Expected ${segLength} durations, but received ${durations.length}.` - ) - } - - const newPlan: ExecutionPlan = [] - - let keyframeIndex = 0 - - // Create dynamic to keyframes based plans - for (const segment of this._baseExecutionPlan) { - if (segment.node.type === 'SEGMENT') { - const dynamicTo = keyframes?.[keyframeIndex] - const dynamicDuration = durations?.[keyframeIndex] - - const newSegmentProps: SegmentNodeProps = { - ...segment.node.props, - - // >> dynamic to - ...(dynamicTo && { - to: - dynamicTo === 'keep' - ? segment.node.props.to - : dynamicTo, - }), - - // >> dynamic duration - ...(dynamicDuration && { - duration: - dynamicDuration === 'keep' - ? segment.node.props.duration - : dynamicDuration, - }), - } - - const newSegment = new SegmentNode( - newSegmentProps, - segment.node.id - ) - newPlan.push({ ...segment, node: newSegment }) - keyframeIndex++ - } else { - // non-segment nodes - newPlan.push({ ...segment }) - } - } - - return newPlan - } - - private notify(): void { - for (const subscriber of this._onUpdateCallbacks) { - subscriber({ - state: this._resolveStateToGroup(this._state), - status: this._status, - }) - } - } - - public play( - config: TimelineStartingConfig, - canBeIntercepted: boolean = true - ): void { - if (this._status === 'PLAYING' && !canBeIntercepted) { - return - } - - const isRepeating = - this._currentConfig?.repeat && this._currentConfig?.repeat >= 1 - const savedRepeatCount = isRepeating ? this._repeatCount : 0 - - // can be intercepted -> reset - this.reset(false) - // recover repeat count - this._repeatCount = savedRepeatCount - - // repeat exceed -> reset repeat - if (isRepeating && this._repeatCount >= config.repeat!) { - this._repeatCount = 0 - return - } - - // update current config - this._currentConfig = config - - if (this._repeatCount === 0) { - this._delay = (this._currentConfig.delay ?? 0) * 1e-3 // MS - } - - this._currentExecutionPlan = this._resolveExecutionPlan( - config.keyframes, - config.durations - ) - - const { keyMap: keys, values } = this._resolveGroup(config.from as G) - this._propertyKeyMap = keys - this._state = values - this._initialState = values - - this._status = 'PLAYING' - this._clock.subscribe(this) - this.notify() - } - - public pause(): void { - this._status = 'PAUSED' - this._clock.unsubscribe(this) - } - - public resume(): void { - if (this._status !== 'PAUSED') return - this._status = 'PLAYING' - this._clock.subscribe(this) - } - - public reset(notify: boolean = true): void { - this._status = 'IDLE' - - this._currentConfig = null - - this._masterTime = 0 - this._delay = 0 - - this._state = [] - this._initialState = [] - - this._propertyKeyMap = null - - this._segmentStartStates.clear() - this._currentExecutionPlan = null - - this._clock.unsubscribe(this) - - this._repeatCount = 0 - - if (notify) { - this.notify() - } - } - - public seek(targetTime: number): void { - if (this._status === 'PLAYING' || this._status === 'ENDED') { - this.pause() - } - const seekTime = Math.max(0, Math.min(targetTime, this.duration)) - - this._masterTime = seekTime - this._state = this._calculateStateAtTime(seekTime, 0) - - this.notify() - } - - public getCurrentValue(): AniGroup | null { - if (this._state.length === 0) return null - return this._resolveStateToGroup(this._state) - } - - public onUpdate(callback: OnUpdateCallback): () => void { - this._onUpdateCallbacks.add(callback) - return () => { - this._onUpdateCallbacks.delete(callback) - } - } - - update(dt: number): void { - if (this._status !== 'PLAYING') { - return - } - - if (this._delay > 0) { - this._delay -= dt - if (this._delay < 0) { - dt = -this._delay - this._delay = 0 - } else { - return - } - } - - this._masterTime += dt - if (this._masterTime >= this.duration) { - this._masterTime = this.duration - } - - this._state = this._calculateStateAtTime(this._masterTime, dt) - this.notify() - - if (isEndOfAnimation(this._masterTime, this.duration)) { - // repeat update - this._repeatCount += 1 - - if (!this._currentConfig) { - throw new Error( - `[Timeline] currentConfig can not be null when update(dt)` - ) - } - - // no-repeat -> ended(don't have to reset) - const noRepeat = (this._currentConfig.repeat ?? 0) === 0 - - if (noRepeat) { - this._status = 'ENDED' - this._clock.unsubscribe(this) - this.notify() - } else { - this.play(this._currentConfig) - } - } - } -} - -export function timeline( - rootNode: AnimationNode, - clock?: AnimationClockInterface -): Timeline { - return new Timeline(rootNode, clock) -} diff --git a/packages/core/src/ani/waapi/__tests__/compiler.test.ts b/packages/core/src/ani/waapi/__tests__/compiler.test.ts new file mode 100644 index 0000000..dac58a3 --- /dev/null +++ b/packages/core/src/ani/waapi/__tests__/compiler.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionPlan, Groupable } from '~/ani/core/interface/core_types' +import type { AnimationNode } from '~/nodes' +import { a } from '../../../index' +import { compileToKeyframes } from '../compiler/keyframe_compiler' + +describe('WebAni Compiler', () => { + const constructExecutionPlan = ( + rootNode: AnimationNode + ): ExecutionPlan => { + const plan: ExecutionPlan = [] + rootNode.construct(plan, 0) + return plan + } + it('should compile a simple segment', () => { + const node = a.ani({ + to: { translateX: 100 }, + duration: 1, + }) + const initialFrom = { translateX: 0 } + + const keyframes = compileToKeyframes( + constructExecutionPlan(node), + initialFrom + ) + + expect(keyframes).toHaveLength(2) + // offset 0 + expect(keyframes[0]!.offset).toBe(0) + expect(keyframes[0]!['transform']).toBe('translateX(0px)') + + // offset 1 + expect(keyframes[1]!.offset).toBe(1) + expect(keyframes[1]!['transform']).toBe('translateX(100px)') + }) + + it('should compile a sequence with offsets', () => { + const node = a.sequence([ + a.ani({ to: { translateX: 100 }, duration: 1 }), // 0-1s + a.ani({ to: { translateX: 200 }, duration: 1 }), // 1-2s + ]) + const initialFrom = { translateX: 0 } + + const keyframes = compileToKeyframes( + constructExecutionPlan(node), + initialFrom + ) + + // Total duration = 2 + // Keyframes should be at: + // t=0 (offset 0): x=0 + // t=1 (offset 0.5): x=100 + // t=2 (offset 1): x=200 + + expect(keyframes).toHaveLength(3) + expect(keyframes[0]).toMatchObject({ + offset: 0, + transform: 'translateX(0px)', + }) + expect(keyframes[1]).toMatchObject({ + offset: 0.5, + transform: 'translateX(100px)', + }) + expect(keyframes[2]).toMatchObject({ + offset: 1, + transform: 'translateX(200px)', + }) + }) + + it('should compile a parallel block', () => { + const node = a.parallel([ + a.ani({ to: { translateX: 100 }, duration: 1 }), + a.ani({ to: { translateY: 100 }, duration: 1 }), + ]) + const initialFrom = { translateX: 0, translateY: 0 } + + const keyframes = compileToKeyframes( + constructExecutionPlan(node), + initialFrom + ) + + // Total duration 1. + // t=0: x=0, y=0 + // t=1: x=100, y=100 + + expect(keyframes).toHaveLength(2) + expect(keyframes[0]).toMatchObject({ + offset: 0, + transform: 'translateX(0px) translateY(0px)', + }) + expect(keyframes[1]).toMatchObject({ + offset: 1, + transform: 'translateX(100px) translateY(100px)', + }) + }) + + it('should handle overlapping parallel animations (complex)', () => { + // A: x -> 100 (0-2s) + // B: y -> 100 (0.5-1.5s) + const _node = a.parallel([ + a.ani({ to: { translateX: 100 }, duration: 2 }), + a.delay(0.5), // Just to offset? No delay in parallel just extends duration. + // Parallel children start at 0. + // We need to use sequence inside parallel to offset B. + ]) + + const complexNode = a.parallel([ + a.ani({ to: { translateX: 200 }, duration: 2 }), // 0-2s + a.sequence([ + a.delay(0.5), + a.ani({ to: { translateY: 100 }, duration: 1 }), // 0.5-1.5s + ]), + ]) + const initialFrom = { translateX: 0, translateY: 0 } + + const keyframes = compileToKeyframes( + constructExecutionPlan(complexNode), + initialFrom + ) + + // Critical points: + // 0 (start of all) + // 0.5 (start of y) + // 1.5 (end of y) + // 2.0 (end of x) + + // Total duration: 2.0. + + // Expect 4 keyframes. + expect(keyframes).toHaveLength(4) + + // t=0 (offset 0) + expect(keyframes[0]!.offset).toBe(0) + expect(keyframes[0]!['transform']).toContain('translateX(0px)') + expect(keyframes[0]!['transform']).toContain('translateY(0px)') + + // t=0.5 (offset 0.25) + // x should be interpolated (linear). 0->200 in 2s. At 0.5s -> 50. + // y is 0. + expect(keyframes[1]!.offset).toBe(0.25) + expect(keyframes[1]!['transform']).toContain('translateX(50px)') + expect(keyframes[1]!['transform']).toContain('translateY(0px)') + + // t=1.5 (offset 0.75) + // x at 1.5s -> 150. + // y at 1.5s -> 100 (finished). + expect(keyframes[2]!.offset).toBe(0.75) + expect(keyframes[2]!['transform']).toContain('translateX(150px)') + expect(keyframes[2]!['transform']).toContain('translateY(100px)') + + // t=2.0 (offset 1.0) + // x -> 200. + // y -> 100 (remains). + expect(keyframes[3]!.offset).toBe(1) + expect(keyframes[3]!['transform']).toContain('translateX(200px)') + expect(keyframes[3]!['transform']).toContain('translateY(100px)') + }) + + it('should extract bezier easing', () => { + const node = a.ani({ + to: { translateX: 100 }, + duration: 1, + timing: a.timing.bezier({ + p2: { x: 0.4, y: 0 }, + p3: { x: 0.2, y: 1 }, + }), + }) + + const keyframes = compileToKeyframes(constructExecutionPlan(node), { + translateX: 0, + }) + expect(keyframes).toHaveLength(2) + // CSS bezier: cubic-bezier(x1, y1, x2, y2) + // Our p2 is x1,y1. p3 is x2,y2. + expect(keyframes[0]!.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)') + }) + + it('should compile a stagger block', () => { + const node = a.stagger( + [ + a.ani({ to: { translateX: 100 }, duration: 1 }), + a.ani({ to: { translateX: 200 }, duration: 1 }), + ], + 0.5 + ) + const initialFrom = { translateX: 0 } + + const keyframes = compileToKeyframes( + constructExecutionPlan(node), + initialFrom + ) + + // Stagger offset 0.5. + // Item 1: 0-1s. x -> 100. + // Item 2: 0.5-1.5s. x -> 200. (Starts from x=50?) + + // Critical points: + // 0, 0.5, 1.0, 1.5. Total 1.5. + + expect(keyframes).toHaveLength(4) + + // t=0 + expect(keyframes[0]!.offset).toBe(0) + expect(keyframes[0]!['transform']).toBe('translateX(0px)') + + // t=0.5 (Item 1 is at 50%. Item 2 starts) + // x = 50. + expect(keyframes[1]!.offset).toBeCloseTo(0.5 / 1.5) + expect(keyframes[1]!['transform']).toBe('translateX(50px)') + + expect(keyframes[2]!.offset).toBeCloseTo(1.0 / 1.5) + expect(keyframes[2]!['transform']).toBe('translateX(150px)') + + // t=1.5. + // Item 1 finished (100). + // Item 2 finished (200). + expect(keyframes[3]!.offset).toBe(1) + expect(keyframes[3]!['transform']).toBe('translateX(200px)') + }) + + it('should compile a loop block', () => { + // Loop a 1s animation 3 times. Total duration = 3s. + const node = a.loop(a.ani({ to: { translateX: 100 }, duration: 1 }), 3) + const initialFrom = { translateX: 0 } + + const keyframes = compileToKeyframes( + constructExecutionPlan(node), + initialFrom + ) + + expect(keyframes).toHaveLength(4) // 0, 1, 2, 3 + + // t=0 (start) + expect(keyframes[0]!.offset).toBe(0) + expect(keyframes[0]!['transform']).toBe('translateX(0px)') + + // t=1 (end of loop 1) + expect(keyframes[1]!.offset).toBeCloseTo(1 / 3) + expect(keyframes[1]!['transform']).toBe('translateX(100px)') + + // t=2 (end of loop 2) + + // Let's test with a reset sequence inside loop for clarity. + const pingPong = a.loop( + a.sequence([ + a.ani({ to: { translateX: 100 }, duration: 0.5 }), + a.ani({ to: { translateX: 0 }, duration: 0.5 }), + ]), + 2 + ) + // Total duration 2s. + // 0 -> 100 (0.5s) + // 100 -> 0 (1.0s) + // 0 -> 100 (1.5s) + // 100 -> 0 (2.0s) + + const pingPongKeyframes = compileToKeyframes( + constructExecutionPlan(pingPong), + { + translateX: 0, + } + ) + + expect(pingPongKeyframes).toHaveLength(5) // 0, 0.5, 1, 1.5, 2 + + expect(pingPongKeyframes[0]!.offset).toBe(0) + expect(pingPongKeyframes[0]!['transform']).toBe('translateX(0px)') + + expect(pingPongKeyframes[1]!.offset).toBe(0.25) + expect(pingPongKeyframes[1]!['transform']).toBe('translateX(100px)') + + expect(pingPongKeyframes[2]!.offset).toBe(0.5) + expect(pingPongKeyframes[2]!['transform']).toBe('translateX(0px)') + + expect(pingPongKeyframes[3]!.offset).toBe(0.75) + expect(pingPongKeyframes[3]!['transform']).toBe('translateX(100px)') + + expect(pingPongKeyframes[4]!.offset).toBe(1) + expect(pingPongKeyframes[4]!['transform']).toBe('translateX(0px)') + }) + + it('should compile a delay node', () => { + const node = a.sequence([ + a.ani({ to: { translateX: 100 }, duration: 1 }), + a.delay(1), // Hold for 1s + a.ani({ to: { translateX: 200 }, duration: 1 }), + ]) + + const keyframes = compileToKeyframes(constructExecutionPlan(node), { + translateX: 0, + }) + + // Total 3s. + // 0-1: 0->100 + // 1-2: 100 (hold) + // 2-3: 100->200 + + expect(keyframes).toHaveLength(4) // 0, 1, 2, 3 + + // t=1 (offset 0.33) + expect(keyframes[1]!.offset).toBeCloseTo(1 / 3) + expect(keyframes[1]!['transform']).toBe('translateX(100px)') + + // t=2 (offset 0.66) - should still be 100 + expect(keyframes[2]!.offset).toBeCloseTo(2 / 3) + expect(keyframes[2]!['transform']).toBe('translateX(100px)') + + // t=3 (offset 1) + expect(keyframes[3]!.offset).toBe(1) + expect(keyframes[3]!['transform']).toBe('translateX(200px)') + }) + + it('should sample spring animations', () => { + const node = a.ani({ + to: { translateX: 100 }, + duration: 1, + timing: a.timing.spring({ m: 1, k: 100, c: 10 }), + }) + + const keyframes = compileToKeyframes(constructExecutionPlan(node), { + translateX: 0, + }) + + // Should be many keyframes due to sampling (60fps) + expect(keyframes.length).toBeGreaterThan(10) + + // Verify start and end + expect(keyframes[0]!.offset).toBe(0) + expect(keyframes[0]!['transform']).toBe('translateX(0px)') + + const last = keyframes[keyframes.length - 1]! + expect(last.offset).toBe(1) + expect(last['transform']).toBe('translateX(100px)') + + // Verify easing is linear for sampled frames + expect(keyframes[5]!.easing).toBe('linear') + }) +}) diff --git a/packages/core/src/ani/waapi/__tests__/timing_compiler.test.ts b/packages/core/src/ani/waapi/__tests__/timing_compiler.test.ts new file mode 100644 index 0000000..9cee79c --- /dev/null +++ b/packages/core/src/ani/waapi/__tests__/timing_compiler.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { a } from '../../../index' +import { compileTiming } from '../compiler/timing_compiler' + +describe('Timing Compiler', () => { + it('should compile undefined timing to linear', () => { + expect(compileTiming(undefined)).toBe('linear') + }) + + it('should compile LinearTimingFunction to linear', () => { + const timing = a.timing.linear() + expect(compileTiming(timing)).toBe('linear') + }) + + it('should compile BezierTimingFunction to cubic-bezier string', () => { + const timing = a.timing.bezier({ + p2: { x: 0.4, y: 0 }, + p3: { x: 0.2, y: 1 }, + }) + expect(compileTiming(timing)).toBe('cubic-bezier(0.4, 0, 0.2, 1)') + }) + + it('should fallback to null for SpringTimingFunction (to trigger sampling)', () => { + const timing = a.timing.spring({ m: 1, k: 100, c: 10 }) + expect(compileTiming(timing)).toBeNull() + }) + + it('should compile standard presets', () => { + expect(compileTiming(a.timing.ease())).toBe( + 'cubic-bezier(0.25, 0.1, 0.25, 1)' + ) + expect(compileTiming(a.timing.easeIn())).toBe( + 'cubic-bezier(0.42, 0, 1, 1)' + ) + expect(compileTiming(a.timing.easeOut())).toBe( + 'cubic-bezier(0, 0, 0.58, 1)' + ) + expect(compileTiming(a.timing.easeInOut())).toBe( + 'cubic-bezier(0.42, 0, 0.58, 1)' + ) + }) +}) diff --git a/packages/core/src/ani/waapi/compiler/index.ts b/packages/core/src/ani/waapi/compiler/index.ts new file mode 100644 index 0000000..ebbfd9f --- /dev/null +++ b/packages/core/src/ani/waapi/compiler/index.ts @@ -0,0 +1 @@ +export * from './keyframe_compiler' diff --git a/packages/core/src/ani/waapi/compiler/keyframe_compiler.ts b/packages/core/src/ani/waapi/compiler/keyframe_compiler.ts new file mode 100644 index 0000000..877551f --- /dev/null +++ b/packages/core/src/ani/waapi/compiler/keyframe_compiler.ts @@ -0,0 +1,120 @@ +import type { ExecutionPlan, Groupable } from '~/ani/core/interface/core_types' +import { createStyleSheet } from '~/style' +import { TimingFunction } from '~/timing' +import { resolveStateAt } from './resolver' +import { compileTiming } from './timing_compiler' + +export interface WebAniKeyframe extends Record { + offset: number + easing?: string +} + +/** + * Compiles an ExecutionPlan into WAAPI Keyframes. + * @param plan The resolved execution plan (from TimelineBase). + * @param initialFrom The initial state (values). + */ +export function compileToKeyframes( + plan: ExecutionPlan, + initialFrom: G +): WebAniKeyframe[] { + if (plan.length === 0) { + return [] + } + + const FPS = 60 + const SAMPLE_RATE = 1 / FPS + + // 1. Calculate Duration from the Plan, not the Node + const duration = Math.max(...plan.map((s) => s.endTime)) + + if (duration === 0) { + const state = resolveStateAt(plan, initialFrom, 0, SAMPLE_RATE) + const style = createStyleSheet(state as Record) + return [ + { offset: 0, ...style }, + { offset: 1, ...style }, + ] + } + + // 2. Collect critical time points + const timePoints = new Set([0, duration]) + for (const seg of plan) { + timePoints.add(seg.startTime) + timePoints.add(seg.endTime) + } + const sortedTimes = Array.from(timePoints).sort((a, b) => a - b) + + const keyframes: WebAniKeyframe[] = [] + + const getEasingForInterval = (t: number, nextT: number): string | null => { + const activeSegments = plan.filter( + (s) => s.startTime <= t && s.endTime >= nextT + ) + + // If no segments are active (gap), linear is fine (maintains state) + if (activeSegments.length === 0) return 'linear' + + const timings = activeSegments + .map((s) => s.node.props.timing) + .filter((t) => t !== undefined) + + if (timings.length === 0) return 'linear' + + const firstTiming = timings[0] + + // If mixing different timings, we must sample + const allSame = timings.every((t) => t === firstTiming) + + if (allSame && firstTiming instanceof TimingFunction) { + return compileTiming(firstTiming) + } + + // Fallback to sampling + return null + } + + for (let i = 0; i < sortedTimes.length; i++) { + const currT = sortedTimes[i]! + const state = resolveStateAt(plan, initialFrom, currT, SAMPLE_RATE) + const style = createStyleSheet(state as Record) + + const keyframe: WebAniKeyframe = { + offset: currT / duration, + ...style, + } + + keyframes.push(keyframe) + + if (i < sortedTimes.length - 1) { + const nextT = sortedTimes[i + 1]! + const easing = getEasingForInterval(currT, nextT) + + if (easing === null) { + let sampleT = currT + SAMPLE_RATE + while (sampleT < nextT) { + const sampleState = resolveStateAt( + plan, + initialFrom, + sampleT, + SAMPLE_RATE + ) + const sampleStyle = createStyleSheet( + sampleState as Record + ) + keyframes.push({ + offset: sampleT / duration, + ...sampleStyle, + easing: 'linear', + }) + sampleT += SAMPLE_RATE + } + keyframe.easing = 'linear' + } else { + keyframe.easing = easing + } + } + } + + return keyframes +} diff --git a/packages/core/src/ani/waapi/compiler/resolver.ts b/packages/core/src/ani/waapi/compiler/resolver.ts new file mode 100644 index 0000000..e0b64dc --- /dev/null +++ b/packages/core/src/ani/waapi/compiler/resolver.ts @@ -0,0 +1,25 @@ +import type { ExecutionPlan, Groupable } from '~/ani/core/interface/core_types' +import { + resolveGroup, + resolvePlanState, + resolveStateToGroup, +} from '~/ani/core/resolver' + +export function resolveStateAt( + plan: ExecutionPlan, + initialFrom: G, + targetTime: number, + dt?: number +): Groupable { + const { keyMap, values: initialValues } = resolveGroup(initialFrom) + + const rawResultState = resolvePlanState( + plan, + initialValues, + keyMap, + targetTime, + dt + ) + + return resolveStateToGroup(rawResultState, keyMap) +} diff --git a/packages/core/src/ani/waapi/compiler/timing_compiler.ts b/packages/core/src/ani/waapi/compiler/timing_compiler.ts new file mode 100644 index 0000000..61f25c1 --- /dev/null +++ b/packages/core/src/ani/waapi/compiler/timing_compiler.ts @@ -0,0 +1,30 @@ +import { + BezierTimingFunction, + LinearTimingFunction, + type TimingFunction, +} from '~/timing' + +/** + * Converts a TimingFunction instance into a WAAPI easing string. + * + * @param timing The TimingFunction instance to compile. + * @returns A valid CSS easing string (e.g., 'linear', 'cubic-bezier(...)'), or null if sampling is required. + */ +export function compileTiming( + timing: TimingFunction | undefined +): string | null { + if (!timing) { + return 'linear' + } + + if (timing instanceof LinearTimingFunction) { + return 'linear' + } + + if (timing instanceof BezierTimingFunction) { + const { p2, p3 } = timing.opt + return `cubic-bezier(${p2.x}, ${p2.y}, ${p3.x}, ${p3.y})` + } + + return null +} diff --git a/packages/core/src/ani/waapi/index.ts b/packages/core/src/ani/waapi/index.ts new file mode 100644 index 0000000..230c79b --- /dev/null +++ b/packages/core/src/ani/waapi/index.ts @@ -0,0 +1,2 @@ +export * from './compiler' +export * from './timeline' diff --git a/packages/core/src/ani/waapi/timeline.ts b/packages/core/src/ani/waapi/timeline.ts new file mode 100644 index 0000000..be50fe4 --- /dev/null +++ b/packages/core/src/ani/waapi/timeline.ts @@ -0,0 +1,108 @@ +import type { Groupable } from '~/ani/core' +import { TimelineBase, type TimelineCommonConfig } from '~/ani/core' +import type { AnimationNode } from '~/nodes' +import { compileToKeyframes, type WebAniKeyframe } from './compiler' + +export interface WebAniTimelineConfig + extends TimelineCommonConfig { + /** + * Web Animations API config. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyframeEffect/KeyframeEffect#options KeyframeEffect Options}. + */ + keyframeEffect?: Omit< + KeyframeEffectOptions, + 'duration' | 'iterations' | 'delay' + > +} + +export class WebAniTimeline extends TimelineBase { + private _animation: Animation | null = null + private _keyframes: WebAniKeyframe[] = [] + + constructor(rootNode: AnimationNode) { + super(rootNode) + } + + /** + * Plays animation. + * @param target Target element. + * @param config {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API Web Animations API} based config. + */ + public play( + target: Element, + config: WebAniTimelineConfig + ): Animation | null { + if (this._animation) { + this._animation.cancel() + } + + this._currentExecutionPlan = this._resolveExecutionPlan( + config.keyframes, + config.durations + ) + + this._keyframes = compileToKeyframes( + this._currentExecutionPlan, + config.from + ) + + if (this._keyframes.length === 0) { + return null + } + + const totalDurationMs = + this._currentExecutionPlan.reduce( + (max, seg) => Math.max(max, seg.endTime), + 0 + ) * 1000 + + const effect = new KeyframeEffect(target, this._keyframes, { + duration: totalDurationMs, + iterations: config.repeat ?? 1, + delay: config.delay ?? 0, + fill: 'forwards', + }) + + this._animation = new Animation(effect, document.timeline) + this._animation.play() + return this._animation + } + + public pause(): void { + this._animation?.pause() + } + public resume(): void { + this._animation?.play() + } + + public reset(): void { + this._animation?.cancel() + this._animation = null + } + + public seek(targetTime: number): void { + if (this._animation) { + this._animation.currentTime = targetTime * 1000 + } + } + + /** + * Native animation object. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Animation Animation}. + */ + public get nativeAnimation(): Animation | null { + return this._animation + } +} + +/** + * Create web timeline. {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API Web Animations API} based. + * @param rootNode Root animation node. + */ +export function webTimeline( + rootNode: AnimationNode +): WebAniTimeline { + return new WebAniTimeline(rootNode) +} diff --git a/packages/core/src/binding_api.ts b/packages/core/src/binding_api.ts index 7c25102..d071549 100644 --- a/packages/core/src/binding_api.ts +++ b/packages/core/src/binding_api.ts @@ -1,9 +1,9 @@ // public binding api type-defs. -import type { AniGroup, Groupable, Timeline, TimelineController } from './ani' +import type { AniGroup, Groupable, TimelineBase } from './ani/core' import type { EventHandlerRegistration } from './event' -export type AniRefContext = TimelineController & { +export type AniRefContext = TimelineBase & { /** * Current animation value */ @@ -11,13 +11,14 @@ export type AniRefContext = TimelineController & { } export interface AniRefProps< + Timeline extends TimelineBase, G extends Groupable, AsGetter extends boolean = false, > { /** * The compositional timeline to bind to the element. */ - timeline: AsGetter extends true ? () => Timeline : Timeline + timeline: AsGetter extends true ? () => Timeline : Timeline /** * The initial style to apply to the element before the animation plays. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d434e21..403a676 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,13 +1,6 @@ -import { - ani, - createStates, - delay, - loop, - parallel, - sequence, - stagger, - timeline, -} from './ani' +import { createStates, rafTimeline } from './ani/raf' +import { webTimeline } from './ani/waapi' +import { ani, delay, loop, parallel, sequence, stagger } from './nodes' import { T } from './timing' export * from './ani' @@ -19,12 +12,20 @@ export * from './timing' export const a = { timing: T, + + dynamicTimeline: rafTimeline, + timeline: webTimeline, + /** + * Create animation segment. + */ ani, - createStates, + /** + * Add delay + */ delay, loop, parallel, sequence, stagger, - timeline, + createStates, } as const diff --git a/packages/core/src/ani/nodes/base.ts b/packages/core/src/nodes/base.ts similarity index 93% rename from packages/core/src/ani/nodes/base.ts rename to packages/core/src/nodes/base.ts index 27ac8df..ec51329 100644 --- a/packages/core/src/ani/nodes/base.ts +++ b/packages/core/src/nodes/base.ts @@ -1,4 +1,4 @@ -import type { ExecutionPlan, Groupable } from '../timeline' +import type { ExecutionPlan, Groupable } from '~/ani/core' export type AnimationId = string export type AnimationNodeType = string diff --git a/packages/core/src/ani/nodes/composition.ts b/packages/core/src/nodes/composition.ts similarity index 96% rename from packages/core/src/ani/nodes/composition.ts rename to packages/core/src/nodes/composition.ts index 3da6432..44774f4 100644 --- a/packages/core/src/ani/nodes/composition.ts +++ b/packages/core/src/nodes/composition.ts @@ -1,5 +1,5 @@ +import type { ExecutionPlan, Groupable } from '~/ani/core' import { LinearTimingFunction, type TimingFunction } from '~/timing' -import type { ExecutionPlan, Groupable } from '../timeline' import { type AnimationId, AnimationNode, diff --git a/packages/core/src/ani/nodes/delay.ts b/packages/core/src/nodes/delay.ts similarity index 87% rename from packages/core/src/ani/nodes/delay.ts rename to packages/core/src/nodes/delay.ts index 09ba7f0..0aa4f8e 100644 --- a/packages/core/src/ani/nodes/delay.ts +++ b/packages/core/src/nodes/delay.ts @@ -5,7 +5,7 @@ type PreserveRecord = Record /** * Creates a pause in an animation sequence. - * @param duration The duration of the delay in seconds. + * @param duration Duration of the delay in `seconds`. * @param id Optional ID for the node. */ export function delay( diff --git a/packages/core/src/ani/nodes/index.ts b/packages/core/src/nodes/index.ts similarity index 100% rename from packages/core/src/ani/nodes/index.ts rename to packages/core/src/nodes/index.ts diff --git a/packages/core/src/ani/nodes/loop.ts b/packages/core/src/nodes/loop.ts similarity index 84% rename from packages/core/src/ani/nodes/loop.ts rename to packages/core/src/nodes/loop.ts index 13e8ff8..6100807 100644 --- a/packages/core/src/ani/nodes/loop.ts +++ b/packages/core/src/nodes/loop.ts @@ -1,5 +1,6 @@ +import type { Groupable } from '~/ani/core' import type { TimingFunction } from '~/timing' -import type { Groupable } from '../timeline' + import type { AnimationId, AnimationNode } from './base' import { CompositionNode, type CompositionPlan } from './composition' @@ -39,10 +40,11 @@ export class LoopNode extends CompositionNode< } /** - * Repeats a child animation node a specified number of times. - * @param child The animation node to repeat. - * @param loopCount The number of times to repeat the child node. - * @param timing loop timing function. + * Create loop for children animation. + * + * @param child Target animation node to repeat. + * @param loopCount Loop count. + * @param timing Loop timing function. * @param id Optional ID for the node. */ export function loop( diff --git a/packages/core/src/ani/nodes/parallel.ts b/packages/core/src/nodes/parallel.ts similarity index 95% rename from packages/core/src/ani/nodes/parallel.ts rename to packages/core/src/nodes/parallel.ts index befafa6..6a36b08 100644 --- a/packages/core/src/ani/nodes/parallel.ts +++ b/packages/core/src/nodes/parallel.ts @@ -1,5 +1,6 @@ +import type { Groupable, GroupableRecord } from '~/ani/core' import type { TimingFunction } from '~/timing' -import type { Groupable, GroupableRecord } from '../timeline' + import type { AnimationId, AnimationNode } from './base' import { type CompositionChildren, @@ -77,6 +78,8 @@ export class ParallelNode< /** * Parallel composition animation + * @param timing Loop timing function. + * @param id Optional ID for the node. */ export function parallel( children: Children, diff --git a/packages/core/src/ani/nodes/segment.ts b/packages/core/src/nodes/segment.ts similarity index 86% rename from packages/core/src/ani/nodes/segment.ts rename to packages/core/src/nodes/segment.ts index bb8c6c4..ce5b97f 100644 --- a/packages/core/src/ani/nodes/segment.ts +++ b/packages/core/src/nodes/segment.ts @@ -1,5 +1,4 @@ -import type { SegmentTiming } from '../engine' -import type { ExecutionPlan, Groupable } from '../timeline' +import type { ExecutionPlan, Groupable, SegmentTiming } from '~/ani/core' import { type AnimationId, AnimationNode } from './base' export interface SegmentNodeProps { @@ -39,7 +38,10 @@ export class SegmentNode extends AnimationNode { } /** - * Factory function to create a ani SegmentNode. + * Single animation segment. + * + * @param props Animation config. + * @param id Optional ID for the node. */ export function ani( props: SegmentNodeProps, diff --git a/packages/core/src/ani/nodes/sequence.ts b/packages/core/src/nodes/sequence.ts similarity index 93% rename from packages/core/src/ani/nodes/sequence.ts rename to packages/core/src/nodes/sequence.ts index 3a35c02..eb6fafb 100644 --- a/packages/core/src/ani/nodes/sequence.ts +++ b/packages/core/src/nodes/sequence.ts @@ -31,6 +31,8 @@ export class SequenceNode< /** * Sequence composition animation + * @param timing Loop timing function. + * @param id Optional ID for the node. */ export function sequence( children: Children, diff --git a/packages/core/src/ani/nodes/stagger.ts b/packages/core/src/nodes/stagger.ts similarity index 73% rename from packages/core/src/ani/nodes/stagger.ts rename to packages/core/src/nodes/stagger.ts index 2c525d7..8b5805f 100644 --- a/packages/core/src/ani/nodes/stagger.ts +++ b/packages/core/src/nodes/stagger.ts @@ -6,10 +6,6 @@ import { type CompositionPlan, } from './composition' -interface StaggerNodeProps { - offset: number - timing?: TimingFunction -} /** * Composition node that runs its children with a fixed delay between each start time. */ @@ -20,10 +16,15 @@ export class StaggerNode< readonly duration: number readonly offset: number - constructor(children: Children, props: StaggerNodeProps, id?: AnimationId) { - super(children, props?.timing, id) + constructor( + children: Children, + offset: number, + timing?: TimingFunction, + id?: AnimationId + ) { + super(children, timing, id) - this.offset = props.offset + this.offset = offset if (children.length === 0) { this.duration = 0 @@ -45,11 +46,15 @@ export class StaggerNode< /** * Stagger composition animation + * @param offset Children animation offset, in seconds. + * @param timing Loop timing function. + * @param id Optional ID for the node. */ export function stagger( children: Children, - props: StaggerNodeProps, + offset: number, + timing?: TimingFunction, id?: AnimationId ): StaggerNode { - return new StaggerNode(children, props, id) + return new StaggerNode(children, offset, timing, id) } diff --git a/packages/core/src/timing/bezier.ts b/packages/core/src/timing/bezier.ts index ded7ea0..1f4da9b 100644 --- a/packages/core/src/timing/bezier.ts +++ b/packages/core/src/timing/bezier.ts @@ -1,4 +1,3 @@ -/** biome-ignore-all lint/style/noMagicNumbers: <>*/ import { type Coord, TimingFunction, @@ -9,40 +8,172 @@ export interface BezierTimingFunctionOpt { p2: Coord p3: Coord } + +// Newton-Raphson constants +const NEWTON_ITERATIONS = 4 +const NEWTON_MIN_SLOPE = 0.001 +const SUBDIVISION_PRECISION = 0.0000001 +const SUBDIVISION_MAX_ITERATIONS = 10 + +const SAMPLE_TABLE_SIZE = 11 +const SAMPLE_STEP_SIZE = 1.0 / (SAMPLE_TABLE_SIZE - 1.0) + export class BezierTimingFunction extends TimingFunction { + private sampleValues: Float32Array | null = null + public constructor(public readonly opt: { p2: Coord; p3: Coord }) { super() + // Pre-calculate sample table + if ( + this.opt.p2.x !== this.opt.p2.y || + this.opt.p3.x !== this.opt.p3.y + ) { + this.sampleValues = new Float32Array(SAMPLE_TABLE_SIZE) + for (let i = 0; i < SAMPLE_TABLE_SIZE; ++i) { + this.sampleValues[i] = this.calcBezier( + i * SAMPLE_STEP_SIZE, + this.opt.p2.x, + this.opt.p3.x + ) + } + } } - private readonly p1: Coord = { - x: 0, - y: 0, - } - private readonly p4: Coord = { - x: 1, - y: 1, + + private calcBezier(t: number, a1: number, a2: number): number { + return ( + ((1 - 3 * a2 + 3 * a1) * t + (3 * a2 - 6 * a1)) * t * t + 3 * a1 * t + ) } - private _bezierFunction(t: number, duration?: number): number { - const end: number = duration || this.p4.y + private getSlope(t: number, a1: number, a2: number): number { return ( - (1 - t) ** 3 * this.p1.y + - 3 * (1 - t) ** 2 * t * this.opt.p2.y + - 3 * (1 - t) * t ** 2 * this.opt.p3.y + - t ** 3 * end + 3 * (1 - 3 * a2 + 3 * a1) * t * t + + 2 * (3 * a2 - 6 * a1) * t + + 3 * a1 + ) + } + + private getTForX(x: number): number { + const mX1 = this.opt.p2.x + const mX2 = this.opt.p3.x + + let intervalStart = 0.0 + let currentSample = 1 + + const lastSample = SAMPLE_TABLE_SIZE - 1 + + for ( + ; + currentSample !== lastSample && + this.sampleValues![currentSample]! <= x; + ++currentSample + ) { + intervalStart += SAMPLE_STEP_SIZE + } + --currentSample + + // Interpolate to provide an initial guess for t + const dist = + (x - this.sampleValues![currentSample]!) / + (this.sampleValues![currentSample + 1]! - + this.sampleValues![currentSample]!) + const guessForT = intervalStart + dist * SAMPLE_STEP_SIZE + + const initialSlope = this.getSlope(guessForT, mX1, mX2) + if (initialSlope >= NEWTON_MIN_SLOPE) { + return this.newtonRaphsonIterate(x, guessForT, mX1, mX2) + } + if (initialSlope === 0.0) { + return guessForT + } + return this.binarySubdivide( + x, + intervalStart, + intervalStart + SAMPLE_STEP_SIZE, + mX1, + mX2 ) } + private binarySubdivide( + aX: number, + aA: number, + aB: number, + mX1: number, + mX2: number + ): number { + let currentX: number + let currentT: number + let i = 0 + let currentA = aA + let currentB = aB + do { + currentT = currentA + (currentB - currentA) / 2.0 + currentX = this.calcBezier(currentT, mX1, mX2) - aX + if (currentX > 0.0) { + currentB = currentT + } else { + currentA = currentT + } + } while ( + Math.abs(currentX) > SUBDIVISION_PRECISION && + ++i < SUBDIVISION_MAX_ITERATIONS + ) + return currentT + } + + private newtonRaphsonIterate( + aX: number, + aGuessT: number, + mX1: number, + mX2: number + ): number { + let guessT = aGuessT + for (let i = 0; i < NEWTON_ITERATIONS; ++i) { + const currentSlope = this.getSlope(guessT, mX1, mX2) + if (currentSlope === 0.0) { + return guessT + } + const currentX = this.calcBezier(guessT, mX1, mX2) - aX + guessT -= currentX / currentSlope + } + return guessT + } + public step( time: number, context: TimingFunctionContext ): { value: number; endOfAnimation: boolean } { - const f = this._bezierFunction(time, context.duration) + const { duration, from, to } = context + if (duration === 0) { + return { value: to, endOfAnimation: true } + } + + // Normalize time [0, 1] + const x = Math.max(0, Math.min(time / duration, 1)) + let easedT = x + + // If linear (p2.x=p2.y, p3.x=p3.y), skip solving. + // Otherwise solve x(t) = time for t. + if ( + this.opt.p2.x !== this.opt.p2.y || + this.opt.p3.x !== this.opt.p3.y + ) { + if (!this.sampleValues) { + // Should have been initialized in constructor + } + // Solve for t given x + const t = this.getTForX(x) + // Calculate y(t) + easedT = this.calcBezier(t, this.opt.p2.y, this.opt.p3.y) + } + + const value = from + (to - from) * easedT + const endOfAnimation = time >= duration + return { - value: f, - endOfAnimation: - (context.duration - ? time >= context.duration - : time >= this.p4.x) && f >= context.to, + value, + endOfAnimation, } } } diff --git a/packages/core/src/timing/index.ts b/packages/core/src/timing/index.ts index 061c386..023e4bc 100644 --- a/packages/core/src/timing/index.ts +++ b/packages/core/src/timing/index.ts @@ -1,3 +1,4 @@ +export * from './bezier' export * from './function' export * from './linear' @@ -37,4 +38,40 @@ export const T = { * Creates linear timing function instance. */ linear: () => new LinearTimingFunction(), + + /** + * Standard CSS 'ease' timing function (0.25, 0.1, 0.25, 1.0). + */ + ease: () => + new Bezier.BezierTimingFunction({ + p2: { x: 0.25, y: 0.1 }, + p3: { x: 0.25, y: 1.0 }, + }), + + /** + * Standard CSS 'ease-in' timing function (0.42, 0, 1.0, 1.0). + */ + easeIn: () => + new Bezier.BezierTimingFunction({ + p2: { x: 0.42, y: 0 }, + p3: { x: 1.0, y: 1.0 }, + }), + + /** + * Standard CSS 'ease-out' timing function (0, 0, 0.58, 1.0). + */ + easeOut: () => + new Bezier.BezierTimingFunction({ + p2: { x: 0, y: 0 }, + p3: { x: 0.58, y: 1.0 }, + }), + + /** + * Standard CSS 'ease-in-out' timing function (0.42, 0, 0.58, 1.0). + */ + easeInOut: () => + new Bezier.BezierTimingFunction({ + p2: { x: 0.42, y: 0 }, + p3: { x: 0.58, y: 1.0 }, + }), } as const diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 51b277f..f8f943f 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,16 @@ # @freestylejs/ani-react +## 1.1.0 + +### Minor Changes + +- Support raf/waapi based animation timeline using compilation. Update framework bindings. + +### Patch Changes + +- Updated dependencies + - @freestylejs/ani-core@1.2.0 + ## 1.0.0 ### Major Changes diff --git a/packages/react/package.json b/packages/react/package.json index 657b2bc..732ff99 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,9 +1,12 @@ { "name": "@freestylejs/ani-react", "author": "freestyle", - "version": "1.0.0", + "version": "1.1.0", "description": "Ani for react.", - "keywords": ["react", "animation"], + "keywords": [ + "react", + "animation" + ], "license": "MIT", "type": "module", "sideEffects": false, @@ -11,7 +14,9 @@ "publishConfig": { "access": "public" }, - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/react/src/ani/use_ani.ts b/packages/react/src/ani/use_ani.ts index c0c54c3..62e6f9c 100644 --- a/packages/react/src/ani/use_ani.ts +++ b/packages/react/src/ani/use_ani.ts @@ -1,10 +1,5 @@ -import type { - AniGroup, - Groupable, - Timeline, - TimelineController, -} from '@freestylejs/ani-core' -import { useCallback, useMemo, useSyncExternalStore } from 'react' +import type { AniGroup, Groupable, RafAniTimeline } from '@freestylejs/ani-core' +import { useCallback, useSyncExternalStore } from 'react' /** * Reactive ani animation hook. @@ -12,12 +7,12 @@ import { useCallback, useMemo, useSyncExternalStore } from 'react' * @param timeline - `Timeline` instance. * @param initialValue - Initial value for the animation. * - * @returns `[value, controller]`. + * @returns `[value, timeline]`. */ export function useAni( - timeline: Timeline, + timeline: RafAniTimeline, initialValue: AniGroup -): readonly [AniGroup, TimelineController] { +): readonly [AniGroup, RafAniTimeline] { const subscribe = useCallback( // [subscribe -> unsubscribe] (onStoreChange: () => void) => timeline.onUpdate(onStoreChange), @@ -30,15 +25,5 @@ export function useAni( const value = useSyncExternalStore(subscribe, getSnapshot) - const controller = useMemo((): TimelineController => { - return { - play: timeline.play, - seek: timeline.seek, - pause: timeline.pause, - resume: timeline.resume, - reset: timeline.reset, - } - }, [timeline]) - - return [value, controller] as const + return [value, timeline] as const } diff --git a/packages/react/src/ani/use_ani_ref.ts b/packages/react/src/ani/use_ani_ref.ts index f2b93dd..368890a 100644 --- a/packages/react/src/ani/use_ani_ref.ts +++ b/packages/react/src/ani/use_ani_ref.ts @@ -6,10 +6,10 @@ import { type EventKey, EventManager, type Groupable, - type TimelineController, + type RafAniTimeline, } from '@freestylejs/ani-core' import type { CSSProperties, RefObject } from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' function applyStylesToRef( ref: RefObject, @@ -30,11 +30,16 @@ function applyStylesToRef( */ export function useAniRef< G extends Groupable, - E extends HTMLElement = HTMLElement, + Element extends HTMLElement = HTMLElement, >( - ref: React.RefObject, - { timeline, initialValue, events, cleanupEvents = true }: AniRefProps -): TimelineController { + ref: React.RefObject, + { + timeline, + initialValue, + events, + cleanupEvents = true, + }: AniRefProps, G> +): RafAniTimeline { const [manager] = useState(() => events ? new EventManager>( @@ -45,55 +50,72 @@ export function useAniRef< const animeValue = useRef | null>(initialValue ?? null) - const controller = useMemo((): TimelineController => { + const controller = useMemo((): RafAniTimeline => { return { play: (config) => { - animeValue.current = config.from + animeValue.current = config.from as AniGroup timeline.play(config) }, - seek: timeline.seek, - pause: timeline.pause, - resume: timeline.resume, + seek: (time) => timeline.seek(time), + pause: () => timeline.pause(), + resume: () => timeline.resume(), reset: () => { timeline.reset() // back to initial value - animeValue.current = initialValue ?? null - if (ref.current && animeValue.current) { + const initial = initialValue ?? null + if (ref.current && initial) { const styleObject = createStyleSheet( - animeValue.current as Record, + initial as Record, timeline.currentConfig?.propertyResolver ) applyStylesToRef(ref, styleObject) } }, - } - }, [timeline, initialValue, ref]) + get currentConfig() { + return timeline.currentConfig + }, + onUpdate: (cb) => timeline.onUpdate(cb), + getCurrentValue: () => timeline.getCurrentValue(), + } as RafAniTimeline + }, [timeline, initialValue]) // >> animation subscription + direct style application. useEffect(() => { - if (!timeline) return + if (!timeline || !ref.current) return + + const target = timeline.getCurrentValue() ?? initialValue + const styleObject = createStyleSheet( + target as Record, + timeline.currentConfig?.propertyResolver + ) + applyStylesToRef(ref, styleObject) const unsubscribe = timeline.onUpdate((value) => { - animeValue.current = value.state + /** + * !TODO: WebAnimation PRIORITY = This code will remove animation when executed. + * if (!ref.current) return + * ref.current.getAnimations().forEach((anim) => anim.cancel()) + */ + + animeValue.current = value.state as AniGroup const styleObject = createStyleSheet( value.state as Record, timeline.currentConfig?.propertyResolver ) applyStylesToRef(ref, styleObject) }) - return () => unsubscribe() }, [timeline, ref]) // >> event subscription - useEffect(() => { + useLayoutEffect(() => { if (!manager || !ref.current || !events) return const contextGetter = (): AniRefContext => { return { current: animeValue.current, ...controller, - } + } as AniRefContext } manager.bind(ref.current) diff --git a/packages/react/src/ani/use_ani_states.ts b/packages/react/src/ani/use_ani_states.ts index 011dfbe..2a280e7 100644 --- a/packages/react/src/ani/use_ani_states.ts +++ b/packages/react/src/ani/use_ani_states.ts @@ -6,33 +6,37 @@ import { type StateProps, } from '@freestylejs/ani-core' import { useEffect, useMemo, useState } from 'react' +import { useAniRef } from './use_ani_ref' + +export function useAniStates< + Element extends HTMLElement, + const AnimationStates extends AnimationStateShape, +>(ref: React.RefObject, props: StateProps) { + const controller = useMemo(() => createStates(props), []) -export function useAniStates( - props: StateProps -) { - const statesController = useMemo(() => createStates(props), []) const [timeline, setTimeline] = useState>( - statesController.timeline() + controller.timeline() ) - useEffect(() => { - const destroy = statesController.onTimelineChange(setTimeline) - return () => destroy() - }, []) + const [state, setState] = useState(props.initial) - const [state, setAniState] = useState(props.initial) + useAniRef(ref, { + timeline, + initialValue: props.initialFrom, + }) + + useEffect(() => { + const unsubscribe = controller.onTimelineChange(setTimeline) + return unsubscribe + }, [controller]) const transitionTo: StateController['transitionTo'] = ( newState: keyof AnimationStates, timelineConfig, canBeIntercepted ) => { - setAniState(newState) - statesController.transitionTo( - newState, - timelineConfig, - canBeIntercepted - ) + setState(newState) + controller.transitionTo(newState, timelineConfig, canBeIntercepted) } return [{ state, timeline }, transitionTo] as const diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md index 018a8a5..8d507cf 100644 --- a/packages/solid/CHANGELOG.md +++ b/packages/solid/CHANGELOG.md @@ -1,5 +1,16 @@ # @freestylejs/ani-solid +## 1.1.0 + +### Minor Changes + +- Support raf/waapi based animation timeline using compilation. Update framework bindings. + +### Patch Changes + +- Updated dependencies + - @freestylejs/ani-core@1.2.0 + ## 1.0.0 ### Major Changes diff --git a/packages/solid/package.json b/packages/solid/package.json index 70e9ef1..3e7df51 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,17 +1,22 @@ { "name": "@freestylejs/ani-solid", "author": "freestyle", - "version": "1.0.0", + "version": "1.1.0", "description": "Ani for solid.", "license": "MIT", "type": "module", - "keywords": ["solid", "animation"], + "keywords": [ + "solid", + "animation" + ], "sideEffects": false, "private": false, "publishConfig": { "access": "public" }, - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/solid/src/ani/create_ani.ts b/packages/solid/src/ani/create_ani.ts index e0b465f..ba48cfb 100644 --- a/packages/solid/src/ani/create_ani.ts +++ b/packages/solid/src/ani/create_ani.ts @@ -1,10 +1,5 @@ -import type { - AniGroup, - Groupable, - Timeline, - TimelineController, -} from '@freestylejs/ani-core' -import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js' +import type { AniGroup, Groupable, RafAniTimeline } from '@freestylejs/ani-core' +import { createEffect, createSignal, onCleanup } from 'solid-js' /** * Reactive ani animation hook. @@ -12,35 +7,27 @@ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js' * @param timeline - `Timeline` instance. * @param initialValue - Initial value for the animation state. * - * @returns `[valueAccessor, controller]`. + * @returns `[valueAccessor, timeline]`. */ export function createAni( - timeline: () => Timeline, + timeline: () => RafAniTimeline, initialValue: AniGroup -): readonly [() => AniGroup, TimelineController] { - const [value, setValue] = createSignal>(initialValue) +): readonly [() => AniGroup, RafAniTimeline] { + const [value, setValue] = createSignal>( + timeline().getCurrentValue() ?? initialValue + ) createEffect(() => { const tl = timeline() + // Sync immediate value + const current = tl.getCurrentValue() ?? initialValue + setValue(() => current) + const unsubscribe = tl.onUpdate((val) => { setValue(() => val.state) }) onCleanup(unsubscribe) }) - const controller = createMemo((): TimelineController => { - const tl = timeline() - return { - play: (config) => { - setValue(() => config.from) - tl.play(config) - }, - seek: tl.seek, - pause: tl.pause, - resume: tl.resume, - reset: tl.reset, - } - }) - - return [value, controller()] as const + return [value, timeline()] as const } diff --git a/packages/solid/src/ani/create_ani_ref.ts b/packages/solid/src/ani/create_ani_ref.ts index 5b49808..92fcca3 100644 --- a/packages/solid/src/ani/create_ani_ref.ts +++ b/packages/solid/src/ani/create_ani_ref.ts @@ -6,11 +6,10 @@ import { type EventKey, EventManager, type Groupable, - type TimelineController, + type RafAniTimeline, } from '@freestylejs/ani-core' import type { JSX } from 'solid-js' import { createEffect, createMemo, onCleanup } from 'solid-js' -import { createStore, produce } from 'solid-js/store' function applyStylesToRef( el: E | null, @@ -31,15 +30,13 @@ export function createAniRef< G extends Groupable, E extends HTMLElement = HTMLElement, >( - props: AniRefProps -): readonly [(el: E) => void, TimelineController] { + props: AniRefProps, G, true> +): readonly [(el: E) => void, RafAniTimeline] { let element: E | null = null const { timeline, initialValue, events, cleanupEvents = true } = props - const [latestValue, setLatestValue] = createStore>( - initialValue ?? ({} as AniGroup) - ) + let animeValue: AniGroup | null = initialValue ?? null const manager = events ? new EventManager>( @@ -47,23 +44,23 @@ export function createAniRef< ) : null - const controller = createMemo((): TimelineController => { + const controller = createMemo((): RafAniTimeline => { const tl = timeline() return { play: (config) => { - setLatestValue( - produce((s) => Object.assign(s as G, config.from)) - ) + if (config.from) { + animeValue = config.from as AniGroup + } tl?.play(config) }, - pause: tl.pause, - resume: tl.resume, - seek: tl.seek, + seek: (time) => tl.seek(time), + pause: () => tl.pause(), + resume: () => tl.resume(), reset: () => { tl?.reset() - const resetVal = initialValue ?? ({} as G) - setLatestValue(produce((s) => Object.assign(s as G, resetVal))) - if (element) { + const resetVal = initialValue ?? null + animeValue = resetVal + if (element && resetVal) { const styleObject = createStyleSheet( resetVal as Record, tl.currentConfig?.propertyResolver @@ -71,7 +68,12 @@ export function createAniRef< applyStylesToRef(element, styleObject) } }, - } + get currentConfig() { + return tl.currentConfig + }, + onUpdate: (cb) => tl.onUpdate(cb), + getCurrentValue: () => tl.getCurrentValue(), + } as RafAniTimeline }) // >> animation subscription + direct style application. @@ -79,8 +81,17 @@ export function createAniRef< const tl = timeline() if (!element) return + const target = tl.getCurrentValue() ?? initialValue + if (target) { + const styleObject = createStyleSheet( + target as Record, + tl.currentConfig?.propertyResolver + ) + applyStylesToRef(element, styleObject) + } + const unsubscribe = tl.onUpdate((value) => { - setLatestValue(produce((s) => Object.assign(s as G, value.state))) + animeValue = value.state as AniGroup const styleObject = createStyleSheet( value.state as Record, tl.currentConfig?.propertyResolver @@ -97,9 +108,9 @@ export function createAniRef< const contextGetter = (): AniRefContext => { return { - current: latestValue, + current: animeValue, ...controller(), - } + } as AniRefContext } manager.bind(element) diff --git a/packages/solid/src/ani/create_ani_states.ts b/packages/solid/src/ani/create_ani_states.ts index 3f4cdd4..7b69143 100644 --- a/packages/solid/src/ani/create_ani_states.ts +++ b/packages/solid/src/ani/create_ani_states.ts @@ -6,12 +6,15 @@ import { type StateProps, } from '@freestylejs/ani-core' import { createEffect, createSignal } from 'solid-js' +import { createAniRef } from './create_ani_ref' export function createAniStates< + Element extends HTMLElement, const AnimationStates extends AnimationStateShape, >( props: StateProps ): readonly [ + (el: Element) => void, { state: () => keyof AnimationStates timeline: () => GetTimeline @@ -30,6 +33,11 @@ export function createAniStates< const [state, setState] = createSignal(props.initial) + const [ref] = createAniRef({ + timeline, + initialValue: props.initialFrom, + }) + const transitionTo: StateController['transitionTo'] = ( newState, timelineConfig, @@ -43,5 +51,5 @@ export function createAniStates< ) } - return [{ state, timeline }, transitionTo] as const + return [ref, { state, timeline }, transitionTo] as const } diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 15506a1..dff2da2 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,16 @@ # @freestylejs/ani-svelte +## 1.1.0 + +### Minor Changes + +- Support raf/waapi based animation timeline using compilation. Update framework bindings. + +### Patch Changes + +- Updated dependencies + - @freestylejs/ani-core@1.2.0 + ## 1.0.0 ### Major Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 6c0c348..beae266 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,17 +1,22 @@ { "name": "@freestylejs/ani-svelte", "author": "freestyle", - "version": "1.0.0", + "version": "1.1.0", "description": "Ani for svelte.", "license": "MIT", "type": "module", - "keywords": ["svelte", "animation"], + "keywords": [ + "svelte", + "animation" + ], "sideEffects": false, "private": false, "publishConfig": { "access": "public" }, - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/svelte/src/ani/use_ani.ts b/packages/svelte/src/ani/use_ani.ts index 82e9f87..081327b 100644 --- a/packages/svelte/src/ani/use_ani.ts +++ b/packages/svelte/src/ani/use_ani.ts @@ -1,29 +1,23 @@ -import type { - AniGroup, - Groupable, - Timeline, - TimelineController, -} from '@freestylejs/ani-core' +import type { AniGroup, Groupable, RafAniTimeline } from '@freestylejs/ani-core' import { type Readable, readable } from 'svelte/store' export function useAni( - timeline: Timeline, + timeline: RafAniTimeline, initialValue: AniGroup -): readonly [Readable>, TimelineController] { - const store = readable(initialValue, (set) => { - const unsubscribe = timeline.onUpdate((e) => set(e.state)) - return () => { - unsubscribe() - } - }) +): readonly [Readable>, RafAniTimeline] { + const store = readable( + timeline.getCurrentValue() ?? initialValue, + (set) => { + // Sync immediate value + const current = timeline.getCurrentValue() ?? initialValue + set(current) - const controller: TimelineController = { - play: timeline.play, - seek: timeline.seek, - pause: timeline.pause, - resume: timeline.resume, - reset: timeline.reset, - } + const unsubscribe = timeline.onUpdate((e) => set(e.state)) + return () => { + unsubscribe() + } + } + ) - return [store, controller] + return [store, timeline] } diff --git a/packages/svelte/src/ani/use_ani_ref.ts b/packages/svelte/src/ani/use_ani_ref.ts index f413871..1b8e395 100644 --- a/packages/svelte/src/ani/use_ani_ref.ts +++ b/packages/svelte/src/ani/use_ani_ref.ts @@ -6,9 +6,10 @@ import { type EventKey, EventManager, type Groupable, - type TimelineController, + type RafAniTimeline, } from '@freestylejs/ani-core' import type { Action } from 'svelte/action' +import { get, type Readable } from 'svelte/store' function applyStylesToNode( node: E, @@ -17,6 +18,17 @@ function applyStylesToNode( Object.assign(node.style, style) } +function isReadable(value: any): value is Readable { + return value && typeof value.subscribe === 'function' +} + +type SvelteAniRefProps = Omit< + AniRefProps, G>, + 'timeline' +> & { + timeline: RafAniTimeline | Readable> +} + /** * Event-based non-reactive(ref) ani animation action for Svelte. * @@ -29,41 +41,88 @@ export function useAniRef({ initialValue, events, cleanupEvents = true, -}: AniRefProps): readonly [Action, TimelineController] { +}: SvelteAniRefProps): readonly [ + Action, + RafAniTimeline, +] { let animeValue: AniGroup | null = initialValue ?? null + let element: HTMLElement | null = null + + const getTimeline = (): RafAniTimeline => { + return isReadable(timeline) ? get(timeline) : timeline + } - const controller: TimelineController = { + const controller: RafAniTimeline = { play: (config) => { - animeValue = config.from - timeline.play(config) + if (config.from) { + animeValue = config.from as AniGroup + } + getTimeline().play(config) }, - seek: timeline.seek, - pause: timeline.pause, - resume: timeline.resume, + seek: (time) => getTimeline().seek(time), + pause: () => getTimeline().pause(), + resume: () => getTimeline().resume(), reset: () => { - timeline.reset() - animeValue = initialValue ?? null + const tl = getTimeline() + tl.reset() + const resetVal = initialValue ?? null + animeValue = resetVal + + if (element && resetVal) { + const styleObject = createStyleSheet( + resetVal as Record, + tl.currentConfig?.propertyResolver + ) + applyStylesToNode(element, styleObject) + } }, - } + get currentConfig() { + return getTimeline().currentConfig + }, + onUpdate: (cb) => getTimeline().onUpdate(cb), + getCurrentValue: () => getTimeline().getCurrentValue(), + } as RafAniTimeline const action: Action = (node) => { - if (initialValue) { - const styleObject = createStyleSheet( - initialValue as Record, - timeline.currentConfig?.propertyResolver - ) - applyStylesToNode(node, styleObject) + element = node + let unsubscribeTimelineUpdate: (() => void) | undefined + + const setupTimeline = (tl: RafAniTimeline) => { + if (unsubscribeTimelineUpdate) { + unsubscribeTimelineUpdate() + } + + // Apply immediate/initial value + const target = tl.getCurrentValue() ?? initialValue + if (target) { + const styleObject = createStyleSheet( + target as Record, + tl.currentConfig?.propertyResolver + ) + applyStylesToNode(node, styleObject) + } + + unsubscribeTimelineUpdate = tl.onUpdate((value) => { + animeValue = value.state + const styleObject = createStyleSheet( + value.state as Record, + tl.currentConfig?.propertyResolver + ) + applyStylesToNode(node, styleObject) + }) } - const unsubscribe = timeline.onUpdate((value) => { - animeValue = value.state - const styleObject = createStyleSheet( - value.state as Record, - timeline.currentConfig?.propertyResolver - ) - applyStylesToNode(node, styleObject) - }) + let unsubscribeStore: (() => void) | undefined + if (isReadable(timeline)) { + unsubscribeStore = timeline.subscribe((tl) => { + setupTimeline(tl) + }) + } else { + setupTimeline(timeline) + } + + // Events let manager: EventManager< readonly EventKey[], AniRefContext @@ -72,10 +131,11 @@ export function useAniRef({ manager = new EventManager( Object.keys(events).map(EventManager.getEvtKey) ) - const contextGetter = (): AniRefContext => ({ - current: animeValue, - ...controller, - }) + const contextGetter = (): AniRefContext => + ({ + current: animeValue, + ...controller, + }) as AniRefContext manager.bind(node) manager.attach(events) manager.setAnimeGetter(contextGetter) @@ -83,7 +143,9 @@ export function useAniRef({ return { destroy() { - unsubscribe() + element = null + if (unsubscribeTimelineUpdate) unsubscribeTimelineUpdate() + if (unsubscribeStore) unsubscribeStore() if (manager && cleanupEvents) { manager.cleanupAll() } diff --git a/packages/svelte/src/ani/use_ani_states.ts b/packages/svelte/src/ani/use_ani_states.ts index cdf2999..07595d3 100644 --- a/packages/svelte/src/ani/use_ani_states.ts +++ b/packages/svelte/src/ani/use_ani_states.ts @@ -1,22 +1,18 @@ import { - type AnimationNode, type AnimationStateShape, createStates, type GetTimeline, - type Groupable, type StateController, type StateProps, } from '@freestylejs/ani-core' +import type { Action } from 'svelte/action' import { type Readable, readable, writable } from 'svelte/store' +import { useAniRef } from './use_ani_ref' -export function useAniStates< - const AnimationStates extends AnimationStateShape = Record< - string, - AnimationNode - >, ->( +export function useAniStates( props: StateProps ): readonly [ + Action, { state: Readable timeline: Readable> @@ -34,6 +30,11 @@ export function useAniStates< const stateStore = writable(props.initial) + const [ref] = useAniRef({ + timeline: timelineStore, + initialValue: props.initialFrom, + }) + const transitionTo: StateController['transitionTo'] = ( newState, timelineConfig, @@ -48,6 +49,7 @@ export function useAniStates< } return [ + ref, { state: readable(props.initial, (set) => stateStore.subscribe(set)), timeline: readable(statesController.timeline(), (set) => diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md index 234ca4b..c93e4eb 100644 --- a/packages/vue/CHANGELOG.md +++ b/packages/vue/CHANGELOG.md @@ -1,5 +1,16 @@ # @freestylejs/ani-vue +## 1.1.0 + +### Minor Changes + +- Support raf/waapi based animation timeline using compilation. Update framework bindings. + +### Patch Changes + +- Updated dependencies + - @freestylejs/ani-core@1.2.0 + ## 1.0.1 ### Patch Changes diff --git a/packages/vue/package.json b/packages/vue/package.json index 9219b53..b69dc82 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,17 +1,22 @@ { "name": "@freestylejs/ani-vue", "author": "freestyle", - "version": "1.0.1", + "version": "1.1.0", "description": "Ani for vue.", "license": "MIT", "type": "module", - "keywords": ["vue", "animation"], + "keywords": [ + "vue", + "animation" + ], "sideEffects": false, "private": false, "publishConfig": { "access": "public" }, - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/vue/src/ani/use_ani.ts b/packages/vue/src/ani/use_ani.ts index 1904351..41fccbe 100644 --- a/packages/vue/src/ani/use_ani.ts +++ b/packages/vue/src/ani/use_ani.ts @@ -1,37 +1,30 @@ -import type { - AniGroup, - Groupable, - Timeline, - TimelineController, -} from '@freestylejs/ani-core' -import { onMounted, onUnmounted, type Ref, ref } from 'vue' +import type { AniGroup, Groupable, RafAniTimeline } from '@freestylejs/ani-core' +import { onBeforeUnmount, onMounted, type Ref, ref } from 'vue' export function useAni( - timeline: Timeline, + timeline: RafAniTimeline, initialValue: AniGroup -): readonly [Ref>, TimelineController] { - const value = ref(initialValue) as Ref> +): readonly [Ref>, RafAniTimeline] { + const value = ref(timeline.getCurrentValue() ?? initialValue) as Ref< + AniGroup + > let unsubscribe: () => void onMounted(() => { + // Sync immediate value on mount + const current = timeline.getCurrentValue() ?? initialValue + value.value = current + unsubscribe = timeline.onUpdate((newValue) => { value.value = newValue.state }) }) - onUnmounted(() => { + onBeforeUnmount(() => { if (unsubscribe) { unsubscribe() } }) - const controller: TimelineController = { - play: timeline.play, - seek: timeline.seek, - pause: timeline.pause, - resume: timeline.resume, - reset: timeline.reset, - } - - return [value, controller] + return [value, timeline] } diff --git a/packages/vue/src/ani/use_ani_ref.ts b/packages/vue/src/ani/use_ani_ref.ts index 8d28218..986b420 100644 --- a/packages/vue/src/ani/use_ani_ref.ts +++ b/packages/vue/src/ani/use_ani_ref.ts @@ -6,9 +6,9 @@ import { type EventKey, EventManager, type Groupable, - type TimelineController, + type RafAniTimeline, } from '@freestylejs/ani-core' -import { onMounted, onUnmounted, type Ref, ref } from 'vue' +import { computed, onBeforeUnmount, type Ref, ref, unref, watch } from 'vue' function applyStylesToNode( node: E, @@ -17,6 +17,13 @@ function applyStylesToNode( Object.assign(node.style, style) } +type VueAniRefProps = Omit< + AniRefProps, G>, + 'timeline' +> & { + timeline: RafAniTimeline | Ref> +} + /** * Event-based non-reactive(ref) ani animation composable for Vue. * @@ -29,72 +36,103 @@ export function useAniRef({ initialValue, events, cleanupEvents = true, -}: AniRefProps): readonly [Ref, TimelineController] { +}: VueAniRefProps): readonly [Ref, RafAniTimeline] { const elementRef = ref(null) - const animeValue = ref | null>( - initialValue ?? null - ) as Ref | null> + let animeValue: AniGroup | null = initialValue ?? null + + const currentTimeline = computed(() => unref(timeline)) - const controller: TimelineController = { + const controller: RafAniTimeline = { play: (config) => { - animeValue.value = config.from - timeline.play(config) + if (config.from) { + animeValue = config.from as AniGroup + } + currentTimeline.value.play(config) }, - seek: timeline.seek, - pause: timeline.pause, - resume: timeline.resume, + seek: (time) => currentTimeline.value.seek(time), + pause: () => currentTimeline.value.pause(), + resume: () => currentTimeline.value.resume(), reset: () => { - timeline.reset() - animeValue.value = initialValue ?? null + currentTimeline.value.reset() + const resetVal = initialValue ?? null + animeValue = resetVal + if (elementRef.value && resetVal) { + const styleObject = createStyleSheet( + resetVal as Record, + currentTimeline.value.currentConfig?.propertyResolver + ) + applyStylesToNode(elementRef.value, styleObject) + } }, - } - - let unsubscribe: () => void - let manager: EventManager> | null = - null + get currentConfig() { + return currentTimeline.value.currentConfig + }, + onUpdate: (cb) => currentTimeline.value.onUpdate(cb), + getCurrentValue: () => currentTimeline.value.getCurrentValue(), + } as RafAniTimeline - onMounted(() => { - if (!elementRef.value) { - return - } + // >> animation subscription + direct style application. + watch( + [currentTimeline, elementRef], + ([tl, el], _, onCleanup) => { + if (!el || !tl) return - if (initialValue) { - const styleObject = createStyleSheet( - initialValue as Record, - timeline.currentConfig?.propertyResolver - ) - applyStylesToNode(elementRef.value, styleObject) - } + const target = tl.getCurrentValue() ?? initialValue + if (target) { + const styleObject = createStyleSheet( + target as Record, + tl.currentConfig?.propertyResolver + ) + applyStylesToNode(el, styleObject) + } - unsubscribe = timeline.onUpdate((value) => { - animeValue.value = value.state - if (elementRef.value) { + const unsubscribe = tl.onUpdate((value) => { + animeValue = value.state const styleObject = createStyleSheet( value.state as Record, - timeline.currentConfig?.propertyResolver + tl.currentConfig?.propertyResolver ) - applyStylesToNode(elementRef.value, styleObject) - } - }) + applyStylesToNode(el, styleObject) + }) - if (events && elementRef.value) { - manager = new EventManager( - Object.keys(events).map(EventManager.getEvtKey) - ) - const contextGetter = (): AniRefContext => ({ - current: animeValue.value, - ...controller, + onCleanup(() => { + unsubscribe() }) - manager.bind(elementRef.value) - manager.attach(events) - manager.setAnimeGetter(contextGetter) - } + }, + { immediate: true } + ) + + // >> event subscription + let manager: EventManager> | null = + null + + if (events) { + manager = new EventManager( + Object.keys(events).map(EventManager.getEvtKey) + ) + } + + watch(elementRef, (el, _, onCleanup) => { + if (!manager || !el || !events) return + + const contextGetter = (): AniRefContext => + ({ + current: animeValue, + ...controller, + }) as AniRefContext + + manager.bind(el) + manager.attach(events) + manager.setAnimeGetter(contextGetter) + + onCleanup(() => { + if (cleanupEvents) { + manager.cleanupAll() + } + }) }) - onUnmounted(() => { - if (unsubscribe) { - unsubscribe() - } + onBeforeUnmount(() => { if (manager && cleanupEvents) { manager.cleanupAll() } diff --git a/packages/vue/src/ani/use_ani_states.ts b/packages/vue/src/ani/use_ani_states.ts index a6322fb..e868fbf 100644 --- a/packages/vue/src/ani/use_ani_states.ts +++ b/packages/vue/src/ani/use_ani_states.ts @@ -5,13 +5,22 @@ import { type StateController, type StateProps, } from '@freestylejs/ani-core' -import { onMounted, onUnmounted, type Ref, readonly, ref } from 'vue' +import { + onBeforeUnmount, + onMounted, + type Ref, + readonly, + ref, + shallowRef, +} from 'vue' +import { useAniRef } from './use_ani_ref' export function useAniStates< const AnimationStates extends Record & AnimationStateShape, >( props: StateProps ): readonly [ + Ref, { state: Readonly> timeline: Readonly>> @@ -19,17 +28,22 @@ export function useAniStates< StateController['transitionTo'], ] { const statesController = createStates(props) - const timeline = ref>( + const timeline = shallowRef>( statesController.timeline() ) + const [elementRef] = useAniRef({ + timeline: timeline, + initialValue: props.initialFrom, + }) + let unsubscribe: () => void onMounted(() => { unsubscribe = statesController.onTimelineChange((newTimeline) => { timeline.value = newTimeline }) }) - onUnmounted(() => { + onBeforeUnmount(() => { if (unsubscribe) unsubscribe() }) @@ -49,6 +63,7 @@ export function useAniStates< } return [ + elementRef, { state: readonly(state) as Readonly>, timeline: readonly(timeline) as Readonly< diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 45db3f4..557eada 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,17 @@ # @freestylejs/ani-web +## 0.2.0 + +### Minor Changes + +- Support raf/waapi based animation timeline using compilation. Update framework bindings. + +### Patch Changes + +- Updated dependencies + - @freestylejs/ani-react@1.1.0 + - @freestylejs/ani-core@1.2.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/web/content/docs/core-api/advanced/event-management.mdx b/packages/web/content/docs/core-api/advanced/event-management.mdx index 30e7810..e9b7d05 100644 --- a/packages/web/content/docs/core-api/advanced/event-management.mdx +++ b/packages/web/content/docs/core-api/advanced/event-management.mdx @@ -56,24 +56,6 @@ By providing a "getter" for your animation context (like a `timeline`), your eve - **Do** call `cleanupAll()` when your component or element is destroyed to prevent memory leaks. - **Don't** create multiple `EventManager` instances for the same element; one is sufficient. -### API Reference - -#### Constructor - -| Parameter | Type | Description | -| :--- | :--- | :--- | -| `supportedEvents` | `readonly string[]` | An array of DOM event names to support (e.g., `['click', 'mousemove']`). | - -#### Methods - -| Method | Description | -| :--- | :--- | -| `bind(el)` | Binds the manager to a DOM element. | -| `setAnimeGetter(getter)` | Provides a function that returns the animation context (e.g., a timeline). | -| `add(name, listener)` | Adds a listener for a specific event. | -| `attach(handlers)` | Attaches a map of `onEventName` handlers. | -| `cleanupAll()` | Removes all registered event listeners from the element. | - ### Related Components - [`timeline`](/en/docs/core-api/timeline) - The typical animation context provided to the `EventManager`. \ No newline at end of file diff --git a/packages/web/content/docs/core-api/ani.mdx b/packages/web/content/docs/core-api/ani.mdx index bca6573..5b026d8 100644 --- a/packages/web/content/docs/core-api/ani.mdx +++ b/packages/web/content/docs/core-api/ani.mdx @@ -3,7 +3,9 @@ title: ani description: Defines a single animation segment from a start value to an end value over a duration. --- -The `ani` function is the fundamental building block of the entire animation system. It represents a single, atomic "tween"—a transition from a starting state to a target `to` state over a specified `duration`. +The `ani` function is the fundamental building block of the entire animation system. + +It represents a single, atomic "tween"—a transition from a starting state to a target `to` state over a specified `duration`. ### Example @@ -32,34 +34,13 @@ myTimeline.play({ from: { x: 0 } }); #### Overview -The `ani` function is a "leaf" node in the animation tree, meaning it doesn't contain other animation nodes. All complex animations are built by composing these `ani` nodes inside "branch" nodes like `sequence` or `parallel`. The shape of the `to` object is what enforces type safety throughout the animation tree. +The `ani` function is a **"leaf" node in the animation tree**, meaning it doesn't contain other animation nodes. All complex animations are built by composing these `ani` nodes inside "branch" nodes like `sequence` or `parallel`. The shape of the `to` object is what enforces type safety throughout the animation tree. #### Best Practices - **Do** use `ani` for any individual tween. - **Don't** try to animate multiple, unrelated properties in a single `ani` call if they belong to different conceptual animations. Instead, use multiple `ani` nodes within a `parallel` block. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| :--- | :--- | :--- | :--- | -| `props` | `SegmentNodeProps` | An object containing the animation's properties. | — | -| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | - -#### Type Definitions - -```typescript -interface SegmentNodeProps { - to: G; - duration: number; - timing?: TimingFunction | TimingFunction[]; -}; - -function ani(props: SegmentNodeProps, id?: AnimationId): AnimationNode; -``` - ### Related Components - [`timeline`](/en/docs/core-api/timeline) - The controller required to run the animation. diff --git a/packages/web/content/docs/core-api/delay.mdx b/packages/web/content/docs/core-api/delay.mdx index 5a4732b..49699f3 100644 --- a/packages/web/content/docs/core-api/delay.mdx +++ b/packages/web/content/docs/core-api/delay.mdx @@ -33,21 +33,6 @@ myTimeline.play({ from: { opacity: 0, x: 0 } }); - **Do** use `delay` inside a `sequence` to create a pause between two animations. - **Don't** use `delay` inside a `parallel` block, as it will have no effect on other parallel animations. It will simply extend the total duration of the `parallel` block if it is the longest item. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| :--- | :--- | :--- | :--- | -| `duration` | `number` | The duration of the delay in seconds. | — | -| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | - -#### Type Definitions - -```typescript -function delay(duration: number, id?: AnimationId): AnimationNode; -``` - ### Related Components - [`sequence`](/en/docs/core-api/sequence) - The primary composition where `delay` is used. \ No newline at end of file diff --git a/packages/web/content/docs/core-api/dynamic_timeline.mdx b/packages/web/content/docs/core-api/dynamic_timeline.mdx new file mode 100644 index 0000000..86dc60d --- /dev/null +++ b/packages/web/content/docs/core-api/dynamic_timeline.mdx @@ -0,0 +1,52 @@ +--- +title: timeline (RequestAnimationFrame) +description: The main controller that runs an animation, manages its state, and controls its playback. Based on RequestAnimationFrame. +--- + +The `timeline` is the execution engine for any animation you create. It takes a root animation node (like a `sequence` or `parallel` block), manages the animation's state, and controls its playback. + +### Example + +```typescript +import { a } from "@freestylejs/ani-core"; + +// 1. Define the animation structure. +// TypeScript infers the data shape `G` as { opacity: number, x: number } +const myAnimation = a.sequence([ + a.ani({ to: { opacity: 1, x: 0 }, duration: 1 }), + a.ani({ to: { opacity: 1, x: 100 }, duration: 2 }), +]); + +// 2. Create a reusable timeline instance from the structure. +const myTimeline = a.timeline(myAnimation); + +// 3. Listen for updates to apply the animated values to your UI. +const unsubscribe = myTimeline.onUpdate(({ state }) => { + // `state` is strongly typed as { opacity: number, x: number } + console.log(`Opacity: ${state.opacity}, X: ${state.x}`); + // e.g., element.style.opacity = state.opacity; + // e.g., element.style.transform = `translateX(${state.x}px)`; +}); + +// 4. Play the animation from a starting state. +myTimeline.play({ from: { opacity: 0, x: 0 } }); +``` + +### Usage & Concepts + +#### Overview + +The `timeline` is the execution engine. It takes a static animation tree and brings it to life. It's responsible for calculating the animation's state at any given time, handling playback (play, pause, seek), and emitting update events. The key design principle is the separation of the animation's definition (the node tree) from its execution and values (the timeline). This makes animations reusable and dynamic. + +When you call `.play()`, the timeline traverses the animation node tree and builds a flat list of "segments". Each segment represents a single `ani` call with its calculated start time, end time, and timing function. On each frame, the timeline calculates the elapsed time, finds the active segment, computes the interpolated value, and notifies subscribers via `onUpdate`. + +#### Best Practices + +- **Do** create one timeline per independent animation. +- **Do** reuse timelines when you need to play the same animation structure with different starting values. +- **Don't** create a new timeline inside a render loop or frequently. Create it once and store it. + +### Related Components + +- [`ani`](/en/docs/core-api/ani) - The basic building block for animations controlled by a timeline. +- [`Dynamic Animations`](/en/docs/core-api/advanced/dynamic-animations) - For creating interactive animations. \ No newline at end of file diff --git a/packages/web/content/docs/core-api/loop.mdx b/packages/web/content/docs/core-api/loop.mdx index fe1d00f..25cb53e 100644 --- a/packages/web/content/docs/core-api/loop.mdx +++ b/packages/web/content/docs/core-api/loop.mdx @@ -35,28 +35,6 @@ myTimeline.play({ from: { scale: 1 } }); - **Do** use `loop` for simple, repetitive animations like pulsing or bouncing. - **Don't** use `loop` for the main application loop. For continuous animations, use the `repeat: Infinity` option in the `timeline.play()` method, which is more efficient. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| :--- | :--- | :--- | :--- | -| `child` | `AnimationNode` | The animation node to repeat. | — | -| `count` | `number` | The number of times to repeat the child node. | — | -| `timing` | `TimingFunction` | (Optional) An easing function to apply to the entire loop's timeline. | `linear` | -| `id` | `AnimationId` | (Optional) A unique identifier for the node. | `undefined` | - -#### Type Definitions - -```typescript -function loop( - child: AnimationNode, - count: number, - timing?: TimingFunction | TimingFunction[], - id?: AnimationId -): AnimationNode; -``` - ### Related Components - [`timeline`](/en/docs/core-api/timeline) - The `play` method's `repeat` option provides an alternative way to loop. diff --git a/packages/web/content/docs/core-api/meta.json b/packages/web/content/docs/core-api/meta.json index dee52e8..d23edca 100644 --- a/packages/web/content/docs/core-api/meta.json +++ b/packages/web/content/docs/core-api/meta.json @@ -2,6 +2,7 @@ "title": "Core API", "pages": [ "timeline", + "dynamic_timeline", "ani", "timing", "sequence", diff --git a/packages/web/content/docs/core-api/parallel.mdx b/packages/web/content/docs/core-api/parallel.mdx index 75efe0d..b28128d 100644 --- a/packages/web/content/docs/core-api/parallel.mdx +++ b/packages/web/content/docs/core-api/parallel.mdx @@ -39,26 +39,6 @@ Like `sequence`, `parallel` enforces that all children operate on a compatible d - **Do** use `parallel` whenever you want two or more animations to happen simultaneously. - **Don't** nest `parallel` blocks if a single one will suffice. `parallel([a, parallel([b, c])])` is the same as `parallel([a, b, c])`. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| :--- | :--- | :--- | :--- | -| `children` | `AnimationNode[]` | An array of animation nodes to play simultaneously. | — | -| `timing` | `TimingFunction` | (Optional) An easing function to apply to the entire block's timeline. | `linear` | -| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | - -#### Type Definitions - -```typescript -function parallel( - children: AnimationNode[], - timing?: TimingFunction | TimingFunction[], - id?: AnimationId -): AnimationNode; -``` - ### Related Components - [`sequence`](/en/docs/core-api/sequence) - To run animations in order instead of simultaneously. diff --git a/packages/web/content/docs/core-api/sequence.mdx b/packages/web/content/docs/core-api/sequence.mdx index f398fa0..db079b7 100644 --- a/packages/web/content/docs/core-api/sequence.mdx +++ b/packages/web/content/docs/core-api/sequence.mdx @@ -42,26 +42,6 @@ myTimeline.play({ from: { opacity: 0, x: 50 } }); - **Do** use `sequence` for multi-stage animations, like an element fading in and then moving. - **Don't** use `sequence` if you just need two properties to animate at the same time. Use `parallel` for that. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| :--- | :--- | :--- | :--- | -| `children` | `AnimationNode[]` | An array of animation nodes to play in order. | — | -| `timing` | `TimingFunction` | (Optional) An easing function to apply to the entire sequence's timeline. | `linear` | -| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | - -#### Type Definitions - -```typescript -function sequence( - children: AnimationNode[], - timing?: TimingFunction | TimingFunction[], - id?: AnimationId -): AnimationNode; -``` - ### Related Components - [`parallel`](/en/docs/core-api/parallel) - To run animations simultaneously instead of in order. diff --git a/packages/web/content/docs/core-api/stagger.mdx b/packages/web/content/docs/core-api/stagger.mdx index 641de86..57be5a2 100644 --- a/packages/web/content/docs/core-api/stagger.mdx +++ b/packages/web/content/docs/core-api/stagger.mdx @@ -43,31 +43,6 @@ myTimeline.play({ from: { opacity: 0, y: 20 } }); - **Do** use `stagger` for animating lists of items into or out of view. - **Don't** use `stagger` if you need variable delays between animations; build a custom `sequence` with `delay` nodes instead. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| :--- | :--- | :--- | :--- | -| `children` | `AnimationNode[]` | An array of animation nodes to play. | — | -| `props` | `StaggerNodeProps` | An object containing the stagger configuration. | — | -| `id` | `AnimationId` | (Optional) A unique identifier for the animation node. | `undefined` | - -#### Type Definitions - -```typescript -interface StaggerNodeProps { - offset: number; - timing?: TimingFunction | TimingFunction[]; -}; - -function stagger( - children: AnimationNode[], - props: StaggerNodeProps, - id?: AnimationId -): AnimationNode; -``` - ### Related Components - [`sequence`](/en/docs/core-api/sequence) - The underlying concept for `stagger`. diff --git a/packages/web/content/docs/core-api/states.mdx b/packages/web/content/docs/core-api/states.mdx index f01b8dd..d97762a 100644 --- a/packages/web/content/docs/core-api/states.mdx +++ b/packages/web/content/docs/core-api/states.mdx @@ -53,50 +53,6 @@ The `createStates` function returns a controller object with the following metho - **Don't** manually manage multiple `timeline` instances and their transitions; `createStates` is designed to abstract this complexity. - **Don't** create a new `createStates` instance frequently; it should be initialized once for a given set of states. -### API Reference - -#### Parameters - -The `createStates` function accepts a single `config` object with the following properties: - -| Name | Type | Description | -| :------------ | :------------------ | :-------------------------------------------------------------- | -| `initial` | `keyof states` | The key of the initial animation state to play. | -| `initialFrom` | `AniGroup<...>` | The initial `from` value for the very first animation. | -| `states` | `AnimationStateShape` | A record where keys are state names and values are animation nodes. | -| `clock` | `AnimationClock` | (Optional) A custom animation clock. | - -#### Type Definitions - -```typescript -import { AnimationClockInterface, AnimationNode, Groupable, Timeline, TimelineStartingConfig, ExtractAnimationNode, AniGroup } from '@freestylejs/ani-core'; - -type AnimationStateShape = Record>; - -type GetTimeline = Timeline, any>; - -interface StateController { - timeline: () => GetTimeline; - transitionTo( - newState: keyof AnimationStates, - timelineConfig?: TimelineStartingConfig, any>, - canBeIntercepted?: boolean - ): void; - onTimelineChange(callback: (newTimeline: GetTimeline) => void): () => void; -} - -interface StateProps { - initial: keyof AnimationStates; - initialFrom: AniGroup>; - states: AnimationStates; - clock?: AnimationClockInterface; -} - -declare function createStates( - config: StateProps -): StateController; -``` - ### Related Components - [`timeline`](/en/docs/core-api/timeline) - The underlying controller for each state within the machine. diff --git a/packages/web/content/docs/core-api/timeline.mdx b/packages/web/content/docs/core-api/timeline.mdx index 054ed95..bdcfc8a 100644 --- a/packages/web/content/docs/core-api/timeline.mdx +++ b/packages/web/content/docs/core-api/timeline.mdx @@ -1,9 +1,10 @@ --- -title: timeline -description: The main controller that runs an animation, manages its state, and controls its playback. +title: timeline (WebAPI) +description: The main controller that runs an animation, manages its state, and controls its playback. Powered by the Web Animations API. --- -The `timeline` is the execution engine for any animation you create. It takes a root animation node (like a `sequence` or `parallel` block), manages the animation's state, and controls its playback. +The `dynamicTimeline` API compiles your declarative animation tree into native browser keyframes and plays them using `Element.animate()`. +This allows animations to run on the compositor thread (where possible), resulting in silky smooth performance even when the JavaScript main thread is busy. ### Example @@ -11,89 +12,73 @@ The `timeline` is the execution engine for any animation you create. It takes a import { a } from "@freestylejs/ani-core"; // 1. Define the animation structure. -// TypeScript infers the data shape `G` as { opacity: number, x: number } const myAnimation = a.sequence([ - a.ani({ to: { opacity: 1, x: 0 }, duration: 1 }), - a.ani({ to: { opacity: 1, x: 100 }, duration: 2 }), + a.ani({ to: { translateX: 100 }, duration: 1 }), + a.ani({ to: { translateY: 100 }, duration: 1 }), ]); -// 2. Create a reusable timeline instance from the structure. -const myTimeline = a.timeline(myAnimation); +// 2. Create a web-native timeline. +const timeline = a.timeline(myAnimation); -// 3. Listen for updates to apply the animated values to your UI. -const unsubscribe = myTimeline.onUpdate(({ state }) => { - // `state` is strongly typed as { opacity: number, x: number } - console.log(`Opacity: ${state.opacity}, X: ${state.x}`); - // e.g., element.style.opacity = state.opacity; - // e.g., element.style.transform = `translateX(${state.x}px)`; -}); - -// 4. Play the animation from a starting state. -myTimeline.play({ from: { opacity: 0, x: 0 } }); +// 3. Play on a specific DOM element. +const element = document.getElementById("box"); +if (element) { + timeline.play(element, { + from: { translateX: 0, translateY: 0 }, + repeat: Infinity + }); +} ``` ### Usage & Concepts #### Overview -The `timeline` is the execution engine. It takes a static animation tree and brings it to life. It's responsible for calculating the animation's state at any given time, handling playback (play, pause, seek), and emitting update events. The key design principle is the separation of the animation's definition (the node tree) from its execution and values (the timeline). This makes animations reusable and dynamic. +Unlike the standard `dynamicTimeline` which runs frame-by-frame via JavaScript, `timeline` uses the browser's native animation engine. This offers significant performance benefits, especially for continuous animations (loops) or on lower-end devices. + +> Runs natively using web [`AnimationAPI`](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API), `timeline` does not emit `onUpdate` events. +> +> State management is handled entirely by the browser's `CSSOM` calculations. -When you call `.play()`, the timeline traverses the animation node tree and builds a flat list of "segments". Each segment represents a single `ani` call with its calculated start time, end time, and timing function. On each frame, the timeline calculates the elapsed time, finds the active segment, computes the interpolated value, and notifies subscribers via `onUpdate`. +#### Key Differences from `dynamicTimeline` + +| Feature | `dynamicTimeline` (JS) | `timeline` (Native) | +| :--- | :--- | :--- | +| **Engine** | JavaScript `requestAnimationFrame` | Browser `Element.animate()` | +| **Performance** | Good, but blocked by main thread | **Excellent**, often compositor-threaded | +| **Events** | `onUpdate` for every frame | No frame updates | +| **Properties** | **Any numeric property** | **`CSS` properties only** | #### Best Practices -- **Do** create one timeline per independent animation. -- **Do** reuse timelines when you need to play the same animation structure with different starting values. -- **Don't** create a new timeline inside a render loop or frequently. Create it once and store it. +- **Do** use `timeline` for purely visual UI transitions (fades, slides, scaling) and infinite background loops. +- **Do** use standard `CSS` property names like `translateX` instead of shorthands like `x` to ensure compatibility. +- **Don't** use `timeline` if you need to sync non-DOM elements (like Canvas or WebGL) or if you need to react to value changes every frame. -### API Reference +#### Limitation 1: Internal Value Can not be accessed. -#### Methods +> Use [`dynamicTimeline`](/en/docs/core-api/dynamic_timeline), if you should access internal animation value directly. -| Method | Description | -| :--- | :--- | -| `play(config, [canBeIntercepted])` | Starts the animation with a given configuration. | -| `pause()` | Pauses the animation at its current state. | -| `resume()` | Resumes a paused animation. | -| `reset()` | Resets the animation to its initial state. | -| `seek(time)` | Jumps to a specific time in the animation (in seconds). | -| `onUpdate(cb)` | Registers a callback for state and status updates. Returns an unsubscribe function. | +`timeline` uses `WebAnimationAPI` so we can not access value directly, which is managed by browser internal logics. -#### Type Definitions +But you can access animation values via [`getAnimations`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getAnimations) api, but it is not recommended approach. -```typescript -// Factory Function -function timeline(rootNode: AnimationNode): Timeline - -// Main play configuration -interface TimelineStartingConfig { - from: G; - keyframes?: Array; - durations?: Array; - repeat?: number; // e.g., Infinity - context?: any; -} +#### Timing Functions -// onUpdate callback signature -type OnUpdateCallback = (current: { - state: G; - status: 'IDLE' | 'PLAYING' | 'PAUSED' | 'ENDED'; -}) => void; - -// Timeline Interface -interface Timeline { - play(config: TimelineStartingConfig, canBeIntercepted?: boolean): void; - pause(): void; - resume(): void; - reset(): void; - seek(targetTime: number): void; - onUpdate(callback: OnUpdateCallback): () => void; -} -``` +Since `timeline` compiles to native `CSS` Keyframes, standard easing functions are compiled directly to `CSS` strings, while complex physics-based timings are **sampled** into a series of linear keyframes. + +| Timing Function | Compilation Behavior | +| :--- | :--- | +| `linear()` | **Compiles** to `linear`. Recommended. | +| `bezier()` | **Compiles** to `cubic-bezier(...)`. Recommended. | +| `spring()` | **Sampled(but can not be duration goes into `Infinity`)**. Compiles into high-density linear keyframes (60fps). | +| `dynamicSpring()` | **Sampled(but can not be duration goes into `Infinity`)**. Compiles frame-by-frame stateful simulation. | +| *User Custom* | **Sampled**, provided the function is stateless (pure function of time). | + +> **Note:** Using `spring()` or custom timing functions with `timeline` will generate a larger number of keyframes to approximate the curve. This is generally performant but significantly more verbose than standard `CSS` easing. Stateful animations like `dynamicSpring` (which depend on velocity from the previous frame) cannot be pre-compiled and are not supported. ### Related Components -- [`ani`](/en/docs/core-api/ani) - The basic building block for animations controlled by a timeline. -- [`sequence`](/en/docs/core-api/sequence) - A composition node to be passed to a timeline. -- [`parallel`](/en/docs/core-api/parallel) - Another composition node to be passed to a timeline. -- [`Dynamic Animations`](/en/docs/core-api/advanced/dynamic-animations) - For creating interactive animations. \ No newline at end of file +- [`dynamicTimeline`](/en/docs/core-api/dynamic_timeline) - The standard JavaScript-based controller. +- [`ani`](/en/docs/core-api/ani) - The building block for animations. +- [`Dynamic Animations`](/en/docs/core-api/advanced/dynamic-animations) - For creating interactive animations. diff --git a/packages/web/content/docs/index.mdx b/packages/web/content/docs/index.mdx index b285dd7..24283f8 100644 --- a/packages/web/content/docs/index.mdx +++ b/packages/web/content/docs/index.mdx @@ -9,9 +9,69 @@ import { BlocksIcon, ShieldCheckIcon, ZapIcon, BoxesIcon, RocketIcon, BookOpenIc ## Philosophy -**FreestyleJS Ani** is a declarative and compositional animation library designed for the modern web. It provides a set of powerful, type-safe primitives to define complex animation structures that can be reused and controlled with precision, offering dedicated, high-performance bindings for React, Solid, Svelte, and Vue. +**FreestyleJS Ani** is a declarative animation library designed for the modern web. -The core philosophy is to **separate an animation's declarative structure from its imperative execution**. You define a complex animation *once* as a static tree, and then use a controller to play it many times with different runtime values. +The core philosophy is to **separate an animation's declarative structure from its imperative execution**. + +You define a complex animation *once* as a **static tree**, and then use a controller to play it many times with different runtime values. + +### What is Animation Tree? + +![animation-tree](/ani.png) + +Let's start with some animation keyframes. As you can see above this is kind tricky for defining the structure of animation using code. + +But using ani, you can create this animation tree easily. + +```ts +import { a } from "@freestylejs/ani-core" + +const animationTree = a.loop( + a.stagger( + [ + // 1. First Group: Parallel execution of Translate X, Rotate, and Skew + a.parallel([ + a.ani({ to: { translateX: 100 }, duration: 1.5 }), // Translate X + a.sequence([ + a.delay(0.75) // Delay 0.75s + a.ani({ to: { rotate: 180 }, duration: 0.75 }), // Rotate + ]), + a.sequence([ + a.delay(0.75) // Delay 0.75s + a.ani({ to: { translateX: 20 }, duration: 1.5 }), // Skew + ]), + ]), + + // 2. Second Group: Parallel execution of Translate X and Translate Y + a.parallel([ + a.ani({ to: { translateX: 0 }, duration: 1 }), // Translate X + a.sequence([ + a.delay(0.5) // Delay 0.5s + a.ani({ to: { translateY: 50 }, duration: 1 }), // Translate Y + ]), + ]), + ], + 0.75, // Offset: Wait 0.5s before starting the second group + ), + 3 // Loop: Repeat the entire sequence 3 times +) +``` + +Yes, after this part you can create static animation keyframes using `timeline`. + +```ts +const timeline = a.timeline(animationTree) + +// You can now, have full control of animation tree. + +timeline.play(targetElement, { + from: { + translateX: 0, + translateY: 0, + rotate: 0, + } // set start values. +}) +``` ## 6x Smaller Bundle Size, but Powerful. diff --git a/packages/web/content/docs/react/use-ani-ref.mdx b/packages/web/content/docs/react/use-ani-ref.mdx index 2ef83b6..641cdc9 100644 --- a/packages/web/content/docs/react/use-ani-ref.mdx +++ b/packages/web/content/docs/react/use-ani-ref.mdx @@ -18,7 +18,7 @@ const InteractiveBox = () => { // 2. Define the animation and create a timeline. const myTimeline = useMemo( - () => a.timeline(a.ani({ to: { x: 500, rotate: 360 }, duration: 2 })), + () => a.dynamicTimeline(a.ani({ to: { x: 500, rotate: 360 }, duration: 2 })), [] ); @@ -72,40 +72,6 @@ const controller = useAniRef(ref, { - **Do** use it for complex, interactive animations that need to respond to user input at 60fps. - **Don't** use `useAniRef` if you need the animated value to render text content or pass to a child component's props. Use `useAni` for that. -### API Reference - -#### Parameters - -| Name | Type | Description | Default | -| ------- | ---------------------- | ---------------------------------------------------- | ------- | -| `ref` | `RefObject` | The React ref attached to the DOM element. | — | -| `props` | `AniRefProps` | The configuration object for the animation. | — | - -#### `AniRefProps` Object - -| Name | Type | Description | Default | -| --------------- | -------------------------- | ---------------------------------------------------------------- | ----------- | -| `timeline` | `Timeline` | The timeline instance to run. | — | -| `initialValue` | `AniGroup` | (Optional) The initial style of the element. | — | -| `events` | `EventHandlerRegistration` | (Optional) A map of event handlers for interactivity. | `undefined` | -| `cleanupEvents` | `boolean` | (Optional) Whether to automatically clean up events on unmount. | `true` | - -#### Return Value - -Returns the `TimelineController` instance, which you can use to control the animation's playback. - -#### Type Definitions - -```typescript -import { Groupable, Timeline, TimelineController, AniRefProps } from '@freestylejs/ani-core'; -import { RefObject } from 'react'; - -function useAniRef( - ref: RefObject, - props: AniRefProps -): TimelineController; -``` - ### Related Components - [`useAni`](/en/docs/react/use-ani) - For reactive animations that integrate with the component render cycle. diff --git a/packages/web/content/docs/react/use-ani-states.mdx b/packages/web/content/docs/react/use-ani-states.mdx index a0764b0..33871d9 100644 --- a/packages/web/content/docs/react/use-ani-states.mdx +++ b/packages/web/content/docs/react/use-ani-states.mdx @@ -9,7 +9,7 @@ This hook is a React-specific wrapper around the core `createStates` function. I ```tsx import { a } from "@freestylejs/ani-core"; -import { useAniRef, useAniStates } from "@freestylejs/ani-react"; +import { useAniStates } from "@freestylejs/ani-react"; import { useMemo, useRef } from "react"; const StateButton = () => { @@ -33,9 +33,9 @@ const StateButton = () => { [] ) - // 2. Use the hook to create the state machine. + // 2. Use the hook to create the state machine, passing the ref directly. // It returns the current state info and a function to transition. - const [{ timeline }, transitionTo] = useAniStates({ + const [{ timeline }, transitionTo] = useAniStates(ref, { initial: 'idle', initialFrom: { opacity: 0, @@ -44,9 +44,6 @@ const StateButton = () => { states: animations, }) - // 3. Connect the active timeline to the DOM element for high-performance updates. - useAniRef(ref, { timeline }) - return (