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

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/src/dom-free.js CHANGED
@@ -1,42 +1,65 @@
1
- import {createDOMInstallation} from './dom-attachment.js';
2
- import {createAutoReveal} from './dom-auto-reveal.js';
3
-
4
- export const FREE_EFFECTS = Object.freeze({textEnter:'dust-wind', textExit:'smoke', imageEnter:'drifting-snow', imageExit:'melt'});
5
- export function createFree(THREE, root = document, options = {}) {
6
- const doc = root.nodeType === 9 ? root : root.ownerDocument;
7
- const owner = createDOMInstallation(THREE, doc, {presentation: options.presentation ?? 'auto',zIndex:options.zIndex??1,documentCanvas:options.documentCanvas??options.experimentalDocumentCanvas??true});
8
- let automatic, disposed=false;
9
- function attach(kind, element, config = {}) {
10
- if(disposed)throw Error('Free installation disposed');
11
- for(const key of Object.keys(config))if(!['revealOnView','presentation'].includes(key))throw TypeError(`Unsupported Free option: ${key}`);
12
- const effectKind=kind==='svg'?'image':kind;
13
- const surface=owner[kind==='text'?'attachText':kind==='svg'?'attachSVG':'attachImage'](element,{
14
- ...config,resting:'native',inputEffect:FREE_EFFECTS[effectKind+'Enter'],
15
- inputEffectOptions:{duration:2000,particleShape:'triangle',exitEffect:FREE_EFFECTS[effectKind+'Exit']}
16
- });
17
- return Object.freeze({ready:surface.ready,play:(phase='enter')=>{
1
+ import {createDOMInstallation} from './dom-attachment.js';
2
+ import {createAutoReveal} from './dom-auto-reveal.js';
3
+
4
+ export const FREE_EFFECTS = Object.freeze({textEnter:'dust-wind', textExit:'smoke', imageEnter:'drifting-snow', imageExit:'melt'});
5
+ export function createFree(THREE, root = document, options = {}) {
6
+ const doc = root.nodeType === 9 ? root : root.ownerDocument;
7
+ const owner = createDOMInstallation(THREE, doc, {presentation: options.presentation ?? 'auto',zIndex:options.zIndex??1,documentCanvas:options.documentCanvas??options.experimentalDocumentCanvas??true});
8
+ let automatic, disposed=false;const installationWaits=new Set();
9
+ function attach(kind, element, config = {}) {
10
+ if(disposed)throw Error('Free installation disposed');
11
+ for(const key of Object.keys(config))if(!['revealOnView','presentation'].includes(key))throw TypeError(`Unsupported Free option: ${key}`);
12
+ const effectKind=kind==='svg'?'image':kind;
13
+ const surface=owner[kind==='text'?'attachText':kind==='svg'?'attachSVG':'attachImage'](element,{
14
+ ...config,resting:'native',inputEffect:FREE_EFFECTS[effectKind+'Enter'],
15
+ inputEffectOptions:{duration:2000,particleShape:'triangle',exitEffect:FREE_EFFECTS[effectKind+'Exit']}
16
+ });
17
+ let currentWait=null,requestedPlay=false;
18
+ const whenFinished=()=>{
19
+ if(currentWait)return currentWait.promise;
20
+ const wait={};currentWait=wait;
21
+ wait.promise=new Promise(resolve=>{
22
+ let timeoutStarted=null,timer,done=false;
23
+ const finish=status=>{if(done)return;done=true;clearTimeout(timer);installationWaits.delete(wait.cancel);if(currentWait===wait)currentWait=null;resolve({status});};
24
+ wait.cancel=()=>finish('cancelled');installationWaits.add(wait.cancel);
25
+ const poll=()=>{const s=surface.stats();
26
+ if(s.disposed||disposed||!element.isConnected){finish('cancelled');return;}
27
+ 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;}
28
+ const completed=typeof s.completed==='boolean'?s.completed:s.mode==='native'&&['native resting presentation','hidden after exit'].includes(s.reason);
29
+ if((!s.reveal||s.reveal.count>0||s.reveal.manual)&&completed){finish('completed');return;}
30
+ // An untouched intersection reveal may legitimately wait minutes.
31
+ // Bound actual preparation/play, not the time before first visibility.
32
+ if(requestedPlay||s.timeline?.started!=null||s.preparing&&!s.suspended)timeoutStarted??=Date.now();
33
+ if(timeoutStarted!==null&&Date.now()-timeoutStarted>30000){finish('cancelled');return;}
34
+ const remaining=s.timeline?.finishAt-doc.defaultView.performance.now();
35
+ timer=setTimeout(poll,s.suspended?Math.min(1000,Math.max(100,remaining||1000)):16);
36
+ };timer=setTimeout(poll,16);
37
+ });return wait.promise;
38
+ };
39
+ return Object.freeze({whenFinished,ready:surface.ready,play:(phase='enter')=>{
18
40
  if(!['enter','exit'].includes(phase))throw TypeError('Invalid phase');
19
- surface.play(phase);
20
- },cancel:()=>surface.cancel(),refresh:()=>surface.refresh(),stats:()=>surface.stats(),destroy:()=>surface.destroy()});
21
- }
22
- // Automatic and manual surfaces lease the same installation.
23
- const autoOwner={attachText:(el,c)=>attach('text',el,{revealOnView:c.revealOnView}),attachImage:(el,c)=>attach('image',el,{revealOnView:c.revealOnView}),stats:()=>owner.stats()};
24
- try { if(options.auto!==false)automatic=createAutoReveal(THREE,root,options,autoOwner); }
25
- catch(error){owner.destroy();throw error;}
26
- return Object.freeze({attachText:(el,c)=>attach('text',el,c),attachImage:(el,c)=>attach('image',el,c),attachSVG:(el,c)=>attach('svg',el,c),
27
- swapImage(previous,next,config={}){
28
- if(disposed)throw Error('Free installation disposed');
29
- for(const key of Object.keys(config))if(!['exit','waitForExit','presentation'].includes(key))throw TypeError(`Unsupported Free swap option: ${key}`);
30
- for(const key of ['exit','waitForExit'])if(config[key]!==undefined&&typeof config[key]!=='boolean')throw TypeError(`Invalid ${key}`);
31
- return owner.swapImage(previous,next,{presentation:config.presentation??'global',waitForExit:config.waitForExit??false,
32
- exitEffect:config.exit===false?null:FREE_EFFECTS.imageExit,inputEffect:FREE_EFFECTS.imageEnter,
33
- inputEffectOptions:{duration:2000,particleShape:'triangle'}});
34
- },
35
- refresh(){if(disposed)throw Error('Free installation disposed');automatic?.refresh();owner.refresh();},
36
- stats:()=>({disposed,automatic:automatic?.stats()??null,renderer:owner.stats()}),
37
- destroy(){if(disposed)return;disposed=true;try{automatic?.destroy();}finally{owner.destroy();}}
38
- });
39
- }
41
+ surface.play(phase);requestedPlay=true;currentWait?.cancel();
42
+ },cancel:()=>{surface.cancel();requestedPlay=false;currentWait?.cancel();},refresh:()=>surface.refresh(),stats:()=>surface.stats(),destroy:()=>{currentWait?.cancel();surface.destroy();}});
43
+ }
44
+ // Automatic and manual surfaces lease the same installation.
45
+ const autoOwner={attachText:(el,c)=>attach('text',el,{revealOnView:c.revealOnView}),attachImage:(el,c)=>attach('image',el,{revealOnView:c.revealOnView}),stats:()=>owner.stats()};
46
+ try { if(options.auto!==false)automatic=createAutoReveal(THREE,root,options,autoOwner); }
47
+ catch(error){owner.destroy();throw error;}
48
+ return Object.freeze({attachText:(el,c)=>attach('text',el,c),attachImage:(el,c)=>attach('image',el,c),attachSVG:(el,c)=>attach('svg',el,c),
49
+ swapImage(previous,next,config={}){
50
+ if(disposed)throw Error('Free installation disposed');
51
+ for(const key of Object.keys(config))if(!['exit','enter','topImage','waitForExit','presentation'].includes(key))throw TypeError(`Unsupported Free swap option: ${key}`);
52
+ for(const key of ['exit','enter','waitForExit'])if(config[key]!==undefined&&typeof config[key]!=='boolean')throw TypeError(`Invalid ${key}`);
53
+ if(config.topImage!==undefined&&!['previous','next'].includes(config.topImage))throw TypeError('Invalid topImage');
54
+ return owner.swapImage(previous,next,{enterEffect:config.enter!==false,topImage:config.topImage??'next',presentation:config.presentation??'global',waitForExit:config.waitForExit??false,
55
+ exitEffect:config.exit===false?null:FREE_EFFECTS.imageExit,inputEffect:FREE_EFFECTS.imageEnter,
56
+ inputEffectOptions:{duration:2000,particleShape:'triangle'}});
57
+ },
58
+ refresh(){if(disposed)throw Error('Free installation disposed');automatic?.refresh();owner.refresh();},
59
+ stats:()=>({disposed,automatic:automatic?.stats()??null,renderer:owner.stats()}),
60
+ destroy(){if(disposed)return;disposed=true;for(const cancel of [...installationWaits])cancel();try{automatic?.destroy();}finally{owner.destroy();}}
61
+ });
62
+ }
40
63
 
