@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/QUICKSTART.fa.md +99 -87
- package/README.md +6 -2
- package/build-report.json +36 -31
- package/docs/GUIDE.md +8 -2
- package/docs/RELEASE-NOTES.md +23 -0
- package/examples/evry-website.md +58 -0
- package/package.json +1 -1
- package/src/dom-attachment.d.ts +1 -1
- package/src/dom-free.d.ts +14 -14
- package/src/dom-free.js +61 -38
- package/src/dom-image-surface.js +150 -103
- package/src/dom-image-swap.js +5 -2
- package/src/dom-once.js +39 -34
- package/src/dom-reveal.js +83 -73
- package/src/dom-svg-surface.js +41 -39
- package/src/dom-text-paint-mask.js +24 -0
- package/src/dom-text-surface.js +63 -32
- package/src/image-surface.js +161 -157
- package/src/render-owner.js +17 -12
- package/src/text-motion-primitives.js +7 -0
- package/src/triangle-effect.js +2 -1
- package/src/viewport-render-owner.js +322 -316
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
package/src/dom-svg-surface.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
if(state.mode==='mesh'||state.reason)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
+
}
|
package/src/dom-text-surface.js
CHANGED
|
@@ -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
|
|
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(){
|
|
60
|
-
|
|
61
|
-
if(
|
|
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=
|
|
188
|
-
nativeAlpha=
|
|
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
|
-
|
|
258
|
-
|
|
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
|
}
|