Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e29e974
refactor: change segment duration as required property
danpacho Nov 17, 2025
3940ece
refactor: change segment timing can be record based on input Groupabl…
danpacho Nov 17, 2025
2e70f2a
feat(timeline): add initial delay support at timeline
danpacho Nov 17, 2025
7af0ff8
test(timeline): add delay feature test suites
danpacho Nov 17, 2025
9a7ae50
feat(dynamic_spring): add new dynamic state(v/s) managed spring physi…
danpacho Nov 17, 2025
bb83ee7
chore: export dynamic timing function at namespace
danpacho Nov 17, 2025
50efd0c
chore: update pnpm-lock
danpacho Nov 17, 2025
1f8d996
refactor: update blog schema
danpacho Nov 17, 2025
03e8045
style: add hide scrollbar opt, sync type-def
danpacho Nov 17, 2025
2f74781
refactor: adjust new blog schema
danpacho Nov 17, 2025
8dc8701
refactor: change code block component into client
danpacho Nov 17, 2025
8a0abb0
refactor: support card show btn for live example, adjust card styling
danpacho Nov 17, 2025
7bf264c
style: change copy btn variant into outline
danpacho Nov 17, 2025
a16630d
style: change blog page structure, remove toc
danpacho Nov 17, 2025
905da75
refactor: add live code example toggle supoprt at feature card
danpacho Nov 17, 2025
d134dd7
feat: add pointer following ball example for dynamic spring
danpacho Nov 17, 2025
428e579
refactor: demo components
danpacho Nov 17, 2025
523c32e
refactor: adjust demo components for component and code
danpacho Nov 17, 2025
adf25c2
style: format pkg.json for vue
danpacho Nov 17, 2025
08fc88e
release: @freestylejs/ani-core 1.1.0
danpacho Nov 17, 2025
a24820b
chore: update pnpm-lock
danpacho Nov 17, 2025
142e964
docs: update ani segment prop doc
danpacho Nov 17, 2025
6d7191c
refactor: dynamic ball component
danpacho Nov 17, 2025
46dfaf6
docs: add timing function concept
danpacho Nov 17, 2025
7b4b05f
docs: add advanced custom timing function creation guidelines
danpacho Nov 17, 2025
e240164
docs: add metadata for doc
danpacho Nov 17, 2025
0677706
docs: add writing ruleset
danpacho Nov 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @freestylejs/ani-core

## 1.1.0

### Minor Changes

- Support dynamic sptring feature and initial delay(MS) at core.

## 1.0.0

### Major Changes
Expand Down
11 changes: 8 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
{
"name": "@freestylejs/ani-core",
"author": "freestyle",
"version": "1.0.0",
"version": "1.1.0",
"description": "Core functionality for the Ani animation library.",
"license": "MIT",
"type": "module",
"keywords": ["animation", "core"],
"keywords": [
"animation",
"core"
],
"sideEffects": false,
"private": false,
"publishConfig": {
"access": "public"
},
"files": ["dist"],
"files": [
"dist"
],
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
Expand Down
149 changes: 149 additions & 0 deletions packages/core/src/ani/__tests__/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,4 +556,153 @@ 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 onUpdate = vi.fn()
line.onUpdate(onUpdate)

line.play({ from: [0], delay: 0.5 * 1e3 })

// Initial state should be set immediately
expect(onUpdate).toHaveBeenCalledWith({
state: [0],
status: 'PLAYING',
})

// During the delay, state should not change
clock.tick(0.25)
expect(onUpdate).toHaveBeenCalledTimes(1) // No new updates

clock.tick(0.25) // Delay finishes
expect(onUpdate).toHaveBeenCalledTimes(1)

// After delay, animation starts
clock.tick(0.5) // Halfway through animation
expect(onUpdate).toHaveBeenLastCalledWith({
state: [50],
status: 'PLAYING',
})

clock.tick(0.5) // Animation finishes
expect(onUpdate).toHaveBeenLastCalledWith({
state: [100],
status: 'ENDED',
})
})

it('should handle a delay of zero correctly', () => {
const root = ani({ to: [100], duration: 1, timing: linear })
const line = timeline(root, clock)
const onUpdate = vi.fn()
line.onUpdate(onUpdate)

line.play({ from: [0], delay: 0 * 1e3 }), clock.tick(0.5)
expect(onUpdate).toHaveBeenLastCalledWith({
state: [50],
status: 'PLAYING',
})
})

it('should pause and resume during the delay period', () => {
const root = ani({ to: [100], duration: 1, timing: linear })
const line = timeline(root, clock)
const onUpdate = vi.fn()
line.onUpdate(onUpdate)

line.play({ from: [0], delay: 1 * 1e3 })

clock.tick(0.5) // Halfway through delay
line.pause()

// Tick while paused, nothing should happen
const callCount = onUpdate.mock.calls.length
clock.tick(1)
expect(onUpdate).toHaveBeenCalledTimes(callCount)

line.resume()
clock.tick(0.25) // Tick for half of the remaining delay
expect(onUpdate).toHaveBeenCalledTimes(callCount) // Still in delay

clock.tick(0.25) // Remaining delay finishes
clock.tick(0.5) // Animation is halfway
expect(onUpdate).toHaveBeenLastCalledWith({
state: [50],
status: 'PLAYING',
})
})

it('should reset correctly during the delay period', () => {
const root = ani({ to: [100], duration: 1, timing: linear })
const line = timeline(root, clock)
const onUpdate = vi.fn()
line.onUpdate(onUpdate)

line.play({ from: [0], delay: 1 * 1e3 })
clock.tick(0.5)

line.reset()
expect(onUpdate).toHaveBeenLastCalledWith({
state: [],
status: 'IDLE',
})

// Ensure clock subscription is removed
const callCount = onUpdate.mock.calls.length
clock.tick(1)
expect(onUpdate).toHaveBeenCalledTimes(callCount)
})

