@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-reveal.js CHANGED
@@ -1,75 +1,85 @@
1
- // Presentation-only masking: SVG snapshots retain their original paint opacity.
2
- export const revealOpacity=new WeakMap();
3
- let sequence=0;
4
- export function withRevealOnView(factory,element,THREE,options){
5
- // Transfer the boot mask in this same task, before paint and opacity capture.
6
- element.removeAttribute('data-thd-pending');
7
- const {revealOnView:config,...settings}=options;
8
- if(config===undefined||config===false)return factory(element,THREE,settings);
9
- if(!config||typeof config!=='object'||Array.isArray(config))throw TypeError('revealOnView requires an options object');
10
- const {threshold=0,once=true,root=null}=config,document=element.ownerDocument,window=document.defaultView;
11
- if(Object.keys(config).some(k=>!['threshold','once','root'].includes(k))||!Number.isFinite(threshold)||threshold<0||threshold>1||typeof once!=='boolean'||root!==null&&(root.ownerDocument!==document||root.nodeType!==1))throw TypeError('Invalid revealOnView options');
12
- if(!window.IntersectionObserver)throw Error('IntersectionObserver unavailable');
13
- const attribute='data-thd-reveal',previous=element.getAttribute(attribute),id=String(++sequence);
14
- const style=document.createElement('style');style.textContent=`[${attribute}="${id}"]{opacity:0!important}`;
15
- const originalOpacity=window.getComputedStyle(element).opacity;revealOpacity.set(element,originalOpacity);
16
- element.setAttribute(attribute,id);document.head.append(style);
17
- let held=true,disposed=false,ready=false,eligible=false,armed=true,count=0,fallback=false;let retry=null,attempts=0;
18
- const empty=new THREE.Scene(),renderers=new Set();let revealPending=false;
19
- const namespace={...THREE,WebGLRenderer:class extends THREE.WebGLRenderer{
20
- constructor(...args){super(...args);renderers.add(this);}
21
- render(scene,camera){this.revealCamera=camera;if(revealPending&&eligible){revealPending=false;count++;unmask();if(once)observer?.disconnect();}super.render(held?empty:scene,camera);
22
- // Readiness is not permanently determined by an empty/unsupported first
23
- // layout. A later successful render can complete the pending first entry.
24
- if((count===0||fallback)&&armed&&eligible&&(!once||count===0))queueMicrotask(()=>{if(disposed||!armed||!eligible||once&&count!==0)return;fallback=false;ready=true;if(!held)mask();trigger();});
25
- }
26
- clearReveal(){if(this.revealCamera)super.render(empty,this.revealCamera);}
27
- }};
28
- let surface,observer,sizeObserver;
29
- function mask(){clearTimeout(retry);retry=null;held=true;revealPending=false;revealOpacity.set(element,originalOpacity);if(!style.isConnected)document.head.append(style);for(const renderer of renderers)renderer.clearReveal();}
30
- function unmask(){clearTimeout(retry);retry=null;held=false;style.remove();revealOpacity.delete(element);}
31
- function isFallback(state){return state.mode!=='mesh'&&!state.preparing&&!!state.reason&&!/native resting presentation|not visible|not connected|hidden after exit/i.test(state.reason);}
32
- function showFallback(){fallback=true;revealPending=false;armed=true;unmask();}
33
- function cleanup(){disposed=true;observer?.disconnect();sizeObserver?.disconnect();unmask();if(previous===null)element.removeAttribute(attribute);else element.setAttribute(attribute,previous);}
34
- // Only supervise an eligible reveal until its first rendered frame.
35
- // A cancelled preparation/visibility frame must not consume the request.
36
- function ensureFrame(){
37
- clearTimeout(retry);retry=null;
38
- if(disposed||!eligible||!held)return;
39
- if(isFallback(surface.stats())){showFallback();return;}
40
- if(++attempts>100){surface.destroy();return;}
41
- if(!surface.stats().preparing){surface.refresh();if(revealPending)requestEntry();}
42
- retry=setTimeout(ensureFrame,100);
43
- }
44
- function requestEntry(){
45
- const state=surface.stats(),timeline=state.timeline;
46
- const running=timeline?timeline.phase==='enter'&&timeline.ends>window.performance.now():state.image?.state==='entering'&&state.image?.active;
47
- // Re-entering view resumes an unfinished entry; do not reset its clock.
48
- if(running)surface.refresh();else surface.play('enter');
49
- }
50
- function trigger(){
51
- if(disposed||!ready||!eligible||!armed)return;
52
- if(isFallback(surface.stats())){showFallback();return;}
53
- armed=false;revealPending=true;requestEntry();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);
54
- }
55
- try{
56
- surface=factory(element,namespace,settings);
57
- observer=new window.IntersectionObserver(entries=>{
58
- for(const entry of entries){
59
- const visible=entry.isIntersecting&&entry.intersectionRect.width>0&&entry.intersectionRect.height>0;
60
- if(!visible){eligible=false;clearTimeout(retry);retry=null;if(!once||count===0){armed=true;mask();}continue;}
61
- eligible=entry.intersectionRatio>=threshold;if(eligible&&armed){surface.refresh();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);}trigger();
62
- }
63
- },{root,threshold:[...new Set([0,threshold===0?0.000001:threshold])]});observer.observe(element);
64
- // A zero-area intersection may already have ratio 1. Growing to a real
65
- // box need not cross an IO threshold, so request a fresh observation once.
66
- let hadArea=element.getBoundingClientRect().width>0&&element.getBoundingClientRect().height>0;
67
- sizeObserver=new window.ResizeObserver(()=>{const box=element.getBoundingClientRect(),hasArea=box.width>0&&box.height>0;if(hasArea&&!hadArea&&(!once||count===0)){observer.unobserve(element);observer.observe(element);}hadArea=hasArea;});sizeObserver.observe(element);
68
- }catch(error){cleanup();surface?.destroy();throw error;}
69
- const destroy=surface.destroy,stats=surface.stats;
70
- surface.stats=()=>({...stats(),reveal:{waiting:held,count,threshold,once}});
71
- surface.destroy=()=>{if(disposed)return;cleanup();destroy();};
72
- surface.ready.then(()=>{if(disposed)return;ready=true;const state=surface.stats();if(isFallback(state)){showFallback();return;}if(eligible)surface.refresh();trigger();},()=>{if(!disposed){unmask();observer.disconnect();}});
73
- return surface;
1
+ // Presentation-only masking: SVG snapshots retain their original paint opacity.
2
+ export const revealOpacity=new WeakMap();
3
+ let sequence=0;
4
+ export function withRevealOnView(factory,element,THREE,options){
5
+ // Transfer the boot mask in this same task, before paint and opacity capture.
6
+ element.removeAttribute('data-thd-pending');
7
+ const {revealOnView:config,...settings}=options;
8
+ if(config===undefined||config===false)return factory(element,THREE,settings);
9
+ if(!config||typeof config!=='object'||Array.isArray(config))throw TypeError('revealOnView requires an options object');
10
+ const {threshold=0,once=true,root=null}=config,document=element.ownerDocument,window=document.defaultView;
11
+ if(Object.keys(config).some(k=>!['threshold','once','root'].includes(k))||!Number.isFinite(threshold)||threshold<0||threshold>1||typeof once!=='boolean'||root!==null&&(root.ownerDocument!==document||root.nodeType!==1))throw TypeError('Invalid revealOnView options');
12
+ if(!window.IntersectionObserver)throw Error('IntersectionObserver unavailable');
13
+ const attribute='data-thd-reveal',previous=element.getAttribute(attribute),id=String(++sequence);
14
+ const style=document.createElement('style');style.textContent=`[${attribute}="${id}"]{opacity:0!important}`;
15
+ const originalOpacity=window.getComputedStyle(element).opacity;revealOpacity.set(element,originalOpacity);
16
+ element.setAttribute(attribute,id);document.head.append(style);
17
+ let held=true,disposed=false,ready=false,eligible=false,armed=true,count=0,fallback=false,manual=false;let basePlay;let retry=null,attempts=0;
18
+ const empty=new THREE.Scene(),renderers=new Set();let revealPending=false;
19
+ const namespace={...THREE,WebGLRenderer:class extends THREE.WebGLRenderer{
20
+ constructor(...args){super(...args);renderers.add(this);}
21
+ render(scene,camera){this.revealCamera=camera;if(revealPending&&eligible){revealPending=false;count++;unmask();if(once)observer?.disconnect();}super.render(held?empty:scene,camera);
22
+ // Readiness is not permanently determined by an empty/unsupported first
23
+ // layout. A later successful render can complete the pending first entry.
24
+ if((count===0||fallback)&&armed&&eligible&&(!once||count===0))queueMicrotask(()=>{if(disposed||!armed||!eligible||once&&count!==0)return;fallback=false;ready=true;if(!held)mask();trigger();});
25
+ }
26
+ clearReveal(){if(this.revealCamera)super.render(empty,this.revealCamera);}
27
+ }};
28
+ let surface,observer,sizeObserver;
29
+ function mask(){clearTimeout(retry);retry=null;held=true;revealPending=false;revealOpacity.set(element,originalOpacity);if(!style.isConnected)document.head.append(style);for(const renderer of renderers)renderer.clearReveal();}
30
+ function unmask(){clearTimeout(retry);retry=null;held=false;style.remove();revealOpacity.delete(element);}
31
+ function isFallback(state){return state.mode!=='mesh'&&!state.preparing&&!!state.reason&&!/native resting presentation|not visible|not connected|hidden after exit/i.test(state.reason);}
32
+ function showFallback(){fallback=true;revealPending=false;armed=true;unmask();}
33
+ function cleanup(){disposed=true;observer?.disconnect();sizeObserver?.disconnect();unmask();if(previous===null)element.removeAttribute(attribute);else element.setAttribute(attribute,previous);}
34
+ // Only supervise an eligible reveal until its first rendered frame.
35
+ // A cancelled preparation/visibility frame must not consume the request.
36
+ function ensureFrame(){
37
+ clearTimeout(retry);retry=null;
38
+ if(disposed||!eligible||!held)return;
39
+ if(isFallback(surface.stats())){showFallback();return;}
40
+ if(++attempts>100){surface.destroy();return;}
41
+ if(!surface.stats().preparing){surface.refresh();if(revealPending)requestEntry();}
42
+ retry=setTimeout(ensureFrame,100);
43
+ }
44
+ function requestEntry(){
45
+ const state=surface.stats(),timeline=state.timeline;
46
+ const running=timeline?timeline.phase==='enter'&&timeline.ends>window.performance.now():state.image?.state==='entering'&&state.image?.active;
47
+ // Re-entering view resumes an unfinished entry; do not reset its clock.
48
+ if(manual||running)surface.refresh();else basePlay('enter');
49
+ }
50
+ function trigger(){
51
+ if(disposed||!ready||!eligible||!armed)return;
52
+ if(isFallback(surface.stats())){showFallback();return;}
53
+ armed=false;revealPending=true;requestEntry();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);
54
+ }
55
+ try{
56
+ surface=factory(element,namespace,settings);basePlay=surface.play.bind(surface);
57
+ observer=new window.IntersectionObserver(entries=>{
58
+ for(const entry of entries){
59
+ const visible=entry.isIntersecting&&entry.intersectionRect.width>0&&entry.intersectionRect.height>0;
60
+ if(!visible){eligible=false;clearTimeout(retry);retry=null;if(!manual&&(!once||count===0)){armed=true;mask();}continue;}
61
+ eligible=entry.intersectionRatio>=threshold;if(eligible&&armed){surface.refresh();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);}trigger();
62
+ }
63
+ },{root,threshold:[...new Set([0,threshold===0?0.000001:threshold])]});observer.observe(element);
64
+ // A zero-area intersection may already have ratio 1. Growing to a real
65
+ // box need not cross an IO threshold, so request a fresh observation once.
66
+ let hadArea=element.getBoundingClientRect().width>0&&element.getBoundingClientRect().height>0;
67
+ sizeObserver=new window.ResizeObserver(()=>{const box=element.getBoundingClientRect(),hasArea=box.width>0&&box.height>0;if(hasArea&&!hadArea&&(!once||count===0)){observer.unobserve(element);observer.observe(element);}hadArea=hasArea;});sizeObserver.observe(element);
68
+ }catch(error){cleanup();surface?.destroy();throw error;}
69
+ const destroy=surface.destroy,stats=surface.stats;
70
+ surface.stats=()=>({...stats(),reveal:{waiting:held,count,threshold,once,manual}});
71
+ // Explicit play owns its clock even before the first intersection. Retain the
72
+ // initial paint mask, but never let automatic entry restart that request.
73
+ surface.play=(phase='enter')=>{
74
+ if(disposed)throw Error('Surface disposed');
75
+ if(!['enter','exit'].includes(phase))throw TypeError('Expected enter or exit');
76
+ manual=true;armed=false;clearTimeout(retry);retry=null;
77
+ revealPending=held;basePlay(phase);
78
+ };
79
+ const cancel=surface.cancel?.bind(surface);
80
+ surface.cancel=()=>{if(disposed)return;manual=true;armed=false;revealPending=false;unmask();cancel?.();};
81
+ surface.destroy=()=>{if(disposed)return;cleanup();destroy();};
82
+ surface.ready.then(()=>{if(disposed)return;ready=true;const state=surface.stats();if(isFallback(state)){showFallback();return;}if(eligible)surface.refresh();trigger();},()=>{if(!disposed){unmask();observer.disconnect();}});
83
+ return surface;
74
84
  }
