@humanspeak/svelte-motion 0.4.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html/_MotionContainer.svelte +8 -8
- package/dist/index.d.ts +17 -11
- package/dist/index.js +9 -9
- package/dist/utils/attachable.js +14 -9
- package/dist/utils/augmentMotionValue.svelte.d.ts +156 -0
- package/dist/utils/augmentMotionValue.svelte.js +193 -0
- package/dist/utils/booleanSnapshot.svelte.d.ts +37 -0
- package/dist/utils/booleanSnapshot.svelte.js +48 -0
- package/dist/utils/dom.d.ts +13 -0
- package/dist/utils/dom.js +19 -0
- package/dist/utils/inView.svelte.d.ts +209 -0
- package/dist/utils/inView.svelte.js +323 -0
- package/dist/utils/motionTemplate.svelte.d.ts +53 -0
- package/dist/utils/motionTemplate.svelte.js +78 -0
- package/dist/utils/motionValue.svelte.d.ts +61 -0
- package/dist/utils/motionValue.svelte.js +49 -0
- package/dist/utils/reducedMotion.svelte.d.ts +43 -0
- package/dist/utils/reducedMotion.svelte.js +80 -0
- package/dist/utils/reducedMotionConfig.svelte.d.ts +74 -0
- package/dist/utils/reducedMotionConfig.svelte.js +144 -0
- package/dist/utils/scroll.svelte.d.ts +91 -0
- package/dist/utils/scroll.svelte.js +259 -0
- package/dist/utils/spring.svelte.d.ts +2 -6
- package/dist/utils/spring.svelte.js +6 -70
- package/dist/utils/time.svelte.d.ts +47 -0
- package/dist/utils/time.svelte.js +128 -0
- package/dist/utils/transform.svelte.d.ts +170 -0
- package/dist/utils/transform.svelte.js +189 -0
- package/dist/utils/velocity.svelte.d.ts +61 -0
- package/dist/utils/velocity.svelte.js +132 -0
- package/package.json +1 -1
- package/dist/utils/inView.d.ts +0 -136
- package/dist/utils/inView.js +0 -266
- package/dist/utils/motionTemplate.d.ts +0 -21
- package/dist/utils/motionTemplate.js +0 -33
- package/dist/utils/motionValue.d.ts +0 -6
- package/dist/utils/motionValue.js +0 -13
- package/dist/utils/reducedMotion.d.ts +0 -20
- package/dist/utils/reducedMotion.js +0 -42
- package/dist/utils/reducedMotionConfig.d.ts +0 -39
- package/dist/utils/reducedMotionConfig.js +0 -92
- package/dist/utils/scroll.d.ts +0 -63
- package/dist/utils/scroll.js +0 -79
- package/dist/utils/time.d.ts +0 -14
- package/dist/utils/time.js +0 -68
- package/dist/utils/transform.d.ts +0 -74
- package/dist/utils/transform.js +0 -211
- package/dist/utils/velocity.d.ts +0 -15
- package/dist/utils/velocity.js +0 -62
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { scroll } from 'motion';
|
|
2
|
+
import { cancelMicrotask, microtask, motionValue, supportsScrollTimeline, supportsViewTimeline } from 'motion-dom';
|
|
3
|
+
import { augmentMotionValue } from './augmentMotionValue.svelte.js';
|
|
4
|
+
import { isRefPending, resolveElement } from './dom.js';
|
|
5
|
+
/**
|
|
6
|
+
* Named view-timeline presets — mirror framer-motion's ScrollOffset table.
|
|
7
|
+
* Each entry is a pair of `[target, container]` intersection points that
|
|
8
|
+
* defines a phase of the scroll relationship.
|
|
9
|
+
*/
|
|
10
|
+
const VIEW_TIMELINE_PRESETS = [
|
|
11
|
+
[
|
|
12
|
+
[
|
|
13
|
+
[0, 1],
|
|
14
|
+
[1, 1]
|
|
15
|
+
],
|
|
16
|
+
'entry'
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
[
|
|
20
|
+
[0, 0],
|
|
21
|
+
[1, 0]
|
|
22
|
+
],
|
|
23
|
+
'exit'
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
[
|
|
27
|
+
[1, 0],
|
|
28
|
+
[0, 1]
|
|
29
|
+
],
|
|
30
|
+
'cover'
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
[
|
|
34
|
+
[0, 0],
|
|
35
|
+
[1, 1]
|
|
36
|
+
],
|
|
37
|
+
'contain'
|
|
38
|
+
]
|
|
39
|
+
];
|
|
40
|
+
const PROGRESS_BY_STRING = { start: 0, end: 1 };
|
|
41
|
+
/**
|
|
42
|
+
* Parse `"start end"` / `"end start"` / etc. into an explicit
|
|
43
|
+
* `[target, container]` intersection pair.
|
|
44
|
+
*/
|
|
45
|
+
const parseStringOffset = (s) => {
|
|
46
|
+
const parts = s.trim().split(/\s+/);
|
|
47
|
+
if (parts.length !== 2)
|
|
48
|
+
return undefined;
|
|
49
|
+
const a = PROGRESS_BY_STRING[parts[0]];
|
|
50
|
+
const b = PROGRESS_BY_STRING[parts[1]];
|
|
51
|
+
if (a === undefined || b === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
return [a, b];
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a {@link ScrollOffset} into an array of explicit intersection
|
|
57
|
+
* pairs, or `undefined` if the shape doesn't match the simple two-element
|
|
58
|
+
* preset form.
|
|
59
|
+
*/
|
|
60
|
+
const normaliseOffset = (offset) => {
|
|
61
|
+
if (offset.length !== 2)
|
|
62
|
+
return undefined;
|
|
63
|
+
const result = [];
|
|
64
|
+
for (const item of offset) {
|
|
65
|
+
if (Array.isArray(item)) {
|
|
66
|
+
result.push(item);
|
|
67
|
+
}
|
|
68
|
+
else if (typeof item === 'string') {
|
|
69
|
+
const parsed = parseStringOffset(item);
|
|
70
|
+
if (!parsed)
|
|
71
|
+
return undefined;
|
|
72
|
+
result.push(parsed);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
const matchesPreset = (offset, preset) => {
|
|
81
|
+
const normalised = normaliseOffset(offset);
|
|
82
|
+
if (!normalised)
|
|
83
|
+
return false;
|
|
84
|
+
for (let i = 0; i < 2; i++) {
|
|
85
|
+
const o = normalised[i];
|
|
86
|
+
const p = preset[i];
|
|
87
|
+
if (o[0] !== p[0] || o[1] !== p[1])
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Map a {@link ScrollOffset} to its corresponding CSS view-timeline range, if
|
|
94
|
+
* any. Returning `undefined` signals the caller to fall back to JS-driven
|
|
95
|
+
* scroll tracking. Mirrors framer-motion's `offsetToViewTimelineRange`.
|
|
96
|
+
*/
|
|
97
|
+
const offsetToViewTimelineRange = (offset) => {
|
|
98
|
+
if (!offset)
|
|
99
|
+
return { rangeStart: 'contain 0%', rangeEnd: 'contain 100%' };
|
|
100
|
+
for (const [preset, name] of VIEW_TIMELINE_PRESETS) {
|
|
101
|
+
if (matchesPreset(offset, preset)) {
|
|
102
|
+
return { rangeStart: `${name} 0%`, rangeEnd: `${name} 100%` };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Whether this scroll configuration can be driven by a native CSS
|
|
109
|
+
* scroll-timeline / view-timeline. When `true`, the resulting motion value
|
|
110
|
+
* runs on the compositor thread without per-frame JS callbacks.
|
|
111
|
+
*/
|
|
112
|
+
const canAccelerateScroll = (target, offset) => {
|
|
113
|
+
if (typeof window === 'undefined')
|
|
114
|
+
return false;
|
|
115
|
+
return target
|
|
116
|
+
? supportsViewTimeline() && !!offsetToViewTimelineRange(offset)
|
|
117
|
+
: supportsScrollTimeline();
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Build the AccelerateConfig for a progress motion value driven by a native
|
|
121
|
+
* scroll-timeline / view-timeline animation. Mirrors framer-motion's
|
|
122
|
+
* `makeAccelerateConfig` 1:1: the `factory` defers `scroll()` until refs
|
|
123
|
+
* hydrate via `microtask.read`, then attaches the animation; the `times` /
|
|
124
|
+
* `keyframes` / `ease` / `duration` describe the 0→1 linear mapping.
|
|
125
|
+
*/
|
|
126
|
+
const makeAccelerateConfig = (axis, options) => ({
|
|
127
|
+
factory: (animation) => {
|
|
128
|
+
let cleanup;
|
|
129
|
+
const start = () => {
|
|
130
|
+
if (isRefPending(options.container) || isRefPending(options.target)) {
|
|
131
|
+
microtask.read(start);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
cleanup = scroll(animation, {
|
|
135
|
+
offset: options.offset,
|
|
136
|
+
axis,
|
|
137
|
+
container: resolveElement(options.container),
|
|
138
|
+
target: resolveElement(options.target)
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
microtask.read(start);
|
|
142
|
+
return () => {
|
|
143
|
+
cancelMicrotask(start);
|
|
144
|
+
cleanup?.();
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
times: [0, 1],
|
|
148
|
+
keyframes: [0, 1],
|
|
149
|
+
ease: (v) => v,
|
|
150
|
+
duration: 1
|
|
151
|
+
});
|
|
152
|
+
/**
|
|
153
|
+
* Creates scroll-linked motion values for building scroll-driven animations
|
|
154
|
+
* such as progress indicators and parallax effects.
|
|
155
|
+
*
|
|
156
|
+
* Returns four `MotionValue<number>`s: `scrollX` / `scrollY` (current
|
|
157
|
+
* position in px) and `scrollXProgress` / `scrollYProgress` (0–1
|
|
158
|
+
* normalised). Each is a real motion-dom `MotionValue` augmented with a
|
|
159
|
+
* `$state`-backed `.current` getter and a `.subscribe` shim, so they
|
|
160
|
+
* compose with `useTransform`, `useSpring`, and the rest of the Tier 2
|
|
161
|
+
* surface, and they read reactively in Svelte 5 templates.
|
|
162
|
+
*
|
|
163
|
+
* **GPU-accelerated when supported.** On browsers that implement CSS
|
|
164
|
+
* scroll-timeline (no `target`) or view-timeline (with a `target` and a
|
|
165
|
+
* preset `offset`), the *Progress motion values run on the compositor
|
|
166
|
+
* thread via WAAPI — no per-frame JS callback. The non-progress
|
|
167
|
+
* `scrollX` / `scrollY` motion values always use the JS-driven `scroll()`
|
|
168
|
+
* primitive since the absolute pixel offset isn't directly available from
|
|
169
|
+
* native timelines.
|
|
170
|
+
*
|
|
171
|
+
* `container` and `target` accept either an `HTMLElement` directly or a
|
|
172
|
+
* getter `() => HTMLElement | undefined`. The getter form is the right
|
|
173
|
+
* choice with `bind:this`. Element resolution is deferred to a microtask
|
|
174
|
+
* (matches React framer-motion 1:1, faster than rAF polling), so a getter
|
|
175
|
+
* that hasn't hydrated yet is retried as soon as Svelte's mount tick
|
|
176
|
+
* settles it.
|
|
177
|
+
*
|
|
178
|
+
* Lifecycle: the underlying `scroll()` observer and any accelerate factory
|
|
179
|
+
* attach at mount via `$effect` and detach at unmount, regardless of how
|
|
180
|
+
* many consumers are reading the values. The four motion values are torn
|
|
181
|
+
* down at the same time.
|
|
182
|
+
*
|
|
183
|
+
* SSR-safe: returns four static `motionValue(0)`s with no scroll observer
|
|
184
|
+
* on the server.
|
|
185
|
+
*
|
|
186
|
+
* @param options Optional scroll tracking configuration.
|
|
187
|
+
* @returns Four `MotionValue<number>`s — `scrollX`, `scrollY`, `scrollXProgress`, `scrollYProgress`.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```svelte
|
|
191
|
+
* <script>
|
|
192
|
+
* import { useScroll, useSpring } from '@humanspeak/svelte-motion'
|
|
193
|
+
*
|
|
194
|
+
* const { scrollYProgress } = useScroll()
|
|
195
|
+
* const scaleX = useSpring(scrollYProgress)
|
|
196
|
+
* </script>
|
|
197
|
+
*
|
|
198
|
+
* <div style="transform: scaleX({scaleX.current}); transform-origin: left;" />
|
|
199
|
+
* ```
|
|
200
|
+
*
|
|
201
|
+
* @see https://motion.dev/docs/react-use-scroll
|
|
202
|
+
*/
|
|
203
|
+
export const useScroll = (options = {}) => {
|
|
204
|
+
const scrollX = motionValue(0);
|
|
205
|
+
const scrollY = motionValue(0);
|
|
206
|
+
const scrollXProgress = motionValue(0);
|
|
207
|
+
const scrollYProgress = motionValue(0);
|
|
208
|
+
// SSR: return static motion values with no observer and no $effect.
|
|
209
|
+
if (typeof window === 'undefined') {
|
|
210
|
+
return {
|
|
211
|
+
scrollX: augmentMotionValue(scrollX),
|
|
212
|
+
scrollY: augmentMotionValue(scrollY),
|
|
213
|
+
scrollXProgress: augmentMotionValue(scrollXProgress),
|
|
214
|
+
scrollYProgress: augmentMotionValue(scrollYProgress)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// The *Progress MVs accelerate to the compositor thread when the browser
|
|
218
|
+
// supports CSS scroll/view-timelines; the non-progress scrollX/scrollY
|
|
219
|
+
// MVs always need the JS callback for absolute pixel offsets.
|
|
220
|
+
if (canAccelerateScroll(options.target, options.offset)) {
|
|
221
|
+
scrollXProgress.accelerate = makeAccelerateConfig('x', options);
|
|
222
|
+
scrollYProgress.accelerate = makeAccelerateConfig('y', options);
|
|
223
|
+
}
|
|
224
|
+
let cleanup;
|
|
225
|
+
const start = () => {
|
|
226
|
+
if (isRefPending(options.container) || isRefPending(options.target)) {
|
|
227
|
+
microtask.read(start);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
cleanup = scroll((_progress, info) => {
|
|
231
|
+
scrollX.set(info.x.current);
|
|
232
|
+
scrollY.set(info.y.current);
|
|
233
|
+
scrollXProgress.set(info.x.progress);
|
|
234
|
+
scrollYProgress.set(info.y.progress);
|
|
235
|
+
}, {
|
|
236
|
+
container: resolveElement(options.container),
|
|
237
|
+
target: resolveElement(options.target),
|
|
238
|
+
offset: options.offset,
|
|
239
|
+
axis: options.axis
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
$effect(() => {
|
|
243
|
+
microtask.read(start);
|
|
244
|
+
return () => {
|
|
245
|
+
cancelMicrotask(start);
|
|
246
|
+
cleanup?.();
|
|
247
|
+
scrollX.destroy();
|
|
248
|
+
scrollY.destroy();
|
|
249
|
+
scrollXProgress.destroy();
|
|
250
|
+
scrollYProgress.destroy();
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
scrollX: augmentMotionValue(scrollX),
|
|
255
|
+
scrollY: augmentMotionValue(scrollY),
|
|
256
|
+
scrollXProgress: augmentMotionValue(scrollXProgress),
|
|
257
|
+
scrollYProgress: augmentMotionValue(scrollYProgress)
|
|
258
|
+
};
|
|
259
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FollowValueOptions, type MotionValue, type SpringOptions } from 'motion-dom';
|
|
2
2
|
import { type Readable } from 'svelte/store';
|
|
3
|
+
import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
|
|
3
4
|
/**
|
|
4
5
|
* Spring + follow options for {@link useSpring}.
|
|
5
6
|
*
|
|
@@ -25,12 +26,7 @@ export type UseSpringOptions = SpringOptions & Pick<FollowValueOptions, 'skipIni
|
|
|
25
26
|
* spring be used with `$spring` template syntax, `get(spring)`, and as a
|
|
26
27
|
* dependency in `useTransform`'s function form.
|
|
27
28
|
*/
|
|
28
|
-
export type SpringMotionValue<T extends number | string> =
|
|
29
|
-
/** Reactive read in Svelte 5 templates / `$derived` / `$effect`. */
|
|
30
|
-
readonly current: T;
|
|
31
|
-
/** Svelte readable store compatibility. */
|
|
32
|
-
subscribe: (run: (value: T) => void) => () => void;
|
|
33
|
-
};
|
|
29
|
+
export type SpringMotionValue<T extends number | string> = AugmentedMotionValue<T>;
|
|
34
30
|
/**
|
|
35
31
|
* Creates a spring-animated `MotionValue`.
|
|
36
32
|
*
|
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
import { attachFollow, isMotionValue, motionValue } from 'motion-dom';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
* Detects a Svelte readable store. Excludes motion-dom `MotionValue` instances
|
|
5
|
-
* (which also expose `subscribe`-shaped APIs in some versions) so the
|
|
6
|
-
* MotionValue path is preferred.
|
|
7
|
-
*/
|
|
8
|
-
const isSvelteReadable = (value) => {
|
|
9
|
-
return (!!value &&
|
|
10
|
-
typeof value === 'object' &&
|
|
11
|
-
typeof value.subscribe === 'function' &&
|
|
12
|
-
!isMotionValue(value));
|
|
13
|
-
};
|
|
2
|
+
import {} from 'svelte/store';
|
|
3
|
+
import { augmentMotionValue, isSvelteReadable, sampleSource } from './augmentMotionValue.svelte.js';
|
|
14
4
|
export function useSpring(source, options = {}) {
|
|
15
5
|
// SSR: return a static MotionValue with no animation. Reads return the
|
|
16
6
|
// best-effort initial value; .set / .jump become no-ops to avoid drifting
|
|
17
7
|
// away from the server-rendered snapshot.
|
|
18
8
|
if (typeof window === 'undefined') {
|
|
19
|
-
const initial =
|
|
9
|
+
const initial = sampleSource(source);
|
|
20
10
|
const ssrValue = motionValue(initial);
|
|
21
11
|
ssrValue.set = () => undefined;
|
|
22
12
|
ssrValue.jump = () => undefined;
|
|
23
|
-
return
|
|
13
|
+
return augmentMotionValue(ssrValue);
|
|
24
14
|
}
|
|
25
15
|
// Resolve initial + follow source.
|
|
26
16
|
let followSource;
|
|
@@ -32,7 +22,7 @@ export function useSpring(source, options = {}) {
|
|
|
32
22
|
else if (isSvelteReadable(source)) {
|
|
33
23
|
// Bridge a Svelte readable into a MotionValue so attachFollow can
|
|
34
24
|
// track it. Synchronous initial sample comes from svelte/store's get().
|
|
35
|
-
const initialFromReadable =
|
|
25
|
+
const initialFromReadable = sampleSource(source);
|
|
36
26
|
svelteBridge = motionValue(initialFromReadable);
|
|
37
27
|
cleanupReadableBridge = source.subscribe((v) => {
|
|
38
28
|
// The Svelte readable contract calls the subscriber synchronously
|
|
@@ -60,59 +50,5 @@ export function useSpring(source, options = {}) {
|
|
|
60
50
|
svelteBridge?.destroy();
|
|
61
51
|
};
|
|
62
52
|
$effect(() => () => value.destroy());
|
|
63
|
-
return
|
|
53
|
+
return augmentMotionValue(value, dispose);
|
|
64
54
|
}
|
|
65
|
-
/**
|
|
66
|
-
* Pull the synchronous initial value out of any accepted source form.
|
|
67
|
-
*/
|
|
68
|
-
const readInitial = (source) => {
|
|
69
|
-
if (typeof source === 'number' || typeof source === 'string')
|
|
70
|
-
return source;
|
|
71
|
-
if (isMotionValue(source))
|
|
72
|
-
return source.get();
|
|
73
|
-
if (isSvelteReadable(source))
|
|
74
|
-
return get(source);
|
|
75
|
-
return 0;
|
|
76
|
-
};
|
|
77
|
-
/**
|
|
78
|
-
* Layer Svelte-friendly affordances onto a motion-dom MotionValue: a
|
|
79
|
-
* `$state`-tracked `.current` accessor (routing motion-dom's internal
|
|
80
|
-
* `this.current = v` writes through `$state` so templates and `$derived` /
|
|
81
|
-
* `$effect` re-run) and a Svelte readable store `.subscribe(run)` shim.
|
|
82
|
-
*/
|
|
83
|
-
const augmentForSvelte = (value, dispose) => {
|
|
84
|
-
let current = $state(value.get());
|
|
85
|
-
Object.defineProperty(value, 'current', {
|
|
86
|
-
get: () => current,
|
|
87
|
-
// Same-value writes are no-ops: motion-dom's `updateAndNotify` calls
|
|
88
|
-
// `setCurrent(v)` before its own change check, so spring frames at
|
|
89
|
-
// rest still hit this setter; skipping equal writes avoids gratuitous
|
|
90
|
-
// accessor work even though $state would itself dedupe.
|
|
91
|
-
set: (v) => {
|
|
92
|
-
if (v !== current)
|
|
93
|
-
current = v;
|
|
94
|
-
},
|
|
95
|
-
enumerable: true,
|
|
96
|
-
configurable: true
|
|
97
|
-
});
|
|
98
|
-
const originalDestroy = value.destroy.bind(value);
|
|
99
|
-
let destroyed = false;
|
|
100
|
-
value.destroy = () => {
|
|
101
|
-
if (destroyed)
|
|
102
|
-
return;
|
|
103
|
-
destroyed = true;
|
|
104
|
-
dispose();
|
|
105
|
-
originalDestroy();
|
|
106
|
-
};
|
|
107
|
-
const subscribe = (run) => {
|
|
108
|
-
run(value.get());
|
|
109
|
-
return value.on('change', run);
|
|
110
|
-
};
|
|
111
|
-
Object.defineProperty(value, 'subscribe', {
|
|
112
|
-
value: subscribe,
|
|
113
|
-
writable: false,
|
|
114
|
-
enumerable: false,
|
|
115
|
-
configurable: true
|
|
116
|
-
});
|
|
117
|
-
return value;
|
|
118
|
-
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
|
|
2
|
+
/**
|
|
3
|
+
* Returns an augmented `MotionValue<number>` that ticks once per render
|
|
4
|
+
* frame with the milliseconds elapsed since the value was created.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors React framer-motion's `useTime` 1:1: a `MotionValue<number>`
|
|
7
|
+
* driven by motion-dom's `frame.update(tick, true)` keep-alive callback.
|
|
8
|
+
* The frame loop dedupes per-frame work across all motion-dom consumers,
|
|
9
|
+
* so multiple `useTime()` calls share the same render schedule.
|
|
10
|
+
*
|
|
11
|
+
* Two modes:
|
|
12
|
+
*
|
|
13
|
+
* - **Unique timeline** — `useTime()` starts its own keep-alive callback.
|
|
14
|
+
* The motion value and the callback are torn down when the surrounding
|
|
15
|
+
* `$effect` scope unmounts.
|
|
16
|
+
* - **Shared timeline** — `useTime(id)` callers passing the same `id` all
|
|
17
|
+
* observe a single shared frame-loop callback. Each call still returns
|
|
18
|
+
* an independent motion value (so destroying one consumer's value
|
|
19
|
+
* doesn't ripple to others) but the values stay in lockstep. The
|
|
20
|
+
* shared callback cancels the moment the last consumer unmounts; the
|
|
21
|
+
* next `useTime(id)` call restarts it.
|
|
22
|
+
*
|
|
23
|
+
* The result is augmented with a `$state`-backed `.current` getter and a
|
|
24
|
+
* `.subscribe` shim — it composes with `useTransform`, `useSpring`, and
|
|
25
|
+
* the rest of the Tier 2 surface.
|
|
26
|
+
*
|
|
27
|
+
* Lifecycle: must be called during component initialization. SSR-safe:
|
|
28
|
+
* returns a static `motionValue(0)` with no frame loop on the server.
|
|
29
|
+
*
|
|
30
|
+
* @param id Optional timeline identifier for sharing across components.
|
|
31
|
+
* @returns A `MotionValue<number>` with `.current` and `.subscribe`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```svelte
|
|
35
|
+
* <script>
|
|
36
|
+
* import { useTime, useTransform } from '@humanspeak/svelte-motion'
|
|
37
|
+
*
|
|
38
|
+
* const time = useTime()
|
|
39
|
+
* const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
|
|
40
|
+
* </script>
|
|
41
|
+
*
|
|
42
|
+
* <div style="transform: rotate({rotate.current}deg)">↻</div>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @see https://motion.dev/docs/react-use-time
|
|
46
|
+
*/
|
|
47
|
+
export declare const useTime: (id?: string) => AugmentedMotionValue<number>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { cancelFrame, frame, motionValue } from 'motion-dom';
|
|
2
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
3
|
+
import { augmentMotionValue } from './augmentMotionValue.svelte.js';
|
|
4
|
+
// `SvelteMap` (not plain Map) per `eslint/svelte/prefer-svelte-reactivity`
|
|
5
|
+
// inside `.svelte.ts` files. The contents aren't read in reactive scopes —
|
|
6
|
+
// this is a plain keyed cache — but the linter rule applies uniformly.
|
|
7
|
+
const sharedTimelines = new SvelteMap();
|
|
8
|
+
// Clear shared timelines on HMR dispose to avoid stale entries across hot
|
|
9
|
+
// reloads.
|
|
10
|
+
const hot = import.meta.hot;
|
|
11
|
+
if (hot) {
|
|
12
|
+
hot.dispose(() => {
|
|
13
|
+
for (const t of sharedTimelines.values()) {
|
|
14
|
+
t.cancel();
|
|
15
|
+
t.base.destroy();
|
|
16
|
+
}
|
|
17
|
+
sharedTimelines.clear();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Starts a keep-alive frame-loop callback that writes elapsed-milliseconds
|
|
22
|
+
* into a fresh `MotionValue<number>`. Returns the value and a cancel
|
|
23
|
+
* function that stops the loop. Caller owns the value's destroy lifecycle.
|
|
24
|
+
*
|
|
25
|
+
* Uses motion-dom's `frame.update(cb, true)` — `true` is the `keepAlive`
|
|
26
|
+
* flag, telling the frame loop to re-schedule the callback every frame
|
|
27
|
+
* automatically. Matches React framer-motion's `useAnimationFrame`.
|
|
28
|
+
*/
|
|
29
|
+
const startTimeBase = () => {
|
|
30
|
+
const base = motionValue(0);
|
|
31
|
+
let start = 0;
|
|
32
|
+
const tick = ({ timestamp }) => {
|
|
33
|
+
if (!start)
|
|
34
|
+
start = timestamp;
|
|
35
|
+
base.set(timestamp - start);
|
|
36
|
+
};
|
|
37
|
+
frame.update(tick, true);
|
|
38
|
+
return {
|
|
39
|
+
base,
|
|
40
|
+
cancel: () => cancelFrame(tick)
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Returns an augmented `MotionValue<number>` that ticks once per render
|
|
45
|
+
* frame with the milliseconds elapsed since the value was created.
|
|
46
|
+
*
|
|
47
|
+
* Mirrors React framer-motion's `useTime` 1:1: a `MotionValue<number>`
|
|
48
|
+
* driven by motion-dom's `frame.update(tick, true)` keep-alive callback.
|
|
49
|
+
* The frame loop dedupes per-frame work across all motion-dom consumers,
|
|
50
|
+
* so multiple `useTime()` calls share the same render schedule.
|
|
51
|
+
*
|
|
52
|
+
* Two modes:
|
|
53
|
+
*
|
|
54
|
+
* - **Unique timeline** — `useTime()` starts its own keep-alive callback.
|
|
55
|
+
* The motion value and the callback are torn down when the surrounding
|
|
56
|
+
* `$effect` scope unmounts.
|
|
57
|
+
* - **Shared timeline** — `useTime(id)` callers passing the same `id` all
|
|
58
|
+
* observe a single shared frame-loop callback. Each call still returns
|
|
59
|
+
* an independent motion value (so destroying one consumer's value
|
|
60
|
+
* doesn't ripple to others) but the values stay in lockstep. The
|
|
61
|
+
* shared callback cancels the moment the last consumer unmounts; the
|
|
62
|
+
* next `useTime(id)` call restarts it.
|
|
63
|
+
*
|
|
64
|
+
* The result is augmented with a `$state`-backed `.current` getter and a
|
|
65
|
+
* `.subscribe` shim — it composes with `useTransform`, `useSpring`, and
|
|
66
|
+
* the rest of the Tier 2 surface.
|
|
67
|
+
*
|
|
68
|
+
* Lifecycle: must be called during component initialization. SSR-safe:
|
|
69
|
+
* returns a static `motionValue(0)` with no frame loop on the server.
|
|
70
|
+
*
|
|
71
|
+
* @param id Optional timeline identifier for sharing across components.
|
|
72
|
+
* @returns A `MotionValue<number>` with `.current` and `.subscribe`.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```svelte
|
|
76
|
+
* <script>
|
|
77
|
+
* import { useTime, useTransform } from '@humanspeak/svelte-motion'
|
|
78
|
+
*
|
|
79
|
+
* const time = useTime()
|
|
80
|
+
* const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
|
|
81
|
+
* </script>
|
|
82
|
+
*
|
|
83
|
+
* <div style="transform: rotate({rotate.current}deg)">↻</div>
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* @see https://motion.dev/docs/react-use-time
|
|
87
|
+
*/
|
|
88
|
+
export const useTime = (id) => {
|
|
89
|
+
// SSR: return a static motion value with no frame loop. Matches
|
|
90
|
+
// useSpring / useScroll's SSR branch — no $effect is registered.
|
|
91
|
+
if (typeof window === 'undefined') {
|
|
92
|
+
return augmentMotionValue(motionValue(0));
|
|
93
|
+
}
|
|
94
|
+
// Unique timeline: own callback, own MV, own lifecycle.
|
|
95
|
+
if (!id) {
|
|
96
|
+
const { base, cancel } = startTimeBase();
|
|
97
|
+
$effect(() => () => {
|
|
98
|
+
cancel();
|
|
99
|
+
base.destroy();
|
|
100
|
+
});
|
|
101
|
+
return augmentMotionValue(base);
|
|
102
|
+
}
|
|
103
|
+
// Shared timeline: one frame callback per `id`, mirrored into per-
|
|
104
|
+
// consumer MVs.
|
|
105
|
+
let timeline = sharedTimelines.get(id);
|
|
106
|
+
if (!timeline) {
|
|
107
|
+
const { base, cancel } = startTimeBase();
|
|
108
|
+
timeline = { base, refcount: 0, cancel };
|
|
109
|
+
sharedTimelines.set(id, timeline);
|
|
110
|
+
}
|
|
111
|
+
timeline.refcount++;
|
|
112
|
+
const consumerMv = motionValue(timeline.base.get());
|
|
113
|
+
const unsubBase = timeline.base.on('change', (t) => consumerMv.set(t));
|
|
114
|
+
$effect(() => () => {
|
|
115
|
+
unsubBase();
|
|
116
|
+
consumerMv.destroy();
|
|
117
|
+
const t = sharedTimelines.get(id);
|
|
118
|
+
if (!t)
|
|
119
|
+
return;
|
|
120
|
+
t.refcount--;
|
|
121
|
+
if (t.refcount > 0)
|
|
122
|
+
return;
|
|
123
|
+
t.cancel();
|
|
124
|
+
t.base.destroy();
|
|
125
|
+
sharedTimelines.delete(id);
|
|
126
|
+
});
|
|
127
|
+
return augmentMotionValue(consumerMv);
|
|
128
|
+
};
|