@humanspeak/svelte-motion 0.5.4 → 0.6.1
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/components/motionDomProjection.context.d.ts +13 -0
- package/dist/components/motionDomProjection.context.js +18 -0
- package/dist/html/_MotionContainer.svelte +831 -81
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +87 -3
- package/dist/utils/animationControls.svelte.d.ts +63 -0
- package/dist/utils/animationControls.svelte.js +111 -0
- package/dist/utils/layout.d.ts +9 -6
- package/dist/utils/layout.js +148 -14
- package/dist/utils/motionDomProjection.d.ts +155 -0
- package/dist/utils/motionDomProjection.js +279 -0
- package/dist/utils/optimizedAppear.d.ts +141 -0
- package/dist/utils/optimizedAppear.js +311 -0
- package/dist/utils/presence.d.ts +3 -2
- package/dist/utils/presence.js +49 -12
- package/dist/utils/projection.d.ts +3 -3
- package/dist/utils/projection.js +1 -1
- package/dist/utils/svg.d.ts +4 -4
- package/dist/utils/svg.js +44 -25
- package/package.json +1 -1
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import type {
|
|
15
15
|
MotionProps,
|
|
16
16
|
MotionTransition,
|
|
17
|
+
AnimationControlsDefinition,
|
|
18
|
+
AnimationControlsSubscriber,
|
|
17
19
|
DragAxis,
|
|
18
20
|
DragConstraints,
|
|
19
21
|
DragControls,
|
|
@@ -28,10 +30,12 @@
|
|
|
28
30
|
import { isNotEmpty } from '../utils/objects'
|
|
29
31
|
import { sleep } from '../utils/testing'
|
|
30
32
|
import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
|
|
33
|
+
import { motionValue, svgEffect, type MotionValue } from 'motion-dom'
|
|
31
34
|
import { isPlaywrightEnv, pwLog } from '../utils/log'
|
|
32
35
|
import { onDestroy, untrack, type Snippet } from 'svelte'
|
|
33
36
|
import { VOID_TAGS } from '../utils/constants'
|
|
34
37
|
import { mergeTransitions, animateWithLifecycle } from '../utils/animation'
|
|
38
|
+
import { isAnimationControls } from '../utils/animationControls.svelte'
|
|
35
39
|
import { attachWhileTap } from '../utils/interaction'
|
|
36
40
|
import { attachWhileHover, computeHoverBaseline, splitHoverDefinition } from '../utils/hover'
|
|
37
41
|
import { attachWhileFocus } from '../utils/focus'
|
|
@@ -58,11 +62,18 @@
|
|
|
58
62
|
import { attachPan, type AttachPanCleanup } from '../utils/pan'
|
|
59
63
|
import { ProjectionNode } from '../utils/projection'
|
|
60
64
|
import { getProjectionParent, setProjectionParent } from '../components/projection.context'
|
|
65
|
+
import { MotionDomProjectionAdapter } from '../utils/motionDomProjection'
|
|
66
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
67
|
+
import {
|
|
68
|
+
getMotionDomProjectionParent,
|
|
69
|
+
setMotionDomProjectionParent
|
|
70
|
+
} from '../components/motionDomProjection.context'
|
|
61
71
|
import {
|
|
62
72
|
resolveInitial,
|
|
63
73
|
resolveAnimate,
|
|
64
74
|
resolveExit,
|
|
65
75
|
resolveWhile,
|
|
76
|
+
resolveVariantList,
|
|
66
77
|
resolveRestingValues
|
|
67
78
|
} from '../utils/variants'
|
|
68
79
|
import {
|
|
@@ -77,9 +88,19 @@
|
|
|
77
88
|
import {
|
|
78
89
|
transformSVGPathProperties,
|
|
79
90
|
computeNormalizedSVGInitialAttrs,
|
|
91
|
+
hasSVGPathProperties,
|
|
92
|
+
isSVGPathElement,
|
|
80
93
|
isSVGTag,
|
|
81
94
|
SVG_NAMESPACE
|
|
82
95
|
} from '../utils/svg'
|
|
96
|
+
import {
|
|
97
|
+
createOptimizedAppearData,
|
|
98
|
+
createOptimizedAppearScript,
|
|
99
|
+
finishOptimizedAppearAnimation,
|
|
100
|
+
hasOptimizedAppearAnimation,
|
|
101
|
+
markMotionMounted,
|
|
102
|
+
optimizedAppearDataAttribute
|
|
103
|
+
} from '../utils/optimizedAppear'
|
|
83
104
|
import { getLayoutIdRegistry } from '../utils/layoutId'
|
|
84
105
|
import {
|
|
85
106
|
getLayoutScrollContainerRef,
|
|
@@ -93,6 +114,8 @@
|
|
|
93
114
|
[key: string]: unknown
|
|
94
115
|
}
|
|
95
116
|
|
|
117
|
+
const componentHydrationId = $props.id()
|
|
118
|
+
|
|
96
119
|
let {
|
|
97
120
|
children,
|
|
98
121
|
tag = 'div',
|
|
@@ -234,6 +257,18 @@
|
|
|
234
257
|
})
|
|
235
258
|
setProjectionParent(projection)
|
|
236
259
|
|
|
260
|
+
const motionDomProjectionParent =
|
|
261
|
+
typeof window !== 'undefined' ? getMotionDomProjectionParent() : null
|
|
262
|
+
const motionDomProjection =
|
|
263
|
+
typeof window !== 'undefined'
|
|
264
|
+
? new MotionDomProjectionAdapter({
|
|
265
|
+
parent: motionDomProjectionParent
|
|
266
|
+
})
|
|
267
|
+
: null
|
|
268
|
+
if (motionDomProjection) {
|
|
269
|
+
setMotionDomProjectionParent(motionDomProjection)
|
|
270
|
+
}
|
|
271
|
+
|
|
237
272
|
// Convert a projection `Box` (ancestor chain reset to base, self
|
|
238
273
|
// transform stripped, scroll containers compensated) to the
|
|
239
274
|
// `RectLike` shape `computeFlipTransforms` consumes.
|
|
@@ -246,9 +281,25 @@
|
|
|
246
281
|
width: box.x.max - box.x.min,
|
|
247
282
|
height: box.y.max - box.y.min
|
|
248
283
|
})
|
|
284
|
+
const domRectToRectLike = (rect: DOMRect): RectLike => ({
|
|
285
|
+
left: rect.left,
|
|
286
|
+
top: rect.top,
|
|
287
|
+
width: rect.width,
|
|
288
|
+
height: rect.height
|
|
289
|
+
})
|
|
290
|
+
const hasRectChanged = (previous: RectLike, next: RectLike): boolean =>
|
|
291
|
+
Math.abs(previous.left - next.left) > 0.5 ||
|
|
292
|
+
Math.abs(previous.top - next.top) > 0.5 ||
|
|
293
|
+
Math.abs(previous.width - next.width) > 0.5 ||
|
|
294
|
+
Math.abs(previous.height - next.height) > 0.5
|
|
295
|
+
const isViewportOffscreen = (rect: DOMRect): boolean =>
|
|
296
|
+
rect.bottom <= 0 ||
|
|
297
|
+
rect.right <= 0 ||
|
|
298
|
+
rect.top >= window.innerHeight ||
|
|
299
|
+
rect.left >= window.innerWidth
|
|
249
300
|
|
|
250
301
|
// Ancestor-transform-invariant layout measurement for seeding the
|
|
251
|
-
// FLIP effect's first rect.
|
|
302
|
+
// fallback FLIP effect's first rect.
|
|
252
303
|
const measureLayoutRect = (): RectLike | null => {
|
|
253
304
|
const box = projection.measure()
|
|
254
305
|
return box ? boxToRectLike(box) : null
|
|
@@ -422,6 +473,8 @@
|
|
|
422
473
|
|
|
423
474
|
// Variant inheritance and resolution
|
|
424
475
|
const parentVariantStore = getVariantContext()
|
|
476
|
+
const animateControls = $derived(isAnimationControls(animateProp) ? animateProp : undefined)
|
|
477
|
+
const declarativeAnimateProp = $derived(animateControls ? undefined : animateProp)
|
|
425
478
|
|
|
426
479
|
// Get initial inherited variant synchronously
|
|
427
480
|
let initialInheritedVariant: string | undefined = undefined
|
|
@@ -431,8 +484,8 @@
|
|
|
431
484
|
|
|
432
485
|
// Create store with initial value so children can inherit immediately
|
|
433
486
|
const initialVariantValue =
|
|
434
|
-
typeof
|
|
435
|
-
?
|
|
487
|
+
typeof declarativeAnimateProp === 'string'
|
|
488
|
+
? declarativeAnimateProp
|
|
436
489
|
: (variantsProp && initialInheritedVariant) || undefined
|
|
437
490
|
const localVariantStore = writable<string | undefined>(initialVariantValue)
|
|
438
491
|
|
|
@@ -449,7 +502,8 @@
|
|
|
449
502
|
|
|
450
503
|
// Use the initial value first, then switch to reactive once mounted
|
|
451
504
|
const effectiveAnimate = $derived(
|
|
452
|
-
|
|
505
|
+
declarativeAnimateProp ??
|
|
506
|
+
(variantsProp ? (inheritedVariant ?? initialInheritedVariant) : undefined)
|
|
453
507
|
)
|
|
454
508
|
|
|
455
509
|
// Propagate initial={false} to children BEFORE setting variant context
|
|
@@ -506,7 +560,8 @@
|
|
|
506
560
|
|
|
507
561
|
$effect(() => {
|
|
508
562
|
if (!variantsProp) return localVariantStore.set(undefined)
|
|
509
|
-
if (typeof
|
|
563
|
+
if (typeof declarativeAnimateProp === 'string')
|
|
564
|
+
return localVariantStore.set(declarativeAnimateProp)
|
|
510
565
|
if (typeof effectiveAnimate === 'string') return localVariantStore.set(effectiveAnimate)
|
|
511
566
|
localVariantStore.set(undefined)
|
|
512
567
|
})
|
|
@@ -551,6 +606,338 @@
|
|
|
551
606
|
reducedMotion
|
|
552
607
|
)
|
|
553
608
|
)
|
|
609
|
+
const optimizedAppearId = $derived(
|
|
610
|
+
effectiveInitialProp !== false &&
|
|
611
|
+
isNotEmpty(initialKeyframes) &&
|
|
612
|
+
isNotEmpty(animateKeyframes)
|
|
613
|
+
? `svelte-motion-${componentHydrationId}`
|
|
614
|
+
: undefined
|
|
615
|
+
)
|
|
616
|
+
const optimizedAppearEntries = $derived(
|
|
617
|
+
createOptimizedAppearData(
|
|
618
|
+
initialKeyframes as Record<string, unknown> | undefined,
|
|
619
|
+
animateKeyframes as Record<string, unknown> | undefined,
|
|
620
|
+
mergedTransition
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
const optimizedAppearScript = $derived(
|
|
624
|
+
createOptimizedAppearScript(optimizedAppearId, optimizedAppearEntries)
|
|
625
|
+
)
|
|
626
|
+
const renderedOptimizedAppearScript = $derived(
|
|
627
|
+
optimizedAppearScript && (typeof window === 'undefined' || !window.MotionIsMounted)
|
|
628
|
+
? optimizedAppearScript
|
|
629
|
+
: ''
|
|
630
|
+
)
|
|
631
|
+
const applyAnimateRestingStyle = () => {
|
|
632
|
+
if (!element) return
|
|
633
|
+
const restingValues = resolveRestingValues(
|
|
634
|
+
animateKeyframes as DOMKeyframesDefinition | undefined
|
|
635
|
+
) as Record<string, unknown> | undefined
|
|
636
|
+
if (!restingValues) return
|
|
637
|
+
element.setAttribute(
|
|
638
|
+
'style',
|
|
639
|
+
mergeInlineStyles(element.getAttribute('style') ?? '', undefined, restingValues)
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
const isJsdomRuntime = (): boolean =>
|
|
643
|
+
typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)
|
|
644
|
+
const getTransitionFallbackMs = (transition: AnimationOptions | undefined): number => {
|
|
645
|
+
const duration = typeof transition?.duration === 'number' ? transition.duration : 0
|
|
646
|
+
const delay = typeof transition?.delay === 'number' ? transition.delay : 0
|
|
647
|
+
return Math.max(0, (duration + delay) * 1000)
|
|
648
|
+
}
|
|
649
|
+
let cleanupSVGPathAttributeEffect: (() => void) | null = null
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Reads the current normalized SVG path drawing state from DOM
|
|
653
|
+
* attributes. `motion-dom`'s svgEffect owns future writes; this only
|
|
654
|
+
* seeds its MotionValues from the currently rendered frame.
|
|
655
|
+
*
|
|
656
|
+
* @param {SVGPathElement} path The SVG path element to inspect.
|
|
657
|
+
* @returns {{ pathLength: number; pathSpacing: number; pathOffset: number }} The normalized drawing state.
|
|
658
|
+
*/
|
|
659
|
+
const readSVGPathDrawingState = (
|
|
660
|
+
path: SVGPathElement
|
|
661
|
+
): { pathLength: number; pathSpacing: number; pathOffset: number } => {
|
|
662
|
+
const dashArray =
|
|
663
|
+
path.getAttribute('stroke-dasharray') || path.style.strokeDasharray || '1 0'
|
|
664
|
+
const [rawLength, rawSpacing] = dashArray
|
|
665
|
+
.split(/[,\s]+/)
|
|
666
|
+
.filter(Boolean)
|
|
667
|
+
.map((part) => Number.parseFloat(part))
|
|
668
|
+
const rawOffset = Number.parseFloat(
|
|
669
|
+
path.getAttribute('stroke-dashoffset') || path.style.strokeDashoffset || '0'
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
pathLength: Number.isFinite(rawLength) ? rawLength : 1,
|
|
674
|
+
pathSpacing: Number.isFinite(rawSpacing) ? rawSpacing : 1,
|
|
675
|
+
pathOffset: Number.isFinite(rawOffset) ? -rawOffset : 0
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Removes custom SVG path props from keyframes after `svgEffect` has
|
|
681
|
+
* taken ownership of them.
|
|
682
|
+
*
|
|
683
|
+
* @param {Record<string, unknown>} keyframes Keyframes to copy.
|
|
684
|
+
* @returns {Record<string, unknown>} Keyframes without SVG path-only props.
|
|
685
|
+
*/
|
|
686
|
+
const stripSVGPathKeyframes = (keyframes: Record<string, unknown>): Record<string, unknown> => {
|
|
687
|
+
const stripped = { ...keyframes }
|
|
688
|
+
delete stripped.pathLength
|
|
689
|
+
delete stripped.pathSpacing
|
|
690
|
+
delete stripped.pathOffset
|
|
691
|
+
return stripped
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Extracts an animation completion promise from a Motion control when
|
|
696
|
+
* one is available.
|
|
697
|
+
*
|
|
698
|
+
* @param {unknown} control The return value from `animate`.
|
|
699
|
+
* @returns {Promise<unknown> | null} The finished promise, or null.
|
|
700
|
+
*/
|
|
701
|
+
const getFinishedPromise = (control: unknown): Promise<unknown> | null => {
|
|
702
|
+
if (!control || typeof control !== 'object') return null
|
|
703
|
+
const finished = (control as { finished?: unknown }).finished
|
|
704
|
+
return finished && typeof (finished as Promise<unknown>).then === 'function'
|
|
705
|
+
? (finished as Promise<unknown>)
|
|
706
|
+
: null
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const getAnimationPromise = (control: unknown): Promise<unknown> => {
|
|
710
|
+
const finished = getFinishedPromise(control)
|
|
711
|
+
if (finished) return finished
|
|
712
|
+
if (control && typeof (control as Promise<unknown>).then === 'function') {
|
|
713
|
+
return control as Promise<unknown>
|
|
714
|
+
}
|
|
715
|
+
return Promise.resolve()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
type StoppableAnimationControl = {
|
|
719
|
+
stop?: () => void
|
|
720
|
+
cancel?: () => void
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const activeAnimationControls = new SvelteSet<StoppableAnimationControl>()
|
|
724
|
+
let animationControlsGeneration = 0
|
|
725
|
+
let animationControlsHasReceivedCommand = false
|
|
726
|
+
|
|
727
|
+
const isStoppableAnimationControl = (control: unknown): control is StoppableAnimationControl =>
|
|
728
|
+
!!control &&
|
|
729
|
+
typeof control === 'object' &&
|
|
730
|
+
(typeof (control as StoppableAnimationControl).stop === 'function' ||
|
|
731
|
+
typeof (control as StoppableAnimationControl).cancel === 'function')
|
|
732
|
+
|
|
733
|
+
const trackAnimationControlsControl = (control: unknown): Promise<unknown> => {
|
|
734
|
+
const promise = getAnimationPromise(control)
|
|
735
|
+
if (isStoppableAnimationControl(control)) {
|
|
736
|
+
activeAnimationControls.add(control)
|
|
737
|
+
promise.then(
|
|
738
|
+
() => activeAnimationControls.delete(control),
|
|
739
|
+
() => activeAnimationControls.delete(control)
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
return promise
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const resolveAnimationControlsDefinition = (
|
|
746
|
+
definition: AnimationControlsDefinition
|
|
747
|
+
): DOMKeyframesDefinition | undefined => {
|
|
748
|
+
const resolvedDefinition =
|
|
749
|
+
typeof definition === 'function' ? definition(effectiveCustom) : definition
|
|
750
|
+
if (typeof resolvedDefinition === 'string' || Array.isArray(resolvedDefinition)) {
|
|
751
|
+
return resolveVariantList(variantsProp, resolvedDefinition, effectiveCustom)
|
|
752
|
+
}
|
|
753
|
+
return resolvedDefinition
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const applyAnimationControlsTarget = (definition: AnimationControlsDefinition) => {
|
|
757
|
+
if (!element) return
|
|
758
|
+
const resolved = resolveAnimationControlsDefinition(definition)
|
|
759
|
+
if (!resolved) return
|
|
760
|
+
|
|
761
|
+
animationControlsHasReceivedCommand = true
|
|
762
|
+
const target = { ...(resolved as Record<string, unknown>) } as Record<string, unknown> & {
|
|
763
|
+
transition?: AnimationOptions
|
|
764
|
+
transitionEnd?: Record<string, unknown>
|
|
765
|
+
}
|
|
766
|
+
const transitionEnd = target.transitionEnd
|
|
767
|
+
delete target.transition
|
|
768
|
+
delete target.transitionEnd
|
|
769
|
+
const finalTarget = resolveRestingValues({
|
|
770
|
+
...target,
|
|
771
|
+
...(transitionEnd ?? {})
|
|
772
|
+
} as DOMKeyframesDefinition) as Record<string, unknown> | undefined
|
|
773
|
+
if (!finalTarget) return
|
|
774
|
+
|
|
775
|
+
const transformedTarget = transformSVGPathProperties(element, finalTarget) as Record<
|
|
776
|
+
string,
|
|
777
|
+
unknown
|
|
778
|
+
>
|
|
779
|
+
animate(element, transformedTarget as DOMKeyframesDefinition, { duration: 0 })
|
|
780
|
+
element.setAttribute(
|
|
781
|
+
'style',
|
|
782
|
+
mergeInlineStyles(element.getAttribute('style') ?? '', undefined, transformedTarget)
|
|
783
|
+
)
|
|
784
|
+
enterAnimationSettled = true
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const stopAnimationControlsAnimations = () => {
|
|
788
|
+
animationControlsHasReceivedCommand = true
|
|
789
|
+
animationControlsGeneration += 1
|
|
790
|
+
|
|
791
|
+
for (const control of activeAnimationControls) {
|
|
792
|
+
if (typeof control.stop === 'function') {
|
|
793
|
+
control.stop()
|
|
794
|
+
} else {
|
|
795
|
+
control.cancel?.()
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
activeAnimationControls.clear()
|
|
799
|
+
|
|
800
|
+
if (!element) return
|
|
801
|
+
if (typeof element.getAnimations !== 'function') return
|
|
802
|
+
for (const animation of element.getAnimations()) {
|
|
803
|
+
try {
|
|
804
|
+
animation.commitStyles?.()
|
|
805
|
+
} catch {
|
|
806
|
+
// Ignore unsupported commitStyles cases.
|
|
807
|
+
}
|
|
808
|
+
animation.cancel()
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const startAnimationControlsDefinition = async (
|
|
813
|
+
definition: AnimationControlsDefinition,
|
|
814
|
+
transitionOverride?: AnimationOptions
|
|
815
|
+
): Promise<unknown> => {
|
|
816
|
+
if (!element) return
|
|
817
|
+
const resolved = resolveAnimationControlsDefinition(definition)
|
|
818
|
+
if (!resolved) return
|
|
819
|
+
|
|
820
|
+
animationControlsHasReceivedCommand = true
|
|
821
|
+
const filtered = filterReducedMotionKeyframes(
|
|
822
|
+
resolved as Record<string, unknown>,
|
|
823
|
+
reducedMotion
|
|
824
|
+
) as Record<string, unknown> & {
|
|
825
|
+
transition?: AnimationOptions
|
|
826
|
+
transitionEnd?: Record<string, unknown>
|
|
827
|
+
}
|
|
828
|
+
const transition = filtered.transition
|
|
829
|
+
const target = { ...filtered }
|
|
830
|
+
delete target.transition
|
|
831
|
+
delete target.transitionEnd
|
|
832
|
+
const transitionAnimate: MotionTransition =
|
|
833
|
+
transitionOverride ?? mergeTransitions(mergedTransition ?? {}, transition ?? {})
|
|
834
|
+
const svgPathFinished =
|
|
835
|
+
isSVGPathElement(element) && hasSVGPathProperties(target)
|
|
836
|
+
? animateSVGPathAttributes(element, target, transitionAnimate, true)
|
|
837
|
+
: []
|
|
838
|
+
const payload = transformSVGPathProperties(
|
|
839
|
+
element,
|
|
840
|
+
svgPathFinished.length > 0 ? stripSVGPathKeyframes(target) : target
|
|
841
|
+
) as Record<string, unknown>
|
|
842
|
+
|
|
843
|
+
const controlsGeneration = ++animationControlsGeneration
|
|
844
|
+
enterAnimationSettled = false
|
|
845
|
+
onAnimationStartProp?.(definition as unknown as DOMKeyframesDefinition)
|
|
846
|
+
|
|
847
|
+
const promises: Promise<unknown>[] = [...svgPathFinished]
|
|
848
|
+
if (isNotEmpty(payload)) {
|
|
849
|
+
promises.push(
|
|
850
|
+
trackAnimationControlsControl(
|
|
851
|
+
animate(
|
|
852
|
+
element,
|
|
853
|
+
payload as DOMKeyframesDefinition,
|
|
854
|
+
transitionAnimate as AnimationOptions
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
await Promise.all(promises)
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (controlsGeneration !== animationControlsGeneration) return
|
|
864
|
+
throw error
|
|
865
|
+
}
|
|
866
|
+
if (controlsGeneration !== animationControlsGeneration) return
|
|
867
|
+
applyAnimationControlsTarget(definition)
|
|
868
|
+
onAnimationCompleteProp?.(definition as unknown as DOMKeyframesDefinition)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Animates SVG path drawing props via motion-dom's `svgEffect`, matching
|
|
873
|
+
* upstream's attribute-based pathLength/pathSpacing/pathOffset behavior.
|
|
874
|
+
*
|
|
875
|
+
* @param {SVGPathElement} path The path element to animate.
|
|
876
|
+
* @param {Record<string, unknown>} keyframes Keyframes containing SVG path props.
|
|
877
|
+
* @param {MotionTransition} transition The transition to apply to generated MotionValues.
|
|
878
|
+
* @returns {Promise<unknown>[]} Promises for generated path animations.
|
|
879
|
+
*/
|
|
880
|
+
const animateSVGPathAttributes = (
|
|
881
|
+
path: SVGPathElement,
|
|
882
|
+
keyframes: Record<string, unknown>,
|
|
883
|
+
transition: MotionTransition,
|
|
884
|
+
trackControl = false
|
|
885
|
+
): Promise<unknown>[] => {
|
|
886
|
+
if (!hasSVGPathProperties(keyframes)) return []
|
|
887
|
+
|
|
888
|
+
cleanupSVGPathAttributeEffect?.()
|
|
889
|
+
const current = readSVGPathDrawingState(path)
|
|
890
|
+
const values: Record<string, MotionValue<number>> = {}
|
|
891
|
+
|
|
892
|
+
if ('pathLength' in keyframes) {
|
|
893
|
+
values.pathLength = motionValue(current.pathLength)
|
|
894
|
+
}
|
|
895
|
+
if ('pathLength' in keyframes || 'pathSpacing' in keyframes) {
|
|
896
|
+
values.pathSpacing = motionValue(current.pathSpacing)
|
|
897
|
+
}
|
|
898
|
+
if ('pathOffset' in keyframes) {
|
|
899
|
+
values.pathOffset = motionValue(current.pathOffset)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
cleanupSVGPathAttributeEffect = svgEffect(path, values)
|
|
903
|
+
|
|
904
|
+
return Object.entries(values)
|
|
905
|
+
.map(([key, value]) => {
|
|
906
|
+
const control = animate(
|
|
907
|
+
value as never,
|
|
908
|
+
(key === 'pathSpacing' && !('pathSpacing' in keyframes)
|
|
909
|
+
? 1
|
|
910
|
+
: keyframes[key]) as never,
|
|
911
|
+
transition as unknown as AnimationOptions
|
|
912
|
+
)
|
|
913
|
+
return trackControl
|
|
914
|
+
? trackAnimationControlsControl(control)
|
|
915
|
+
: getFinishedPromise(control)
|
|
916
|
+
})
|
|
917
|
+
.filter((promise): promise is Promise<unknown> => promise !== null)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
onDestroy(() => {
|
|
921
|
+
cleanupSVGPathAttributeEffect?.()
|
|
922
|
+
cleanupSVGPathAttributeEffect = null
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// Wait-mode enter coordination needs to affect the first rendered attrs,
|
|
926
|
+
// before the blocked entrant can participate in layout.
|
|
927
|
+
let waitCallbackRegistered = $state(false)
|
|
928
|
+
let waitUnsubscribe: (() => void) | null = null
|
|
929
|
+
let waitHiddenDisplay: string | null = null
|
|
930
|
+
let waitEnterReleased = $state(false)
|
|
931
|
+
let waitLayoutParent: HTMLElement | null = null
|
|
932
|
+
let waitLayoutParentWidth = ''
|
|
933
|
+
let waitLayoutParentHeight = ''
|
|
934
|
+
let waitLayoutViewportScrollX = 0
|
|
935
|
+
let waitLayoutViewportScrollY = 0
|
|
936
|
+
const presenceLayoutHoldAttribute = 'data-presence-layout-hold'
|
|
937
|
+
const presenceLayoutReleaseEvent = 'svelte-motion:presence-layout-release'
|
|
938
|
+
const waitEnterBlockedBeforeMount = $derived(
|
|
939
|
+
context?.mode === 'wait' && !waitEnterReleased && context.isEnterBlocked(presenceKey)
|
|
940
|
+
)
|
|
554
941
|
|
|
555
942
|
// Derived attributes to keep both branches in sync (focusability, data flags, style, class)
|
|
556
943
|
const derivedAttrs = $derived<Record<string, unknown>>({
|
|
@@ -575,6 +962,16 @@
|
|
|
575
962
|
'data-path': dataPath
|
|
576
963
|
}
|
|
577
964
|
: {}),
|
|
965
|
+
...(renderedOptimizedAppearScript
|
|
966
|
+
? { [optimizedAppearDataAttribute]: optimizedAppearId }
|
|
967
|
+
: {}),
|
|
968
|
+
...(layoutProp
|
|
969
|
+
? { 'data-layout': String(layoutProp), 'data-svelte-motion-layout': '' }
|
|
970
|
+
: {}),
|
|
971
|
+
...(scopedLayoutId ? { 'data-layout-id': scopedLayoutId } : {}),
|
|
972
|
+
...(waitEnterBlockedBeforeMount || waitHiddenDisplay !== null
|
|
973
|
+
? { 'data-presence-wait-hidden': 'true' }
|
|
974
|
+
: {}),
|
|
578
975
|
// Apply normalized SVG path attributes synchronously on first render to avoid flash
|
|
579
976
|
// Compute via svg utils (no dynamic import in SSR/derived expressions)
|
|
580
977
|
...(() => {
|
|
@@ -588,9 +985,7 @@
|
|
|
588
985
|
return {}
|
|
589
986
|
})(),
|
|
590
987
|
style: mergeInlineStyles(
|
|
591
|
-
initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
|
|
592
|
-
? `${styleProp || ''};visibility:hidden`
|
|
593
|
-
: styleProp,
|
|
988
|
+
`${initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting' ? `${styleProp || ''};visibility:hidden` : (styleProp ?? '')}${waitEnterBlockedBeforeMount || waitHiddenDisplay !== null ? ';display:none' : ''}`,
|
|
594
989
|
// The "from" slot: apply initialKeyframes as inline styles during
|
|
595
990
|
// the mounting/initial phases (before the WAAPI animation locks
|
|
596
991
|
// its from-value and we promote to 'ready' — see the lifecycle
|
|
@@ -615,9 +1010,13 @@
|
|
|
615
1010
|
? (resolveRestingValues(
|
|
616
1011
|
animateKeyframes as DOMKeyframesDefinition | undefined
|
|
617
1012
|
) as unknown as Record<string, unknown>)
|
|
618
|
-
:
|
|
619
|
-
|
|
620
|
-
|
|
1013
|
+
: animateControls &&
|
|
1014
|
+
!animationControlsHasReceivedCommand &&
|
|
1015
|
+
isNotEmpty(initialKeyframes)
|
|
1016
|
+
? (initialKeyframes as unknown as Record<string, unknown>)
|
|
1017
|
+
: isNotEmpty(initialKeyframes)
|
|
1018
|
+
? undefined
|
|
1019
|
+
: (animateKeyframes as unknown as Record<string, unknown>)
|
|
621
1020
|
),
|
|
622
1021
|
class: classProp
|
|
623
1022
|
})
|
|
@@ -869,7 +1268,17 @@
|
|
|
869
1268
|
}
|
|
870
1269
|
|
|
871
1270
|
const transitionAnimate: MotionTransition = mergedTransition ?? {}
|
|
872
|
-
|
|
1271
|
+
const rawPayload = filterReducedMotionKeyframes(
|
|
1272
|
+
$state.snapshot(resolvedAnimate) as Record<string, unknown>,
|
|
1273
|
+
reducedMotion
|
|
1274
|
+
) as Record<string, unknown>
|
|
1275
|
+
const svgPathFinished =
|
|
1276
|
+
isSVGPathElement(element) && hasSVGPathProperties(rawPayload)
|
|
1277
|
+
? animateSVGPathAttributes(element, rawPayload, transitionAnimate)
|
|
1278
|
+
: []
|
|
1279
|
+
let payload = (
|
|
1280
|
+
svgPathFinished.length > 0 ? stripSVGPathKeyframes(rawPayload) : rawPayload
|
|
1281
|
+
) as typeof rawPayload
|
|
873
1282
|
|
|
874
1283
|
// Transform SVG path properties (pathLength, pathOffset) to their CSS equivalents
|
|
875
1284
|
payload = transformSVGPathProperties(
|
|
@@ -877,13 +1286,6 @@
|
|
|
877
1286
|
payload as Record<string, unknown>
|
|
878
1287
|
) as typeof payload
|
|
879
1288
|
|
|
880
|
-
// Strip transform keys when reduced-motion is active so the element
|
|
881
|
-
// stays in place while opacity / color etc. still animate.
|
|
882
|
-
payload = filterReducedMotionKeyframes(
|
|
883
|
-
payload as Record<string, unknown>,
|
|
884
|
-
reducedMotion
|
|
885
|
-
) as typeof payload
|
|
886
|
-
|
|
887
1289
|
// Ensure dash properties aren't pinned as inline styles
|
|
888
1290
|
if (element && (element as HTMLElement).style) {
|
|
889
1291
|
;(element as HTMLElement).style.removeProperty('stroke-dasharray')
|
|
@@ -897,33 +1299,134 @@
|
|
|
897
1299
|
|
|
898
1300
|
// A fresh run owns the transform again until it completes.
|
|
899
1301
|
enterAnimationSettled = false
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1302
|
+
const completeEnterAnimation = (
|
|
1303
|
+
def: DOMKeyframesDefinition | undefined = payload as unknown as DOMKeyframesDefinition
|
|
1304
|
+
) => {
|
|
1305
|
+
if (enterAnimationSettled) return
|
|
1306
|
+
// Now the target is the resting state — promote it to the
|
|
1307
|
+
// inline baseline so it persists after WAAPI surrenders the
|
|
1308
|
+
// property (default fill:'none'). (#377)
|
|
1309
|
+
applyAnimateRestingStyle()
|
|
1310
|
+
enterAnimationSettled = true
|
|
1311
|
+
onAnimationCompleteProp?.(def)
|
|
1312
|
+
}
|
|
1313
|
+
if (isNotEmpty(payload)) {
|
|
1314
|
+
animateWithLifecycle(
|
|
1315
|
+
element,
|
|
1316
|
+
payload as unknown as DOMKeyframesDefinition,
|
|
1317
|
+
transitionAnimate as unknown as AnimationOptions,
|
|
1318
|
+
(def) =>
|
|
1319
|
+
onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
|
|
1320
|
+
(def) =>
|
|
1321
|
+
completeEnterAnimation(def as unknown as DOMKeyframesDefinition | undefined)
|
|
1322
|
+
)
|
|
1323
|
+
} else if (svgPathFinished.length > 0) {
|
|
1324
|
+
onAnimationStartProp?.(rawPayload as unknown as DOMKeyframesDefinition)
|
|
1325
|
+
Promise.all(svgPathFinished)
|
|
1326
|
+
.then(() => completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition))
|
|
1327
|
+
.catch(() =>
|
|
1328
|
+
completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition)
|
|
1329
|
+
)
|
|
1330
|
+
}
|
|
1331
|
+
if (isJsdomRuntime()) {
|
|
1332
|
+
window.setTimeout(
|
|
1333
|
+
() => completeEnterAnimation(),
|
|
1334
|
+
getTransitionFallbackMs(transitionAnimate as AnimationOptions)
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
913
1337
|
}
|
|
914
1338
|
|
|
915
|
-
// Track if we've already registered a wait callback to prevent duplicates
|
|
916
|
-
let waitCallbackRegistered = $state(false)
|
|
917
|
-
let waitUnsubscribe: (() => void) | null = null
|
|
918
|
-
|
|
919
1339
|
// Cleanup wait callback on component unmount to prevent memory leaks
|
|
920
1340
|
$effect(() => {
|
|
921
1341
|
return () => {
|
|
1342
|
+
if (element && waitHiddenDisplay !== null) {
|
|
1343
|
+
element.style.display = waitHiddenDisplay
|
|
1344
|
+
element.removeAttribute('data-presence-wait-hidden')
|
|
1345
|
+
waitHiddenDisplay = null
|
|
1346
|
+
}
|
|
1347
|
+
releaseWaitLayoutHold()
|
|
922
1348
|
waitUnsubscribe?.()
|
|
923
1349
|
waitUnsubscribe = null
|
|
924
1350
|
}
|
|
925
1351
|
})
|
|
926
1352
|
|
|
1353
|
+
const getPresenceLayoutParent = (): HTMLElement | null => {
|
|
1354
|
+
let parent = element?.parentElement ?? null
|
|
1355
|
+
const layoutParent = element?.parentElement?.closest<HTMLElement>(
|
|
1356
|
+
'[data-svelte-motion-layout]'
|
|
1357
|
+
)
|
|
1358
|
+
if (layoutParent) return layoutParent
|
|
1359
|
+
|
|
1360
|
+
while (parent && getComputedStyle(parent).display === 'contents') {
|
|
1361
|
+
parent = parent.parentElement
|
|
1362
|
+
}
|
|
1363
|
+
return parent
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const holdWaitLayout = () => {
|
|
1367
|
+
if (!element || waitLayoutParent) return
|
|
1368
|
+
const parent = getPresenceLayoutParent()
|
|
1369
|
+
if (!parent) return
|
|
1370
|
+
|
|
1371
|
+
const rect = parent.getBoundingClientRect()
|
|
1372
|
+
waitLayoutParent = parent
|
|
1373
|
+
waitLayoutParentWidth = parent.style.width
|
|
1374
|
+
waitLayoutParentHeight = parent.style.height
|
|
1375
|
+
waitLayoutViewportScrollX = typeof window !== 'undefined' ? window.scrollX : 0
|
|
1376
|
+
waitLayoutViewportScrollY = typeof window !== 'undefined' ? window.scrollY : 0
|
|
1377
|
+
parent.setAttribute(presenceLayoutHoldAttribute, 'true')
|
|
1378
|
+
parent.style.width = `${rect.width}px`
|
|
1379
|
+
parent.style.height = `${rect.height}px`
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function releaseWaitLayoutHold() {
|
|
1383
|
+
if (!waitLayoutParent) return
|
|
1384
|
+
const parent = waitLayoutParent
|
|
1385
|
+
const previousRect = parent.getBoundingClientRect()
|
|
1386
|
+
parent.removeAttribute(presenceLayoutHoldAttribute)
|
|
1387
|
+
if (waitLayoutParentWidth) {
|
|
1388
|
+
parent.style.width = waitLayoutParentWidth
|
|
1389
|
+
} else {
|
|
1390
|
+
parent.style.removeProperty('width')
|
|
1391
|
+
}
|
|
1392
|
+
if (waitLayoutParentHeight) {
|
|
1393
|
+
parent.style.height = waitLayoutParentHeight
|
|
1394
|
+
} else {
|
|
1395
|
+
parent.style.removeProperty('height')
|
|
1396
|
+
}
|
|
1397
|
+
const viewportScrolledDuringHold =
|
|
1398
|
+
typeof window !== 'undefined' &&
|
|
1399
|
+
(window.scrollX !== waitLayoutViewportScrollX ||
|
|
1400
|
+
window.scrollY !== waitLayoutViewportScrollY)
|
|
1401
|
+
parent.dispatchEvent(
|
|
1402
|
+
new CustomEvent(presenceLayoutReleaseEvent, {
|
|
1403
|
+
detail: {
|
|
1404
|
+
previousRect: domRectToRectLike(previousRect),
|
|
1405
|
+
viewportScrolledDuringHold
|
|
1406
|
+
}
|
|
1407
|
+
})
|
|
1408
|
+
)
|
|
1409
|
+
waitLayoutParent = null
|
|
1410
|
+
waitLayoutParentWidth = ''
|
|
1411
|
+
waitLayoutParentHeight = ''
|
|
1412
|
+
waitLayoutViewportScrollX = 0
|
|
1413
|
+
waitLayoutViewportScrollY = 0
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const revealWaitHiddenElement = () => {
|
|
1417
|
+
waitEnterReleased = true
|
|
1418
|
+
if (waitHiddenDisplay !== null && element) {
|
|
1419
|
+
if (waitHiddenDisplay) {
|
|
1420
|
+
element.style.display = waitHiddenDisplay
|
|
1421
|
+
} else {
|
|
1422
|
+
element.style.removeProperty('display')
|
|
1423
|
+
}
|
|
1424
|
+
element.removeAttribute('data-presence-wait-hidden')
|
|
1425
|
+
waitHiddenDisplay = null
|
|
1426
|
+
}
|
|
1427
|
+
releaseWaitLayoutHold()
|
|
1428
|
+
}
|
|
1429
|
+
|
|
927
1430
|
/**
|
|
928
1431
|
* Run the enter animation, respecting wait mode if inside AnimatePresence.
|
|
929
1432
|
* Returns true if animation was deferred (wait mode with blocked enters).
|
|
@@ -949,12 +1452,21 @@
|
|
|
949
1452
|
return true // Still deferred
|
|
950
1453
|
}
|
|
951
1454
|
|
|
952
|
-
const blocked = context.isEnterBlocked?.()
|
|
1455
|
+
const blocked = context.isEnterBlocked?.(presenceKey)
|
|
953
1456
|
pwLog('[motion] runAnimation: wait mode', { blocked })
|
|
954
1457
|
|
|
955
1458
|
if (blocked) {
|
|
956
1459
|
pwLog('[motion] runAnimation: enters blocked, deferring')
|
|
957
1460
|
|
|
1461
|
+
waitEnterReleased = false
|
|
1462
|
+
if (waitHiddenDisplay === null) {
|
|
1463
|
+
waitHiddenDisplay =
|
|
1464
|
+
element.style.display === 'none' ? '' : element.style.display
|
|
1465
|
+
element.style.display = 'none'
|
|
1466
|
+
element.setAttribute('data-presence-wait-hidden', 'true')
|
|
1467
|
+
holdWaitLayout()
|
|
1468
|
+
}
|
|
1469
|
+
|
|
958
1470
|
waitCallbackRegistered = true
|
|
959
1471
|
|
|
960
1472
|
// Register callback to run animation when unblocked
|
|
@@ -964,6 +1476,12 @@
|
|
|
964
1476
|
waitUnsubscribe = null
|
|
965
1477
|
waitCallbackRegistered = false
|
|
966
1478
|
|
|
1479
|
+
// Reveal synchronously after the exiting placeholder has
|
|
1480
|
+
// been removed. The parent is fixed-size until the next
|
|
1481
|
+
// frame, so it measures the final entrant instead of an
|
|
1482
|
+
// overlap between exiting and entering content.
|
|
1483
|
+
revealWaitHiddenElement()
|
|
1484
|
+
|
|
967
1485
|
// Snap to initial state first (in case inline styles were removed)
|
|
968
1486
|
if (initialKeyframes && element) {
|
|
969
1487
|
const transformedInitial = transformSVGPathProperties(
|
|
@@ -985,9 +1503,12 @@
|
|
|
985
1503
|
// which shows up as a "pop" after the deferred animation completes.
|
|
986
1504
|
pwLog('[motion] wait-unblocked: marking enter handled')
|
|
987
1505
|
initialAnimationTriggered = true
|
|
988
|
-
if (
|
|
1506
|
+
if (
|
|
1507
|
+
declarativeAnimateProp &&
|
|
1508
|
+
typeof declarativeAnimateProp !== 'string'
|
|
1509
|
+
) {
|
|
989
1510
|
objectAnimateRanOnMount = true
|
|
990
|
-
lastAnimatePropJson = JSON.stringify(
|
|
1511
|
+
lastAnimatePropJson = JSON.stringify(declarativeAnimateProp)
|
|
991
1512
|
}
|
|
992
1513
|
isLoaded = 'ready'
|
|
993
1514
|
})
|
|
@@ -995,6 +1516,11 @@
|
|
|
995
1516
|
})
|
|
996
1517
|
return true // Animation was deferred
|
|
997
1518
|
}
|
|
1519
|
+
|
|
1520
|
+
if (waitHiddenDisplay !== null || waitEnterBlockedBeforeMount) {
|
|
1521
|
+
pwLog('[motion] runAnimation: wait mode no longer blocked, revealing')
|
|
1522
|
+
revealWaitHiddenElement()
|
|
1523
|
+
}
|
|
998
1524
|
}
|
|
999
1525
|
|
|
1000
1526
|
// Not blocked - run animation immediately
|
|
@@ -1018,14 +1544,97 @@
|
|
|
1018
1544
|
let objectAnimateRanOnMount = $state(false)
|
|
1019
1545
|
// Track the serialized animateProp to detect changes for object animate props
|
|
1020
1546
|
let lastAnimatePropJson = $state<string | undefined>(undefined)
|
|
1547
|
+
let motionDomProjectionUpdatePending = false
|
|
1021
1548
|
const currentAnimateKey = $derived(
|
|
1022
|
-
typeof
|
|
1023
|
-
?
|
|
1549
|
+
typeof declarativeAnimateProp === 'string'
|
|
1550
|
+
? declarativeAnimateProp
|
|
1024
1551
|
: typeof effectiveAnimate === 'string'
|
|
1025
1552
|
? effectiveAnimate
|
|
1026
1553
|
: undefined
|
|
1027
1554
|
)
|
|
1028
1555
|
|
|
1556
|
+
$effect(() => {
|
|
1557
|
+
if (!motionDomProjection) return
|
|
1558
|
+
motionDomProjection.updateOptions({
|
|
1559
|
+
layout: layoutProp,
|
|
1560
|
+
layoutId: scopedLayoutId,
|
|
1561
|
+
layoutScroll: layoutScrollProp,
|
|
1562
|
+
transition: mergedTransition as never,
|
|
1563
|
+
style: styleProp
|
|
1564
|
+
})
|
|
1565
|
+
})
|
|
1566
|
+
|
|
1567
|
+
$effect(() => {
|
|
1568
|
+
if (!motionDomProjection) return
|
|
1569
|
+
if (!element) return
|
|
1570
|
+
motionDomProjection.updateOptions({
|
|
1571
|
+
layout: layoutProp,
|
|
1572
|
+
layoutId: scopedLayoutId,
|
|
1573
|
+
layoutScroll: layoutScrollProp,
|
|
1574
|
+
transition: mergedTransition as never,
|
|
1575
|
+
style: styleProp
|
|
1576
|
+
})
|
|
1577
|
+
motionDomProjection.mount(element)
|
|
1578
|
+
return () => {
|
|
1579
|
+
motionDomProjection.unmount()
|
|
1580
|
+
}
|
|
1581
|
+
})
|
|
1582
|
+
|
|
1583
|
+
let explicitLayoutSnapshot: RectLike | null = null
|
|
1584
|
+
let lastRect: RectLike | null = null
|
|
1585
|
+
const trackLayoutProjectionDependencies = () => [
|
|
1586
|
+
classProp,
|
|
1587
|
+
styleProp,
|
|
1588
|
+
scopedLayoutId,
|
|
1589
|
+
mergedTransition
|
|
1590
|
+
]
|
|
1591
|
+
|
|
1592
|
+
$effect.pre(() => {
|
|
1593
|
+
const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
|
|
1594
|
+
// Track common layout-affecting props so Svelte-owned updates can
|
|
1595
|
+
// snapshot before the DOM patch, matching upstream MeasureLayout.
|
|
1596
|
+
trackLayoutProjectionDependencies()
|
|
1597
|
+
|
|
1598
|
+
if (!shouldProject) {
|
|
1599
|
+
explicitLayoutSnapshot = null
|
|
1600
|
+
return
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
explicitLayoutSnapshot = measureLayoutRect()
|
|
1604
|
+
projection.willUpdate()
|
|
1605
|
+
motionDomProjection?.willUpdate()
|
|
1606
|
+
motionDomProjectionUpdatePending = true
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
$effect(() => {
|
|
1610
|
+
const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
|
|
1611
|
+
trackLayoutProjectionDependencies()
|
|
1612
|
+
|
|
1613
|
+
if (!shouldProject || !motionDomProjectionUpdatePending) return
|
|
1614
|
+
motionDomProjectionUpdatePending = false
|
|
1615
|
+
const previous = explicitLayoutSnapshot
|
|
1616
|
+
explicitLayoutSnapshot = null
|
|
1617
|
+
if (previous) {
|
|
1618
|
+
const next = measureLayoutRect()
|
|
1619
|
+
if (next) {
|
|
1620
|
+
if (motionDomProjection) {
|
|
1621
|
+
lastRect = next
|
|
1622
|
+
} else {
|
|
1623
|
+
const flipLayoutMode = layoutProp === 'position' ? 'position' : true
|
|
1624
|
+
const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
|
|
1625
|
+
runFlipAnimation(
|
|
1626
|
+
element!,
|
|
1627
|
+
transforms,
|
|
1628
|
+
(mergedTransition ?? {}) as AnimationOptions
|
|
1629
|
+
)
|
|
1630
|
+
lastRect = next
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
projection.didUpdate()
|
|
1635
|
+
motionDomProjection?.didUpdate()
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1029
1638
|
// Projection node lifecycle + the `onProjectionUpdate` listener.
|
|
1030
1639
|
// Mount once the element binds; seed the baseline layout; unmount on
|
|
1031
1640
|
// cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
|
|
@@ -1053,56 +1662,140 @@
|
|
|
1053
1662
|
}
|
|
1054
1663
|
})
|
|
1055
1664
|
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1665
|
+
// Upstream layout projection via motion-dom. Svelte runes mode doesn't
|
|
1666
|
+
// expose the React-style pre/post render hook pair used upstream, so the
|
|
1667
|
+
// component snapshots committed layout changes through DOM observers while
|
|
1668
|
+
// keeping the existing local ProjectionNode event fan-out alive.
|
|
1060
1669
|
$effect(() => {
|
|
1061
1670
|
if (!(element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures)) return
|
|
1062
1671
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
// child re-measures and FLIPs whenever an ancestor's transform
|
|
1069
|
-
// animates, even though the child's own layout never moved. (#379)
|
|
1672
|
+
let rafId: number | null = null
|
|
1673
|
+
let wasViewportOffscreenSinceLastLayout = false
|
|
1674
|
+
let wasViewportScrolledSinceLastLayout = false
|
|
1675
|
+
const flipLayoutMode = layoutProp === 'position' ? 'position' : true
|
|
1676
|
+
motionDomProjection?.seedLayout()
|
|
1070
1677
|
lastRect = measureLayoutRect()
|
|
1071
|
-
// Hint compositor for smoother FLIP transforms
|
|
1072
1678
|
setCompositorHints(element!, true)
|
|
1073
1679
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1680
|
+
const rememberOffscreenScroll = () => {
|
|
1681
|
+
wasViewportScrolledSinceLastLayout = true
|
|
1682
|
+
if (motionDomProjection?.isAnimating()) {
|
|
1683
|
+
motionDomProjection.finishAnimation()
|
|
1684
|
+
}
|
|
1685
|
+
if (isViewportOffscreen(element!.getBoundingClientRect())) {
|
|
1686
|
+
wasViewportOffscreenSinceLastLayout = true
|
|
1687
|
+
motionDomProjection?.finishAnimation()
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const commitObservedLayout = () => {
|
|
1692
|
+
if (element!.hasAttribute('data-layout-size-animation')) {
|
|
1693
|
+
return
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const hasPresenceHold = element!.hasAttribute(presenceLayoutHoldAttribute)
|
|
1697
|
+
const hasHiddenWaitEnter = !!element!.querySelector(
|
|
1698
|
+
'[data-presence-wait-hidden="true"]'
|
|
1699
|
+
)
|
|
1700
|
+
const hasPresencePlaceholder = !!element!.querySelector(
|
|
1701
|
+
'[data-presence-placeholder="true"]'
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
if (hasPresenceHold || hasHiddenWaitEnter) {
|
|
1705
|
+
return
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (hasPresencePlaceholder) {
|
|
1709
|
+
lastRect = measureLayoutRect()
|
|
1710
|
+
motionDomProjection?.seedLayout()
|
|
1711
|
+
return
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (
|
|
1715
|
+
wasViewportScrolledSinceLastLayout ||
|
|
1716
|
+
wasViewportOffscreenSinceLastLayout ||
|
|
1717
|
+
isViewportOffscreen(element!.getBoundingClientRect())
|
|
1718
|
+
) {
|
|
1719
|
+
lastRect = measureLayoutRect()
|
|
1720
|
+
motionDomProjection?.finishAnimation()
|
|
1721
|
+
wasViewportScrolledSinceLastLayout = false
|
|
1722
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1723
|
+
return
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1081
1726
|
const nextBox = projection.commitLayoutChange()
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1727
|
+
let shouldCommitMotionDomLayout = false
|
|
1728
|
+
if (nextBox && lastRect) {
|
|
1729
|
+
const next = boxToRectLike(nextBox)
|
|
1730
|
+
shouldCommitMotionDomLayout = hasRectChanged(lastRect, next)
|
|
1731
|
+
if (!motionDomProjection) {
|
|
1732
|
+
const transforms = computeFlipTransforms(lastRect, next, flipLayoutMode)
|
|
1733
|
+
runFlipAnimation(
|
|
1734
|
+
element!,
|
|
1735
|
+
transforms,
|
|
1736
|
+
(mergedTransition ?? {}) as AnimationOptions
|
|
1737
|
+
)
|
|
1738
|
+
}
|
|
1085
1739
|
lastRect = next
|
|
1086
|
-
|
|
1740
|
+
wasViewportScrolledSinceLastLayout = false
|
|
1741
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1742
|
+
} else if (nextBox) {
|
|
1743
|
+
lastRect = boxToRectLike(nextBox)
|
|
1744
|
+
wasViewportScrolledSinceLastLayout = false
|
|
1745
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1746
|
+
}
|
|
1747
|
+
if (shouldCommitMotionDomLayout) {
|
|
1748
|
+
motionDomProjection?.commitObservedLayoutChange()
|
|
1087
1749
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const commitPresenceLayoutRelease = (event: Event) => {
|
|
1753
|
+
const detail = (
|
|
1754
|
+
event as CustomEvent<{
|
|
1755
|
+
previousRect?: RectLike
|
|
1756
|
+
viewportScrolledDuringHold?: boolean
|
|
1757
|
+
}>
|
|
1758
|
+
).detail
|
|
1759
|
+
const previous = detail?.previousRect
|
|
1760
|
+
const viewportRect = element!.getBoundingClientRect()
|
|
1761
|
+
const next = measureLayoutRect()
|
|
1762
|
+
if (!(previous && next)) return
|
|
1763
|
+
|
|
1090
1764
|
lastRect = next
|
|
1765
|
+
const shouldSkipLayoutAnimation =
|
|
1766
|
+
detail?.viewportScrolledDuringHold ||
|
|
1767
|
+
wasViewportScrolledSinceLastLayout ||
|
|
1768
|
+
wasViewportOffscreenSinceLastLayout ||
|
|
1769
|
+
isViewportOffscreen(viewportRect)
|
|
1770
|
+
if (!shouldSkipLayoutAnimation && !motionDomProjection) {
|
|
1771
|
+
const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
|
|
1772
|
+
runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
|
|
1773
|
+
}
|
|
1774
|
+
wasViewportScrolledSinceLastLayout = false
|
|
1775
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1776
|
+
if (!shouldSkipLayoutAnimation && hasRectChanged(previous, next)) {
|
|
1777
|
+
motionDomProjection?.commitObservedLayoutChange()
|
|
1778
|
+
} else if (shouldSkipLayoutAnimation) {
|
|
1779
|
+
motionDomProjection?.finishAnimation()
|
|
1780
|
+
}
|
|
1091
1781
|
}
|
|
1092
1782
|
|
|
1093
|
-
const
|
|
1094
|
-
if (rafId)
|
|
1783
|
+
const scheduleProjectionCommit = () => {
|
|
1784
|
+
if (rafId) return
|
|
1785
|
+
commitObservedLayout()
|
|
1095
1786
|
rafId = requestAnimationFrame(() => {
|
|
1096
1787
|
rafId = null
|
|
1097
|
-
runFlip()
|
|
1098
1788
|
})
|
|
1099
1789
|
}
|
|
1100
|
-
const disconnectObservers = observeLayoutChanges(element!, () =>
|
|
1790
|
+
const disconnectObservers = observeLayoutChanges(element!, () => scheduleProjectionCommit())
|
|
1791
|
+
window.addEventListener('scroll', rememberOffscreenScroll, { passive: true })
|
|
1792
|
+
element!.addEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
|
|
1101
1793
|
|
|
1102
1794
|
return () => {
|
|
1103
1795
|
disconnectObservers()
|
|
1796
|
+
window.removeEventListener('scroll', rememberOffscreenScroll)
|
|
1797
|
+
element?.removeEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
|
|
1104
1798
|
lastRect = null
|
|
1105
|
-
// Reset compositor hints on teardown
|
|
1106
1799
|
if (element) {
|
|
1107
1800
|
setCompositorHints(element, false)
|
|
1108
1801
|
}
|
|
@@ -1126,6 +1819,7 @@
|
|
|
1126
1819
|
|
|
1127
1820
|
const prev = layoutIdRegistry.consume(scopedLayoutId)
|
|
1128
1821
|
if (!prev) return // First appearance, no animation needed
|
|
1822
|
+
if (motionDomProjection) return
|
|
1129
1823
|
|
|
1130
1824
|
const next = measureRect(element, resolveLayoutScrollAncestors())
|
|
1131
1825
|
const transforms = computeFlipTransforms(prev.rect, next, true)
|
|
@@ -1235,6 +1929,22 @@
|
|
|
1235
1929
|
)
|
|
1236
1930
|
})
|
|
1237
1931
|
|
|
1932
|
+
// Legacy animation controls (`animate={controls}`) mirror upstream's
|
|
1933
|
+
// VisualElement subscription model with a small Svelte adapter. The
|
|
1934
|
+
// controls own when animations start; this component only resolves
|
|
1935
|
+
// variants/custom data and runs the resulting target on its element.
|
|
1936
|
+
$effect(() => {
|
|
1937
|
+
if (!(element && animateControls)) return
|
|
1938
|
+
|
|
1939
|
+
const subscriber: AnimationControlsSubscriber = {
|
|
1940
|
+
start: startAnimationControlsDefinition,
|
|
1941
|
+
set: applyAnimationControlsTarget,
|
|
1942
|
+
stop: stopAnimationControlsAnimations
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
return animateControls.subscribe(subscriber)
|
|
1946
|
+
})
|
|
1947
|
+
|
|
1238
1948
|
// Handle key prop changes inside AnimatePresence (simulates React's key-based remounting)
|
|
1239
1949
|
// When key changes, run exit → initial → animate sequence on the same element
|
|
1240
1950
|
$effect(() => {
|
|
@@ -1343,10 +2053,14 @@
|
|
|
1343
2053
|
// Re-run animate when animateProp changes while ready
|
|
1344
2054
|
$effect(() => {
|
|
1345
2055
|
if (!(element && isLoaded === 'ready')) return
|
|
2056
|
+
if (animateControls) return
|
|
1346
2057
|
// Skip first run if we mounted with initial={false} AND the variant hasn't changed
|
|
1347
2058
|
if (mountedWithInitialFalse) {
|
|
1348
2059
|
// Only skip if the variant is the same as what we mounted with
|
|
1349
|
-
if (
|
|
2060
|
+
if (
|
|
2061
|
+
typeof declarativeAnimateProp === 'string' &&
|
|
2062
|
+
lastRanVariantKey === declarativeAnimateProp
|
|
2063
|
+
) {
|
|
1350
2064
|
mountedWithInitialFalse = false
|
|
1351
2065
|
return
|
|
1352
2066
|
}
|
|
@@ -1358,25 +2072,28 @@
|
|
|
1358
2072
|
pwLog('[motion] effect: skipping, initial animation already triggered')
|
|
1359
2073
|
initialAnimationTriggered = false
|
|
1360
2074
|
// Also mark object animate as ran to prevent duplicate runs from effect re-triggers
|
|
1361
|
-
if (
|
|
2075
|
+
if (declarativeAnimateProp && typeof declarativeAnimateProp !== 'string') {
|
|
1362
2076
|
objectAnimateRanOnMount = true
|
|
1363
2077
|
}
|
|
1364
2078
|
return
|
|
1365
2079
|
}
|
|
1366
|
-
if (typeof
|
|
2080
|
+
if (typeof declarativeAnimateProp === 'string') {
|
|
1367
2081
|
// Compare BOTH the variant key and the resolved keyframes JSON.
|
|
1368
2082
|
// For static variants the JSON is constant per key; for
|
|
1369
2083
|
// function-form variants the JSON changes when `custom`
|
|
1370
2084
|
// changes, which we must treat as a new animation target.
|
|
1371
2085
|
const resolvedJson = resolvedAnimate ? JSON.stringify(resolvedAnimate) : undefined
|
|
1372
|
-
if (
|
|
1373
|
-
lastRanVariantKey
|
|
2086
|
+
if (
|
|
2087
|
+
lastRanVariantKey !== declarativeAnimateProp ||
|
|
2088
|
+
lastRanResolvedJson !== resolvedJson
|
|
2089
|
+
) {
|
|
2090
|
+
lastRanVariantKey = declarativeAnimateProp
|
|
1374
2091
|
lastRanResolvedJson = resolvedJson
|
|
1375
2092
|
runAnimation()
|
|
1376
2093
|
}
|
|
1377
|
-
} else if (
|
|
2094
|
+
} else if (declarativeAnimateProp) {
|
|
1378
2095
|
// Object animate props - detect if the prop actually changed
|
|
1379
|
-
const currentJson = JSON.stringify(
|
|
2096
|
+
const currentJson = JSON.stringify(declarativeAnimateProp)
|
|
1380
2097
|
const propChanged = lastAnimatePropJson !== currentJson
|
|
1381
2098
|
|
|
1382
2099
|
// Reset flag if animate prop changed
|
|
@@ -1398,7 +2115,7 @@
|
|
|
1398
2115
|
// Also run when inherited/effective variant changes
|
|
1399
2116
|
$effect(() => {
|
|
1400
2117
|
void resolvedAnimate
|
|
1401
|
-
if (!(element && isLoaded === 'ready' && !
|
|
2118
|
+
if (!(element && isLoaded === 'ready' && !declarativeAnimateProp && resolvedAnimate)) return
|
|
1402
2119
|
// Skip first run if we mounted with initial={false} AND the variant hasn't changed
|
|
1403
2120
|
if (mountedWithInitialFalse) {
|
|
1404
2121
|
// Only skip if the variant is the same as what we mounted with
|
|
@@ -1423,6 +2140,7 @@
|
|
|
1423
2140
|
|
|
1424
2141
|
$effect(() => {
|
|
1425
2142
|
if (!(element && isLoaded === 'mounting')) return
|
|
2143
|
+
markMotionMounted()
|
|
1426
2144
|
|
|
1427
2145
|
pwLog('[motion] main effect running', {
|
|
1428
2146
|
effectiveAnimate: !!effectiveAnimate,
|
|
@@ -1452,6 +2170,30 @@
|
|
|
1452
2170
|
dataPath = 5
|
|
1453
2171
|
isLoaded = 'ready'
|
|
1454
2172
|
} else if (isNotEmpty(initialKeyframes)) {
|
|
2173
|
+
const canHandoffOptimizedAppear = hasOptimizedAppearAnimation(optimizedAppearId)
|
|
2174
|
+
if (canHandoffOptimizedAppear) {
|
|
2175
|
+
pwLog('[motion] path: optimized appear handoff')
|
|
2176
|
+
dataPath = 6
|
|
2177
|
+
isLoaded = 'initial'
|
|
2178
|
+
initialAnimationTriggered = true
|
|
2179
|
+
if (declarativeAnimateProp && typeof declarativeAnimateProp !== 'string') {
|
|
2180
|
+
objectAnimateRanOnMount = true
|
|
2181
|
+
lastAnimatePropJson = JSON.stringify(declarativeAnimateProp)
|
|
2182
|
+
}
|
|
2183
|
+
finishOptimizedAppearAnimation(optimizedAppearId)
|
|
2184
|
+
.then(() => {
|
|
2185
|
+
applyAnimateRestingStyle()
|
|
2186
|
+
enterAnimationSettled = true
|
|
2187
|
+
isLoaded = 'ready'
|
|
2188
|
+
onAnimationCompleteProp?.(
|
|
2189
|
+
resolvedAnimate as DOMKeyframesDefinition | undefined
|
|
2190
|
+
)
|
|
2191
|
+
})
|
|
2192
|
+
.catch(() => {
|
|
2193
|
+
isLoaded = 'ready'
|
|
2194
|
+
})
|
|
2195
|
+
return
|
|
2196
|
+
}
|
|
1455
2197
|
pwLog('[motion] path: has initialKeyframes, will animate to target')
|
|
1456
2198
|
// Apply initial instantly BEFORE exposing 'initial' state
|
|
1457
2199
|
const transformedInitial = transformSVGPathProperties(
|
|
@@ -1500,8 +2242,8 @@
|
|
|
1500
2242
|
|
|
1501
2243
|
// Mark that we're triggering the initial animation to prevent duplicate runs
|
|
1502
2244
|
initialAnimationTriggered = true
|
|
1503
|
-
if (
|
|
1504
|
-
lastAnimatePropJson = JSON.stringify(
|
|
2245
|
+
if (declarativeAnimateProp && typeof declarativeAnimateProp !== 'string') {
|
|
2246
|
+
lastAnimatePropJson = JSON.stringify(declarativeAnimateProp)
|
|
1505
2247
|
}
|
|
1506
2248
|
|
|
1507
2249
|
// IMPORTANT: Start the animation BEFORE changing isLoaded.
|
|
@@ -1572,15 +2314,23 @@
|
|
|
1572
2314
|
{#if isVoidTag}
|
|
1573
2315
|
{#if isSVGTag(String(tag))}
|
|
1574
2316
|
<svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs} />
|
|
2317
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2318
|
+
{@html renderedOptimizedAppearScript}
|
|
1575
2319
|
{:else}
|
|
1576
2320
|
<svelte:element this={tag} bind:this={element} {...derivedAttrs} />
|
|
2321
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2322
|
+
{@html renderedOptimizedAppearScript}
|
|
1577
2323
|
{/if}
|
|
1578
2324
|
{:else if isSVGTag(String(tag))}
|
|
1579
2325
|
<svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs}>
|
|
1580
2326
|
{@render children?.()}
|
|
1581
2327
|
</svelte:element>
|
|
2328
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2329
|
+
{@html renderedOptimizedAppearScript}
|
|
1582
2330
|
{:else}
|
|
1583
2331
|
<svelte:element this={tag} bind:this={element} {...derivedAttrs}>
|
|
1584
2332
|
{@render children?.()}
|
|
1585
2333
|
</svelte:element>
|
|
2334
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2335
|
+
{@html renderedOptimizedAppearScript}
|
|
1586
2336
|
{/if}
|