75
85
 
@@ -1,42 +1,44 @@
1
- import {revealOpacity} from './dom-reveal.js';
2
- import {attachImageSurface} from './dom-image-surface.js';
3
- function snapshot(element){
4
- if(element.querySelector('script,foreignObject,use,image,text,animate,animateTransform,animateMotion'))throw Error('Unsupported SVG content');
5
- const view=element.ownerDocument.defaultView,clone=element.cloneNode(true),nodes=[element,...element.querySelectorAll('*')],copies=[clone,...clone.querySelectorAll('*')];
6
- for(let i=0;i<nodes.length;i++){
7
- const css=view.getComputedStyle(nodes[i]),copy=copies[i];
8
- if(css.filter!=='none'||css.clipPath!=='none'||css.maskImage!=='none'||css.transform!=='none'&&!nodes[i].hasAttribute('transform'))throw Error('Unsupported SVG geometry');
9
- copy.removeAttribute('class');copy.removeAttribute('style');
10
- for(const prop of ['fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','stroke-miterlimit','stroke-dasharray','stroke-dashoffset','fill-rule','fill-opacity','stroke-opacity','opacity','color','paint-order','vector-effect','display']){const value=prop==='opacity'&&i===0&&revealOpacity.has(element)?revealOpacity.get(element):css.getPropertyValue(prop);if(/url\(/i.test(value))throw Error('SVG paint references unsupported');if(value)copy.style.setProperty(prop,value);}
11
- }
12
- clone.querySelectorAll('style').forEach(e=>e.remove());clone.setAttribute('xmlns','http://www.w3.org/2000/svg');const r=element.getBoundingClientRect();clone.setAttribute('width',r.width);clone.setAttribute('height',r.height);return new view.XMLSerializer().serializeToString(clone);
13
- }
14
- export function attachSVGSurface(element,THREE,options={}){
15
- if(element?.localName!=='svg'||!element.parentElement)throw TypeError('A mounted SVG is required');
16
- const document=element.ownerDocument,view=document.defaultView,parent=element.parentElement,oldVisibility=element.style.visibility;
17
- const image=document.createElement('img');image.alt='';image.setAttribute('aria-hidden','true');Object.assign(image.style,{position:'absolute',pointerEvents:'none',opacity:'0'});parent.append(image);
18
- let disposed=false,revision=0,timer=null,reason=null,xml='',surface,resolve;const ready=new Promise(r=>resolve=r);
19
- function restore(){if(element.style.visibility==='hidden')element.style.visibility=oldVisibility;}
20
- function present(state){
21
- if(disposed)return;
22
- if(state.mode==='mesh'||state.reason==='hidden after exit'){if(element.style.visibility!=='hidden')element.style.visibility='hidden';}else restore();
23
- reason=state.reason;
24
- if(state.mode==='mesh'||state.reason)resolve(state);
25
- }
26
- async function refresh(){if(disposed)return;const token=++revision;clearTimeout(timer);
27
- try{const next=snapshot(element),r=element.getBoundingClientRect(),p=parent.getBoundingClientRect();if(!(r.width>0&&r.height>0))throw Error('SVG not visible');
28
- Object.assign(image.style,{left:(r.left-p.left-parent.clientLeft+parent.scrollLeft)+'px',top:(r.top-p.top-parent.clientTop+parent.scrollTop)+'px',width:r.width+'px',height:r.height+'px'});
29
- if(next!==xml){xml=next;restore();image.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(xml);}
30
- if(!surface)surface=attachImageSurface(image,THREE,{...options,onPresentation:present});
31
- await surface.refresh();if(disposed||token!==revision)return;
32
- const settle=()=>{if(disposed||token!==revision)return;const s=surface.stats();if(s.mode==='mesh'||s.reason)present(s);else timer=setTimeout(settle,16);};settle();
33
- }catch(error){reason=error.message;xml='';surface?.destroy();surface=null;restore();resolve({mode:'native',reason});}
34
- }
35
- const styleKey=()=>element.style.cssText.replace(/(?:^|;)\s*visibility\s*:[^;]*/g,'');let lastStyle=styleKey();
36
- const observer=new view.MutationObserver(records=>{const next=styleKey();if(records.some(r=>r.target!==element||r.attributeName!=='style')||next!==lastStyle){lastStyle=next;refresh();}});
37
- observer.observe(element,{subtree:true,attributes:true,childList:true,characterData:true});for(let a=parent;a;a=a.parentElement)observer.observe(a,{attributes:true,attributeFilter:['class','style']});
38
- const resize=new view.ResizeObserver(refresh);resize.observe(element);refresh();
39
- return {element,ready,refresh,cancel:()=>surface?.cancel(),play(phase='enter'){if(!['enter','exit'].includes(phase))throw TypeError('Expected enter or exit');if(phase==='enter'&&surface?.stats().image?.source)element.style.visibility='hidden';surface?.play(phase);},update(next){options={...options,...next};surface?.update(next);},stats:()=>({...surface?.stats(),disposed,reason:reason||surface?.stats().reason,mode:reason?'native':surface?.stats().mode||'native'}),destroy(){if(disposed)return;disposed=true;revision++;clearTimeout(timer);observer.disconnect();resize.disconnect();surface?.destroy();image.remove();restore();resolve({mode:'native',reason:'destroyed'});}};
1
+ import {revealOpacity} from './dom-reveal.js';
2
+ import {attachImageSurface} from './dom-image-surface.js';
3
+ function snapshot(element){
4
+ if(element.querySelector('script,foreignObject,use,image,text,animate,animateTransform,animateMotion'))throw Error('Unsupported SVG content');
5
+ const view=element.ownerDocument.defaultView,clone=element.cloneNode(true),nodes=[element,...element.querySelectorAll('*')],copies=[clone,...clone.querySelectorAll('*')];
6
+ for(let i=0;i<nodes.length;i++){
7
+ const css=view.getComputedStyle(nodes[i]),copy=copies[i];
8
+ if(css.filter!=='none'||css.clipPath!=='none'||css.maskImage!=='none'||css.transform!=='none'&&!nodes[i].hasAttribute('transform'))throw Error('Unsupported SVG geometry');
9
+ copy.removeAttribute('class');copy.removeAttribute('style');
10
+ for(const prop of ['fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','stroke-miterlimit','stroke-dasharray','stroke-dashoffset','fill-rule','fill-opacity','stroke-opacity','opacity','color','paint-order','vector-effect','display']){const value=prop==='opacity'&&i===0&&revealOpacity.has(element)?revealOpacity.get(element):css.getPropertyValue(prop);if(/url\(/i.test(value))throw Error('SVG paint references unsupported');if(value)copy.style.setProperty(prop,value);}
11
+ }
12
+ clone.querySelectorAll('style').forEach(e=>e.remove());clone.setAttribute('xmlns','http://www.w3.org/2000/svg');const r=element.getBoundingClientRect();clone.setAttribute('width',r.width);clone.setAttribute('height',r.height);return new view.XMLSerializer().serializeToString(clone);
13
+ }
14
+ export function attachSVGSurface(element,THREE,options={}){
15
+ if(element?.localName!=='svg'||!element.parentElement)throw TypeError('A mounted SVG is required');
16
+ const document=element.ownerDocument,view=document.defaultView,parent=element.parentElement,oldVisibility=element.style.visibility;
17
+ const image=document.createElement('img');image.alt='';image.setAttribute('aria-hidden','true');Object.assign(image.style,{position:'absolute',pointerEvents:'none',opacity:'0'});parent.append(image);
18
+ let disposed=false,revision=0,timer=null,reason=null,xml='',surface,resolve;const ready=new Promise(r=>resolve=r);
19
+ function restore(){if(element.style.visibility==='hidden')element.style.visibility=oldVisibility;}
20
+ function present(state){
21
+ if(disposed)return;
22
+ // The image adapter exposes native pixels before its settled mesh finishes
23
+ // fading. An explicit hidden:false must win over the remaining mesh mode.
24
+ if(state.hidden??(state.mode==='mesh'||state.reason==='hidden after exit')){if(element.style.visibility!=='hidden')element.style.visibility='hidden';}else restore();
25
+ reason=state.reason;
26
+ if(state.mode==='mesh'||state.reason)resolve(state);
27
+ }
28
+ async function refresh(){if(disposed)return;const token=++revision;clearTimeout(timer);
29
+ try{const next=snapshot(element),r=element.getBoundingClientRect(),p=parent.getBoundingClientRect();if(!(r.width>0&&r.height>0))throw Error('SVG not visible');
30
+ Object.assign(image.style,{left:(r.left-p.left-parent.clientLeft+parent.scrollLeft)+'px',top:(r.top-p.top-parent.clientTop+parent.scrollTop)+'px',width:r.width+'px',height:r.height+'px'});
31
+ if(next!==xml){xml=next;if(!surface)restore();image.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(xml);}
32
+ if(!surface)surface=attachImageSurface(image,THREE,{...options,onPresentation:present});
33
+ await surface.refresh();if(disposed||token!==revision)return;
34
+ const settle=()=>{if(disposed||token!==revision)return;const s=surface.stats();if(s.mode==='mesh'||s.reason)present(s);else timer=setTimeout(settle,16);};settle();
35
+ }catch(error){xml='';surface?.destroy();surface=null;restore();reason=error.message;resolve({mode:'native',reason});}
36
+ }
37
+ const styleKey=()=>element.style.cssText.replace(/(?:^|;)\s*visibility\s*:[^;]*/g,'');let lastStyle=styleKey();
38
+ const observer=new view.MutationObserver(records=>{const next=styleKey();if(records.some(r=>r.target!==element||r.attributeName!=='style')||next!==lastStyle){lastStyle=next;refresh();}});
39
+ observer.observe(element,{subtree:true,attributes:true,childList:true,characterData:true});for(let a=parent;a;a=a.parentElement)observer.observe(a,{attributes:true,attributeFilter:['class','style']});
40
+ const resize=new view.ResizeObserver(refresh);resize.observe(element);refresh();
41
+ return {element,ready,refresh,cancel:()=>surface?.cancel(),play(phase='enter'){if(!['enter','exit'].includes(phase))throw TypeError('Expected enter or exit');if(phase==='enter'&&surface?.stats().image?.source)element.style.visibility='hidden';surface?.play(phase);},update(next){options={...options,...next};surface?.update(next);},stats:()=>({...surface?.stats(),disposed,reason:reason||surface?.stats().reason,mode:reason?'native':surface?.stats().mode||'native'}),destroy(){if(disposed)return;disposed=true;revision++;clearTimeout(timer);observer.disconnect();resize.disconnect();surface?.destroy();image.remove();restore();resolve({mode:'native',reason:'destroyed'});}};
40
42
  }
41
43
 
42
44
 
@@ -0,0 +1,24 @@
1
+ // Hide native text without hiding the canvas or changing layout/raster colors.
2
+ // This temporary owner covers cold rich text before its measured runs exist.
3
+ export function createTextPaintMask(element,canvas){
4
+ const document=element.ownerDocument,window=document.defaultView,styles=new Map();
5
+ function hold(){
6
+ const parents=new Set(),walker=document.createTreeWalker(element,window.NodeFilter.SHOW_TEXT);
7
+ while(walker.nextNode())if(walker.currentNode.data.trim()&&!canvas?.contains(walker.currentNode))parents.add(walker.currentNode.parentElement);
8
+ for(const node of parents){
9
+ let values=styles.get(node);if(!values){values=new Map();styles.set(node,values);}
10
+ for(const [name,value] of [['-webkit-text-fill-color','transparent'],['text-shadow','none']]){
11
+ if(!values.has(name))values.set(name,[node.style.getPropertyValue(name),node.style.getPropertyPriority(name),value]);
12
+ if(node.style.getPropertyValue(name)!==value||node.style.getPropertyPriority(name)!=='important')node.style.setProperty(name,value,'important');
13
+ }
14
+ }
15
+ }
16
+ function release(){
17
+ for(const [node,values] of styles)for(const [name,[original,priority,owned]] of values){
18
+ if(node.style.getPropertyValue(name)!==owned||node.style.getPropertyPriority(name)!=='important')continue;
19
+ if(original)node.style.setProperty(name,original,priority);else node.style.removeProperty(name);
20
+ }
21
+ styles.clear();
22
+ }
23
+ return {hold,release};
24
+ }
@@ -7,7 +7,8 @@ import {textEditMotions} from './text-edit-motions.js';
7
7
  import {normalizeTextEffectOptions} from './text-effect-options.js';
8
8
  import {adoptDOMFont,domFontAtSize,matchesDOMFont} from './dom-surface-font.js';
9
9
  import {DOMRichText} from './dom-rich-text.js';
10
- import {createDOMTextFingerprint} from './dom-text-fingerprint.js';
10
+ import {createDOMTextFingerprint} from './dom-text-fingerprint.js';
11
+ import {createTextPaintMask} from './dom-text-paint-mask.js';
11
12
 
12
13
  // Presentation attachment: the original text node, parent control and semantics
13
14
  // remain owned by the host. The deliberately narrow first contract is a plain,
@@ -24,7 +25,8 @@ export function attachTextSurface(element,THREE,options={}){
24
25
  let enabled=options.enabled!==false,renderer,canvas,engine,view,effect,scene,content,camera;
25
26
  let frame=null,revision=0,preparing=false,dirty=true,layout=null,committed=null,reason=null,fatal=false,intersecting=true;
26
27
  let mode='native',draws=0,builds=0,submittedSize='',pendingPhase=options.initialPhase??null,phase='enter',lastSeed=null;
27
- let effectStarted=null,pendingStarted=null,settleStarted=null;
28
+ let effectStarted=null,pendingStarted=null,settleStarted=null;
29
+ let nativeMask;
28
30
  function preserveMotion(){if(pendingPhase===null&&effectStarted!==null&&window.performance.now()-effectStarted<settings.duration){pendingPhase=phase;pendingStarted=effectStarted;}}
29
31
  let resolveReady;const ready=new Promise(resolve=>{resolveReady=resolve;});
30
32
  let rich=null;
@@ -44,9 +46,12 @@ export function attachTextSurface(element,THREE,options={}){
44
46
  }
45
47
  ownedStyle.delete(name);originalStyle.delete(name);
46
48
  }
47
- function native(why=null){
49
+ function exitOwnsPaint(why=reason){return (pendingPhase??phase)==='exit'&&!fatal&&enabled&&!forced.matches&&(!why||/not visible|not connected|native resting presentation|hidden after exit/i.test(why));}
50
+ function native(why=null){
51
+ nativeMask?.release();
48
52
  rich?.restore();
49
- mode='native';reason=why;restoreStyle('-webkit-text-fill-color');restoreStyle('text-shadow');
53
+ mode='native';reason=why;restoreStyle('-webkit-text-fill-color');restoreStyle('text-shadow');
54
+ if(exitOwnsPaint(why))nativeMask?.hold();
50
55
  if(canvas)canvas.style.display='none';
51
56
  renderer?.invalidate?.();
52
57
  }
@@ -56,10 +61,27 @@ export function attachTextSurface(element,THREE,options={}){
56
61
  resolveReady({mode,reason});
57
62
  }
58
63
  function request(){if(!life.disposed&&!fatal&&!document.hidden&&intersecting&&frame===null)frame=window.requestAnimationFrame(render);}
59
- function suspend(){if(frame!==null)window.cancelAnimationFrame(frame);frame=null;if(renderer?.globalCanvas)native('not visible');renderer?.invalidate?.();}
60
- function refresh(){
61
- if(life.disposed)return;revision++;dirty=true;request();
62
- }
64
+ function suspend(){
65
+ if(pendingPhase!==null&&pendingStarted===null)pendingStarted=window.performance.now();
66
+ if(frame!==null)window.cancelAnimationFrame(frame);frame=null;
67
+ const hideExit=exitOwnsPaint();
68
+ // The last canvas frame and restored native text must not flash on reentry.
69
+ // A queued/current exit owns native paint even while no frames are drawn.
70
+ if(renderer?.globalCanvas||hideExit)native(reason??'not visible');
71
+ if(canvas)canvas.style.display='none';renderer?.invalidate?.();
72
+ }
73
+ function refresh(){
74
+ if(life.disposed)return;revision++;dirty=true;request();
75
+ }
76
+ function play(next='enter'){
77
+ if(life.disposed)throw Error('Text surface disposed');if(!['enter','exit'].includes(next))throw TypeError('Expected enter or exit');
78
+ pendingPhase=next;if(next==='enter')lastSeed=null;const box=element.getBoundingClientRect();
79
+ const outside=box.bottom<=0||box.top>=window.innerHeight||box.right<=0||box.left>=window.innerWidth;
80
+ pendingStarted=document.hidden||!intersecting||outside?window.performance.now():null;
81
+ // Do not spend a cold preparation frame waiting for IO's first delivery.
82
+ if(outside&&window.IntersectionObserver)intersecting=false;
83
+ if(document.hidden||!intersecting)suspend();refresh();
84
+ }
63
85
  function slot(){
64
86
  const children=[...element.childNodes].filter(node=>node!==canvas&&node.nodeType!==8);
65
87
  if(children.some(node=>node.nodeType!==3)||children.length>1)return null;
@@ -177,26 +199,32 @@ export function attachTextSurface(element,THREE,options={}){
177
199
  view.uniforms.tint.value.setStyle(window.getComputedStyle(element).color);
178
200
  beginEffect(now);const active=rich?rich.step(now,reduced.matches):effect.step(now,reduced.matches);
179
201
  if(!active&&phase==='exit')content.visible=false;
180
- let nativeAlpha=0,meshFade=0;
181
- if(active&&phase!=='exit'&&resting==='native'&&effectStarted!==null)
182
- nativeAlpha=Math.max(0,Math.min(1,(now-(effectStarted+settings.duration-175))/350));
183
- if(!active&&phase!=='exit'&&resting==='native'){
184
- // Use the effect clock, not the first idle frame. Slow frames must not
185
- // restart the handoff or jump native alpha back to its halfway point.
202
+ let nativeAlpha=0,meshFade=0;
203
+ if(active&&phase!=='exit'&&resting==='native'&&effectStarted!==null)
204
+ nativeAlpha=Math.max(0,Math.min(1,(now-(effectStarted+settings.duration-175))/350));
205
+ if(!active&&phase!=='exit'&&resting==='native'){
206
+ // Use the effect clock, not the first idle frame. Slow frames must not
207
+ // restart the handoff or jump native alpha back to its halfway point.
208
+ // Cancellation/settings refresh have no entry clock. Keep their
209
+ // restored native paint instead of beginning a second handoff.
210
+ const instant=effectStarted===null||effectName==='none'||reduced.matches;
186
211
  if(settleStarted===null)settleStarted=effectStarted===null?now:effectStarted+settings.duration;
187
- meshFade=reduced.matches?1:Math.min(1,(now-settleStarted)/350);
188
- nativeAlpha=reduced.matches?1:Math.min(1,(now-settleStarted+175)/350);
189
- if(meshFade>=1){renderer.render(scene,camera);native('native resting presentation');resolveReady({mode,reason});return;}
212
+ meshFade=instant?1:Math.min(1,(now-settleStarted)/350);
213
+ nativeAlpha=instant?1:Math.min(1,(now-settleStarted+175)/350);
214
+ if(meshFade>=1){renderer.render(scene,camera);native('native resting presentation');resolveReady({mode,reason});return;}
190
215
  }else settleStarted=null;
191
- view.uniforms.presentationOpacity.value=1-meshFade;rich?.setPresentationOpacity(1-meshFade);
192
- const handingOffNative=mode==='native';
193
- renderer.render(scene,camera);draws++;
216
+ view.uniforms.presentationOpacity.value=1-meshFade;rich?.setPresentationOpacity(1-meshFade);
217
+ const handingOffNative=mode==='native';
218
+ renderer.render(scene,camera);draws++;
219
+ // Transfer temporary offscreen ownership to the regular mesh paint mask
220
+ // in the same task, before the browser can paint native glyphs.
221
+ nativeMask?.release();
194
222
  canvas.style.display='block';if(!rich)ownStyle('-webkit-text-fill-color',nativeAlpha>0?`color-mix(in srgb, ${window.getComputedStyle(element).color} ${nativeAlpha*100}%, transparent)`:'transparent');ownStyle('text-shadow','none');mode='mesh';reason=null;
195
223
  rich?.hide(nativeAlpha);
196
224
  // Commit the shared canvas before the browser paints hidden native text.
197
225
  if(handingOffNative)renderer.flushPresentation?.();
198
226
  renderer.invalidate?.();
199
- resolveReady({mode,reason});if(active||meshFade<1&&settleStarted!==null)request();
227
+ resolveReady({mode,reason});if(active||meshFade<1&&settleStarted!==null)request();
200
228
  }catch(error){fail(error);}
201
229
  }
202
230
  life.own(()=>{if(frame!==null)window.cancelAnimationFrame(frame);frame=null;});
@@ -204,7 +232,8 @@ export function attachTextSurface(element,THREE,options={}){
204
232
  try{
205
233
  renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});life.own(()=>{renderer.dispose();renderer.forceContextLoss();});
206
234
  renderer.setSurface?.(element,{escapeHost:element.closest('button,a,[role="button"]')||element});
207
- canvas=renderer.domElement;canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-text-surface','');
235
+ canvas=renderer.domElement;canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-text-surface','');
236
+ nativeMask=createTextPaintMask(element,canvas);life.own(()=>nativeMask.release());
208
237
  fingerprint=createDOMTextFingerprint(element,canvas);
209
238
  Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none',margin:'0',padding:'0',border:'0',maxWidth:'none',maxHeight:'none'});
210
239
  element.append(canvas);life.own(()=>canvas.remove());
@@ -237,8 +266,8 @@ export function attachTextSurface(element,THREE,options={}){
237
266
  life.listen(window,'resize',refresh);
238
267
  for(const event of ['loadingdone','loadingerror'])life.listen(document.fonts,event,()=>{fontRevision++;refresh();});
239
268
  life.listen(forced,'change',()=>{native();refresh();});life.listen(reduced,'change',refresh);
240
- request();
241
- }catch(error){fail(error);}
269
+ if(pendingPhase!==null)play(pendingPhase);else request();
270
+ }catch(error){fail(error);}
242
271
  return {element,ready,
243
272
  refresh,
244
273
  update(next={}){
@@ -247,15 +276,17 @@ export function attachTextSurface(element,THREE,options={}){
247
276
  validResting(next.resting??resting);resting=next.resting??resting;
248
277
  const normalized=normalizeTextEffectOptions(next.inputEffectOptions?{formation:-1,...next.inputEffectOptions}:settings,candidate==='none'?'dust-wind':candidate);
249
278
  effectName=candidate;settings=normalized;if('enabled' in next)enabled=next.enabled!==false;
250
- effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();
251
- },
252
- cancel(){if(life.disposed)return;effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();},
253
- play(next='enter'){
254
- if(life.disposed)throw Error('Text surface disposed');if(!['enter','exit'].includes(next))throw TypeError('Expected enter or exit');
255
- pendingPhase=next;pendingStarted=null;refresh();
279
+ effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;native();refresh();
256
280
  },
257
- stats:()=>({timeline:{phase,started:effectStarted,ends:effectStarted===null?null:effectStarted+settings.duration},disposed:life.disposed,mode,reason,committed,preparing,draws,builds,pending:frame!==null,suspended:document.hidden||!intersecting,triangles:rich?rich.stats().triangles:view?.triangleCount??0,
258
- width:layout?.width??0,height:layout?.height??0,fontFamily:engine?.face?.family,displayFontSize:layout?.size,divisions:engine?.divisions,effect:effect?.stats(),rich:rich?.stats()??null}),
281
+ cancel(){if(life.disposed)return;effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;native();refresh();},
282
+ play,
283
+ stats:()=>{const started=pendingPhase!==null?pendingStarted:effectStarted,currentPhase=pendingPhase??phase;
284
+ const duration=reduced.matches||effectName==='none'?0:settings.duration;
285
+ const finishAt=started===null?null:started+(duration===0?0:duration+(currentPhase==='enter'&&resting==='native'?350:0));
286
+ const suspended=document.hidden||!intersecting;
287
+ const completed=started!==null&&(suspended?window.performance.now()>=finishAt:pendingPhase===null&&(currentPhase==='exit'?content?.visible===false:resting==='mesh'?mode==='mesh'&&!(rich?rich.stats().active:effect?.active):mode==='native'&&reason==='native resting presentation'));
288
+ return {completed,timeline:{phase:currentPhase,started,ends:started===null?null:started+duration,finishAt},disposed:life.disposed,mode,reason,committed,preparing,draws,builds,pending:frame!==null,suspended:document.hidden||!intersecting,triangles:rich?rich.stats().triangles:view?.triangleCount??0,
289
+ width:layout?.width??0,height:layout?.height??0,fontFamily:engine?.face?.family,displayFontSize:layout?.size,divisions:engine?.divisions,effect:effect?.stats(),rich:rich?.stats()??null};},
259
290
  destroy(){if(life.disposed)return;native('destroyed');resolveReady({mode,reason});life.destroy();}
260
291
  };
261
292
  }