From 01e4e9779cfb32f398469820850a4a2178d3b651 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 8 May 2024 22:57:43 -0400 Subject: [PATCH 01/10] feat: add Spring class --- packages/svelte/src/internal/shared/utils.js | 22 +++ packages/svelte/src/motion/private.d.ts | 8 +- packages/svelte/src/motion/spring.js | 156 +++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index f39f7118fef6..e21ed78b0eb8 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -23,3 +23,25 @@ export function run_all(arr) { arr[i](); } } + +/** + * TODO replace with Promise.withResolvers once supported widely enough + * @template T + * @returns {PromiseWithResolvers} + */ +export function deferred() { + /** @type {(value: T) => void} */ + var resolve; + + /** @type {(reason: any) => void} */ + var reject; + + /** @type {Promise} */ + var promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + // @ts-expect-error + return { promise, resolve, reject }; +} diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts index 1a9afb0781b2..8c7865c523c9 100644 --- a/packages/svelte/src/motion/private.d.ts +++ b/packages/svelte/src/motion/private.d.ts @@ -1,9 +1,11 @@ -import { Spring } from './public.js'; - export interface TickContext { inv_mass: number; dt: number; - opts: Spring; + opts: { + stiffness: number; + damping: number; + precision: number; + }; settled: boolean; } diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index ec56d354109a..8f19615d17ad 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -2,6 +2,10 @@ import { writable } from '../store/index.js'; import { loop } from '../internal/client/loop.js'; import { raf } from '../internal/client/timing.js'; import { is_date } from './utils.js'; +import { set, source } from '../internal/client/reactivity/sources.js'; +import { render_effect } from '../internal/client/reactivity/effects.js'; +import { get } from '../internal/client/runtime.js'; +import { deferred, noop } from '../internal/shared/utils.js'; /** * @template T @@ -136,3 +140,155 @@ export function spring(value, opts = {}) { }; return spring; } + +/** + * @template T + */ +export class Spring { + #stiffness = source(0.15); + #damping = source(0.8); + #precision = source(0.01); + + #current = source(/** @type {T} */ (undefined)); + + #target_value = /** @type {T} */ (undefined); + #last_value = /** @type {T} */ (undefined); + #last_time = 0; + + #inverse_mass = 1; + #momentum = 0; + + /** @type {import('../internal/client/types').Task | null} */ + #task = null; + + /** @type {PromiseWithResolvers | null} */ + #deferred = null; + + /** + * @param {T | (() => T)} value + * @param {{ stiffness?: number, damping?: number, precision?: number }} [options] + */ + constructor(value, options = {}) { + if (typeof value === 'function') { + render_effect(() => { + this.#update(/** @type {() => T} */ (value)()); + }); + } else { + this.#current.v = this.#target_value = value; + } + + if (typeof options.stiffness === 'number') this.#stiffness.v = clamp(options.stiffness, 0, 1); + if (typeof options.damping === 'number') this.#damping.v = clamp(options.damping, 0, 1); + if (typeof options.precision === 'number') this.#precision.v = options.precision; + } + + /** @param {T} value */ + #update(value) { + this.#target_value = value; + + this.#current.v ??= value; + this.#last_value ??= this.#current.v; + + if (!this.#task) { + this.#last_time = raf.now(); + + var inv_mass_recovery_rate = 1 / (this.#momentum * 60); + + this.#task ??= loop((now) => { + this.#inverse_mass = Math.min(this.#inverse_mass + inv_mass_recovery_rate, 1); + + /** @type {import('./private').TickContext} */ + const ctx = { + inv_mass: this.#inverse_mass, + opts: { + stiffness: this.#stiffness.v, + damping: this.#damping.v, + precision: this.#precision.v + }, + settled: true, + dt: ((now - this.#last_time) * 60) / 1000 + }; + + var next = tick_spring(ctx, this.#last_value, this.#current.v, this.#target_value); + this.#last_value = this.#current.v; + this.#last_time = now; + set(this.#current, next); + + if (ctx.settled) { + this.#task = null; + } + + return !ctx.settled; + }); + } + + return this.#task.promise; + } + + /** + * @param {T} value + * @param {{ instant?: boolean; preserveMomentum?: number }} [options] + */ + set(value, options) { + this.#deferred?.reject(new Error('Aborted')); + + if (options?.instant || this.#current.v === undefined) { + this.#task?.abort(); + this.#task = null; + set(this.#current, (this.#target_value = value)); + return Promise.resolve(); + } + + if (options?.preserveMomentum) { + this.#inverse_mass = 0; + this.#momentum = options.preserveMomentum; + } + + var d = (this.#deferred = deferred()); + d.promise.catch(noop); + + this.#update(value).then(() => { + if (d !== this.#deferred) return; + d.resolve(undefined); + }); + + return d.promise; + } + + get current() { + return get(this.#current); + } + + get damping() { + return get(this.#damping); + } + + set damping(v) { + set(this.#damping, clamp(v, 0, 1)); + } + + get precision() { + return get(this.#precision); + } + + set precision(v) { + set(this.#precision, v); + } + + get stiffness() { + return get(this.#stiffness); + } + + set stiffness(v) { + set(this.#stiffness, clamp(v, 0, 1)); + } +} + +/** + * @param {number} n + * @param {number} min + * @param {number} max + */ +function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); +} From a3535fbce20aa11ea0f4973789bdbce7da644068 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 20:03:37 -0500 Subject: [PATCH 02/10] add some docs, Spring.of static method --- packages/svelte/src/motion/public.d.ts | 4 +- packages/svelte/src/motion/spring.js | 80 +++++++++++++++++++++----- packages/svelte/src/motion/tweened.js | 4 +- packages/svelte/types/index.d.ts | 76 ++++++++++++++++++++++-- 4 files changed, 141 insertions(+), 23 deletions(-) diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts index c0f55a4cca53..5f932a12d221 100644 --- a/packages/svelte/src/motion/public.d.ts +++ b/packages/svelte/src/motion/public.d.ts @@ -1,7 +1,7 @@ import { Readable } from '../store/public.js'; import { SpringUpdateOpts, TweenedOptions, Updater } from './private.js'; -export interface Spring extends Readable { +export interface SpringStore extends Readable { set: (new_value: T, opts?: SpringUpdateOpts) => Promise; update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; precision: number; @@ -9,7 +9,7 @@ export interface Spring extends Readable { stiffness: number; } -export interface Tweened extends Readable { +export interface TweenedStore extends Readable { set(value: T, opts?: TweenedOptions): Promise; update(updater: Updater, opts?: TweenedOptions): Promise; } diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 0a21bc0824e0..466683ddcf84 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -1,6 +1,6 @@ /** @import { Task } from '#client' */ /** @import { SpringOpts, SpringUpdateOpts, TickContext } from './private.js' */ -/** @import { Spring } from './public.js' */ +/** @import { SpringStore } from './public.js' */ import { writable } from '../store/shared/index.js'; import { loop } from '../internal/client/loop.js'; import { raf } from '../internal/client/timing.js'; @@ -57,10 +57,11 @@ function tick_spring(ctx, last_value, current_value, target_value) { /** * The spring function in Svelte creates a store whose value is animated, with a motion that simulates the behavior of a spring. This means when the value changes, instead of transitioning at a steady rate, it "bounces" like a spring would, depending on the physics parameters provided. This adds a level of realism to the transitions and can enhance the user experience. * + * @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead * @template [T=any] * @param {T} [value] * @param {SpringOpts} [opts] - * @returns {Spring} + * @returns {SpringStore} */ export function spring(value, opts = {}) { const store = writable(value); @@ -131,7 +132,7 @@ export function spring(value, opts = {}) { }); }); } - /** @type {Spring} */ + /** @type {SpringStore} */ const spring = { set, update: (fn, opts) => set(fn(/** @type {T} */ (target_value), /** @type {T} */ (value)), opts), @@ -144,6 +145,19 @@ export function spring(value, opts = {}) { } /** + * A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to + * move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters. + * + * ```svelte + * + * + * + * + * ``` * @template T */ export class Spring { @@ -152,8 +166,8 @@ export class Spring { #precision = source(0.01); #current = source(/** @type {T} */ (undefined)); + #target = source(/** @type {T} */ (undefined)); - #target_value = /** @type {T} */ (undefined); #last_value = /** @type {T} */ (undefined); #last_time = 0; @@ -167,26 +181,47 @@ export class Spring { #deferred = null; /** - * @param {T | (() => T)} value + * @param {T} value * @param {{ stiffness?: number, damping?: number, precision?: number }} [options] */ constructor(value, options = {}) { - if (typeof value === 'function') { - render_effect(() => { - this.#update(/** @type {() => T} */ (value)()); - }); - } else { - this.#current.v = this.#target_value = value; - } + this.#current.v = this.#target.v = value; if (typeof options.stiffness === 'number') this.#stiffness.v = clamp(options.stiffness, 0, 1); if (typeof options.damping === 'number') this.#damping.v = clamp(options.damping, 0, 1); if (typeof options.precision === 'number') this.#precision.v = options.precision; } + /** + * Create a spring whose value is bound to the return value of `fn`. This must be called + * inside an effect root (for example, during component initialisation). + * + * ```svelte + * + * ``` + * @template U + * @param {() => U} fn + * @param {{ stiffness?: number, damping?: number, precision?: number }} [options] + */ + static of(fn, options) { + const spring = new Spring(fn(), options); + + render_effect(() => { + spring.set(fn()); + }); + + return spring; + } + /** @param {T} value */ #update(value) { - this.#target_value = value; + set(this.#target, value); this.#current.v ??= value; this.#last_value ??= this.#current.v; @@ -211,7 +246,7 @@ export class Spring { dt: ((now - this.#last_time) * 60) / 1000 }; - var next = tick_spring(ctx, this.#last_value, this.#current.v, this.#target_value); + var next = tick_spring(ctx, this.#last_value, this.#current.v, this.#target.v); this.#last_value = this.#current.v; this.#last_time = now; set(this.#current, next); @@ -228,6 +263,13 @@ export class Spring { } /** + * Sets `spring.target` to `value` and returns a `Promise` if and when `spring.current` catches up to it. + * + * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. + * + * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for + * the specified number of seconds. This is useful for things like 'fling' gestures. + * * @param {T} value * @param {{ instant?: boolean; preserveMomentum?: number }} [options] */ @@ -237,7 +279,7 @@ export class Spring { if (options?.instant || this.#current.v === undefined) { this.#task?.abort(); this.#task = null; - set(this.#current, (this.#target_value = value)); + set(this.#current, set(this.#target, value)); return Promise.resolve(); } @@ -284,6 +326,14 @@ export class Spring { set stiffness(v) { set(this.#stiffness, clamp(v, 0, 1)); } + + get target() { + return get(this.#target); + } + + set target(v) { + this.set(v); + } } /** diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index 8f689878f736..a34f3912a1e0 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -1,5 +1,5 @@ /** @import { Task } from '../internal/client/types' */ -/** @import { Tweened } from './public' */ +/** @import { TweenedStore } from './public' */ /** @import { TweenedOptions } from './private' */ import { writable } from '../store/shared/index.js'; import { raf } from '../internal/client/timing.js'; @@ -79,7 +79,7 @@ function get_interpolator(a, b) { * @template T * @param {T} [value] * @param {TweenedOptions} [defaults] - * @returns {Tweened} + * @returns {TweenedStore} */ export function tweened(value, defaults = {}) { const store = writable(value); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 0814796d27e6..c2f6818162e7 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1637,7 +1637,7 @@ declare module 'svelte/legacy' { } declare module 'svelte/motion' { - export interface Spring extends Readable { + export interface SpringStore extends Readable { set: (new_value: T, opts?: SpringUpdateOpts) => Promise; update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; precision: number; @@ -1645,7 +1645,7 @@ declare module 'svelte/motion' { stiffness: number; } - export interface Tweened extends Readable { + export interface TweenedStore extends Readable { set(value: T, opts?: TweenedOptions): Promise; update(updater: Updater, opts?: TweenedOptions): Promise; } @@ -1686,13 +1686,81 @@ declare module 'svelte/motion' { /** * The spring function in Svelte creates a store whose value is animated, with a motion that simulates the behavior of a spring. This means when the value changes, instead of transitioning at a steady rate, it "bounces" like a spring would, depending on the physics parameters provided. This adds a level of realism to the transitions and can enhance the user experience. * + * @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead * */ - export function spring(value?: T | undefined, opts?: SpringOpts | undefined): Spring; + export function spring(value?: T | undefined, opts?: SpringOpts | undefined): SpringStore; + /** + * A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to + * move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters. + * + * ```svelte + * + * + * + * + * ``` + * */ + export class Spring { + /** + * Create a spring whose value is bound to the return value of `fn`. This must be called + * inside an effect root (for example, during component initialisation). + * + * ```svelte + * + * ``` + * + */ + static of(fn: () => U, options?: { + stiffness?: number; + damping?: number; + precision?: number; + } | undefined): Spring; + + constructor(value: T, options?: { + stiffness?: number; + damping?: number; + precision?: number; + } | undefined); + /** + * Sets `spring.target` to `value` and returns a `Promise` if and when `spring.current` catches up to it. + * + * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. + * + * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for + * the specified number of seconds. This is useful for things like 'fling' gestures. + * + * + */ + set(value: T, options?: { + instant?: boolean; + preserveMomentum?: number; + } | undefined): Promise; + get current(): T; + set damping(v: number); + get damping(): number; + set precision(v: number); + get precision(): number; + set stiffness(v: number); + get stiffness(): number; + set target(v: T); + get target(): T; + #private; + } /** * A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time. * * */ - export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): Tweened; + export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): TweenedStore; export {}; } From 992bb83b6f8807c034bed039e63ecaeac2f9b952 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 20:29:20 -0500 Subject: [PATCH 03/10] add Tween class --- packages/svelte/src/motion/private.d.ts | 2 +- packages/svelte/src/motion/spring.js | 10 +- packages/svelte/src/motion/tweened.js | 135 ++++++++++++++++++++++++ packages/svelte/types/index.d.ts | 49 ++++++++- 4 files changed, 188 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts index 8c7865c523c9..cb7a27e6ef02 100644 --- a/packages/svelte/src/motion/private.d.ts +++ b/packages/svelte/src/motion/private.d.ts @@ -1,4 +1,4 @@ -export interface TickContext { +export interface TickContext { inv_mass: number; dt: number; opts: { diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 466683ddcf84..b1dcda976944 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -12,7 +12,7 @@ import { deferred, noop } from '../internal/shared/utils.js'; /** * @template T - * @param {TickContext} ctx + * @param {TickContext} ctx * @param {T} last_value * @param {T} current_value * @param {T} target_value @@ -108,7 +108,7 @@ export function spring(value, opts = {}) { return false; } inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1); - /** @type {TickContext} */ + /** @type {TickContext} */ const ctx = { inv_mass, opts: spring, @@ -152,7 +152,7 @@ export function spring(value, opts = {}) { * * * @@ -234,7 +234,7 @@ export class Spring { this.#task ??= loop((now) => { this.#inverse_mass = Math.min(this.#inverse_mass + inv_mass_recovery_rate, 1); - /** @type {import('./private').TickContext} */ + /** @type {import('./private').TickContext} */ const ctx = { inv_mass: this.#inverse_mass, opts: { @@ -263,7 +263,7 @@ export class Spring { } /** - * Sets `spring.target` to `value` and returns a `Promise` if and when `spring.current` catches up to it. + * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. * * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. * diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index a34f3912a1e0..706508e03230 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -6,6 +6,8 @@ import { raf } from '../internal/client/timing.js'; import { loop } from '../internal/client/loop.js'; import { linear } from '../easing/index.js'; import { is_date } from './utils.js'; +import { set, source } from '../internal/client/reactivity/sources.js'; +import { get, render_effect } from 'svelte/internal/client'; /** * @template T @@ -152,3 +154,136 @@ export function tweened(value, defaults = {}) { subscribe: store.subscribe }; } + +/** + * A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to + * move towards it over time, taking account of the `delay`, `duration` and `easing` options. + * + * ```svelte + * + * + * + * + * ``` + * @template T + */ +export class Tween { + #current = source(/** @type {T} */ (undefined)); + #target = source(/** @type {T} */ (undefined)); + + /** @type {TweenedOptions} */ + #defaults; + + /** @type {import('../internal/client/types').Task | null} */ + #task = null; + + /** + * @param {T} value + * @param {TweenedOptions} options + */ + constructor(value, options = {}) { + this.#current.v = this.#target.v = value; + this.#defaults = options; + } + + /** + * Create a tween whose value is bound to the return value of `fn`. This must be called + * inside an effect root (for example, during component initialisation). + * + * ```svelte + * + * ``` + * @template U + * @param {() => U} fn + * @param {TweenedOptions} [options] + */ + static of(fn, options) { + const tween = new Tween(fn(), options); + + render_effect(() => { + tween.set(fn()); + }); + + return tween; + } + + /** + * Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it. + * + * If `options` are provided, they will override the tween's defaults. + * @param {T} value + * @param {TweenedOptions} [options] + * @returns + */ + set(value, options) { + set(this.#target, value); + + let previous_value = this.#current.v; + let previous_task = this.#task; + + let started = false; + let { + delay = 0, + duration = 400, + easing = linear, + interpolate = get_interpolator + } = { ...this.#defaults, ...options }; + + const start = raf.now() + delay; + + /** @type {(t: number) => T} */ + let fn; + + this.#task = loop((now) => { + if (now < start) { + return true; + } + + if (!started) { + started = true; + + fn = interpolate(/** @type {any} */ (previous_value), value); + + if (typeof duration === 'function') { + duration = duration(/** @type {any} */ (previous_value), value); + } + + previous_task?.abort(); + } + + const elapsed = now - start; + + if (elapsed > /** @type {number} */ (duration)) { + set(this.#current, value); + return false; + } + + set(this.#current, fn(easing(elapsed / /** @type {number} */ (duration)))); + return true; + }); + + return this.#task.promise; + } + + get current() { + return get(this.#current); + } + + get target() { + return get(this.#target); + } + + set target(v) { + this.set(v); + } +} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index c2f6818162e7..f494d7bfdd6c 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1697,7 +1697,7 @@ declare module 'svelte/motion' { * * * @@ -1732,7 +1732,7 @@ declare module 'svelte/motion' { precision?: number; } | undefined); /** - * Sets `spring.target` to `value` and returns a `Promise` if and when `spring.current` catches up to it. + * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. * * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. * @@ -1761,6 +1761,51 @@ declare module 'svelte/motion' { * * */ export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): TweenedStore; + /** + * A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to + * move towards it over time, taking account of the `delay`, `duration` and `easing` options. + * + * ```svelte + * + * + * + * + * ``` + * */ + export class Tween { + /** + * Create a tween whose value is bound to the return value of `fn`. This must be called + * inside an effect root (for example, during component initialisation). + * + * ```svelte + * + * ``` + * + */ + static of(fn: () => U, options?: TweenedOptions | undefined): Tween; + + constructor(value: T, options?: TweenedOptions); + /** + * Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it. + * + * If `options` are provided, they will override the tween's defaults. + * */ + set(value: T, options?: TweenedOptions | undefined): Promise; + get current(): T; + set target(v: T); + get target(): T; + #private; + } export {}; } From dc66e62d5259f1dc3528ab50629b0544ed6a1e9e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 20:46:21 -0500 Subject: [PATCH 04/10] lint --- packages/svelte/src/internal/shared/utils.js | 1 - packages/svelte/src/motion/spring.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 2c0feb60e8ae..92d29d9e1d68 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -47,7 +47,6 @@ export function run_all(arr) { /** * TODO replace with Promise.withResolvers once supported widely enough * @template T - * @returns {PromiseWithResolvers} */ export function deferred() { /** @type {(value: T) => void} */ diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index b1dcda976944..6d95f7944d5f 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -177,7 +177,7 @@ export class Spring { /** @type {import('../internal/client/types').Task | null} */ #task = null; - /** @type {PromiseWithResolvers | null} */ + /** @type {ReturnType | null} */ #deferred = null; /** From 1843090ed136550ec62b8aa2bb42bd9df0cab4c2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 20:58:36 -0500 Subject: [PATCH 05/10] preserveMomentum in milliseconds --- packages/svelte/src/motion/spring.js | 4 ++-- packages/svelte/types/index.d.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 6d95f7944d5f..5615b00f6f0d 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -229,7 +229,7 @@ export class Spring { if (!this.#task) { this.#last_time = raf.now(); - var inv_mass_recovery_rate = 1 / (this.#momentum * 60); + var inv_mass_recovery_rate = 1000 / (this.#momentum * 60); this.#task ??= loop((now) => { this.#inverse_mass = Math.min(this.#inverse_mass + inv_mass_recovery_rate, 1); @@ -268,7 +268,7 @@ export class Spring { * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. * * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for - * the specified number of seconds. This is useful for things like 'fling' gestures. + * the specified number of milliseconds. This is useful for things like 'fling' gestures. * * @param {T} value * @param {{ instant?: boolean; preserveMomentum?: number }} [options] diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index f494d7bfdd6c..61098de98aa6 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1737,14 +1737,14 @@ declare module 'svelte/motion' { * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. * * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for - * the specified number of seconds. This is useful for things like 'fling' gestures. + * the specified number of milliseconds. This is useful for things like 'fling' gestures. * * */ set(value: T, options?: { instant?: boolean; preserveMomentum?: number; - } | undefined): Promise; + } | undefined): Promise; get current(): T; set damping(v: number); get damping(): number; From 854f183afda154f56c4d9558950931727ef6e1b0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 21:08:51 -0500 Subject: [PATCH 06/10] deprecate tweened --- packages/svelte/src/motion/tweened.js | 1 + packages/svelte/types/index.d.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index 706508e03230..ebb6f661a31c 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -78,6 +78,7 @@ function get_interpolator(a, b) { /** * A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time. * + * @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead * @template T * @param {T} [value] * @param {TweenedOptions} [defaults] diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 61098de98aa6..2b4493fce9c0 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1759,6 +1759,7 @@ declare module 'svelte/motion' { /** * A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time. * + * @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead * */ export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): TweenedStore; /** From 1e457a867819ff7081175c46a970bc9ec5ea6afe Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 21:12:37 -0500 Subject: [PATCH 07/10] changeset --- .changeset/tame-bottles-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tame-bottles-switch.md diff --git a/.changeset/tame-bottles-switch.md b/.changeset/tame-bottles-switch.md new file mode 100644 index 000000000000..c597f5ea997c --- /dev/null +++ b/.changeset/tame-bottles-switch.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `Spring` and `Tween` classes to `svelte/motion` From b76ea5fb673992bd4c72ad7207d3595d34a52a99 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 3 Dec 2024 18:42:04 +0100 Subject: [PATCH 08/10] wrestle with types --- packages/svelte/src/motion/private.d.ts | 6 + packages/svelte/src/motion/public.d.ts | 76 +++++++++++- packages/svelte/src/motion/spring.js | 6 +- packages/svelte/src/motion/tweened.js | 4 +- packages/svelte/types/index.d.ts | 149 ++++++++++++------------ 5 files changed, 159 insertions(+), 82 deletions(-) diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts index cb7a27e6ef02..b4e647ce0619 100644 --- a/packages/svelte/src/motion/private.d.ts +++ b/packages/svelte/src/motion/private.d.ts @@ -16,7 +16,13 @@ export interface SpringOpts { } export interface SpringUpdateOpts { + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ hard?: any; + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ soft?: string | number | boolean; } diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts index 5f932a12d221..50b7ae4d0903 100644 --- a/packages/svelte/src/motion/public.d.ts +++ b/packages/svelte/src/motion/public.d.ts @@ -1,17 +1,83 @@ import { Readable } from '../store/public.js'; -import { SpringUpdateOpts, TweenedOptions, Updater } from './private.js'; +import { SpringUpdateOpts, TweenedOptions, Updater, SpringOpts } from './private.js'; -export interface SpringStore extends Readable { - set: (new_value: T, opts?: SpringUpdateOpts) => Promise; +// TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface) +// this means both the Spring class and the Spring interface are merged into one with some things only +// existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js + +export interface Spring extends Readable { + set(new_value: T, opts?: SpringUpdateOpts): Promise; + /** + * @deprecated Only exists on the Spring store + */ update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; precision: number; damping: number; stiffness: number; } -export interface TweenedStore extends Readable { +/** + * A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to + * move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters. + * + * ```svelte + * + * + * + * + * ``` + */ +export class Spring { + constructor(value: T, options?: SpringOpts); + + /** + * Create a spring whose value is bound to the return value of `fn`. This must be called + * inside an effect root (for example, during component initialisation). + * + * ```svelte + * + * ``` + */ + static of(fn: () => U, options?: SpringOpts): Spring; + + /** + * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. + * + * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. + * + * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for + * the specified number of milliseconds. This is useful for things like 'fling' gestures. + */ + set(value: T, options?: { instant?: boolean; preserveMomentum?: number }): Promise; + + damping: number; + precision: number; + stiffness: number; + /** + * The end value of the spring. + * This property only exists on the Spring class. + */ + target: T; + /** + * The current value of the spring. + * This property only exists on the Spring class. + */ + get current(): T; +} + +export interface Tweened extends Readable { set(value: T, opts?: TweenedOptions): Promise; update(updater: Updater, opts?: TweenedOptions): Promise; } -export * from './index.js'; +export { spring, tweened, Tween } from './index.js'; diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 5615b00f6f0d..d1c91a1c4d7a 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -1,6 +1,6 @@ /** @import { Task } from '#client' */ /** @import { SpringOpts, SpringUpdateOpts, TickContext } from './private.js' */ -/** @import { SpringStore } from './public.js' */ +/** @import { Spring as SpringStore } from './public.js' */ import { writable } from '../store/shared/index.js'; import { loop } from '../internal/client/loop.js'; import { raf } from '../internal/client/timing.js'; @@ -182,7 +182,7 @@ export class Spring { /** * @param {T} value - * @param {{ stiffness?: number, damping?: number, precision?: number }} [options] + * @param {SpringOpts} [options] */ constructor(value, options = {}) { this.#current.v = this.#target.v = value; @@ -207,7 +207,7 @@ export class Spring { * ``` * @template U * @param {() => U} fn - * @param {{ stiffness?: number, damping?: number, precision?: number }} [options] + * @param {SpringOpts} [options] */ static of(fn, options) { const spring = new Spring(fn(), options); diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index ebb6f661a31c..bd43964062c8 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -1,5 +1,5 @@ /** @import { Task } from '../internal/client/types' */ -/** @import { TweenedStore } from './public' */ +/** @import { Tweened } from './public' */ /** @import { TweenedOptions } from './private' */ import { writable } from '../store/shared/index.js'; import { raf } from '../internal/client/timing.js'; @@ -82,7 +82,7 @@ function get_interpolator(a, b) { * @template T * @param {T} [value] * @param {TweenedOptions} [defaults] - * @returns {TweenedStore} + * @returns {Tweened} */ export function tweened(value, defaults = {}) { const store = writable(value); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 07729098181a..381b57e457bd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1637,15 +1637,81 @@ declare module 'svelte/legacy' { } declare module 'svelte/motion' { - export interface SpringStore extends Readable { - set: (new_value: T, opts?: SpringUpdateOpts) => Promise; + // TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface) + // this means both the Spring class and the Spring interface are merged into one with some things only + // existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js + + export interface Spring extends Readable { + set(new_value: T, opts?: SpringUpdateOpts): Promise; + /** + * @deprecated Only exists on the Spring store + */ update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; precision: number; damping: number; stiffness: number; } - export interface TweenedStore extends Readable { + /** + * A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to + * move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters. + * + * ```svelte + * + * + * + * + * ``` + */ + export class Spring { + constructor(value: T, options?: SpringOpts); + + /** + * Create a spring whose value is bound to the return value of `fn`. This must be called + * inside an effect root (for example, during component initialisation). + * + * ```svelte + * + * ``` + */ + static of(fn: () => U, options?: SpringOpts): Spring; + + /** + * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. + * + * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. + * + * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for + * the specified number of milliseconds. This is useful for things like 'fling' gestures. + */ + set(value: T, options?: { instant?: boolean; preserveMomentum?: number }): Promise; + + damping: number; + precision: number; + stiffness: number; + /** + * The end value of the spring. + * This property only exists on the Spring class. + */ + target: T; + /** + * The current value of the spring. + * This property only exists on the Spring class. + */ + get current(): T; + } + + export interface Tweened extends Readable { set(value: T, opts?: TweenedOptions): Promise; update(updater: Updater, opts?: TweenedOptions): Promise; } @@ -1671,7 +1737,13 @@ declare module 'svelte/motion' { } interface SpringUpdateOpts { + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ hard?: any; + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ soft?: string | number | boolean; } @@ -1688,80 +1760,13 @@ declare module 'svelte/motion' { * * @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead * */ - export function spring(value?: T | undefined, opts?: SpringOpts | undefined): SpringStore; - /** - * A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to - * move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters. - * - * ```svelte - * - * - * - * - * ``` - * */ - export class Spring { - /** - * Create a spring whose value is bound to the return value of `fn`. This must be called - * inside an effect root (for example, during component initialisation). - * - * ```svelte - * - * ``` - * - */ - static of(fn: () => U, options?: { - stiffness?: number; - damping?: number; - precision?: number; - } | undefined): Spring; - - constructor(value: T, options?: { - stiffness?: number; - damping?: number; - precision?: number; - } | undefined); - /** - * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. - * - * If `options.instant` is `true`, `spring.current` immediately matches `spring.target`. - * - * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for - * the specified number of milliseconds. This is useful for things like 'fling' gestures. - * - * - */ - set(value: T, options?: { - instant?: boolean; - preserveMomentum?: number; - } | undefined): Promise; - get current(): T; - set damping(v: number); - get damping(): number; - set precision(v: number); - get precision(): number; - set stiffness(v: number); - get stiffness(): number; - set target(v: T); - get target(): T; - #private; - } + export function spring(value?: T | undefined, opts?: SpringOpts | undefined): Spring; /** * A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time. * * @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead * */ - export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): TweenedStore; + export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): Tweened; /** * A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to * move towards it over time, taking account of the `delay`, `duration` and `easing` options. From d0e3664f8f12a99eb39b214ac8b6ab1765992adf Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 3 Dec 2024 19:04:07 +0100 Subject: [PATCH 09/10] more consolidation --- packages/svelte/src/motion/private.d.ts | 8 ++++++++ packages/svelte/src/motion/public.d.ts | 2 +- packages/svelte/src/motion/spring.js | 3 ++- packages/svelte/types/index.d.ts | 10 +++++++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts index b4e647ce0619..22b8cc4af39d 100644 --- a/packages/svelte/src/motion/private.d.ts +++ b/packages/svelte/src/motion/private.d.ts @@ -24,6 +24,14 @@ export interface SpringUpdateOpts { * @deprecated Only use this for the spring store; does nothing when set on the Spring class */ soft?: string | number | boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + instant?: boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + preserveMomentum?: number; } export type Updater = (target_value: T, value: T) => T; diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts index 50b7ae4d0903..d9ec417e9dff 100644 --- a/packages/svelte/src/motion/public.d.ts +++ b/packages/svelte/src/motion/public.d.ts @@ -58,7 +58,7 @@ export class Spring { * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for * the specified number of milliseconds. This is useful for things like 'fling' gestures. */ - set(value: T, options?: { instant?: boolean; preserveMomentum?: number }): Promise; + set(value: T, options?: SpringUpdateOpts): Promise; damping: number; precision: number; diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index d1c91a1c4d7a..2afe64e71f61 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -133,6 +133,7 @@ export function spring(value, opts = {}) { }); } /** @type {SpringStore} */ + // @ts-expect-error - class-only properties are missing const spring = { set, update: (fn, opts) => set(fn(/** @type {T} */ (target_value), /** @type {T} */ (value)), opts), @@ -271,7 +272,7 @@ export class Spring { * the specified number of milliseconds. This is useful for things like 'fling' gestures. * * @param {T} value - * @param {{ instant?: boolean; preserveMomentum?: number }} [options] + * @param {SpringUpdateOpts} [options] */ set(value, options) { this.#deferred?.reject(new Error('Aborted')); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 381b57e457bd..63d710c24503 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1694,7 +1694,7 @@ declare module 'svelte/motion' { * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for * the specified number of milliseconds. This is useful for things like 'fling' gestures. */ - set(value: T, options?: { instant?: boolean; preserveMomentum?: number }): Promise; + set(value: T, options?: SpringUpdateOpts): Promise; damping: number; precision: number; @@ -1745,6 +1745,14 @@ declare module 'svelte/motion' { * @deprecated Only use this for the spring store; does nothing when set on the Spring class */ soft?: string | number | boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + instant?: boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + preserveMomentum?: number; } type Updater = (target_value: T, value: T) => T; From 9115eca0fef333552afd176cc7b4e81b4d34f099 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 3 Dec 2024 13:41:19 -0500 Subject: [PATCH 10/10] flesh out the distinction a bit more, deprecate `subscribe` --- packages/svelte/src/motion/public.d.ts | 12 ++++++++---- packages/svelte/types/index.d.ts | 10 +++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts index d9ec417e9dff..8fb9b9e66a1b 100644 --- a/packages/svelte/src/motion/public.d.ts +++ b/packages/svelte/src/motion/public.d.ts @@ -1,4 +1,4 @@ -import { Readable } from '../store/public.js'; +import { Readable, type Unsubscriber } from '../store/public.js'; import { SpringUpdateOpts, TweenedOptions, Updater, SpringOpts } from './private.js'; // TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface) @@ -8,9 +8,13 @@ import { SpringUpdateOpts, TweenedOptions, Updater, SpringOpts } from './private export interface Spring extends Readable { set(new_value: T, opts?: SpringUpdateOpts): Promise; /** - * @deprecated Only exists on the Spring store + * @deprecated Only exists on the legacy `spring` store, not the `Spring` class */ update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; + /** + * @deprecated Only exists on the legacy `spring` store, not the `Spring` class + */ + subscribe(fn: (value: T) => void): Unsubscriber; precision: number; damping: number; stiffness: number; @@ -65,12 +69,12 @@ export class Spring { stiffness: number; /** * The end value of the spring. - * This property only exists on the Spring class. + * This property only exists on the `Spring` class, not the legacy `spring` store. */ target: T; /** * The current value of the spring. - * This property only exists on the Spring class. + * This property only exists on the `Spring` class, not the legacy `spring` store. */ get current(): T; } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 63d710c24503..eca8d8af4d29 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1644,9 +1644,13 @@ declare module 'svelte/motion' { export interface Spring extends Readable { set(new_value: T, opts?: SpringUpdateOpts): Promise; /** - * @deprecated Only exists on the Spring store + * @deprecated Only exists on the legacy `spring` store, not the `Spring` class */ update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; + /** + * @deprecated Only exists on the legacy `spring` store, not the `Spring` class + */ + subscribe(fn: (value: T) => void): Unsubscriber; precision: number; damping: number; stiffness: number; @@ -1701,12 +1705,12 @@ declare module 'svelte/motion' { stiffness: number; /** * The end value of the spring. - * This property only exists on the Spring class. + * This property only exists on the `Spring` class, not the legacy `spring` store. */ target: T; /** * The current value of the spring. - * This property only exists on the Spring class. + * This property only exists on the `Spring` class, not the legacy `spring` store. */ get current(): T; }