From acb95e8351795b69c7317950e1fcf8b89e772356 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 20 Mar 2024 22:20:30 -0400 Subject: [PATCH] WIP --- .../client/dom/elements/transitions.js | 346 ++++++++++-------- .../src/internal/client/reactivity/effects.js | 4 +- .../svelte/src/internal/client/types.d.ts | 11 +- 3 files changed, 210 insertions(+), 151 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index 5e270dfdab..03e5ee638d 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -1,4 +1,4 @@ -import { noop, run } from '../../../common.js'; +import { noop } from '../../../common.js'; import { user_effect } from '../../reactivity/effects.js'; import { current_effect, untrack } from '../../runtime.js'; import { raf } from '../../timing.js'; @@ -7,18 +7,6 @@ import { run_transitions } from '../../render.js'; import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../constants.js'; import { is_function } from '../../utils.js'; -/** - * @template P - * @param {HTMLElement} dom - * @param {() => import('#client').TransitionFn

} get_transition_fn - * @param {(() => P) | null} props - * @returns {void} - */ -export function animate(dom, get_transition_fn, props) { - // TODO - // bind_transition(dom, get_transition_fn, props, 'key', false); -} - /** * @template T * @param {string} type @@ -33,7 +21,7 @@ function custom_event(type, detail, { bubbles = false, cancelable = false } = {} } /** - * @param {HTMLElement} dom + * @param {Element} dom * @param {'introstart' | 'introend' | 'outrostart' | 'outroend'} type * @returns {void} */ @@ -78,7 +66,11 @@ function css_to_keyframe(css) { /** @param {number} t */ const linear = (t) => t; -/** @param {() => any} fn */ +/** + * @template T + * @param {() => T} fn + * @returns {Promise} + */ function defer(fn) { return new Promise((fulfil) => { user_effect(() => { @@ -98,165 +90,76 @@ function defer(fn) { export function transition(flags, element, get_fn, get_params) { const effect = /** @type {import('#client').Effect} */ (current_effect); - const intro = (flags & TRANSITION_IN) !== 0; - const outro = (flags & TRANSITION_OUT) !== 0; - const global = (flags & TRANSITION_GLOBAL) !== 0; + const is_intro = (flags & TRANSITION_IN) !== 0; + const is_outro = (flags & TRANSITION_OUT) !== 0; + const is_global = (flags & TRANSITION_GLOBAL) !== 0; - const direction = intro && outro ? 'both' : intro ? 'in' : 'out'; + const direction = is_intro && is_outro ? 'both' : is_intro ? 'in' : 'out'; let p = 0; - /** @type {Animation | null} */ - let current_animation; - - /** @type {import('#client').Task | null} */ - let current_task; - - /** @type {import('#client').TransitionPayload | null} */ + /** @type {import('#client').TransitionPayload | ((opts: { direction: 'in' | 'out' }) => import('#client').TransitionPayload) | undefined} */ let current_options; - let current_delta = 0; - /** @type {(() => void) | undefined} */ let callback; - /** @param {number} target */ - async function start(target) { - stop(); - - dispatch_event(element, target === 1 ? 'introstart' : 'outrostart'); - - if (!current_options) { - var result = get_fn()(element, get_params?.(), { direction }); - - current_options = is_function(result) - ? await defer(() => - /** @type {Function} */ (result)({ direction: target === 1 ? 'in' : 'out' }) - ) - : result; - } - - if (!current_options?.duration) { - current_options = null; - callback?.(); - return; - } - - const { delay = 0, duration, css, tick, easing = linear } = current_options; - - const n = current_options.duration / (1000 / 60); - current_delta = target - p; - - const adjusted_duration = duration * Math.abs(current_delta); - - if (css) { - // WAAPI - const keyframes = []; - - for (let i = 0; i <= n; i += 1) { - const eased = easing(i / n); - const t = p + current_delta * eased; - const styles = css(t, 1 - t); - keyframes.push(css_to_keyframe(styles)); - } - - current_animation = element.animate(keyframes, { - delay, - duration: adjusted_duration, - easing: 'linear', - fill: 'forwards' - }); - - current_animation.finished - .then(() => { - if (target === 1) { - current_options = null; - current_animation = null; - } - - p = target; - callback?.(); - dispatch_event(element, target === 1 ? 'introend' : 'outroend'); - }) - .catch(noop); - } else { - // Timer - const start_time = raf.now() + delay; - const start_p = p; - const end_time = start_time + adjusted_duration; - - if (p === 0) { - tick?.(p, 1 - p); // TODO put in nested effect, to avoid interleaved reads/writes? - } - - current_task = loop((now) => { - if (now >= end_time) { - if (target === 1) { - current_task = null; - current_options = null; - } - - p = target; - tick?.(target, 1 - target); - callback?.(); - dispatch_event(element, target === 1 ? 'introend' : 'outroend'); - return false; - } - - if (now >= start_time) { - p = start_p + current_delta * easing((now - start_time) / adjusted_duration); - tick?.(p, 1 - p); - } - - return true; - }); - } + function get_options() { + return (current_options ??= get_fn()(element, get_params?.(), { direction })); } - function stop() { - if (current_task) { - current_task.abort(); - current_task = null; - } + var inert = element.inert; - if (current_animation && current_options) { - const time = /** @type {number} */ (current_animation.currentTime); - const duration = /** @type {number} */ (current_options.duration); - p = (Math.abs(current_delta) * time) / duration; - } - } + /** @type {import('#client').Animation | undefined} */ + var intro; - var inert = element.inert; + /** @type {import('#client').Animation | undefined} */ + var outro; + + /** @type {(() => void) | undefined} */ + var reset; /** @type {import('#client').Transition} */ const transition = { - global, - in() { + is_global, + async in() { callback = undefined; element.inert = inert; - if (current_animation) { - current_animation.cancel(); - current_animation = null; - } + if (is_intro) { + intro = animate(element, get_options(), outro ? outro.p() : 0, 1, () => { + intro = current_options = undefined; + }); - if (intro) { - start(1); + outro?.neuter(); } else { - current_options?.tick?.(1, 0); - stop(); + outro?.abort(); + reset?.(); } + + reset = undefined; }, - out(fn) { - if (outro) { + async out(fn) { + if (is_outro) { callback = fn; element.inert = true; - start(0); + + outro = animate(element, get_options(), intro ? intro.p() : 1, 0, () => { + outro = current_options = undefined; + fn?.(); + }); + + reset = outro.reset; + + intro?.neuter(); } else { fn?.(); } }, - stop + stop: () => { + intro?.abort(); + outro?.abort(); + } }; (effect.transitions ??= []).push(transition); @@ -265,9 +168,9 @@ export function transition(flags, element, get_fn, get_params) { // parent (branch) effect is where the state change happened. we can determine that by // looking at whether the branch effect is currently initializing if ( - intro && + is_intro && run_transitions && - (global || /** @type {import('#client').Effect} */ (effect.parent).ran) + (is_global || /** @type {import('#client').Effect} */ (effect.parent).ran) ) { user_effect(() => { untrack(() => transition.in()); @@ -276,3 +179,152 @@ export function transition(flags, element, get_fn, get_params) { p = 1; } } + +/** + * @param {Element} element + * @param {import('#client').TransitionPayload | ((opts: { direction: 'in' | 'out' }) => import('#client').TransitionPayload)} options + * @param {number} p + * @param {number} target + * @param {(() => void) | undefined} callback + * @returns {import('#client').Animation} + */ +function animate(element, options, p, target, callback) { + dispatch_event(element, target === 1 ? 'introstart' : 'outrostart'); + + if (is_function(options)) { + /** @type {import('#client').Animation} */ + var a; + + user_effect(() => { + var o = untrack(() => options({ direction: target === 1 ? 'in' : 'out' })); + a = animate(element, o, p, target, callback); + }); + + return { + abort: () => a.abort(), + neuter: () => a.neuter(), + reset: noop, + p: () => a.p() + }; + } + + if (!options?.duration) { + callback?.(); + return { + abort: noop, + neuter: noop, + reset: noop, + p: () => target + }; + } + + const { delay = 0, duration, css, tick, easing = linear } = options; + + const n = options.duration / (1000 / 60); + const current_delta = target - p; + + const adjusted_duration = duration * Math.abs(current_delta); + + const start_time = raf.now() + delay; + const start_p = p; + const end_time = start_time + adjusted_duration; + + if (css) { + // WAAPI + const keyframes = []; + + for (let i = 0; i <= n; i += 1) { + const eased = easing(i / n); + const t = p + current_delta * eased; + const styles = css(t, 1 - t); + keyframes.push(css_to_keyframe(styles)); + } + + var animation = element.animate(keyframes, { + delay, + duration: adjusted_duration, + easing: 'linear', + fill: 'forwards' + }); + + animation.finished + .then(() => { + // if (target === 1) { + // current_options = null; + // current_animation = null; + // } + + // p = target; + callback?.(); + dispatch_event(element, target === 1 ? 'introend' : 'outroend'); + }) + .catch(noop); + + return { + abort: () => { + animation.cancel(); + }, + neuter: () => { + callback = undefined; + }, + reset: noop, + p: () => { + return clamp( + start_p + current_delta * easing((raf.now() - start_time) / adjusted_duration), + 0, + 1 + ); + } + }; + } + + // Timer + if (p === 0) { + tick?.(p, 1 - p); // TODO put in nested effect, to avoid interleaved reads/writes? + } + + var task = loop((now) => { + if (now >= end_time) { + // if (target === 1) { + // current_task = null; + // current_options = null; + // } + + // p = target; + tick?.(target, 1 - target); + callback?.(); + dispatch_event(element, target === 1 ? 'introend' : 'outroend'); + return false; + } + + if (now >= start_time) { + p = start_p + current_delta * easing((now - start_time) / adjusted_duration); + tick?.(p, 1 - p); + } + + return true; + }); + + return { + abort: () => { + if (target === 0) tick?.(1, 0); + task.abort(); + }, + neuter: () => { + callback = undefined; + }, + reset: () => { + if (target === 0) tick?.(1, 0); + }, + p: () => p + }; +} + +/** + * @param {number} n + * @param {number} min + * @param {number} max + */ +function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); +} diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 25674b2a8f..4ff0fd78be 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -299,7 +299,7 @@ function pause_children(effect, transitions, local) { if (effect.transitions) { for (const transition of effect.transitions) { - if (transition.global || local) { + if (transition.is_global || local) { transitions.push(transition); } } @@ -341,7 +341,7 @@ function resume_children(effect, local) { if (effect.transitions) { for (const transition of effect.transitions) { - if (transition.global || local) { + if (transition.is_global || local) { transition.in(); } } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 35f2473bbc..4ca11944cf 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -156,17 +156,24 @@ export type Block = | SnippetBlock; export interface Transition { - global: boolean; + is_global: boolean; in: () => void; out: (callback?: () => void) => void; stop: () => void; } +export interface Animation { + abort: () => void; + neuter: () => void; + reset: () => void; + p: () => number; +} + export type TransitionFn

= ( element: Element, props: P, options: { direction?: 'in' | 'out' | 'both' } -) => TransitionPayload | ((options: { direction?: 'in' | 'out' | 'both' }) => TransitionPayload); +) => TransitionPayload | ((options: { direction?: 'in' | 'out' }) => TransitionPayload); export type AnimateFn

= ( element: Element,