@@ -14,16 +14,52 @@ const SAFE_OBSERVER_CONFIG: IntersectionObserverInit = {
1414 threshold : [ 0 ] ,
1515} ;
1616
17+ // Exit observer config for repeat/state types - watches when element is completely out of view
18+ const EXIT_OBSERVER_CONFIG : IntersectionObserverInit = {
19+ root : null ,
20+ rootMargin : '0px' ,
21+ threshold : [ 0 ] ,
22+ } ;
23+
1724const observers : Record < string , IntersectionObserver > = { } ;
1825const handlerMap = new WeakMap ( ) as HandlerObjectMap ;
1926const elementFirstRun = new WeakSet < HTMLElement > ( ) ;
2027const elementObserverMap = new WeakMap < HTMLElement , IntersectionObserver > ( ) ;
2128let viewEnterOptions : Partial < ViewEnterParams > = { } ;
29+ let sharedExitObserver : IntersectionObserver | null = null ;
2230
2331function setOptions ( options : Partial < ViewEnterParams > ) {
2432 viewEnterOptions = options ;
2533}
2634
35+ function invokeHandlers ( target : HTMLElement , isIntersecting : boolean ) {
36+ const handlers = handlerMap . get ( target ) ;
37+ handlers ?. forEach ( ( { source, handler } ) => {
38+ if ( source === target ) {
39+ handler ! ( isIntersecting ) ;
40+ }
41+ } ) ;
42+ }
43+
44+ function getExitObserver ( ) {
45+ if ( sharedExitObserver ) {
46+ return sharedExitObserver ;
47+ }
48+
49+ sharedExitObserver = new IntersectionObserver ( ( entries ) => {
50+ entries . forEach ( ( entry ) => {
51+ const target = entry . target as HTMLElement ;
52+
53+ if ( ! entry . isIntersecting ) {
54+ // Element has completely exited the view
55+ invokeHandlers ( target , false ) ;
56+ }
57+ } ) ;
58+ } , EXIT_OBSERVER_CONFIG ) ;
59+
60+ return sharedExitObserver ;
61+ }
62+
2763function getObserver ( options : ViewEnterParams , isSafeMode : boolean = false ) {
2864 const key = JSON . stringify ( { ...options , isSafeMode } ) ;
2965
@@ -78,20 +114,19 @@ const observer = new IntersectionObserver((entries) => {
78114 }
79115 }
80116
81- if ( entry . isIntersecting ) {
82- const handlers = handlerMap . get ( target ) ;
117+ const type = options . type || 'once' ;
83118
84- handlers ?. forEach ( ( { source, handler } ) => {
85- if ( source === entry . target ) {
86- handler ! ( ) ;
87- }
88- } ) ;
119+ if ( entry . isIntersecting || ( type === 'alternate' && ! isFirstRun ) ) {
120+ // For alternate type, handle exit using same observer as entry
121+ invokeHandlers ( target , entry . isIntersecting ) ;
89122
90- if ( options . type === 'once' ) {
123+ if ( type === 'once' ) {
91124 observer . unobserve ( entry . target ) ;
92125 elementFirstRun . delete ( target ) ;
93126 }
94127 }
128+ // Note: repeat and state exit handling is done by a separate exit observer
129+ // that watches when element is completely out of view
95130 } ) ;
96131} , config ) ;
97132
@@ -107,31 +142,81 @@ function addViewEnterHandler(
107142 options : ViewEnterParams = { } ,
108143 { reducedMotion, selectorCondition } : InteractOptions = { } ,
109144) {
110- const observer = getObserver ( { ...viewEnterOptions , ...options } ) ;
145+ const mergedOptions = { ...viewEnterOptions , ...options } ;
146+ const observer = getObserver ( mergedOptions ) ;
147+ const type = mergedOptions . type || 'once' ;
111148 const animation = getAnimation (
112149 target ,
113150 effectToAnimationOptions ( effect ) ,
114151 undefined ,
115152 reducedMotion ,
116153 ) as AnimationGroup ;
117154
118- if ( animation ?. isCSS && options . type === 'once' ) {
155+ // Persist animation for non-once types to prevent auto-cleanup
156+ if ( type !== 'once' ) {
157+ // Use persist() if available (Web Animations API)
158+ ( animation as AnimationGroup & { persist ?: ( ) => void } ) . persist ?.( ) ;
159+ }
160+
161+ // Track initial play state for alternate type
162+ let isInitialPlay = true ;
163+
164+ if ( animation ?. isCSS ) {
119165 animation . onFinish ( ( ) => {
120166 target . dataset . motionEnter = 'done' ;
121167 } ) ;
122168 }
123169
124- const handler = ( ) => {
170+ const handler = ( isIntersecting ?: boolean ) => {
125171 if ( selectorCondition && ! target . matches ( selectorCondition ) ) return ;
126- animation . play ( ( ) => {
127- if ( ! animation . isCSS ) {
128- target . dataset . motionEnter = 'done' ;
172+
173+ if ( type === 'once' ) {
174+ if ( isIntersecting ) {
175+ animation . play ( ( ) => {
176+ if ( ! animation . isCSS ) {
177+ target . dataset . motionEnter = 'done' ;
178+ }
179+ } ) ;
129180 }
130- } ) ;
181+ } else if ( type === 'alternate' ) {
182+ if ( isInitialPlay && isIntersecting ) {
183+ isInitialPlay = false ;
184+ animation . play ( ) ;
185+ } else if ( ! isInitialPlay ) {
186+ // On subsequent entry/exit reverse the animation
187+ animation . reverse ( ) ;
188+ }
189+ } else if ( type === 'repeat' ) {
190+ if ( isIntersecting ) {
191+ // On entry, reset progress to 0 before playing since the exit is a separate observer/range
192+ animation . progress ( 0 ) ;
193+ animation . play ( ) ;
194+ } else {
195+ // On exit (completely out of view), pause and reset
196+ animation . pause ( ) ;
197+ animation . progress ( 0 ) ;
198+ }
199+ } else if ( type === 'state' ) {
200+ if ( isIntersecting ) {
201+ // Resume or start playing
202+ animation . play ( ) ;
203+ } else {
204+ // On exit (completely out of view), just pause
205+ animation . pause ( ) ;
206+ }
207+ }
131208 } ;
209+
132210 const cleanup = ( ) => {
133211 const currentObserver = elementObserverMap . get ( source ) || observer ;
134212 currentObserver . unobserve ( source ) ;
213+
214+ if ( type === 'repeat' || type === 'state' ) {
215+ // Clean up exit observer if it exists
216+ const exitObserver = getExitObserver ( ) ;
217+ exitObserver . unobserve ( source ) ;
218+ }
219+
135220 animation . cancel ( ) ;
136221 elementFirstRun . delete ( source ) ;
137222 elementObserverMap . delete ( source ) ;
@@ -143,14 +228,27 @@ function addViewEnterHandler(
143228
144229 elementObserverMap . set ( source , observer ) ;
145230 observer . observe ( source ) ;
231+
232+ // For repeat and state types, set up a separate exit observer
233+ // that watches when element is completely out of view
234+ if ( type === 'repeat' || type === 'state' ) {
235+ const exitObserver = getExitObserver ( ) ;
236+ exitObserver . observe ( source ) ;
237+ }
146238}
147239
148240function removeViewEnterHandler ( element : HTMLElement ) {
149241 removeElementFromHandlerMap ( handlerMap , element ) ;
150242}
151243
244+ function reset ( ) {
245+ sharedExitObserver = null ;
246+ Object . keys ( observers ) . forEach ( ( key ) => delete observers [ key ] ) ;
247+ }
248+
152249export default {
153250 add : addViewEnterHandler ,
154251 remove : removeViewEnterHandler ,
155252 setOptions,
253+ reset,
156254} ;
0 commit comments