UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

510 lines (507 loc) • 16.4 kB
import { GroupAnimationWithThen, motionValue, animateSingleValue } from 'motion-dom'; import { removeItem } from 'motion-utils'; import { animateSequence } from '../animate/sequence.mjs'; import { calcLabelTime } from '../sequence/utils/calc-time.mjs'; let markerId = 0; /** * Copy a segment with a new `at`, adding a transition if it has none. */ function withAt(segment, at) { const copy = [...segment]; const i = typeof copy[0] === "function" ? Math.max(1, copy.length - 1) : 2; copy[i] = { ...copy[i], at }; return copy; } const label = (name, at) => at === undefined ? name : { name, at }; /** * Rewind to the start and stop, so every value is back at the value it * started from and the next build reads those values as its origin. */ function rewind(animation) { animation.pause(); animation.time = 0; animation.stop(); } /** * A sequence that can be changed after it's created. Segments, labels, * callbacks, pauses and other timelines can be added, moved and removed * at any point, including while it plays. * * Changes are batched and applied once per microtask. Each change * rebuilds the underlying animations from the timeline's starting * values, then restores the playhead, speed and play state, so changes * made while playing take effect without a jump. */ class Timeline { /** * `animations` are already running animations of `items`, as * animate() makes them. The timeline plays these until it's first * changed, so it costs nothing over the animations themselves until * then. */ constructor(items = [], options = {}, animations) { this.options = options; this.entries = []; /** * The repeat the playhead is in, to detect when it starts another. */ this.repeatIndex = 0; this.prevTime = 0; this.markers = []; this.labels = new Map(); this.totalDuration = 0; this.isDirty = false; this.isDone = false; this.isRebuilding = false; this.playbackSpeed = 1; /** * True while this plays the animations it was created with, before * any change compiles it as a sequence. */ this.isWrapped = false; this.stop = () => { this.flush(); this.group?.stop(); }; this.isPaused = options.autoplay === false; this.newFinished(); items.forEach((item) => this.add(item)); if (animations) { this.isDirty = false; this.isWrapped = true; this.group = new GroupAnimationWithThen(animations); this.watch(); } else { /** * Build now, like animate(), so selectors resolve while a * scope is attached. Later changes are batched. */ this.invalidate(); this.flush(); } /** * Register with the scope (for instance from useAnimate()) so it * stops when the scope is torn down. */ const { scope } = options; if (scope) { scope.animations.push(this); this.finished.then(() => removeItem(scope.animations, this)); } } /** * Add a segment, label or timeline. `at` overrides any `at` already * on the segment. The item itself is the handle for `move()` and * `remove()`. */ add(item, at) { if (item instanceof Timeline) item.adopt(this); return this.push({ item, at }); } /** * Fire a callback when the playhead crosses `at`, in either direction. * It receives the direction: 1 forwards, -1 backwards. */ call(callback, at) { return this.push({ item: callback, at, marker: { name: "_m" + markerId++, time: 0, callback }, }); } /** * Pause when the playhead reaches `at`. */ addPause(at, callback) { return this.push({ item: callback || {}, at, marker: { name: "_m" + markerId++, time: 0, callback, pause: true }, }); } /** * Change where an item starts. */ move(item, at) { this.entries.forEach((entry) => { if (entry.item === item) entry.at = at; }); return this.invalidate(); } /** * Remove a segment, label, callback or timeline. */ remove(item) { if (item instanceof Timeline) item.parent = undefined; this.entries = this.entries.filter((entry) => entry.item !== item); return this.invalidate(); } clear() { this.entries = []; return this.invalidate(); } /** * The time of a label in seconds, or `undefined` if there's no label * with this name. */ getLabelTime(name) { this.flush(); return this.labels.get(name); } /** * Jump to a time in seconds or a label. */ seek(position) { this.time = this.resolve(position); return this; } /** * Play forwards or backwards to a time or label, then pause there. */ playTo(position) { const target = this.resolve(position); const speed = Math.abs(this.speed); this.speed = target < this.time ? -speed : speed; this.stopAt = target; this.clock || this.invalidate(); this.play(); return this; } reverse() { this.speed = -this.speed; this.play(); return this; } get progress() { return this.duration ? this.time / this.duration : 0; } set progress(progress) { this.time = progress * this.duration; } get time() { return this.active()?.time ?? 0; } set time(time) { this.flush(); this.prevTime = time; this.group && (this.group.time = time); this.repeatIndex = this.currentRepeat(); } get speed() { return this.playbackSpeed; } set speed(speed) { this.playbackSpeed = speed; this.group && (this.group.speed = speed); } get duration() { this.flush(); return this.isWrapped ? this.group.duration : this.totalDuration; } get iterationDuration() { this.flush(); return this.isWrapped ? this.group.iterationDuration : this.totalDuration; } get state() { return this.active()?.state ?? "idle"; } get startTime() { return this.active()?.startTime ?? null; } play() { this.flush(); this.isPaused = false; this.group?.play(); /** * Watch after playing, so it waits for the restarted animations * rather than the ones that already finished. */ if (this.isDone) { this.newFinished(); this.watch(); } } pause() { this.flush(); this.isPaused = true; this.group?.pause(); } cancel() { this.flush(); this.group?.cancel(); } complete() { this.flush(); this.group?.complete(); } attachTimeline(timeline) { this.flush(); this.scrollTimeline = timeline; this.detachScroll = this.group?.attachTimeline(timeline); return () => { this.scrollTimeline = undefined; this.detachScroll?.(); }; } then(onResolve, onReject) { return this.finished.then(onResolve, onReject); } /** * The current animations, if any are running. Skipped animations * (skipAnimations, reduced motion) can leave the group empty. */ active() { this.flush(); return this.group?.animations.length ? this.group : undefined; } push(entry) { this.entries.push(entry); return this.invalidate(); } /** * Schedule a rebuild. A nested timeline passes changes up, as it's * built as part of its parent. */ invalidate() { if (this.parent) { this.parent.invalidate(); } else if (!this.isDirty) { this.isDirty = true; queueMicrotask(() => this.flush()); } return this; } /** * Apply pending changes now, so reads straight after a change * reflect it. */ flush() { this.isDirty && !this.parent && this.build(); } adopt(parent) { if (this.group) { rewind(this.group); this.group = undefined; } this.isDirty = false; this.parent = parent; } build() { this.isDirty = false; this.isWrapped = false; const prev = this.group; let time = 0; if (prev) { this.isRebuilding = true; if (this.scrollTimeline) { /** * Scroll-linked animations can't be seeked in seconds. * Cancelling also returns them to their starting values, * and the new build is attached to scroll instead. */ this.detachScroll?.(); prev.cancel(); } else { time = prev.animations.length ? prev.time : 0; rewind(prev); } this.isRebuilding = false; } const { defaultTransition, onComplete, onUpdate, onRepeat, onReverseComplete, autoplay, scope, ...options } = this.options; /** * Inherit MotionConfig settings from a useAnimate() scope. */ const playback = {}; if (scope?.reduceMotion !== undefined) { playback.reduceMotion = scope.reduceMotion; } if (scope?.skipAnimations !== undefined) { playback.skipAnimations = scope.skipAnimations; } Object.assign(playback, options); const markers = new Map(); const info = {}; const animations = animateSequence(this.toSequence(markers), { defaultTransition, ...playback }, scope, info); const labels = info.labels; let duration = playback.duration ?? info.duration; /** * A `duration` option stretches every animation to fit, so scale * label and marker times to match. */ const scale = info.duration ? duration / info.duration : 1; labels.forEach((time, name) => labels.set(name, time * scale)); /** * Markers are resolved as labels. Move them out of the label map * and extend the duration to reach any marker past the end. */ this.markers = []; markers.forEach((marker, name) => { marker.time = labels.get(name); labels.delete(name); duration = Math.max(duration, marker.time); this.markers.push(marker); }); this.markers.sort((a, b) => a.time - b.time); this.labels = labels; this.totalDuration = duration; /** * Callbacks, pauses and playTo() watch a linear clock. Without * them, the timeline is only its animations. */ this.clock = this.clockAnimation = undefined; if (this.markers.length || this.stopAt !== undefined || onUpdate || onRepeat) { const clock = (this.clock = motionValue(0)); clock.on("change", (t) => clock === this.clock && this.onClock(t)); animations.unshift((this.clockAnimation = animateSingleValue(clock, [0, duration], { ...playback, duration, ease: "linear", }))); } const group = (this.group = new GroupAnimationWithThen(animations)); time = Math.min(time, duration); if (this.isDone && time < duration) this.newFinished(); if (this.scrollTimeline) { this.detachScroll = group.attachTimeline(this.scrollTimeline); } else { group.speed = this.playbackSpeed; if (this.isPaused) group.pause(); if (time) group.time = time; } this.repeatIndex = this.currentRepeat(); /** * Treat a start from 0 as coming from just before it, so markers * at 0 fire. */ this.prevTime = time || -1; this.watch(); } toSequence(markers) { return this.entries.map(({ item, at, marker }) => { if (marker) { markers.set(marker.name, marker); return label(marker.name, at); } if (item instanceof Timeline) { return { sequence: item.toSequence(markers), at }; } if (typeof item === "string") return label(item, at); if (at === undefined) return item; return Array.isArray(item) ? withAt(item, at) : { ...item, at }; }); } resolve(position) { this.flush(); return typeof position === "number" ? position : calcLabelTime(position, this.labels) ?? this.time; } /** * The repeat the clock is in, from its time across all repeats. */ currentRepeat() { const { repeat = 0, repeatDelay = 0, delay = 0 } = this.options; const cycle = this.totalDuration + repeatDelay; const time = (this.clockAnimation?.time ?? 0) - delay; return cycle && time > 0 ? Math.min(repeat, Math.floor(time / cycle)) : 0; } onClock(time) { let prevTime = this.prevTime; this.prevTime = time; if (this.isRebuilding) return; const { onUpdate, onRepeat, repeatType = "loop" } = this.options; onUpdate?.(time); const repeatIndex = this.currentRepeat(); if (repeatIndex !== this.repeatIndex) { this.repeatIndex = repeatIndex; onRepeat?.(); /** * A loop jumps the playhead back to the start, or to the * end when playing backwards. Finish crossing the repeat * that ended, then cross the new one from its edge. */ if (repeatType === "loop") { const isForward = this.speed > 0; if (this.cross(prevTime, isForward ? this.totalDuration : 0)) { return; } prevTime = isForward ? -1 : Infinity; } } this.cross(prevTime, time); } /** * Fire markers between two playhead times. Returns `true` if it * paused. */ cross(from, to) { if (from === to) return; const isForward = to > from; const crossed = (t) => isForward ? t > from && t <= to : t < from && t >= to; const markers = isForward ? this.markers : [...this.markers].reverse(); for (const marker of markers) { if (!crossed(marker.time)) continue; marker.callback?.(isForward ? 1 : -1); if (marker.pause) return this.pauseAt(marker.time); } if (this.stopAt !== undefined && crossed(this.stopAt)) { this.pauseAt(this.stopAt); this.stopAt = undefined; return true; } } pauseAt(time) { this.pause(); this.time = time; return true; } watch() { const group = this.group; group.finished.then(() => { if (group !== this.group || this.isDone) return; this.isDone = true; const { onComplete, onReverseComplete } = this.options; /** * Without onReverseComplete, onComplete fires in either * direction, as it does for a single animation. */ const callback = (this.speed < 0 && onReverseComplete) || onComplete; callback?.(); this.resolveFinished(); }); } newFinished() { this.isDone = false; this.finished = new Promise((resolve) => { this.resolveFinished = resolve; }); } } export { Timeline }; //# sourceMappingURL=index.mjs.map