41
64
 
42
65
 
@@ -1,106 +1,153 @@
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();}}
76
- }
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);
91
- }
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
- }
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,nativeRestOpacity=options.nativeRestOpacity??(originalOpacity===''?1:Number(originalOpacity)),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 effectStarted=null,queuedStarted=null,inViewport=true,exitHeld=false,nativeHidden=false;
22
+ let queuedPhase=options.initialPhase??null;
23
+ const ready=new Promise(r=>resolve=r),reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
24
+ function native(error,restore=false){
25
+ if(restore)exitHeld=false;
26
+ const hidden=!disposed&&!restore&&(exitHeld||surface?.stats().state==='hidden');
27
+ nativeHidden=hidden;mode='native';reason=error||null;element.style.opacity=hidden?'0':(options.nativeRestOpacity!==undefined?String(nativeRestOpacity):originalOpacity);canvas.style.display='none';options.onPresentation?.({mode,reason,hidden});renderer.invalidate?.();
28
+ }
29
+ function holdExit(){exitHeld=true;nativeHidden=true;element.style.opacity='0';options.onPresentation?.({mode,reason,hidden:true});}
30
+ function suspend(){
31
+ if(queuedPhase!==null&&queuedStarted===null)queuedStarted=window.performance.now();
32
+ if(queuedPhase==='exit'||queuedPhase===null&&phase==='exit'&&effectStarted!==null)holdExit();
33
+ if(frame!==null)window.cancelAnimationFrame(frame);frame=null;canvas.style.display='none';renderer.invalidate?.();
34
+ }
35
+ function request(){if(!disposed&&inViewport&&!document.hidden&&frame===null)frame=window.requestAnimationFrame(draw);}
36
+ const runtime={THREE,document,renderer,camera,requestRender:request,register(control){scene.add(control.object);return ()=>scene.remove(control.object);}};
37
+ function draw(now){frame=null;if(disposed||!surface||failed||queuedPhase!==null&&preparing)return;if(!inViewport||document.hidden){suspend();return;}try{
38
+ const box=element.getBoundingClientRect(),p=parent.getBoundingClientRect(),css=window.getComputedStyle(element);
39
+ if(!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});return;}
40
+ 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;}
41
+ const s=surface.stats();if(!s.source)return;
42
+ const w=box.width,h=box.height;
43
+ surface.object.scale.set(w/baseWidth,h/baseHeight,1);surface.setDisplaySize(w,h);
44
+ camera.left=-box.width/2;camera.right=box.width/2;camera.top=box.height/2;camera.bottom=-box.height/2;camera.updateProjectionMatrix();
45
+ 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'});
46
+ 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);
47
+ const state=surface.stats();
48
+ const handoff=options.resting==='native'&&phase==='enter'&&effectStarted!==null&&!reduced.matches;
49
+ const end=effectStarted===null?now:effectStarted+state.duration;
50
+ // Reveal complete native pixels only once assembly finishes.
51
+ const nativeAlpha=handoff&&now>=end?1:0;
52
+ const meshFade=handoff?Math.max(0,Math.min(1,(now-end)/250)):0;
53
+ surface.setPresentationOpacity(1-meshFade);
54
+ if(!state.active&&(options.resting==='native'||reduced.matches)&&(!handoff||meshFade>=1)){
55
+ // Submit before handing off: reveal gates acknowledge even reduced motion.
56
+ renderer.render(scene,camera);
57
+ native(state.state==='hidden'?'hidden after exit':'native resting presentation');
58
+ if(state.state==='hidden')element.style.opacity='0';
59
+ resolve({mode,reason});return;
60
+ }
61
+ const handingOffNative=mode==='native';
62
+ element.style.opacity=String(nativeAlpha*nativeRestOpacity);nativeHidden=nativeAlpha===0;mode='mesh';reason=null;renderer.render(scene,camera);
63
+ options.onPresentation?.({mode,reason,hidden:nativeHidden});
64
+ if(handingOffNative)renderer.flushPresentation?.();
65
+ resolve({mode,reason});if(handoff&&meshFade<1)request();
66
+ }catch(error){native(error.message,true);resolve({mode,reason});}}
67
+ function nearViewport(box){const margin=window.innerHeight*.5;return box.bottom>=-margin&&box.top<=window.innerHeight+margin&&box.right>=0&&box.left<=window.innerWidth;}
68
+ 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])};}
69
+ function schedule(delay=0){clearTimeout(prepareTimer);if(disposed||inFlight||!requested)return;preparing=true;prepareTimer=setTimeout(prepare,delay);}
70
+ async function prepare(){
71
+ prepareTimer=null;if(disposed||inFlight||!requested)return;
72
+ inFlight=true;preparing=true;const token=revision,current=requested;
73
+ try{
74
+ if(!element.complete||!element.naturalWidth)await element.decode();
75
+ if(disposed||token!==revision)return;
76
+ queueJob=queueImagePreparation(window,async()=>{
77
+ if(disposed||token!==revision)return false;
78
+ // Resize/decode may have changed the measured box without an observer
79
+ // callback yet. Do not allocate pixels for the stale dimensions.
80
+ const measured=measure();if(measured.key!==current.key){refresh();return false;}
81
+ const raster=await rasterizeDOMImage(element,{maxSide:Math.min(2048,renderer.capabilities?.maxTextureSize||2048)});
82
+ if(disposed||token!==revision)return false;
83
+ const next=surface??createImageSurface(runtime,{width:raster.width,height:raster.height,effect:options.inputEffect??'dust-wind',settings:options.inputEffectOptions??{}});
84
+ if(!surface)pendingSurface=next;
85
+ next.setDisplaySize(current.box.width,current.box.height);
86
+ // DOM source/paint changes replace pixels, not the caller's animation
87
+ // intent. Keep a departure hidden and preserve an active clock/seed
88
+ // across src, srcset and SVG snapshot replacements just as on resize.
89
+ await next.setSource(raster.source,{preserveMotion:!!surface,bounds:{width:raster.width,height:raster.height}});
90
+ if(disposed||token!==revision){if(next!==surface)next.destroy();return false;}
91
+ surface=next;pendingSurface=null;baseWidth=raster.width;baseHeight=raster.height;source=current.key;committedURL=current.url;requested=null;resizeStarted=0;return true;
92
+ });
93
+ await queueJob.promise;
94
+ }catch(error){pendingSurface?.destroy();pendingSurface=null;if(!disposed&&token===revision){requested=null;source='';resizeStarted=0;failed=true;native(error.message,true);resolve({mode,reason});}}
95
+ finally{queueJob=null;inFlight=false;preparing=false;if(!disposed){if(requested)schedule(surface?50:0);request();}}
96
+ }
97
+ function refresh(){
98
+ if(disposed)return;const current=measure(),{box,css}=current;
99
+ if(!element.isConnected||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'||!nearViewport(box)){
100
+ if(requested){revision++;requested=null;queueJob?.cancel();}clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;
101
+ if(!surface||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});}return;
102
+ }
103
+ if(current.key===source){if(requested){revision++;requested=null;queueJob?.cancel();clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;}if(surface)request();return;}
104
+ if(current.key===requested?.key)return;
105
+ requested=current;revision++;failed=false;queueJob?.cancel();
106
+ // Keep the current texture/motion during a resize burst. Rebuild the latest
107
+ // size after 50ms of quiet, with a 150ms bound for continuous transitions.
108
+ const resizing=surface&&committedURL===current.url;
109
+ if(resizing&&!resizeStarted)resizeStarted=performance.now();
110
+ schedule(resizing?Math.max(0,Math.min(50,150-(performance.now()-resizeStarted))):0);
111
+ }
112
+ const resize=new window.ResizeObserver(refresh);resize.observe(element);
113
+ const styleKey=()=>element.style.cssText.replace(/(?:^|;)\s*opacity\s*:[^;]*/g,'');let lastStyle=styleKey();
114
+ 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']});
115
+ 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);
116
+ const visibility=new window.IntersectionObserver(entries=>{
117
+ inViewport=entries.at(-1)?.isIntersecting!==false;
118
+ if(inViewport){refresh();request();}
119
+ else suspend();
120
+ });visibility.observe(element);
121
+ const onVisibility=()=>{if(document.hidden)suspend();else{refresh();request();}};
122
+ document.addEventListener('visibilitychange',onVisibility);
123
+ const onScroll=()=>{if(mode==='mesh')request();if(!proximity&&!surface)refresh();};
124
+ reduced.addEventListener('change',request);element.addEventListener('load',refresh);window.addEventListener('resize',refresh);window.addEventListener('scroll',onScroll,true);
125
+ 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();}}
126
+ refresh();
127
+ 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'){
128
+ if(disposed)throw Error('Surface disposed');
129
+ if(!['enter','exit'].includes(value))throw TypeError('Expected enter or exit');
130
+ phase=value;queuedPhase=value;exitHeld=false;
131
+ const box=element.getBoundingClientRect(),outside=box.bottom<=0||box.top>=window.innerHeight||box.right<=0||box.left>=window.innerWidth;
132
+ queuedStarted=document.hidden||!inViewport||outside?window.performance.now():null;
133
+ if(outside)inViewport=false;
134
+ if(document.hidden||!inViewport)suspend();
135
+ request();
136
+ },
137
+ update(next={}){const candidate={...options,...next};validate(candidate);options=candidate;surface?.setEffect(options.inputEffect??'dust-wind',options.inputEffectOptions??{});request();},
138
+ stats:()=>{
139
+ const image=surface?.stats(),started=queuedPhase!==null?queuedStarted:effectStarted,currentPhase=queuedPhase??phase;
140
+ const duration=image?.duration??options.inputEffectOptions?.duration??2000;
141
+ const finishAt=started===null?null:started+(reduced.matches?0:duration+(currentPhase==='enter'&&options.resting==='native'?250:0));
142
+ const suspended=document.hidden||!inViewport;
143
+ const unsupported=failed||reason==='Unsupported image layout';
144
+ const completed=!unsupported&&started!==null&&(suspended?window.performance.now()>=finishAt:
145
+ queuedPhase===null&&(currentPhase==='exit'?image?.state==='hidden':options.resting==='native'?mode==='native'&&reason==='native resting presentation':image?.state==='visible'&&!image.active));
146
+ 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};
147
+ },
148
+ 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'});}
149
+ };
150
+ }
104
151
 
105
152
 
106
153
 
@@ -8,7 +8,10 @@ export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=
8
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
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
10
  // Local layers preserve the native next image above a departing canvas too.
11
- const layerOptions=(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
11
+ // A departing previous image can use the shared canvas above the native
12
+ // next image. Forcing local here clips particles to the rounded host.
13
+ const departingOverNative=!enterEffect&&topImage==='previous';
14
+ const layerOptions=!departingOverNative&&(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
12
15
  const beginEntry=()=>{next.style.visibility='visible';if(enterEffect)jobs.push(animate(next,{...layerOptions,phase:'enter'}));};
13
16
  const earlyEntry=!enterEffect||(!waitForExit&&topImage==='previous');if(earlyEntry)beginEntry();
14
17
  if(exitEffect){
@@ -20,7 +23,7 @@ export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=
20
23
  if(success){previous.style.visibility='hidden';return {status:'completed'};}
21
24
  rollback();return {status:cancelled?'cancelled':'unsupported'};
22
25
  }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;}
26
+ 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);else next.remove();parent.style.position=parentPosition;}
24
27
  return {finished,cancel(){cancelled=true;jobs.forEach(j=>j.cancel());}};
25
28
  }
26
29
 
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
+ }