@ev-ry/fx 0.1.0-rc.2 → 0.1.0-rc.4

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.
@@ -1,106 +1,166 @@
1
- import {createImageSurface,IMAGE_EFFECT_MODES} from './image-surface.js';
2
- import {normalizeTextEffectOptions} from './text-effect-options.js';
3
- import {rasterizeDOMImage} from './dom-image-raster.js';
4
- import {queueImagePreparation} from './image-preparation-queue.js';
5
-
6
- // Native IMG retains source, alternative text and events. This adapter supplies
7
- // only a presentation scene to the existing DOM owner and image effect engine.
8
- export function attachImageSurface(element,THREE,options={}){
9
- if(element?.tagName!=='IMG'||!element.parentElement)throw TypeError('A mounted IMG is required');
10
- function validate(value){if(!['mesh','native'].includes(value.resting??'mesh'))throw TypeError('Invalid resting presentation');if(!IMAGE_EFFECT_MODES.includes(value.inputEffect??'dust-wind'))throw TypeError('Invalid image effect');normalizeTextEffectOptions(value.inputEffectOptions??{},value.inputEffect??'dust-wind');}validate(options);
11
- const document=element.ownerDocument,window=document.defaultView,parent=element.parentElement;
12
- const renderer=new THREE.WebGLRenderer({alpha:true,antialias:true}),canvas=renderer.domElement;
13
- const scene=new THREE.Scene(),camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=10;
14
- const originalOpacity=element.style.opacity,originalPosition=parent.style.position;
15
- const ownsPosition=window.getComputedStyle(parent).position==='static';if(ownsPosition)parent.style.position='relative';
16
- Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none'});canvas.setAttribute('aria-hidden','true');parent.append(canvas);
17
- renderer.setSurface?.(parent);
18
- let pendingSurface=null,baseWidth=1,baseHeight=1;
19
- let requested=null,preparing=false,inFlight=false,prepareTimer=null,queueJob=null,committedURL='',resizeStarted=0,failed=false;
20
- let disposed=false,frame=null,surface=null,revision=0,source='',mode='native',reason=null,phase='enter',resolve;
21
- let queuedPhase=options.initialPhase??null;
22
- const ready=new Promise(r=>resolve=r),reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
23
- function native(error){mode='native';reason=error||null;element.style.opacity=!disposed&&surface?.stats().state==='hidden'?'0':originalOpacity;canvas.style.display='none';options.onPresentation?.({mode,reason});renderer.invalidate?.();}
24
- function request(){if(!disposed&&frame===null)frame=window.requestAnimationFrame(draw);}
25
- const runtime={THREE,document,renderer,camera,requestRender:request,register(control){scene.add(control.object);return ()=>scene.remove(control.object);}};
26
- function draw(now){frame=null;if(disposed||!surface||failed||queuedPhase!==null&&preparing)return;try{
27
- const box=element.getBoundingClientRect(),p=parent.getBoundingClientRect(),css=window.getComputedStyle(element);
28
- if(!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});return;}
29
- if(css.transform!=='none'||css.objectPosition!=='50% 50%'||!['fill','contain','cover'].includes(css.objectFit)||parseFloat(css.paddingTop)||parseFloat(css.paddingLeft)||parseFloat(css.borderTopWidth)){native('Unsupported image layout');resolve({mode,reason});return;}
30
- const s=surface.stats();if(!s.source)return;
31
- const w=box.width,h=box.height;
32
- surface.object.scale.set(w/baseWidth,h/baseHeight,1);surface.setDisplaySize(w,h);
33
- camera.left=-box.width/2;camera.right=box.width/2;camera.top=box.height/2;camera.bottom=-box.height/2;camera.updateProjectionMatrix();
34
- Object.assign(canvas.style,{left:(box.left-p.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-p.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',display:'block'});
35
- canvas.style.zIndex=css.zIndex;renderer.setSize(box.width,box.height,false);if(queuedPhase!==null&&!preparing){phase=queuedPhase;surface[queuedPhase]();queuedPhase=null;}surface.frame(now,reduced.matches);
36
- const state=surface.stats();
37
- if(!state.active&&(options.resting==='native'||reduced.matches)){
38
- // Submit before handing off: reveal gates acknowledge even reduced motion.
39
- renderer.render(scene,camera);
40
- native(state.state==='hidden'?'hidden after exit':'native resting presentation');
41
- if(state.state==='hidden')element.style.opacity='0';
42
- resolve({mode,reason});return;
43
- }
44
- const handingOffNative=mode==='native';
45
- if(element.style.opacity!=='0')element.style.opacity='0';mode='mesh';reason=null;renderer.render(scene,camera);
46
- options.onPresentation?.({mode,reason});
47
- if(handingOffNative)renderer.flushPresentation?.();
48
- resolve({mode,reason});
49
- }catch(error){native(error.message);resolve({mode,reason});}}
50
- function nearViewport(box){const margin=window.innerHeight*.5;return box.bottom>=-margin&&box.top<=window.innerHeight+margin&&box.right>=0&&box.left<=window.innerWidth;}
51
- function measure(){const url=element.currentSrc||element.src,css=window.getComputedStyle(element),box=element.getBoundingClientRect();return {url,css,box,key:JSON.stringify([url,box.width,box.height,css.objectFit,css.objectPosition,css.borderTopLeftRadius,css.borderTopRightRadius,css.borderBottomLeftRadius,css.borderBottomRightRadius])};}
52
- function schedule(delay=0){clearTimeout(prepareTimer);if(disposed||inFlight||!requested)return;preparing=true;prepareTimer=setTimeout(prepare,delay);}
53
- async function prepare(){
54
- prepareTimer=null;if(disposed||inFlight||!requested)return;
55
- inFlight=true;preparing=true;const token=revision,current=requested;
56
- try{
57
- if(!element.complete||!element.naturalWidth)await element.decode();
58
- if(disposed||token!==revision)return;
59
- queueJob=queueImagePreparation(window,async()=>{
60
- if(disposed||token!==revision)return false;
61
- // Resize/decode may have changed the measured box without an observer
62
- // callback yet. Do not allocate pixels for the stale dimensions.
63
- const measured=measure();if(measured.key!==current.key){refresh();return false;}
64
- const raster=await rasterizeDOMImage(element,{maxSide:Math.min(2048,renderer.capabilities?.maxTextureSize||2048)});
65
- if(disposed||token!==revision)return false;
66
- const next=surface??createImageSurface(runtime,{width:raster.width,height:raster.height,effect:options.inputEffect??'dust-wind',settings:options.inputEffectOptions??{}});
67
- if(!surface)pendingSurface=next;
68
- next.setDisplaySize(current.box.width,current.box.height);
69
- await next.setSource(raster.source,{preserveMotion:!!surface&&committedURL===current.url,bounds:{width:raster.width,height:raster.height}});
70
- if(disposed||token!==revision){if(next!==surface)next.destroy();return false;}
71
- surface=next;pendingSurface=null;baseWidth=raster.width;baseHeight=raster.height;source=current.key;committedURL=current.url;requested=null;resizeStarted=0;return true;
72
- });
73
- await queueJob.promise;
74
- }catch(error){pendingSurface?.destroy();pendingSurface=null;if(!disposed&&token===revision){requested=null;source='';resizeStarted=0;failed=true;native(error.message);resolve({mode,reason});}}
75
- finally{queueJob=null;inFlight=false;preparing=false;if(!disposed){if(requested)schedule(surface?50:0);request();}}
1
+ import {createImageSurface,IMAGE_EFFECT_MODES} from './image-surface.js';
2
+ import {normalizeTextEffectOptions} from './text-effect-options.js';
3
+ import {rasterizeDOMImage} from './dom-image-raster.js';
4
+ import {queueImagePreparation} from './image-preparation-queue.js';
5
+
6
+ // Native IMG retains source, alternative text and events. This adapter supplies
7
+ // only a presentation scene to the existing DOM owner and image effect engine.
8
+ export function attachImageSurface(element,THREE,options={}){
9
+ if(element?.tagName!=='IMG'||!element.parentElement)throw TypeError('A mounted IMG is required');
10
+ function validate(value){if(!['mesh','native'].includes(value.resting??'mesh'))throw TypeError('Invalid resting presentation');if(!IMAGE_EFFECT_MODES.includes(value.inputEffect??'dust-wind'))throw TypeError('Invalid image effect');normalizeTextEffectOptions(value.inputEffectOptions??{},value.inputEffect??'dust-wind');}validate(options);
11
+ const document=element.ownerDocument,window=document.defaultView,parent=element.parentElement;
12
+ const renderer=new THREE.WebGLRenderer({alpha:true,antialias:true}),canvas=renderer.domElement;
13
+ const scene=new THREE.Scene(),camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=10;
14
+ let originalOpacity=element.style.opacity,ownedOpacity=null;
15
+ const nativeRestOpacity=options.nativeRestOpacity??(originalOpacity===''?1:Number(originalOpacity)),originalPosition=parent.style.position;
16
+ function captureHostOpacity(){
17
+ if(ownedOpacity===null||element.style.opacity!==ownedOpacity)originalOpacity=element.style.opacity;
18
+ }
19
+ function writeOpacity(value){captureHostOpacity();element.style.opacity=value;ownedOpacity=element.style.opacity;}
20
+ function restoreOpacity(){
21
+ captureHostOpacity();
22
+ if(ownedOpacity!==null&&element.style.opacity===ownedOpacity)element.style.opacity=originalOpacity;
23
+ ownedOpacity=null;
76
24
  }
77
- function refresh(){
78
- if(disposed)return;const current=measure(),{box,css}=current;
79
- if(!element.isConnected||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'||!nearViewport(box)){
80
- if(requested){revision++;requested=null;queueJob?.cancel();}clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;
81
- if(!surface||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});}return;
82
- }
83
- if(current.key===source){if(requested){revision++;requested=null;queueJob?.cancel();clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;}if(surface)request();return;}
84
- if(current.key===requested?.key)return;
85
- requested=current;revision++;failed=false;queueJob?.cancel();
86
- // Keep the current texture/motion during a resize burst. Rebuild the latest
87
- // size after 50ms of quiet, with a 150ms bound for continuous transitions.
88
- const resizing=surface&&committedURL===current.url;
89
- if(resizing&&!resizeStarted)resizeStarted=performance.now();
90
- schedule(resizing?Math.max(0,Math.min(50,150-(performance.now()-resizeStarted))):0);
25
+ const ownsPosition=window.getComputedStyle(parent).position==='static';if(ownsPosition)parent.style.position='relative';
26
+ Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none'});canvas.setAttribute('aria-hidden','true');parent.append(canvas);
27
+ renderer.setSurface?.(parent);
28
+ let pendingSurface=null,baseWidth=1,baseHeight=1;
29
+ let requested=null,preparing=false,inFlight=false,prepareTimer=null,queueJob=null,committedURL='',resizeStarted=0,failed=false;
30
+ let disposed=false,frame=null,surface=null,revision=0,source='',mode='native',reason=null,phase='enter',resolve;
31
+ let effectStarted=null,queuedStarted=null,inViewport=true,exitHeld=false,nativeHidden=false;
32
+ let queuedPhase=options.initialPhase??null;
33
+ const ready=new Promise(r=>resolve=r),reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
34
+ function native(error,restore=false){
35
+ if(restore)exitHeld=false;
36
+ const hidden=!disposed&&!restore&&(exitHeld||surface?.stats().state==='hidden');
37
+ nativeHidden=hidden;mode='native';reason=error||null;
38
+ if(disposed)restoreOpacity();
39
+ else{captureHostOpacity();writeOpacity(hidden?'0':(options.nativeRestOpacity!==undefined?String(nativeRestOpacity):originalOpacity));}
40
+ canvas.style.display='none';options.onPresentation?.({mode,reason,hidden});renderer.invalidate?.();
91
41
  }
92
- const resize=new window.ResizeObserver(refresh);resize.observe(element);
93
- const styleKey=()=>element.style.cssText.replace(/(?:^|;)\s*opacity\s*:[^;]*/g,'');let lastStyle=styleKey();
94
- const observer=new window.MutationObserver(records=>{const next=styleKey();if(records.some(r=>r.attributeName!=='style')||next!==lastStyle){lastStyle=next;refresh();}});observer.observe(element,{attributes:true,attributeFilter:['src','srcset','sizes','class','style']});
95
- const proximity=window.IntersectionObserver?new window.IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting))refresh();},{rootMargin:`${window.innerHeight*.5}px 0px`}):null;proximity?.observe(element);
96
- const onScroll=()=>{if(mode==='mesh')request();if(!proximity&&!surface)refresh();};
97
- reduced.addEventListener('change',request);element.addEventListener('load',refresh);window.addEventListener('resize',refresh);window.addEventListener('scroll',onScroll,true);refresh();
98
- return {element,ready,refresh,cancel(){if(disposed)return;phase='enter';queuedPhase=null;surface?.show();request();},play(value='enter'){if(!['enter','exit'].includes(value))throw TypeError('Expected enter or exit');phase=value;queuedPhase=value;request();},
99
- update(next={}){const candidate={...options,...next};validate(candidate);options=candidate;surface?.setEffect(options.inputEffect??'dust-wind',options.inputEffectOptions??{});request();},
100
- stats:()=>({disposed,mode,reason,phase,preparing,image:surface?.stats()}),
101
- destroy(){if(disposed)return;disposed=true;revision++;requested=null;clearTimeout(prepareTimer);queueJob?.cancel();if(frame!==null)window.cancelAnimationFrame(frame);resize.disconnect();observer.disconnect();proximity?.disconnect();reduced.removeEventListener('change',request);element.removeEventListener('load',refresh);window.removeEventListener('resize',refresh);window.removeEventListener('scroll',onScroll,true);surface?.destroy();pendingSurface?.destroy();native();renderer.dispose();renderer.forceContextLoss();canvas.remove();if(ownsPosition&&parent.style.position==='relative')parent.style.position=originalPosition;resolve({mode:'native',reason:'destroyed'});}
102
- };
103
- }
42
+ function holdExit(){exitHeld=true;nativeHidden=true;writeOpacity('0');options.onPresentation?.({mode,reason,hidden:true});}
43
+ function suspend(){
44
+ if(queuedPhase!==null&&queuedStarted===null)queuedStarted=window.performance.now();
45
+ if(queuedPhase==='exit'||queuedPhase===null&&phase==='exit'&&effectStarted!==null)holdExit();
46
+ if(frame!==null)window.cancelAnimationFrame(frame);frame=null;canvas.style.display='none';renderer.invalidate?.();
47
+ }
48
+ function request(){if(!disposed&&inViewport&&!document.hidden&&frame===null)frame=window.requestAnimationFrame(draw);}
49
+ const runtime={THREE,document,renderer,camera,requestRender:request,register(control){scene.add(control.object);return ()=>scene.remove(control.object);}};
50
+ function draw(now){frame=null;if(disposed||!surface||failed||queuedPhase!==null&&preparing)return;if(!inViewport||document.hidden){suspend();return;}try{
51
+ const box=element.getBoundingClientRect(),p=parent.getBoundingClientRect(),css=window.getComputedStyle(element);
52
+ if(!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});return;}
53
+ if(css.transform!=='none'||css.objectPosition!=='50% 50%'||!['fill','contain','cover'].includes(css.objectFit)||parseFloat(css.paddingTop)||parseFloat(css.paddingLeft)||parseFloat(css.borderTopWidth)){native('Unsupported image layout',true);resolve({mode,reason});return;}
54
+ const s=surface.stats();if(!s.source)return;
55
+ const w=box.width,h=box.height;
56
+ surface.object.scale.set(w/baseWidth,h/baseHeight,1);surface.setDisplaySize(w,h);
57
+ camera.left=-box.width/2;camera.right=box.width/2;camera.top=box.height/2;camera.bottom=-box.height/2;camera.updateProjectionMatrix();
58
+ Object.assign(canvas.style,{left:(box.left-p.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-p.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',display:'block'});
59
+ canvas.style.zIndex=css.zIndex;renderer.setSize(box.width,box.height,false);if(queuedPhase!==null&&!preparing){phase=queuedPhase;effectStarted=queuedStarted??now;queuedStarted=null;if(phase==='exit'&&surface.stats().state==='entering')surface.frame(effectStarted,reduced.matches);surface.setPresentationOpacity(1);surface[queuedPhase](effectStarted);queuedPhase=null;}surface.frame(now,reduced.matches);
60
+ const state=surface.stats();
61
+ const handoff=options.resting==='native'&&phase==='enter'&&effectStarted!==null&&!reduced.matches;
62
+ const end=effectStarted===null?now:effectStarted+state.duration;
63
+ // Reveal complete native pixels only once assembly finishes.
64
+ const nativeAlpha=handoff&&now>=end?1:0;
65
+ const meshFade=handoff?Math.max(0,Math.min(1,(now-end)/250)):0;
66
+ surface.setPresentationOpacity(1-meshFade);
67
+ if(!state.active&&(options.resting==='native'||reduced.matches)&&(!handoff||meshFade>=1)){
68
+ // Submit before handing off: reveal gates acknowledge even reduced motion.
69
+ renderer.render(scene,camera);
70
+ native(state.state==='hidden'?'hidden after exit':'native resting presentation');
71
+ if(state.state==='hidden')writeOpacity('0');
72
+ resolve({mode,reason});return;
73
+ }
74
+ const handingOffNative=mode==='native';
75
+ writeOpacity(String(nativeAlpha*nativeRestOpacity));nativeHidden=nativeAlpha===0;mode='mesh';reason=null;renderer.render(scene,camera);
76
+ options.onPresentation?.({mode,reason,hidden:nativeHidden});
77
+ if(handingOffNative)renderer.flushPresentation?.();
78
+ resolve({mode,reason});if(handoff&&meshFade<1)request();
79
+ }catch(error){native(error.message,true);resolve({mode,reason});}}
80
+ function nearViewport(box){const margin=window.innerHeight*.5;return box.bottom>=-margin&&box.top<=window.innerHeight+margin&&box.right>=0&&box.left<=window.innerWidth;}
81
+ function measure(){const url=element.currentSrc||element.src,css=window.getComputedStyle(element),box=element.getBoundingClientRect();return {url,css,box,key:JSON.stringify([url,box.width,box.height,css.objectFit,css.objectPosition,css.borderTopLeftRadius,css.borderTopRightRadius,css.borderBottomLeftRadius,css.borderBottomRightRadius])};}
82
+ function schedule(delay=0){clearTimeout(prepareTimer);if(disposed||inFlight||!requested)return;preparing=true;prepareTimer=setTimeout(prepare,delay);}
83
+ async function prepare(){
84
+ prepareTimer=null;if(disposed||inFlight||!requested)return;
85
+ inFlight=true;preparing=true;const token=revision,current=requested;
86
+ try{
87
+ if(!element.complete||!element.naturalWidth)await element.decode();
88
+ if(disposed||token!==revision)return;
89
+ queueJob=queueImagePreparation(window,async()=>{
90
+ if(disposed||token!==revision)return false;
91
+ // Resize/decode may have changed the measured box without an observer
92
+ // callback yet. Do not allocate pixels for the stale dimensions.
93
+ const measured=measure();if(measured.key!==current.key){refresh();return false;}
94
+ const raster=await rasterizeDOMImage(element,{maxSide:Math.min(2048,renderer.capabilities?.maxTextureSize||2048)});
95
+ if(disposed||token!==revision)return false;
96
+ const next=surface??createImageSurface(runtime,{width:raster.width,height:raster.height,effect:options.inputEffect??'dust-wind',settings:options.inputEffectOptions??{}});
97
+ if(!surface)pendingSurface=next;
98
+ next.setDisplaySize(current.box.width,current.box.height);
99
+ // DOM source/paint changes replace pixels, not the caller's animation
100
+ // intent. Keep a departure hidden and preserve an active clock/seed
101
+ // across src, srcset and SVG snapshot replacements just as on resize.
102
+ await next.setSource(raster.source,{preserveMotion:!!surface,bounds:{width:raster.width,height:raster.height}});
103
+ if(disposed||token!==revision){if(next!==surface)next.destroy();return false;}
104
+ surface=next;pendingSurface=null;baseWidth=raster.width;baseHeight=raster.height;source=current.key;committedURL=current.url;requested=null;resizeStarted=0;return true;
105
+ });
106
+ await queueJob.promise;
107
+ }catch(error){pendingSurface?.destroy();pendingSurface=null;if(!disposed&&token===revision){requested=null;source='';resizeStarted=0;failed=true;native(error.message,true);resolve({mode,reason});}}
108
+ finally{queueJob=null;inFlight=false;preparing=false;if(!disposed){if(requested)schedule(surface?50:0);request();}}
109
+ }
110
+ function refresh(){
111
+ if(disposed)return;const current=measure(),{box,css}=current;
112
+ if(!element.isConnected||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'||!nearViewport(box)){
113
+ if(requested){revision++;requested=null;queueJob?.cancel();}clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;
114
+ if(!surface||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});}return;
115
+ }
116
+ if(current.key===source){if(requested){revision++;requested=null;queueJob?.cancel();clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;}if(surface)request();return;}
117
+ if(current.key===requested?.key)return;
118
+ requested=current;revision++;failed=false;queueJob?.cancel();
119
+ // Keep the current texture/motion during a resize burst. Rebuild the latest
120
+ // size after 50ms of quiet, with a 150ms bound for continuous transitions.
121
+ const resizing=surface&&committedURL===current.url;
122
+ if(resizing&&!resizeStarted)resizeStarted=performance.now();
123
+ schedule(resizing?Math.max(0,Math.min(50,150-(performance.now()-resizeStarted))):0);
124
+ }
125
+ const resize=new window.ResizeObserver(refresh);resize.observe(element);
126
+ const styleKey=()=>element.style.cssText.replace(/(?:^|;)\s*opacity\s*:[^;]*/g,'');let lastStyle=styleKey();
127
+ const observer=new window.MutationObserver(records=>{const next=styleKey();if(records.some(r=>r.attributeName!=='style')||next!==lastStyle){lastStyle=next;refresh();}});observer.observe(element,{attributes:true,attributeFilter:['src','srcset','sizes','class','style']});
128
+ const proximity=window.IntersectionObserver?new window.IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting))refresh();},{rootMargin:`${window.innerHeight*.5}px 0px`}):null;proximity?.observe(element);
129
+ const visibility=new window.IntersectionObserver(entries=>{
130
+ inViewport=entries.at(-1)?.isIntersecting!==false;
131
+ if(inViewport){refresh();request();}
132
+ else suspend();
133
+ });visibility.observe(element);
134
+ const onVisibility=()=>{if(document.hidden)suspend();else{refresh();request();}};
135
+ document.addEventListener('visibilitychange',onVisibility);
136
+ const onScroll=()=>{if(mode==='mesh')request();if(!proximity&&!surface)refresh();};
137
+ reduced.addEventListener('change',request);element.addEventListener('load',refresh);window.addEventListener('resize',refresh);window.addEventListener('scroll',onScroll,true);
138
+ if(queuedPhase!==null){const box=element.getBoundingClientRect();if(document.hidden||box.bottom<=0||box.top>=window.innerHeight||box.right<=0||box.left>=window.innerWidth){inViewport=false;suspend();}}
139
+ refresh();
140
+ return {element,ready,refresh,cancel(){if(disposed)return;phase='enter';effectStarted=null;queuedPhase=null;queuedStarted=null;exitHeld=false;surface?.setPresentationOpacity(1);surface?.show();native();request();},play(value='enter'){
141
+ if(disposed)throw Error('Surface disposed');
142
+ if(!['enter','exit'].includes(value))throw TypeError('Expected enter or exit');
143
+ phase=value;queuedPhase=value;exitHeld=false;
144
+ const box=element.getBoundingClientRect(),outside=box.bottom<=0||box.top>=window.innerHeight||box.right<=0||box.left>=window.innerWidth;
145
+ queuedStarted=document.hidden||!inViewport||outside?window.performance.now():null;
146
+ if(outside)inViewport=false;
147
+ if(document.hidden||!inViewport)suspend();
148
+ request();
149
+ },
150
+ update(next={}){const candidate={...options,...next};validate(candidate);options=candidate;surface?.setEffect(options.inputEffect??'dust-wind',options.inputEffectOptions??{});request();},
151
+ stats:()=>{
152
+ const image=surface?.stats(),started=queuedPhase!==null?queuedStarted:effectStarted,currentPhase=queuedPhase??phase;
153
+ const duration=image?.duration??options.inputEffectOptions?.duration??2000;
154
+ const finishAt=started===null?null:started+(reduced.matches?0:duration+(currentPhase==='enter'&&options.resting==='native'?250:0));
155
+ const suspended=document.hidden||!inViewport;
156
+ const unsupported=failed||reason==='Unsupported image layout';
157
+ const completed=!unsupported&&started!==null&&(suspended?window.performance.now()>=finishAt:
158
+ queuedPhase===null&&(currentPhase==='exit'?image?.state==='hidden':options.resting==='native'?mode==='native'&&reason==='native resting presentation':image?.state==='visible'&&!image.active));
159
+ return {disposed,mode,reason,phase:currentPhase,preparing,suspended,completed,hidden:exitHeld||nativeHidden,timeline:{phase:currentPhase,started,ends:started===null?null:started+duration,finishAt},handoff:mode==='mesh'&&options.resting==='native'&&phase==='enter'&&effectStarted!==null,image};
160
+ },
161
+ destroy(){if(disposed)return;disposed=true;revision++;requested=null;clearTimeout(prepareTimer);queueJob?.cancel();if(frame!==null)window.cancelAnimationFrame(frame);resize.disconnect();observer.disconnect();proximity?.disconnect();visibility.disconnect();document.removeEventListener('visibilitychange',onVisibility);reduced.removeEventListener('change',request);element.removeEventListener('load',refresh);window.removeEventListener('resize',refresh);window.removeEventListener('scroll',onScroll,true);surface?.destroy();pendingSurface?.destroy();native();renderer.dispose();renderer.forceContextLoss();canvas.remove();if(ownsPosition&&parent.style.position==='relative')parent.style.position=originalPosition;resolve({mode:'native',reason:'destroyed'});}
162
+ };
163
+ }
104
164
 
