@humanspeak/svelte-motion 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/motionDomProjection.context.d.ts +13 -0
- package/dist/components/motionDomProjection.context.js +18 -0
- package/dist/html/_MotionContainer.svelte +548 -60
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +2 -2
- package/dist/utils/layout.d.ts +9 -6
- package/dist/utils/layout.js +141 -14
- package/dist/utils/motionDomProjection.d.ts +126 -0
- package/dist/utils/motionDomProjection.js +212 -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
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { isNotEmpty } from '../utils/objects'
|
|
29
29
|
import { sleep } from '../utils/testing'
|
|
30
30
|
import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
|
|
31
|
+
import { motionValue, svgEffect, type MotionValue } from 'motion-dom'
|
|
31
32
|
import { isPlaywrightEnv, pwLog } from '../utils/log'
|
|
32
33
|
import { onDestroy, untrack, type Snippet } from 'svelte'
|
|
33
34
|
import { VOID_TAGS } from '../utils/constants'
|
|
@@ -58,6 +59,11 @@
|
|
|
58
59
|
import { attachPan, type AttachPanCleanup } from '../utils/pan'
|
|
59
60
|
import { ProjectionNode } from '../utils/projection'
|
|
60
61
|
import { getProjectionParent, setProjectionParent } from '../components/projection.context'
|
|
62
|
+
import { MotionDomProjectionAdapter } from '../utils/motionDomProjection'
|
|
63
|
+
import {
|
|
64
|
+
getMotionDomProjectionParent,
|
|
65
|
+
setMotionDomProjectionParent
|
|
66
|
+
} from '../components/motionDomProjection.context'
|
|
61
67
|
import {
|
|
62
68
|
resolveInitial,
|
|
63
69
|
resolveAnimate,
|
|
@@ -77,9 +83,19 @@
|
|
|
77
83
|
import {
|
|
78
84
|
transformSVGPathProperties,
|
|
79
85
|
computeNormalizedSVGInitialAttrs,
|
|
86
|
+
hasSVGPathProperties,
|
|
87
|
+
isSVGPathElement,
|
|
80
88
|
isSVGTag,
|
|
81
89
|
SVG_NAMESPACE
|
|
82
90
|
} from '../utils/svg'
|
|
91
|
+
import {
|
|
92
|
+
createOptimizedAppearData,
|
|
93
|
+
createOptimizedAppearScript,
|
|
94
|
+
finishOptimizedAppearAnimation,
|
|
95
|
+
hasOptimizedAppearAnimation,
|
|
96
|
+
markMotionMounted,
|
|
97
|
+
optimizedAppearDataAttribute
|
|
98
|
+
} from '../utils/optimizedAppear'
|
|
83
99
|
import { getLayoutIdRegistry } from '../utils/layoutId'
|
|
84
100
|
import {
|
|
85
101
|
getLayoutScrollContainerRef,
|
|
@@ -93,6 +109,8 @@
|
|
|
93
109
|
[key: string]: unknown
|
|
94
110
|
}
|
|
95
111
|
|
|
112
|
+
const componentHydrationId = $props.id()
|
|
113
|
+
|
|
96
114
|
let {
|
|
97
115
|
children,
|
|
98
116
|
tag = 'div',
|
|
@@ -234,6 +252,18 @@
|
|
|
234
252
|
})
|
|
235
253
|
setProjectionParent(projection)
|
|
236
254
|
|
|
255
|
+
const motionDomProjectionParent =
|
|
256
|
+
typeof window !== 'undefined' ? getMotionDomProjectionParent() : null
|
|
257
|
+
const motionDomProjection =
|
|
258
|
+
typeof window !== 'undefined'
|
|
259
|
+
? new MotionDomProjectionAdapter({
|
|
260
|
+
parent: motionDomProjectionParent
|
|
261
|
+
})
|
|
262
|
+
: null
|
|
263
|
+
if (motionDomProjection) {
|
|
264
|
+
setMotionDomProjectionParent(motionDomProjection)
|
|
265
|
+
}
|
|
266
|
+
|
|
237
267
|
// Convert a projection `Box` (ancestor chain reset to base, self
|
|
238
268
|
// transform stripped, scroll containers compensated) to the
|
|
239
269
|
// `RectLike` shape `computeFlipTransforms` consumes.
|
|
@@ -246,9 +276,20 @@
|
|
|
246
276
|
width: box.x.max - box.x.min,
|
|
247
277
|
height: box.y.max - box.y.min
|
|
248
278
|
})
|
|
279
|
+
const domRectToRectLike = (rect: DOMRect): RectLike => ({
|
|
280
|
+
left: rect.left,
|
|
281
|
+
top: rect.top,
|
|
282
|
+
width: rect.width,
|
|
283
|
+
height: rect.height
|
|
284
|
+
})
|
|
285
|
+
const isViewportOffscreen = (rect: DOMRect): boolean =>
|
|
286
|
+
rect.bottom <= 0 ||
|
|
287
|
+
rect.right <= 0 ||
|
|
288
|
+
rect.top >= window.innerHeight ||
|
|
289
|
+
rect.left >= window.innerWidth
|
|
249
290
|
|
|
250
291
|
// Ancestor-transform-invariant layout measurement for seeding the
|
|
251
|
-
// FLIP effect's first rect.
|
|
292
|
+
// fallback FLIP effect's first rect.
|
|
252
293
|
const measureLayoutRect = (): RectLike | null => {
|
|
253
294
|
const box = projection.measure()
|
|
254
295
|
return box ? boxToRectLike(box) : null
|
|
@@ -551,6 +592,173 @@
|
|
|
551
592
|
reducedMotion
|
|
552
593
|
)
|
|
553
594
|
)
|
|
595
|
+
const optimizedAppearId = $derived(
|
|
596
|
+
effectiveInitialProp !== false &&
|
|
597
|
+
isNotEmpty(initialKeyframes) &&
|
|
598
|
+
isNotEmpty(animateKeyframes)
|
|
599
|
+
? `svelte-motion-${componentHydrationId}`
|
|
600
|
+
: undefined
|
|
601
|
+
)
|
|
602
|
+
const optimizedAppearEntries = $derived(
|
|
603
|
+
createOptimizedAppearData(
|
|
604
|
+
initialKeyframes as Record<string, unknown> | undefined,
|
|
605
|
+
animateKeyframes as Record<string, unknown> | undefined,
|
|
606
|
+
mergedTransition
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
const optimizedAppearScript = $derived(
|
|
610
|
+
createOptimizedAppearScript(optimizedAppearId, optimizedAppearEntries)
|
|
611
|
+
)
|
|
612
|
+
const renderedOptimizedAppearScript = $derived(
|
|
613
|
+
optimizedAppearScript && (typeof window === 'undefined' || !window.MotionIsMounted)
|
|
614
|
+
? optimizedAppearScript
|
|
615
|
+
: ''
|
|
616
|
+
)
|
|
617
|
+
const applyAnimateRestingStyle = () => {
|
|
618
|
+
if (!element) return
|
|
619
|
+
const restingValues = resolveRestingValues(
|
|
620
|
+
animateKeyframes as DOMKeyframesDefinition | undefined
|
|
621
|
+
) as Record<string, unknown> | undefined
|
|
622
|
+
if (!restingValues) return
|
|
623
|
+
element.setAttribute(
|
|
624
|
+
'style',
|
|
625
|
+
mergeInlineStyles(element.getAttribute('style') ?? '', undefined, restingValues)
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
const isJsdomRuntime = (): boolean =>
|
|
629
|
+
typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)
|
|
630
|
+
const getTransitionFallbackMs = (transition: AnimationOptions | undefined): number => {
|
|
631
|
+
const duration = typeof transition?.duration === 'number' ? transition.duration : 0
|
|
632
|
+
const delay = typeof transition?.delay === 'number' ? transition.delay : 0
|
|
633
|
+
return Math.max(0, (duration + delay) * 1000)
|
|
634
|
+
}
|
|
635
|
+
let cleanupSVGPathAttributeEffect: (() => void) | null = null
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Reads the current normalized SVG path drawing state from DOM
|
|
639
|
+
* attributes. `motion-dom`'s svgEffect owns future writes; this only
|
|
640
|
+
* seeds its MotionValues from the currently rendered frame.
|
|
641
|
+
*
|
|
642
|
+
* @param {SVGPathElement} path The SVG path element to inspect.
|
|
643
|
+
* @returns {{ pathLength: number; pathSpacing: number; pathOffset: number }} The normalized drawing state.
|
|
644
|
+
*/
|
|
645
|
+
const readSVGPathDrawingState = (
|
|
646
|
+
path: SVGPathElement
|
|
647
|
+
): { pathLength: number; pathSpacing: number; pathOffset: number } => {
|
|
648
|
+
const dashArray =
|
|
649
|
+
path.getAttribute('stroke-dasharray') || path.style.strokeDasharray || '1 0'
|
|
650
|
+
const [rawLength, rawSpacing] = dashArray
|
|
651
|
+
.split(/[,\s]+/)
|
|
652
|
+
.filter(Boolean)
|
|
653
|
+
.map((part) => Number.parseFloat(part))
|
|
654
|
+
const rawOffset = Number.parseFloat(
|
|
655
|
+
path.getAttribute('stroke-dashoffset') || path.style.strokeDashoffset || '0'
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
pathLength: Number.isFinite(rawLength) ? rawLength : 1,
|
|
660
|
+
pathSpacing: Number.isFinite(rawSpacing) ? rawSpacing : 1,
|
|
661
|
+
pathOffset: Number.isFinite(rawOffset) ? -rawOffset : 0
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Removes custom SVG path props from keyframes after `svgEffect` has
|
|
667
|
+
* taken ownership of them.
|
|
668
|
+
*
|
|
669
|
+
* @param {Record<string, unknown>} keyframes Keyframes to copy.
|
|
670
|
+
* @returns {Record<string, unknown>} Keyframes without SVG path-only props.
|
|
671
|
+
*/
|
|
672
|
+
const stripSVGPathKeyframes = (keyframes: Record<string, unknown>): Record<string, unknown> => {
|
|
673
|
+
const stripped = { ...keyframes }
|
|
674
|
+
delete stripped.pathLength
|
|
675
|
+
delete stripped.pathSpacing
|
|
676
|
+
delete stripped.pathOffset
|
|
677
|
+
return stripped
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Extracts an animation completion promise from a Motion control when
|
|
682
|
+
* one is available.
|
|
683
|
+
*
|
|
684
|
+
* @param {unknown} control The return value from `animate`.
|
|
685
|
+
* @returns {Promise<unknown> | null} The finished promise, or null.
|
|
686
|
+
*/
|
|
687
|
+
const getFinishedPromise = (control: unknown): Promise<unknown> | null => {
|
|
688
|
+
if (!control || typeof control !== 'object') return null
|
|
689
|
+
const finished = (control as { finished?: unknown }).finished
|
|
690
|
+
return finished && typeof (finished as Promise<unknown>).then === 'function'
|
|
691
|
+
? (finished as Promise<unknown>)
|
|
692
|
+
: null
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Animates SVG path drawing props via motion-dom's `svgEffect`, matching
|
|
697
|
+
* upstream's attribute-based pathLength/pathSpacing/pathOffset behavior.
|
|
698
|
+
*
|
|
699
|
+
* @param {SVGPathElement} path The path element to animate.
|
|
700
|
+
* @param {Record<string, unknown>} keyframes Keyframes containing SVG path props.
|
|
701
|
+
* @param {MotionTransition} transition The transition to apply to generated MotionValues.
|
|
702
|
+
* @returns {Promise<unknown>[]} Promises for generated path animations.
|
|
703
|
+
*/
|
|
704
|
+
const animateSVGPathAttributes = (
|
|
705
|
+
path: SVGPathElement,
|
|
706
|
+
keyframes: Record<string, unknown>,
|
|
707
|
+
transition: MotionTransition
|
|
708
|
+
): Promise<unknown>[] => {
|
|
709
|
+
if (!hasSVGPathProperties(keyframes)) return []
|
|
710
|
+
|
|
711
|
+
cleanupSVGPathAttributeEffect?.()
|
|
712
|
+
const current = readSVGPathDrawingState(path)
|
|
713
|
+
const values: Record<string, MotionValue<number>> = {}
|
|
714
|
+
|
|
715
|
+
if ('pathLength' in keyframes) {
|
|
716
|
+
values.pathLength = motionValue(current.pathLength)
|
|
717
|
+
}
|
|
718
|
+
if ('pathLength' in keyframes || 'pathSpacing' in keyframes) {
|
|
719
|
+
values.pathSpacing = motionValue(current.pathSpacing)
|
|
720
|
+
}
|
|
721
|
+
if ('pathOffset' in keyframes) {
|
|
722
|
+
values.pathOffset = motionValue(current.pathOffset)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
cleanupSVGPathAttributeEffect = svgEffect(path, values)
|
|
726
|
+
|
|
727
|
+
return Object.entries(values)
|
|
728
|
+
.map(([key, value]) => {
|
|
729
|
+
const control = animate(
|
|
730
|
+
value as never,
|
|
731
|
+
(key === 'pathSpacing' && !('pathSpacing' in keyframes)
|
|
732
|
+
? 1
|
|
733
|
+
: keyframes[key]) as never,
|
|
734
|
+
transition as unknown as AnimationOptions
|
|
735
|
+
)
|
|
736
|
+
return getFinishedPromise(control)
|
|
737
|
+
})
|
|
738
|
+
.filter((promise): promise is Promise<unknown> => promise !== null)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
onDestroy(() => {
|
|
742
|
+
cleanupSVGPathAttributeEffect?.()
|
|
743
|
+
cleanupSVGPathAttributeEffect = null
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
// Wait-mode enter coordination needs to affect the first rendered attrs,
|
|
747
|
+
// before the blocked entrant can participate in layout.
|
|
748
|
+
let waitCallbackRegistered = $state(false)
|
|
749
|
+
let waitUnsubscribe: (() => void) | null = null
|
|
750
|
+
let waitHiddenDisplay: string | null = null
|
|
751
|
+
let waitEnterReleased = $state(false)
|
|
752
|
+
let waitLayoutParent: HTMLElement | null = null
|
|
753
|
+
let waitLayoutParentWidth = ''
|
|
754
|
+
let waitLayoutParentHeight = ''
|
|
755
|
+
let waitLayoutViewportScrollX = 0
|
|
756
|
+
let waitLayoutViewportScrollY = 0
|
|
757
|
+
const presenceLayoutHoldAttribute = 'data-presence-layout-hold'
|
|
758
|
+
const presenceLayoutReleaseEvent = 'svelte-motion:presence-layout-release'
|
|
759
|
+
const waitEnterBlockedBeforeMount = $derived(
|
|
760
|
+
context?.mode === 'wait' && !waitEnterReleased && context.isEnterBlocked(presenceKey)
|
|
761
|
+
)
|
|
554
762
|
|
|
555
763
|
// Derived attributes to keep both branches in sync (focusability, data flags, style, class)
|
|
556
764
|
const derivedAttrs = $derived<Record<string, unknown>>({
|
|
@@ -575,6 +783,16 @@
|
|
|
575
783
|
'data-path': dataPath
|
|
576
784
|
}
|
|
577
785
|
: {}),
|
|
786
|
+
...(renderedOptimizedAppearScript
|
|
787
|
+
? { [optimizedAppearDataAttribute]: optimizedAppearId }
|
|
788
|
+
: {}),
|
|
789
|
+
...(layoutProp
|
|
790
|
+
? { 'data-layout': String(layoutProp), 'data-svelte-motion-layout': '' }
|
|
791
|
+
: {}),
|
|
792
|
+
...(scopedLayoutId ? { 'data-layout-id': scopedLayoutId } : {}),
|
|
793
|
+
...(waitEnterBlockedBeforeMount || waitHiddenDisplay !== null
|
|
794
|
+
? { 'data-presence-wait-hidden': 'true' }
|
|
795
|
+
: {}),
|
|
578
796
|
// Apply normalized SVG path attributes synchronously on first render to avoid flash
|
|
579
797
|
// Compute via svg utils (no dynamic import in SSR/derived expressions)
|
|
580
798
|
...(() => {
|
|
@@ -588,9 +806,7 @@
|
|
|
588
806
|
return {}
|
|
589
807
|
})(),
|
|
590
808
|
style: mergeInlineStyles(
|
|
591
|
-
initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
|
|
592
|
-
? `${styleProp || ''};visibility:hidden`
|
|
593
|
-
: styleProp,
|
|
809
|
+
`${initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting' ? `${styleProp || ''};visibility:hidden` : (styleProp ?? '')}${waitEnterBlockedBeforeMount || waitHiddenDisplay !== null ? ';display:none' : ''}`,
|
|
594
810
|
// The "from" slot: apply initialKeyframes as inline styles during
|
|
595
811
|
// the mounting/initial phases (before the WAAPI animation locks
|
|
596
812
|
// its from-value and we promote to 'ready' — see the lifecycle
|
|
@@ -869,7 +1085,17 @@
|
|
|
869
1085
|
}
|
|
870
1086
|
|
|
871
1087
|
const transitionAnimate: MotionTransition = mergedTransition ?? {}
|
|
872
|
-
|
|
1088
|
+
const rawPayload = filterReducedMotionKeyframes(
|
|
1089
|
+
$state.snapshot(resolvedAnimate) as Record<string, unknown>,
|
|
1090
|
+
reducedMotion
|
|
1091
|
+
) as Record<string, unknown>
|
|
1092
|
+
const svgPathFinished =
|
|
1093
|
+
isSVGPathElement(element) && hasSVGPathProperties(rawPayload)
|
|
1094
|
+
? animateSVGPathAttributes(element, rawPayload, transitionAnimate)
|
|
1095
|
+
: []
|
|
1096
|
+
let payload = (
|
|
1097
|
+
svgPathFinished.length > 0 ? stripSVGPathKeyframes(rawPayload) : rawPayload
|
|
1098
|
+
) as typeof rawPayload
|
|
873
1099
|
|
|
874
1100
|
// Transform SVG path properties (pathLength, pathOffset) to their CSS equivalents
|
|
875
1101
|
payload = transformSVGPathProperties(
|
|
@@ -877,13 +1103,6 @@
|
|
|
877
1103
|
payload as Record<string, unknown>
|
|
878
1104
|
) as typeof payload
|
|
879
1105
|
|
|
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
1106
|
// Ensure dash properties aren't pinned as inline styles
|
|
888
1107
|
if (element && (element as HTMLElement).style) {
|
|
889
1108
|
;(element as HTMLElement).style.removeProperty('stroke-dasharray')
|
|
@@ -897,33 +1116,134 @@
|
|
|
897
1116
|
|
|
898
1117
|
// A fresh run owns the transform again until it completes.
|
|
899
1118
|
enterAnimationSettled = false
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1119
|
+
const completeEnterAnimation = (
|
|
1120
|
+
def: DOMKeyframesDefinition | undefined = payload as unknown as DOMKeyframesDefinition
|
|
1121
|
+
) => {
|
|
1122
|
+
if (enterAnimationSettled) return
|
|
1123
|
+
// Now the target is the resting state — promote it to the
|
|
1124
|
+
// inline baseline so it persists after WAAPI surrenders the
|
|
1125
|
+
// property (default fill:'none'). (#377)
|
|
1126
|
+
applyAnimateRestingStyle()
|
|
1127
|
+
enterAnimationSettled = true
|
|
1128
|
+
onAnimationCompleteProp?.(def)
|
|
1129
|
+
}
|
|
1130
|
+
if (isNotEmpty(payload)) {
|
|
1131
|
+
animateWithLifecycle(
|
|
1132
|
+
element,
|
|
1133
|
+
payload as unknown as DOMKeyframesDefinition,
|
|
1134
|
+
transitionAnimate as unknown as AnimationOptions,
|
|
1135
|
+
(def) =>
|
|
1136
|
+
onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
|
|
1137
|
+
(def) =>
|
|
1138
|
+
completeEnterAnimation(def as unknown as DOMKeyframesDefinition | undefined)
|
|
1139
|
+
)
|
|
1140
|
+
} else if (svgPathFinished.length > 0) {
|
|
1141
|
+
onAnimationStartProp?.(rawPayload as unknown as DOMKeyframesDefinition)
|
|
1142
|
+
Promise.all(svgPathFinished)
|
|
1143
|
+
.then(() => completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition))
|
|
1144
|
+
.catch(() =>
|
|
1145
|
+
completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition)
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
if (isJsdomRuntime()) {
|
|
1149
|
+
window.setTimeout(
|
|
1150
|
+
() => completeEnterAnimation(),
|
|
1151
|
+
getTransitionFallbackMs(transitionAnimate as AnimationOptions)
|
|
1152
|
+
)
|
|
1153
|
+
}
|
|
913
1154
|
}
|
|
914
1155
|
|
|
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
1156
|
// Cleanup wait callback on component unmount to prevent memory leaks
|
|
920
1157
|
$effect(() => {
|
|
921
1158
|
return () => {
|
|
1159
|
+
if (element && waitHiddenDisplay !== null) {
|
|
1160
|
+
element.style.display = waitHiddenDisplay
|
|
1161
|
+
element.removeAttribute('data-presence-wait-hidden')
|
|
1162
|
+
waitHiddenDisplay = null
|
|
1163
|
+
}
|
|
1164
|
+
releaseWaitLayoutHold()
|
|
922
1165
|
waitUnsubscribe?.()
|
|
923
1166
|
waitUnsubscribe = null
|
|
924
1167
|
}
|
|
925
1168
|
})
|
|
926
1169
|
|
|
1170
|
+
const getPresenceLayoutParent = (): HTMLElement | null => {
|
|
1171
|
+
let parent = element?.parentElement ?? null
|
|
1172
|
+
const layoutParent = element?.parentElement?.closest<HTMLElement>(
|
|
1173
|
+
'[data-svelte-motion-layout]'
|
|
1174
|
+
)
|
|
1175
|
+
if (layoutParent) return layoutParent
|
|
1176
|
+
|
|
1177
|
+
while (parent && getComputedStyle(parent).display === 'contents') {
|
|
1178
|
+
parent = parent.parentElement
|
|
1179
|
+
}
|
|
1180
|
+
return parent
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const holdWaitLayout = () => {
|
|
1184
|
+
if (!element || waitLayoutParent) return
|
|
1185
|
+
const parent = getPresenceLayoutParent()
|
|
1186
|
+
if (!parent) return
|
|
1187
|
+
|
|
1188
|
+
const rect = parent.getBoundingClientRect()
|
|
1189
|
+
waitLayoutParent = parent
|
|
1190
|
+
waitLayoutParentWidth = parent.style.width
|
|
1191
|
+
waitLayoutParentHeight = parent.style.height
|
|
1192
|
+
waitLayoutViewportScrollX = typeof window !== 'undefined' ? window.scrollX : 0
|
|
1193
|
+
waitLayoutViewportScrollY = typeof window !== 'undefined' ? window.scrollY : 0
|
|
1194
|
+
parent.setAttribute(presenceLayoutHoldAttribute, 'true')
|
|
1195
|
+
parent.style.width = `${rect.width}px`
|
|
1196
|
+
parent.style.height = `${rect.height}px`
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function releaseWaitLayoutHold() {
|
|
1200
|
+
if (!waitLayoutParent) return
|
|
1201
|
+
const parent = waitLayoutParent
|
|
1202
|
+
const previousRect = parent.getBoundingClientRect()
|
|
1203
|
+
parent.removeAttribute(presenceLayoutHoldAttribute)
|
|
1204
|
+
if (waitLayoutParentWidth) {
|
|
1205
|
+
parent.style.width = waitLayoutParentWidth
|
|
1206
|
+
} else {
|
|
1207
|
+
parent.style.removeProperty('width')
|
|
1208
|
+
}
|
|
1209
|
+
if (waitLayoutParentHeight) {
|
|
1210
|
+
parent.style.height = waitLayoutParentHeight
|
|
1211
|
+
} else {
|
|
1212
|
+
parent.style.removeProperty('height')
|
|
1213
|
+
}
|
|
1214
|
+
const viewportScrolledDuringHold =
|
|
1215
|
+
typeof window !== 'undefined' &&
|
|
1216
|
+
(window.scrollX !== waitLayoutViewportScrollX ||
|
|
1217
|
+
window.scrollY !== waitLayoutViewportScrollY)
|
|
1218
|
+
parent.dispatchEvent(
|
|
1219
|
+
new CustomEvent(presenceLayoutReleaseEvent, {
|
|
1220
|
+
detail: {
|
|
1221
|
+
previousRect: domRectToRectLike(previousRect),
|
|
1222
|
+
viewportScrolledDuringHold
|
|
1223
|
+
}
|
|
1224
|
+
})
|
|
1225
|
+
)
|
|
1226
|
+
waitLayoutParent = null
|
|
1227
|
+
waitLayoutParentWidth = ''
|
|
1228
|
+
waitLayoutParentHeight = ''
|
|
1229
|
+
waitLayoutViewportScrollX = 0
|
|
1230
|
+
waitLayoutViewportScrollY = 0
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const revealWaitHiddenElement = () => {
|
|
1234
|
+
waitEnterReleased = true
|
|
1235
|
+
if (waitHiddenDisplay !== null && element) {
|
|
1236
|
+
if (waitHiddenDisplay) {
|
|
1237
|
+
element.style.display = waitHiddenDisplay
|
|
1238
|
+
} else {
|
|
1239
|
+
element.style.removeProperty('display')
|
|
1240
|
+
}
|
|
1241
|
+
element.removeAttribute('data-presence-wait-hidden')
|
|
1242
|
+
waitHiddenDisplay = null
|
|
1243
|
+
}
|
|
1244
|
+
releaseWaitLayoutHold()
|
|
1245
|
+
}
|
|
1246
|
+
|
|
927
1247
|
/**
|
|
928
1248
|
* Run the enter animation, respecting wait mode if inside AnimatePresence.
|
|
929
1249
|
* Returns true if animation was deferred (wait mode with blocked enters).
|
|
@@ -949,12 +1269,21 @@
|
|
|
949
1269
|
return true // Still deferred
|
|
950
1270
|
}
|
|
951
1271
|
|
|
952
|
-
const blocked = context.isEnterBlocked?.()
|
|
1272
|
+
const blocked = context.isEnterBlocked?.(presenceKey)
|
|
953
1273
|
pwLog('[motion] runAnimation: wait mode', { blocked })
|
|
954
1274
|
|
|
955
1275
|
if (blocked) {
|
|
956
1276
|
pwLog('[motion] runAnimation: enters blocked, deferring')
|
|
957
1277
|
|
|
1278
|
+
waitEnterReleased = false
|
|
1279
|
+
if (waitHiddenDisplay === null) {
|
|
1280
|
+
waitHiddenDisplay =
|
|
1281
|
+
element.style.display === 'none' ? '' : element.style.display
|
|
1282
|
+
element.style.display = 'none'
|
|
1283
|
+
element.setAttribute('data-presence-wait-hidden', 'true')
|
|
1284
|
+
holdWaitLayout()
|
|
1285
|
+
}
|
|
1286
|
+
|
|
958
1287
|
waitCallbackRegistered = true
|
|
959
1288
|
|
|
960
1289
|
// Register callback to run animation when unblocked
|
|
@@ -964,6 +1293,12 @@
|
|
|
964
1293
|
waitUnsubscribe = null
|
|
965
1294
|
waitCallbackRegistered = false
|
|
966
1295
|
|
|
1296
|
+
// Reveal synchronously after the exiting placeholder has
|
|
1297
|
+
// been removed. The parent is fixed-size until the next
|
|
1298
|
+
// frame, so it measures the final entrant instead of an
|
|
1299
|
+
// overlap between exiting and entering content.
|
|
1300
|
+
revealWaitHiddenElement()
|
|
1301
|
+
|
|
967
1302
|
// Snap to initial state first (in case inline styles were removed)
|
|
968
1303
|
if (initialKeyframes && element) {
|
|
969
1304
|
const transformedInitial = transformSVGPathProperties(
|
|
@@ -995,6 +1330,11 @@
|
|
|
995
1330
|
})
|
|
996
1331
|
return true // Animation was deferred
|
|
997
1332
|
}
|
|
1333
|
+
|
|
1334
|
+
if (waitHiddenDisplay !== null || waitEnterBlockedBeforeMount) {
|
|
1335
|
+
pwLog('[motion] runAnimation: wait mode no longer blocked, revealing')
|
|
1336
|
+
revealWaitHiddenElement()
|
|
1337
|
+
}
|
|
998
1338
|
}
|
|
999
1339
|
|
|
1000
1340
|
// Not blocked - run animation immediately
|
|
@@ -1018,6 +1358,7 @@
|
|
|
1018
1358
|
let objectAnimateRanOnMount = $state(false)
|
|
1019
1359
|
// Track the serialized animateProp to detect changes for object animate props
|
|
1020
1360
|
let lastAnimatePropJson = $state<string | undefined>(undefined)
|
|
1361
|
+
let motionDomProjectionUpdatePending = false
|
|
1021
1362
|
const currentAnimateKey = $derived(
|
|
1022
1363
|
typeof animateProp === 'string'
|
|
1023
1364
|
? animateProp
|
|
@@ -1026,6 +1367,72 @@
|
|
|
1026
1367
|
: undefined
|
|
1027
1368
|
)
|
|
1028
1369
|
|
|
1370
|
+
$effect(() => {
|
|
1371
|
+
if (!motionDomProjection) return
|
|
1372
|
+
motionDomProjection.updateOptions({
|
|
1373
|
+
layout: layoutProp,
|
|
1374
|
+
layoutId: scopedLayoutId,
|
|
1375
|
+
transition: mergedTransition as never,
|
|
1376
|
+
style: styleProp
|
|
1377
|
+
})
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
$effect(() => {
|
|
1381
|
+
if (!motionDomProjection) return
|
|
1382
|
+
if (!element) return
|
|
1383
|
+
motionDomProjection.mount(element)
|
|
1384
|
+
return () => {
|
|
1385
|
+
motionDomProjection.unmount()
|
|
1386
|
+
}
|
|
1387
|
+
})
|
|
1388
|
+
|
|
1389
|
+
let explicitLayoutSnapshot: RectLike | null = null
|
|
1390
|
+
let lastRect: RectLike | null = null
|
|
1391
|
+
const trackLayoutProjectionDependencies = () => [
|
|
1392
|
+
classProp,
|
|
1393
|
+
styleProp,
|
|
1394
|
+
scopedLayoutId,
|
|
1395
|
+
mergedTransition
|
|
1396
|
+
]
|
|
1397
|
+
|
|
1398
|
+
$effect.pre(() => {
|
|
1399
|
+
const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
|
|
1400
|
+
// Track common layout-affecting props so Svelte-owned updates can
|
|
1401
|
+
// snapshot before the DOM patch, matching upstream MeasureLayout.
|
|
1402
|
+
trackLayoutProjectionDependencies()
|
|
1403
|
+
|
|
1404
|
+
if (!shouldProject) {
|
|
1405
|
+
explicitLayoutSnapshot = null
|
|
1406
|
+
return
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
explicitLayoutSnapshot = measureLayoutRect()
|
|
1410
|
+
projection.willUpdate()
|
|
1411
|
+
motionDomProjection?.willUpdate()
|
|
1412
|
+
motionDomProjectionUpdatePending = true
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
$effect(() => {
|
|
1416
|
+
const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
|
|
1417
|
+
trackLayoutProjectionDependencies()
|
|
1418
|
+
|
|
1419
|
+
if (!shouldProject || !motionDomProjectionUpdatePending) return
|
|
1420
|
+
motionDomProjectionUpdatePending = false
|
|
1421
|
+
const previous = explicitLayoutSnapshot
|
|
1422
|
+
explicitLayoutSnapshot = null
|
|
1423
|
+
if (previous) {
|
|
1424
|
+
const next = measureLayoutRect()
|
|
1425
|
+
if (next) {
|
|
1426
|
+
const flipLayoutMode = layoutProp === 'position' ? 'position' : true
|
|
1427
|
+
const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
|
|
1428
|
+
runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
|
|
1429
|
+
lastRect = next
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
projection.didUpdate()
|
|
1433
|
+
motionDomProjection?.didUpdate()
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1029
1436
|
// Projection node lifecycle + the `onProjectionUpdate` listener.
|
|
1030
1437
|
// Mount once the element binds; seed the baseline layout; unmount on
|
|
1031
1438
|
// cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
|
|
@@ -1053,56 +1460,104 @@
|
|
|
1053
1460
|
}
|
|
1054
1461
|
})
|
|
1055
1462
|
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1463
|
+
// Upstream layout projection via motion-dom. Svelte runes mode doesn't
|
|
1464
|
+
// expose the React-style pre/post render hook pair used upstream, so the
|
|
1465
|
+
// component snapshots committed layout changes through DOM observers while
|
|
1466
|
+
// keeping the existing local ProjectionNode event fan-out alive.
|
|
1060
1467
|
$effect(() => {
|
|
1061
1468
|
if (!(element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures)) return
|
|
1062
1469
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
// motion-applied (FLIP/drag) transform up the tree. Without this a
|
|
1068
|
-
// child re-measures and FLIPs whenever an ancestor's transform
|
|
1069
|
-
// animates, even though the child's own layout never moved. (#379)
|
|
1470
|
+
let rafId: number | null = null
|
|
1471
|
+
let wasViewportOffscreenSinceLastLayout = false
|
|
1472
|
+
const flipLayoutMode = layoutProp === 'position' ? 'position' : true
|
|
1473
|
+
motionDomProjection?.seedLayout()
|
|
1070
1474
|
lastRect = measureLayoutRect()
|
|
1071
|
-
// Hint compositor for smoother FLIP transforms
|
|
1072
1475
|
setCompositorHints(element!, true)
|
|
1073
1476
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1477
|
+
const rememberOffscreenScroll = () => {
|
|
1478
|
+
if (isViewportOffscreen(element!.getBoundingClientRect())) {
|
|
1479
|
+
wasViewportOffscreenSinceLastLayout = true
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const commitObservedLayout = () => {
|
|
1484
|
+
if (element!.hasAttribute('data-layout-size-animation')) {
|
|
1485
|
+
return
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const hasPresenceHold = element!.hasAttribute(presenceLayoutHoldAttribute)
|
|
1489
|
+
const hasHiddenWaitEnter = !!element!.querySelector(
|
|
1490
|
+
'[data-presence-wait-hidden="true"]'
|
|
1491
|
+
)
|
|
1492
|
+
const hasPresencePlaceholder = !!element!.querySelector(
|
|
1493
|
+
'[data-presence-placeholder="true"]'
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
if (hasPresenceHold || hasHiddenWaitEnter) {
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (hasPresencePlaceholder) {
|
|
1501
|
+
lastRect = measureLayoutRect()
|
|
1502
|
+
motionDomProjection?.seedLayout()
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1081
1506
|
const nextBox = projection.commitLayoutChange()
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
|
|
1507
|
+
if (nextBox && lastRect) {
|
|
1508
|
+
const next = boxToRectLike(nextBox)
|
|
1509
|
+
const transforms = computeFlipTransforms(lastRect, next, flipLayoutMode)
|
|
1510
|
+
runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
|
|
1085
1511
|
lastRect = next
|
|
1086
|
-
|
|
1512
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1513
|
+
} else if (nextBox) {
|
|
1514
|
+
lastRect = boxToRectLike(nextBox)
|
|
1515
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1087
1516
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1517
|
+
motionDomProjection?.commitObservedLayoutChange()
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const commitPresenceLayoutRelease = (event: Event) => {
|
|
1521
|
+
const detail = (
|
|
1522
|
+
event as CustomEvent<{
|
|
1523
|
+
previousRect?: RectLike
|
|
1524
|
+
viewportScrolledDuringHold?: boolean
|
|
1525
|
+
}>
|
|
1526
|
+
).detail
|
|
1527
|
+
const previous = detail?.previousRect
|
|
1528
|
+
const viewportRect = element!.getBoundingClientRect()
|
|
1529
|
+
const next = measureLayoutRect()
|
|
1530
|
+
if (!(previous && next)) return
|
|
1531
|
+
|
|
1090
1532
|
lastRect = next
|
|
1533
|
+
const shouldSkipLayoutAnimation =
|
|
1534
|
+
detail?.viewportScrolledDuringHold ||
|
|
1535
|
+
wasViewportOffscreenSinceLastLayout ||
|
|
1536
|
+
isViewportOffscreen(viewportRect)
|
|
1537
|
+
if (!shouldSkipLayoutAnimation) {
|
|
1538
|
+
const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
|
|
1539
|
+
runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
|
|
1540
|
+
}
|
|
1541
|
+
wasViewportOffscreenSinceLastLayout = false
|
|
1542
|
+
motionDomProjection?.commitObservedLayoutChange()
|
|
1091
1543
|
}
|
|
1092
1544
|
|
|
1093
|
-
const
|
|
1094
|
-
if (rafId)
|
|
1545
|
+
const scheduleProjectionCommit = () => {
|
|
1546
|
+
if (rafId) return
|
|
1547
|
+
commitObservedLayout()
|
|
1095
1548
|
rafId = requestAnimationFrame(() => {
|
|
1096
1549
|
rafId = null
|
|
1097
|
-
runFlip()
|
|
1098
1550
|
})
|
|
1099
1551
|
}
|
|
1100
|
-
const disconnectObservers = observeLayoutChanges(element!, () =>
|
|
1552
|
+
const disconnectObservers = observeLayoutChanges(element!, () => scheduleProjectionCommit())
|
|
1553
|
+
window.addEventListener('scroll', rememberOffscreenScroll, { passive: true })
|
|
1554
|
+
element!.addEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
|
|
1101
1555
|
|
|
1102
1556
|
return () => {
|
|
1103
1557
|
disconnectObservers()
|
|
1558
|
+
window.removeEventListener('scroll', rememberOffscreenScroll)
|
|
1559
|
+
element?.removeEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
|
|
1104
1560
|
lastRect = null
|
|
1105
|
-
// Reset compositor hints on teardown
|
|
1106
1561
|
if (element) {
|
|
1107
1562
|
setCompositorHints(element, false)
|
|
1108
1563
|
}
|
|
@@ -1423,6 +1878,7 @@
|
|
|
1423
1878
|
|
|
1424
1879
|
$effect(() => {
|
|
1425
1880
|
if (!(element && isLoaded === 'mounting')) return
|
|
1881
|
+
markMotionMounted()
|
|
1426
1882
|
|
|
1427
1883
|
pwLog('[motion] main effect running', {
|
|
1428
1884
|
effectiveAnimate: !!effectiveAnimate,
|
|
@@ -1452,6 +1908,30 @@
|
|
|
1452
1908
|
dataPath = 5
|
|
1453
1909
|
isLoaded = 'ready'
|
|
1454
1910
|
} else if (isNotEmpty(initialKeyframes)) {
|
|
1911
|
+
const canHandoffOptimizedAppear = hasOptimizedAppearAnimation(optimizedAppearId)
|
|
1912
|
+
if (canHandoffOptimizedAppear) {
|
|
1913
|
+
pwLog('[motion] path: optimized appear handoff')
|
|
1914
|
+
dataPath = 6
|
|
1915
|
+
isLoaded = 'initial'
|
|
1916
|
+
initialAnimationTriggered = true
|
|
1917
|
+
if (animateProp && typeof animateProp !== 'string') {
|
|
1918
|
+
objectAnimateRanOnMount = true
|
|
1919
|
+
lastAnimatePropJson = JSON.stringify(animateProp)
|
|
1920
|
+
}
|
|
1921
|
+
finishOptimizedAppearAnimation(optimizedAppearId)
|
|
1922
|
+
.then(() => {
|
|
1923
|
+
applyAnimateRestingStyle()
|
|
1924
|
+
enterAnimationSettled = true
|
|
1925
|
+
isLoaded = 'ready'
|
|
1926
|
+
onAnimationCompleteProp?.(
|
|
1927
|
+
resolvedAnimate as DOMKeyframesDefinition | undefined
|
|
1928
|
+
)
|
|
1929
|
+
})
|
|
1930
|
+
.catch(() => {
|
|
1931
|
+
isLoaded = 'ready'
|
|
1932
|
+
})
|
|
1933
|
+
return
|
|
1934
|
+
}
|
|
1455
1935
|
pwLog('[motion] path: has initialKeyframes, will animate to target')
|
|
1456
1936
|
// Apply initial instantly BEFORE exposing 'initial' state
|
|
1457
1937
|
const transformedInitial = transformSVGPathProperties(
|
|
@@ -1572,15 +2052,23 @@
|
|
|
1572
2052
|
{#if isVoidTag}
|
|
1573
2053
|
{#if isSVGTag(String(tag))}
|
|
1574
2054
|
<svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs} />
|
|
2055
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2056
|
+
{@html renderedOptimizedAppearScript}
|
|
1575
2057
|
{:else}
|
|
1576
2058
|
<svelte:element this={tag} bind:this={element} {...derivedAttrs} />
|
|
2059
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2060
|
+
{@html renderedOptimizedAppearScript}
|
|
1577
2061
|
{/if}
|
|
1578
2062
|
{:else if isSVGTag(String(tag))}
|
|
1579
2063
|
<svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs}>
|
|
1580
2064
|
{@render children?.()}
|
|
1581
2065
|
</svelte:element>
|
|
2066
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2067
|
+
{@html renderedOptimizedAppearScript}
|
|
1582
2068
|
{:else}
|
|
1583
2069
|
<svelte:element this={tag} bind:this={element} {...derivedAttrs}>
|
|
1584
2070
|
{@render children?.()}
|
|
1585
2071
|
</svelte:element>
|
|
2072
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
|
|
2073
|
+
{@html renderedOptimizedAppearScript}
|
|
1586
2074
|
{/if}
|