it('should bypass the delay when seeking', () => {
const root = ani({ to: [100], duration: 1, timing: linear })
const line = timeline(root, clock)
const onUpdate = vi.fn()
line.onUpdate(onUpdate)

line.play({ from: [0], delay: 1 * 1e3 })
line.seek(0.5)

expect(onUpdate).toHaveBeenLastCalledWith({
state: [50],
status: 'PAUSED',
})
})

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 onUpdate = vi.fn()
line.onUpdate(onUpdate)

line.play({ from: [0], delay: 0.5 * 1e3, repeat: 1 })

// Initial delay
clock.tick(0.5)

// First run
clock.tick(1)
expect(onUpdate).toHaveBeenLastCalledWith({
state: [100],
status: 'PLAYING',
})

line.play({
from: [0],
delay: 0 * 1e3,
})
expect(onUpdate).toHaveBeenCalledTimes(3) // tick(0.5) + tick(1) + play()
expect(onUpdate).toHaveBeenLastCalledWith({
state: [0],
status: 'PLAYING',
})

clock.tick(0.5)
expect(onUpdate).toHaveBeenLastCalledWith({
state: [50],
status: 'PLAYING',
})
})
})
})
8 changes: 7 additions & 1 deletion packages/core/src/ani/engine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TimingFunction } from '~/timing'
import { isEndOfAnimation } from '~/utils/time'
import type { Groupable, GroupableRecord } from './timeline'

export type AnimePrimitive = readonly number[]

Expand All @@ -10,7 +11,12 @@ export interface SegmentDefinition {
timing: SegmentTiming
}

export type SegmentTiming = TimingFunction | readonly TimingFunction[]
export type SegmentTiming<G extends Groupable = Groupable> =
G extends AnimePrimitive
? readonly TimingFunction[] | TimingFunction
: G extends GroupableRecord
? Record<keyof G, TimingFunction> | TimingFunction
: never

export interface SegmentState {
values: AnimePrimitive
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/ani/nodes/segment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { type AnimationId, AnimationNode } from './base'

export interface SegmentNodeProps<G extends Groupable> {
readonly to: G
readonly duration?: number
readonly timing?: SegmentTiming
readonly duration: number
readonly timing?: SegmentTiming<G>
}

/**
Expand All @@ -19,14 +19,14 @@ export class SegmentNode<G extends Groupable> extends AnimationNode<G> {
super(id)
const nodeProps: SegmentNodeProps<G> = {
to: props.to,
duration: props.duration ?? 0,
duration: props.duration,
...(props.timing !== undefined && { timing: props.timing }),
}
this.props = nodeProps
}

get duration(): number {
return this.props.duration!
public get duration(): number {
return this.props.duration
}

public construct(plan: ExecutionPlan<G>, startTime: number): void {
Expand Down
43 changes: 38 additions & 5 deletions packages/core/src/ani/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type AnimationClockInterface,
} from '~/loop'
import type { Resolver, StylesheetSupportedLiteral } from '~/style'
import { TimingFunction } from '~/timing'
import { isEndOfAnimation } from '~/utils/time'
import type {
Prettify,
Expand Down Expand Up @@ -83,6 +84,13 @@ export interface TimelineStartingConfig<G extends Groupable, Ctx = any> {
*/
repeat?: number

/**
* Initial delay before animation starts.
*
* - unit = `MS`
*/
delay?: number

/**
* Custom style property resolver.
*
Expand Down Expand Up @@ -143,6 +151,8 @@ export class Timeline<G extends Groupable, Ctx = any>

private _masterTime = 0

private _delay = 0

private _status: TimelineStatus = 'IDLE'
private _currentConfig: TimelineStartingConfig<G, Ctx> | null = null
/**
Expand Down Expand Up @@ -264,11 +274,19 @@ export class Timeline<G extends Groupable, Ctx = any>

// From value calculations
let fromValues: Array<number> = []
const timings: Array<TimingFunction> = []
const t = segment.node.props.timing
const isRecordTiming: boolean = t && !(t instanceof TimingFunction)

if (isRecordAni) {
for (const key of keyMap!.keys()) {
fromValues.push(
stateAtLastStartTime[this._propertyKeyMap!.get(key)!]!
)
const index = this._propertyKeyMap!.get(key)!
fromValues.push(stateAtLastStartTime[index]!)
if (isRecordTiming) {
timings.push(
(t as Record<string, TimingFunction>)[key]!
)
}
}
} else {
fromValues = stateAtLastStartTime
Expand All @@ -282,7 +300,7 @@ export class Timeline<G extends Groupable, Ctx = any>
from: fromValues,
to: toValues,
duration: segment.node.duration,
timing: segment.node.props.timing,
timing: isRecordAni && isRecordTiming ? timings : t,
}

const segmentState = calculateSegmentState(
Expand Down Expand Up @@ -424,6 +442,10 @@ export class Timeline<G extends Groupable, Ctx = any>
// 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
Expand All @@ -436,7 +458,7 @@ export class Timeline<G extends Groupable, Ctx = any>

this._status = 'PLAYING'
this._clock.subscribe(this)
this.update(0) // Render initial frame
this.notify()
}

public pause(): void {
Expand All @@ -456,6 +478,7 @@ export class Timeline<G extends Groupable, Ctx = any>
this._currentConfig = null

this._masterTime = 0
this._delay = 0

this._state = []
this._initialState = []
Expand Down Expand Up @@ -503,6 +526,16 @@ export class Timeline<G extends Groupable, Ctx = any>
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
Expand Down
Loading
Loading