105
165
 
106
166
 
@@ -1,27 +1,33 @@
1
- // Place the next native image over the previous image; animate transiently.
2
- export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=false,enterEffect=true,topImage='next',...options}={}){
3
- if(!['previous','next'].includes(topImage))throw TypeError('Expected previous or next topImage');
4
- if(previous===next||previous?.tagName!=='IMG'||next?.tagName!=='IMG'||!previous.parentElement)throw TypeError('Two different IMG elements are required');
5
- const previousVisibility=previous.style.visibility,previousPosition=previous.style.position,previousZ=previous.style.zIndex;let cancelled=false,jobs=[];const parent=previous.parentElement,origin=next.parentNode,sibling=next.nextSibling,css=next.style.cssText,parentPosition=parent.style.position;
6
- const finished=(async()=>{try{await Promise.all([previous.decode(),next.decode()]);if(cancelled)return {status:'cancelled'};
7
- const view=previous.ownerDocument.defaultView,box=previous.getBoundingClientRect();if(view.getComputedStyle(parent).position==='static')parent.style.position='relative';const bounds=parent.getBoundingClientRect();
8
- parent.append(next);Object.assign(next.style,{position:'absolute',left:(box.left-bounds.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-bounds.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',margin:'0',visibility:'hidden'});
9
- if(view.getComputedStyle(previous).position==='static')previous.style.position='relative';previous.style.zIndex=topImage==='previous'?'1':'0';next.style.zIndex=topImage==='next'?'1':'0';
10
- // Local layers preserve the native next image above a departing canvas too.
11
- const layerOptions=(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
12
- const beginEntry=()=>{next.style.visibility='visible';if(enterEffect)jobs.push(animate(next,{...layerOptions,phase:'enter'}));};
13
- const earlyEntry=!enterEffect||(!waitForExit&&topImage==='previous');if(earlyEntry)beginEntry();
14
- if(exitEffect){
15
- const exit=animate(previous,{...layerOptions,inputEffect:exitEffect,phase:'exit'});jobs.push(exit);
16
- if(waitForExit){const result=await exit.finished;if(cancelled||result.status!=='completed'){rollback();return {status:cancelled?'cancelled':result.status};}}
17
- }
18
- if(!earlyEntry)beginEntry();
19
- const results=await Promise.all(jobs.map(job=>job.finished));const success=!cancelled&&results.every(r=>r.status==='completed');
20
- if(success){previous.style.visibility='hidden';return {status:'completed'};}
21
- rollback();return {status:cancelled?'cancelled':'unsupported'};
1
+ // Place the next native image over the previous image; animate transiently.
2
+ export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=false,enterEffect=true,topImage='next',...options}={}){
3
+ if(!['previous','next'].includes(topImage))throw TypeError('Expected previous or next topImage');
4
+ if(previous===next||previous?.tagName!=='IMG'||next?.tagName!=='IMG'||!previous.parentElement)throw TypeError('Two different IMG elements are required');
5
+ const previousVisibility=previous.style.visibility,previousPosition=previous.style.position,previousZ=previous.style.zIndex;let cancelled=false,settled=false,rolledBack=false,jobs=[];const parent=previous.parentElement,origin=next.parentNode,sibling=next.nextSibling,css=next.style.cssText,parentPosition=parent.style.position;
6
+ let resolveCancellation;
7
+ const cancellation=new Promise(resolve=>{resolveCancellation=resolve;});
8
+ const operation=(async()=>{try{await Promise.all([previous.decode(),next.decode()]);if(cancelled)return {status:'cancelled'};
9
+ const view=previous.ownerDocument.defaultView,box=previous.getBoundingClientRect();if(view.getComputedStyle(parent).position==='static')parent.style.position='relative';const bounds=parent.getBoundingClientRect();
10
+ parent.append(next);Object.assign(next.style,{position:'absolute',left:(box.left-bounds.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-bounds.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',margin:'0',visibility:'hidden'});
11
+ if(view.getComputedStyle(previous).position==='static')previous.style.position='relative';previous.style.zIndex=topImage==='previous'?'1':'0';next.style.zIndex=topImage==='next'?'1':'0';
12
+ // Local layers preserve the native next image above a departing canvas too.
13
+ // A departing previous image can use the shared canvas above the native
14
+ // next image. Forcing local here clips particles to the rounded host.
15
+ const departingOverNative=!enterEffect&&topImage==='previous';
16
+ const layerOptions=!departingOverNative&&(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
17
+ const beginEntry=()=>{next.style.visibility='visible';if(enterEffect)jobs.push(animate(next,{...layerOptions,phase:'enter'}));};
18
+ const earlyEntry=!enterEffect||(!waitForExit&&topImage==='previous');if(earlyEntry)beginEntry();
19
+ if(exitEffect){
20
+ const exit=animate(previous,{...layerOptions,inputEffect:exitEffect,phase:'exit'});jobs.push(exit);
21
+ if(waitForExit){const result=await exit.finished;if(cancelled||result.status!=='completed'){rollback();return {status:cancelled?'cancelled':result.status};}}
22
+ }
23
+ if(!earlyEntry)beginEntry();
24
+ const results=await Promise.all(jobs.map(job=>job.finished));const success=!cancelled&&results.every(r=>r.status==='completed');
25
+ if(success){previous.style.visibility='hidden';return {status:'completed'};}
26
+ rollback();return {status:cancelled?'cancelled':'unsupported'};
22
27
  }catch(error){jobs.forEach(j=>j.cancel());rollback();throw error;}})();
23
- function rollback(){previous.style.position=previousPosition;previous.style.zIndex=previousZ;previous.style.visibility=previousVisibility;next.style.cssText=css;if(origin)origin.insertBefore(next,sibling?.parentNode===origin?sibling:null);parent.style.position=parentPosition;}
24
- return {finished,cancel(){cancelled=true;jobs.forEach(j=>j.cancel());}};
25
- }
28
+ const finished=Promise.race([operation,cancellation]).then(result=>{settled=true;return result;},error=>{settled=true;throw error;});
29
+ function rollback(){if(rolledBack)return;rolledBack=true;previous.style.position=previousPosition;previous.style.zIndex=previousZ;previous.style.visibility=previousVisibility;next.style.cssText=css;if(origin)origin.insertBefore(next,sibling?.parentNode===origin?sibling:null);else next.remove();parent.style.position=parentPosition;}
30
+ return {finished,cancel(){if(settled||cancelled)return;cancelled=true;jobs.forEach(j=>j.cancel());rollback();resolveCancellation({status:'cancelled'});}};
31
+ }
26
32
 
27
33
 
package/src/dom-once.js CHANGED
@@ -1,34 +1,39 @@
1
- const running=new WeakMap(),hidden=new WeakMap();
2
- // Transient use of the same surfaces; no second animation implementation.
3
- export function animateDOMOnce(attach,element,{phase='enter',presentation='global',...options}={}){
4
- if(options.revealOnView)throw TypeError('revealOnView is an attachment option, not a transient animation option');
5
- if(!['enter','exit'].includes(phase))throw TypeError('Expected enter or exit');
6
- if(running.has(element))throw Error('An animation is already running on this element');
7
- const previous=hidden.get(element);
8
- const paint=element.tagName==='IMG'?'opacity':'-webkit-text-fill-color';
9
- const paintValue=element.style.getPropertyValue(paint),paintPriority=element.style.getPropertyPriority(paint);
10
- if(phase==='enter')element.style.setProperty(paint,element.tagName==='IMG'?'0':'transparent');
11
- if(previous&&element.style.visibility==='hidden'){element.style.setProperty('visibility',previous.value,previous.priority);hidden.delete(element);}
12
- let surface,done=false,timer;let resolve,reject;
13
- const finished=new Promise((a,b)=>{resolve=a;reject=b;});
14
- function finish(status,error){if(done)return;done=true;clearTimeout(timer);running.delete(element);surface?.destroy();
15
- if(phase==='enter'){if(paintValue)element.style.setProperty(paint,paintValue,paintPriority);else element.style.removeProperty(paint);}
16
- if(status==='completed'&&phase==='exit'){hidden.set(element,{value:element.style.getPropertyValue('visibility'),priority:element.style.getPropertyPriority('visibility')});element.style.visibility='hidden';}
17
- if(error)reject(error);else resolve({status,phase});
18
- }
19
- try{surface=attach(element.tagName==='IMG'?'attachImage':'attachText',element,{...options,presentation,resting:'mesh',initialPhase:phase});}catch(error){if(phase==='enter'){if(paintValue)element.style.setProperty(paint,paintValue,paintPriority);else element.style.removeProperty(paint);}if(previous){element.style.visibility='hidden';hidden.set(element,previous);}throw error;}
20
- running.set(element,surface);const started=performance.now();
21
- timer=setTimeout(()=>finish('cancelled',Error('Animation preparation timed out')),30000);
22
- surface.ready.then(()=>{if(done)return;clearTimeout(timer);if(surface.stats().mode!=='mesh'){finish('unsupported');return;}
23
- // Wait for the scheduled render before testing the shared effect's state.
24
- let observed=false;
25
- function poll(){if(done)return;const s=surface.stats();if(s.disposed){finish('cancelled');return;}
26
- const active=!!(s.image?.active||s.rich?.active||s.effect?.active||s.pending);
27
- if(active)observed=true;
28
- if(!element.isConnected){finish('cancelled');return;}
29
- if(!active&&(observed||performance.now()-started>150)){finish(s.mode==='mesh'?'completed':'unsupported');return;}
30
- if(performance.now()-started>30000){finish('cancelled');return;}timer=setTimeout(poll,16);
31
- }timer=setTimeout(poll,32);
32
- }).catch(error=>finish('cancelled',error));
33
- return {finished,cancel:()=>finish('cancelled')};
34
- }
1
+ const running=new WeakMap(),hidden=new WeakMap();
2
+ // Transient use of the same surfaces; no second animation implementation.
3
+ export function animateDOMOnce(attach,element,{phase='enter',presentation='global',...options}={}){
4
+ if(options.revealOnView)throw TypeError('revealOnView is an attachment option, not a transient animation option');
5
+ if(!['enter','exit'].includes(phase))throw TypeError('Expected enter or exit');
6
+ if(running.has(element))throw Error('An animation is already running on this element');
7
+ const previous=hidden.get(element);
8
+ const paint=element.tagName==='IMG'?'opacity':'-webkit-text-fill-color';
9
+ const imageEntry=element.tagName==='IMG'&&phase==='enter';
10
+ const nativeRestOpacity=imageEntry?Number(element.ownerDocument.defaultView.getComputedStyle(element).opacity):1;
11
+ const paintValue=element.style.getPropertyValue(paint),paintPriority=element.style.getPropertyPriority(paint);
12
+ if(phase==='enter')element.style.setProperty(paint,element.tagName==='IMG'?'0':'transparent');
13
+ if(previous&&element.style.visibility==='hidden'){element.style.setProperty('visibility',previous.value,previous.priority);hidden.delete(element);}
14
+ let surface,done=false,timer;let resolve,reject;
15
+ const finished=new Promise((a,b)=>{resolve=a;reject=b;});
16
+ function finish(status,error){if(done)return;done=true;clearTimeout(timer);running.delete(element);surface?.destroy();
17
+ if(phase==='enter'){if(paintValue)element.style.setProperty(paint,paintValue,paintPriority);else element.style.removeProperty(paint);}
18
+ if(status==='completed'&&phase==='exit'){hidden.set(element,{value:element.style.getPropertyValue('visibility'),priority:element.style.getPropertyPriority('visibility')});element.style.visibility='hidden';}
19
+ if(error)reject(error);else resolve({status,phase});
20
+ }
21
+ try{surface=attach(element.tagName==='IMG'?'attachImage':'attachText',element,{...options,presentation,resting:imageEntry?'native':'mesh',...(imageEntry?{nativeRestOpacity}:{}),initialPhase:phase});}catch(error){if(phase==='enter'){if(paintValue)element.style.setProperty(paint,paintValue,paintPriority);else element.style.removeProperty(paint);}if(previous){element.style.visibility='hidden';hidden.set(element,previous);}throw error;}
22
+ running.set(element,surface);const view=element.ownerDocument.defaultView,started=view.performance.now();
23
+ // Completion belongs to the effect clock, not to the first visible frame.
24
+ // A hidden transient may finish without ever preparing a mesh or resolving
25
+ // visual readiness; visibility only changes how often we inspect its clock.
26
+ function poll(){
27
+ if(done)return;
28
+ const s=surface.stats();
29
+ if(s.disposed||!element.isConnected){finish('cancelled');return;}
30
+ if(!s.preparing&&s.mode==='native'&&s.reason&&!/initializing|not visible|not connected|native resting presentation|hidden after exit/i.test(s.reason)){finish('unsupported');return;}
31
+ if(s.completed===true){finish('completed');return;}
32
+ if(view.performance.now()-started>30000){finish('cancelled',Error('Animation preparation timed out'));return;}
33
+ const remaining=s.timeline?.finishAt-view.performance.now();
34
+ timer=setTimeout(poll,s.suspended?Math.min(1000,Math.max(100,remaining||1000)):16);
35
+ }
36
+ timer=setTimeout(poll,16);
37
+ surface.ready.then(()=>{if(!done){clearTimeout(timer);poll();}},error=>finish('cancelled',error));
38
+ return {finished,cancel:()=>finish('cancelled')};
39
+ }
@@ -1,33 +1,33 @@
1
- // Document-scoped, bounded source pixels only. Geometry, tint and motion remain
2
- // attachment-owned. Live glyph records can outlive an LRU entry safely.
3
- const documents=new WeakMap();
4
- const MAX_BYTES=16*1024*1024,MAX_ENTRIES=128,MAX_FONTS=64;
5
- export function domRasterCache(document){
6
- let cache=documents.get(document);if(cache)return cache;
7
- const rasters=new Map(),fonts=new Map();let bytes=0,hits=0,misses=0,epoch=0;
8
- function invalidate(){epoch++;rasters.clear();fonts.clear();bytes=0;}
9
- cache={
10
- get epoch(){return epoch;},
11
- font(key,load){
12
- let promise=fonts.get(key);
13
- if(!promise){promise=Promise.resolve().then(load);fonts.set(key,promise);if(fonts.size>MAX_FONTS)fonts.delete(fonts.keys().next().value);promise.catch(()=>{if(fonts.get(key)===promise)fonts.delete(key);});}
14
- return promise;
15
- },
16
- raster(key,build){
17
- const old=rasters.get(key);
18
- if(old){rasters.delete(key);rasters.set(key,old);hits++;return old;}
19
- misses++;const result=build(),size=result.rgba.byteLength;
20
- if(size<=MAX_BYTES){
21
- while(rasters.size&&(bytes+size>MAX_BYTES||rasters.size>=MAX_ENTRIES)){const first=rasters.keys().next().value;bytes-=rasters.get(first).rgba.byteLength;rasters.delete(first);}
22
- rasters.set(key,result);bytes+=size;
23
- }
24
- return result;
25
- },
26
- stats:()=>({bytes,entries:rasters.size,hits,misses,epoch,maxBytes:MAX_BYTES}),
27
- invalidate
28
- };
29
- // Font files may finish after an engine first used the CSS fallback font.
30
- document.fonts?.addEventListener('loadingdone',invalidate);
31
- document.fonts?.addEventListener('loadingerror',invalidate);
32
- documents.set(document,cache);return cache;
33
- }
1
+ // Document-scoped, bounded source pixels only. Geometry, tint and motion remain
2
+ // attachment-owned. Live glyph records can outlive an LRU entry safely.
3
+ const documents=new WeakMap();
4
+ const MAX_BYTES=16*1024*1024,MAX_ENTRIES=128,MAX_FONTS=64;
5
+ export function domRasterCache(document){
6
+ let cache=documents.get(document);if(cache)return cache;
7
+ const rasters=new Map(),fonts=new Map();let bytes=0,hits=0,misses=0,epoch=0;
8
+ function invalidate(){epoch++;rasters.clear();fonts.clear();bytes=0;}
9
+ cache={
10
+ get epoch(){return epoch;},
11
+ font(key,load){
12
+ let promise=fonts.get(key);
13
+ if(!promise){promise=Promise.resolve().then(load);fonts.set(key,promise);if(fonts.size>MAX_FONTS)fonts.delete(fonts.keys().next().value);promise.catch(()=>{if(fonts.get(key)===promise)fonts.delete(key);});}
14
+ return promise;
15
+ },
16
+ raster(key,build){
17
+ const old=rasters.get(key);
18
+ if(old){rasters.delete(key);rasters.set(key,old);hits++;return old;}
19
+ misses++;const result=build(),size=result.rgba.byteLength;
20
+ if(size<=MAX_BYTES){
21
+ while(rasters.size&&(bytes+size>MAX_BYTES||rasters.size>=MAX_ENTRIES)){const first=rasters.keys().next().value;bytes-=rasters.get(first).rgba.byteLength;rasters.delete(first);}
22
+ rasters.set(key,result);bytes+=size;
23
+ }
24
+ return result;
25
+ },
26
+ stats:()=>({bytes,entries:rasters.size,hits,misses,epoch,maxBytes:MAX_BYTES}),
27
+ invalidate
28
+ };
29
+ // Font files may finish after an engine first used the CSS fallback font.
30
+ document.fonts?.addEventListener('loadingdone',invalidate);
31
+ document.fonts?.addEventListener('loadingerror',invalidate);
32
+ documents.set(document,cache);return cache;
33
+ }