@ev-ry/fx 0.1.0-rc.1
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/LICENSE +21 -0
- package/NOTICE.md +13 -0
- package/QUICKSTART.fa.md +94 -0
- package/README.md +116 -0
- package/assets/fonts/Estedad-OFL.txt +93 -0
- package/assets/vendor/bidi-LICENSE.txt +22 -0
- package/assets/vendor/bidi.min.js +1 -0
- package/assets/vendor/three-LICENSE.txt +21 -0
- package/assets/vendor/three.min.js +7 -0
- package/build-report.json +328 -0
- package/docs/GUIDE.md +125 -0
- package/docs/RELEASE-NOTES.md +40 -0
- package/examples/AnimatedTitle.jsx +21 -0
- package/examples/navigation-away.html +1 -0
- package/examples/navigation.html +38 -0
- package/examples/script.html +2 -0
- package/package.json +57 -0
- package/src/dom-attachment.d.ts +78 -0
- package/src/dom-attachment.js +187 -0
- package/src/dom-auto-reveal.d.ts +17 -0
- package/src/dom-auto-reveal.js +53 -0
- package/src/dom-auto-route.js +25 -0
- package/src/dom-free-bootstrap.js +42 -0
- package/src/dom-free-loader.d.ts +13 -0
- package/src/dom-free-loader.js +12 -0
- package/src/dom-free-script.js +12 -0
- package/src/dom-free.d.ts +16 -0
- package/src/dom-free.js +42 -0
- package/src/dom-image-raster.js +22 -0
- package/src/dom-image-surface.js +109 -0
- package/src/dom-image-swap.js +27 -0
- package/src/dom-once.js +34 -0
- package/src/dom-raster-cache.js +33 -0
- package/src/dom-reveal-boot.js +11 -0
- package/src/dom-reveal.js +75 -0
- package/src/dom-rich-text.js +119 -0
- package/src/dom-surface-font.js +50 -0
- package/src/dom-svg-surface.js +43 -0
- package/src/dom-text-fingerprint.js +27 -0
- package/src/dom-text-runs.js +45 -0
- package/src/dom-text-surface.js +254 -0
- package/src/font-mesh-engine.js +368 -0
- package/src/font-rasterizer.js +65 -0
- package/src/hybrid-text-flow.js +45 -0
- package/src/image-preparation-queue.js +16 -0
- package/src/image-source.js +61 -0
- package/src/image-surface.js +162 -0
- package/src/insertion-range.js +29 -0
- package/src/mesh-generator-core.js +149 -0
- package/src/motion-envelope.js +22 -0
- package/src/motion.js +52 -0
- package/src/native-run-shaping.js +60 -0
- package/src/particle-centers.js +50 -0
- package/src/raster-texture-material.js +79 -0
- package/src/raster-texture-mesh.js +35 -0
- package/src/render-owner.js +63 -0
- package/src/runtime-font-engine.js +242 -0
- package/src/runtime-lifecycle.js +26 -0
- package/src/text-direction.js +25 -0
- package/src/text-edit-effect.js +101 -0
- package/src/text-edit-motions.js +15 -0
- package/src/text-effect-options.js +29 -0
- package/src/text-effect-path.js +61 -0
- package/src/text-mesh-density.js +36 -0
- package/src/text-motion-character-centers.js +74 -0
- package/src/text-motion-contour.js +22 -0
- package/src/text-motion-primitives.js +91 -0
- package/src/text-motion-programs.js +1 -0
- package/src/text-motion-recipes.js +71 -0
- package/src/text-scene.js +272 -0
- package/src/triangle-coverage.js +25 -0
- package/src/triangle-effect.js +194 -0
- package/src/triangle-motion-frame.js +38 -0
- package/src/viewport-clip.js +119 -0
- package/src/viewport-render-owner.js +318 -0
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Place the next native image over the previous image; animate transiently.
|
|
2
|
+
export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=false,enterEffect=true,topImage='next',...options}={}){
|
|
3
|
+
if(!['previous','next'].includes(topImage))throw TypeError('Expected previous or next topImage');
|
|
4
|
+
if(previous===next||previous?.tagName!=='IMG'||next?.tagName!=='IMG'||!previous.parentElement)throw TypeError('Two different IMG elements are required');
|
|
5
|
+
const previousVisibility=previous.style.visibility,previousPosition=previous.style.position,previousZ=previous.style.zIndex;let cancelled=false,jobs=[];const parent=previous.parentElement,origin=next.parentNode,sibling=next.nextSibling,css=next.style.cssText,parentPosition=parent.style.position;
|
|
6
|
+
const finished=(async()=>{try{await Promise.all([previous.decode(),next.decode()]);if(cancelled)return {status:'cancelled'};
|
|
7
|
+
const view=previous.ownerDocument.defaultView,box=previous.getBoundingClientRect();if(view.getComputedStyle(parent).position==='static')parent.style.position='relative';const bounds=parent.getBoundingClientRect();
|
|
8
|
+
parent.append(next);Object.assign(next.style,{position:'absolute',left:(box.left-bounds.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-bounds.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',margin:'0',visibility:'hidden'});
|
|
9
|
+
if(view.getComputedStyle(previous).position==='static')previous.style.position='relative';previous.style.zIndex=topImage==='previous'?'1':'0';next.style.zIndex=topImage==='next'?'1':'0';
|
|
10
|
+
// Local layers preserve the native next image above a departing canvas too.
|
|
11
|
+
const layerOptions=(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
|
|
12
|
+
const beginEntry=()=>{next.style.visibility='visible';if(enterEffect)jobs.push(animate(next,{...layerOptions,phase:'enter'}));};
|
|
13
|
+
const earlyEntry=!enterEffect||(!waitForExit&&topImage==='previous');if(earlyEntry)beginEntry();
|
|
14
|
+
if(exitEffect){
|
|
15
|
+
const exit=animate(previous,{...layerOptions,inputEffect:exitEffect,phase:'exit'});jobs.push(exit);
|
|
16
|
+
if(waitForExit){const result=await exit.finished;if(cancelled||result.status!=='completed'){rollback();return {status:cancelled?'cancelled':result.status};}}
|
|
17
|
+
}
|
|
18
|
+
if(!earlyEntry)beginEntry();
|
|
19
|
+
const results=await Promise.all(jobs.map(job=>job.finished));const success=!cancelled&&results.every(r=>r.status==='completed');
|
|
20
|
+
if(success){previous.style.visibility='hidden';return {status:'completed'};}
|
|
21
|
+
rollback();return {status:cancelled?'cancelled':'unsupported'};
|
|
22
|
+
}catch(error){jobs.forEach(j=>j.cancel());rollback();throw error;}})();
|
|
23
|
+
function rollback(){previous.style.position=previousPosition;previous.style.zIndex=previousZ;previous.style.visibility=previousVisibility;next.style.cssText=css;if(origin)origin.insertBefore(next,sibling?.parentNode===origin?sibling:null);parent.style.position=parentPosition;}
|
|
24
|
+
return {finished,cancel(){cancelled=true;jobs.forEach(j=>j.cancel());}};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
package/src/dom-once.js
ADDED
|
@@ -0,0 +1,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 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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Document-scoped, bounded source pixels only. Geometry, tint and motion remain
|
|
2
|
+
// attachment-owned. Live glyph records can outlive an LRU entry safely.
|
|
3
|
+
const documents=new WeakMap();
|
|
4
|
+
const MAX_BYTES=16*1024*1024,MAX_ENTRIES=128,MAX_FONTS=64;
|
|
5
|
+
export function domRasterCache(document){
|
|
6
|
+
let cache=documents.get(document);if(cache)return cache;
|
|
7
|
+
const rasters=new Map(),fonts=new Map();let bytes=0,hits=0,misses=0,epoch=0;
|
|
8
|
+
function invalidate(){epoch++;rasters.clear();fonts.clear();bytes=0;}
|
|
9
|
+
cache={
|
|
10
|
+
get epoch(){return epoch;},
|
|
11
|
+
font(key,load){
|
|
12
|
+
let promise=fonts.get(key);
|
|
13
|
+
if(!promise){promise=Promise.resolve().then(load);fonts.set(key,promise);if(fonts.size>MAX_FONTS)fonts.delete(fonts.keys().next().value);promise.catch(()=>{if(fonts.get(key)===promise)fonts.delete(key);});}
|
|
14
|
+
return promise;
|
|
15
|
+
},
|
|
16
|
+
raster(key,build){
|
|
17
|
+
const old=rasters.get(key);
|
|
18
|
+
if(old){rasters.delete(key);rasters.set(key,old);hits++;return old;}
|
|
19
|
+
misses++;const result=build(),size=result.rgba.byteLength;
|
|
20
|
+
if(size<=MAX_BYTES){
|
|
21
|
+
while(rasters.size&&(bytes+size>MAX_BYTES||rasters.size>=MAX_ENTRIES)){const first=rasters.keys().next().value;bytes-=rasters.get(first).rgba.byteLength;rasters.delete(first);}
|
|
22
|
+
rasters.set(key,result);bytes+=size;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
},
|
|
26
|
+
stats:()=>({bytes,entries:rasters.size,hits,misses,epoch,maxBytes:MAX_BYTES}),
|
|
27
|
+
invalidate
|
|
28
|
+
};
|
|
29
|
+
// Font files may finish after an engine first used the CSS fallback font.
|
|
30
|
+
document.fonts?.addEventListener('loadingdone',invalidate);
|
|
31
|
+
document.fonts?.addEventListener('loadingerror',invalidate);
|
|
32
|
+
documents.set(document,cache);return cache;
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Load synchronously in <head>, before body paint. No JavaScript = native content.
|
|
2
|
+
(function(){
|
|
3
|
+
const style=document.createElement('style');
|
|
4
|
+
style.textContent='[data-thd-pending]{opacity:0!important}';
|
|
5
|
+
document.head.append(style);
|
|
6
|
+
function release(){style.remove();}
|
|
7
|
+
// Fail open even when the main module never loads or a target is unsupported.
|
|
8
|
+
const timer=setTimeout(release,8000);
|
|
9
|
+
window.addEventListener('thd:error',()=>{clearTimeout(timer);release();},{once:true});
|
|
10
|
+
window.addEventListener('pagehide',()=>{clearTimeout(timer);release();},{once:true});
|
|
11
|
+
})();
|
|
@@ -0,0 +1,75 @@
|
|
|
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;
|
|
74
|
+
}
|
|
75
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {HybridTextFlowEngine} from './hybrid-text-flow.js';
|
|
2
|
+
import {SharedTextScene} from './text-scene.js';
|
|
3
|
+
import {TextEditEffect} from './text-edit-effect.js';
|
|
4
|
+
import {adoptDOMFont,domFontAtSize} from './dom-surface-font.js';
|
|
5
|
+
import {groupDOMTextNodes,setRunRange,prepareFontBoundaries} from './dom-text-runs.js';
|
|
6
|
+
|
|
7
|
+
// One scene, independent styled text runs. DOM Range supplies line placement;
|
|
8
|
+
// the browser remains responsible for wrapping, links and label semantics.
|
|
9
|
+
export class DOMRichText {
|
|
10
|
+
constructor(element,THREE,content,canvas,options){Object.assign(this,{element,THREE,content,canvas,options});this.runs=new Map();this.styles=new Map();this.disposed=false;}
|
|
11
|
+
restore(){for(const [node,values] of this.styles)for(const [name,[original,priority,owned]] of values)if(node.style.getPropertyValue(name)===owned){if(original)node.style.setProperty(name,original,priority);else node.style.removeProperty(name);}this.styles.clear();}
|
|
12
|
+
hide(){for(const run of this.runs.values())for(const text of run.nodes)for(const [name,value] of [['-webkit-text-fill-color','transparent'],['text-shadow','none']]){
|
|
13
|
+
const node=text.parentElement;let values=this.styles.get(node);if(!values){values=new Map();this.styles.set(node,values);}
|
|
14
|
+
if(!values.has(name))values.set(name,[node.style.getPropertyValue(name),node.style.getPropertyPriority(name),value]);
|
|
15
|
+
if(node.style.getPropertyValue(name)!==value)node.style.setProperty(name,value);
|
|
16
|
+
}}
|
|
17
|
+
cancel(){for(const r of this.runs.values())r.effect.cancel();}
|
|
18
|
+
drop(run){run.effect.dispose();run.view.dispose();run.engine.dispose();run.group.removeFromParent();}
|
|
19
|
+
dispose(){this.disposed=true;this.restore();for(const r of this.runs.values())this.drop(r);this.runs.clear();}
|
|
20
|
+
async prepare(cancelled,onRebuild=()=>{}){
|
|
21
|
+
const {element,canvas,THREE}=this,document=element.ownerDocument,window=document.defaultView;
|
|
22
|
+
if(element.isContentEditable||element.matches('input,textarea,select,img,svg'))return {reason:'Rich attachment is for display text only'};
|
|
23
|
+
const raw=[],walker=document.createTreeWalker(element,window.NodeFilter.SHOW_TEXT);
|
|
24
|
+
while(walker.nextNode()){const node=walker.currentNode;if(!canvas.contains(node)&&node.data.trim())raw.push(node);}
|
|
25
|
+
const nodes=groupDOMTextNodes(raw,document);
|
|
26
|
+
if([...element.querySelectorAll('input,textarea,select,img,svg,canvas,[contenteditable="true"]')].some(node=>node!==canvas&&!canvas.contains(node)))return {reason:'Rich text currently accepts text elements only'};
|
|
27
|
+
// Shaping across styled-node boundaries needs a shared shaping context.
|
|
28
|
+
if(!prepareFontBoundaries(nodes,document))return {reason:'Unsupported joining boundary uses native shaping'};
|
|
29
|
+
const box=element.getBoundingClientRect(),width=element.clientWidth||box.width,height=element.clientHeight||box.height;
|
|
30
|
+
if(!(width>0&&height>0))return {reason:'Text is not visible'};
|
|
31
|
+
const wanted=new Set(nodes.map(n=>n.first));for(const [node,run] of this.runs)if(!wanted.has(node)){this.restore();this.drop(run);this.runs.delete(node);}
|
|
32
|
+
const segmenter=new Intl.Segmenter(undefined,{granularity:'grapheme'});
|
|
33
|
+
for(const node of nodes){
|
|
34
|
+
if(cancelled()||this.disposed)return null;
|
|
35
|
+
const parent=node.parentElement,style=window.getComputedStyle(parent),size=parseFloat(style.fontSize);
|
|
36
|
+
const collapseWhitespace=['normal','nowrap'].includes(style.whiteSpace);
|
|
37
|
+
if(!(size>0)||style.writingMode!=='horizontal-tb'||style.textTransform!=='none'||style.textAlign==='justify'||node.data.includes('\t')&&!collapseWhitespace)return {reason:'Unsupported rich typography uses native text'};
|
|
38
|
+
for(let ancestor=parent;ancestor;ancestor=ancestor.parentElement){
|
|
39
|
+
const css=window.getComputedStyle(ancestor);
|
|
40
|
+
for(const pseudo of ['::before','::after']){const p=window.getComputedStyle(ancestor,pseudo);if(p.display!=='none'&&!['none','normal','""',"''"].includes(p.content))return {reason:'Generated content uses native text'};}
|
|
41
|
+
if(ancestor!==element&&(css.transform!=='none'||css.opacity!=='1'||css.overflowX!=='visible'||css.overflowY!=='visible'))return {reason:'Nested transformed, faded or clipped text uses native text'};
|
|
42
|
+
if(ancestor===element)break;
|
|
43
|
+
}
|
|
44
|
+
let run=this.runs.get(node.first);
|
|
45
|
+
if(!run){const group=new THREE.Group();this.content.add(group);const engine=new HybridTextFlowEngine({scale:.036,divisions:this.options.divisions??'auto',textRendering:'texture'});
|
|
46
|
+
run={node:node.first,group,engine,view:new SharedTextScene(THREE,engine,group),effect:new TextEditEffect(THREE,{mode:'dust-wind'}),key:null};this.runs.set(node.first,run);}
|
|
47
|
+
run.nodes=node.nodes;
|
|
48
|
+
const fontChanged=await adoptDOMFont(run.engine,parent);
|
|
49
|
+
// A newly loaded font can change glyph pixels without changing CSS names,
|
|
50
|
+
// advances or line boxes. Rebind the run to the refreshed raster source.
|
|
51
|
+
if(fontChanged)run.key=null;
|
|
52
|
+
if(cancelled()||this.disposed)return null;
|
|
53
|
+
const factor=size/200,context=run.engine.rasterizer.context;context.font=domFontAtSize(run.engine,size);context.direction=style.direction;
|
|
54
|
+
const spacing=[style.letterSpacing,style.wordSpacing].map(value=>value==='normal'?0:parseFloat(value));
|
|
55
|
+
if(spacing.some(value=>!Number.isFinite(value))||spacing.some((value,i)=>value!==0&&!(['letterSpacing','wordSpacing'][i] in context)))return {reason:'Canvas spacing unavailable; native text retained'};
|
|
56
|
+
const spacingKey=JSON.stringify(spacing.map(value=>value/factor));
|
|
57
|
+
if(run.spacingKey!==spacingKey){run.engine.clearMeshes();run.engine.rasterizer.cache.clear();run.key=null;run.spacingKey=spacingKey;}
|
|
58
|
+
run.engine.rasterizer.letterSpacing=spacing[0]/factor;run.engine.rasterizer.wordSpacing=spacing[1]/factor;
|
|
59
|
+
if('letterSpacing' in context)context.letterSpacing=`${spacing[0]}px`;
|
|
60
|
+
if('wordSpacing' in context)context.wordSpacing=`${spacing[1]}px`;
|
|
61
|
+
const metrics=context.measureText('Hgآی'),rows=[],characters=[];let row=null;
|
|
62
|
+
if(!Number.isFinite(metrics.fontBoundingBoxAscent))return {reason:'Native font metrics unavailable'};
|
|
63
|
+
for(const part of segmenter.segment(node.data)){
|
|
64
|
+
const range=document.createRange();setRunRange(range,node,part.index,part.index+part.segment.length);
|
|
65
|
+
const rect=range.getBoundingClientRect();if(!rect.width||!rect.height)continue;
|
|
66
|
+
const top=rect.top-box.top-element.clientTop;
|
|
67
|
+
if(!row||Math.abs(row.top-top)>.75){row={start:part.index,end:part.index,top,left:Infinity,right:-Infinity,direction:style.direction,baseline:top+(rect.height+metrics.fontBoundingBoxAscent-metrics.fontBoundingBoxDescent)/2};rows.push(row);}
|
|
68
|
+
row.end=part.index+part.segment.length;row.left=Math.min(row.left,rect.left-box.left-element.clientLeft);row.right=Math.max(row.right,rect.right-box.left-element.clientLeft);
|
|
69
|
+
const unit=run.engine.scale/factor;
|
|
70
|
+
characters.push({x:(rect.left-box.left-element.clientLeft)*unit,y:-(row.baseline+metrics.fontBoundingBoxDescent)*unit,width:rect.width*unit,height:(metrics.fontBoundingBoxAscent+metrics.fontBoundingBoxDescent)*unit,direction:style.direction});
|
|
71
|
+
}
|
|
72
|
+
for(const r of rows){
|
|
73
|
+
// Preserve original offsets for DOM Range/color mapping; normalize only
|
|
74
|
+
// the raster string when CSS collapses ASCII whitespace. NBSP and
|
|
75
|
+
// preformatted spacing are deliberately untouched.
|
|
76
|
+
let text=node.data.slice(r.start,r.end);
|
|
77
|
+
if(collapseWhitespace)text=text.replace(/[ \t\r\n\f]+/g,' ');
|
|
78
|
+
r.text=(r.start===0&&node.joinStart?'\u200d':'')+text+(r.end===node.data.length&&node.joinEnd?'\u200d':'');
|
|
79
|
+
const expected=context.measureText(r.text).width;if(Math.abs(expected-(r.right-r.left))>Math.max(1,expected*.015))return {reason:'Rich run shaping differs from DOM; native text retained'};
|
|
80
|
+
}
|
|
81
|
+
run.engine.rasterizer.configure();const layout={rows,factor};
|
|
82
|
+
const paints=node.nodes.map(text=>({text,color:window.getComputedStyle(text.parentElement).color}));
|
|
83
|
+
const multicolor=new Set(paints.map(p=>p.color)).size>1;
|
|
84
|
+
const key=JSON.stringify([node.data,size,run.engine.domFont.key,rows,paints.map(p=>p.color)]);
|
|
85
|
+
if(key!==run.key){onRebuild();run.effect.cancel();run.engine.setDisplayFontSize(size);await run.engine.prepareRows(layout,{cancelled});if(cancelled()||this.disposed)return null;
|
|
86
|
+
if(multicolor)for(let i=0;i<rows.length;i++){
|
|
87
|
+
const row=rows[i],entry=run.engine.flowLayout.entries[i],item=entry.metrics.items[0],glyph=item.glyph;
|
|
88
|
+
const spans=[];let offset=0;
|
|
89
|
+
for(const paint of paints){const start=Math.max(row.start,offset),end=Math.min(row.end,offset+paint.text.length);offset+=paint.text.length;if(end<=start)continue;
|
|
90
|
+
const range=document.createRange();setRunRange(range,node,start,end);
|
|
91
|
+
const color=new THREE.Color().setStyle(paint.color).convertLinearToSRGB();
|
|
92
|
+
for(const rect of range.getClientRects())if(Math.abs(rect.top-box.top-element.clientTop-row.top)<.75)spans.push({left:rect.left-box.left-element.clientLeft,right:rect.right-box.left-element.clientLeft,color});
|
|
93
|
+
}
|
|
94
|
+
const source=glyph.rasterSurface,rgba=new Uint8ClampedArray(source.rgba);
|
|
95
|
+
for(let x=0;x<source.width;x++){
|
|
96
|
+
const px=row.left+(x+.5-glyph.drawOffsetX)*factor;
|
|
97
|
+
let nearest=null,distance=Infinity;for(const span of spans){const d=Math.max(span.left-px,px-span.right,0);if(d<distance){distance=d;nearest=span;}}
|
|
98
|
+
if(nearest)for(let y=0;y<source.height;y++){const p=(y*source.width+x)*4;rgba[p]=Math.round(nearest.color.r*255);rgba[p+1]=Math.round(nearest.color.g*255);rgba[p+2]=Math.round(nearest.color.b*255);}
|
|
99
|
+
}
|
|
100
|
+
// A presentation-owned copy preserves the engine's reusable alpha raster.
|
|
101
|
+
item.glyph={...glyph,rasterSurface:{...source,rgba,mask:false}};
|
|
102
|
+
}
|
|
103
|
+
run.view.setText(node.data);run.key=key;}
|
|
104
|
+
run.group.scale.setScalar(factor/run.engine.scale);run.view.uniforms.tint.value.setStyle(multicolor?'white':style.color);run.size=size;run.characters=characters;
|
|
105
|
+
}
|
|
106
|
+
return {width,height,size:parseFloat(window.getComputedStyle(element).fontSize),factor:.036,text:nodes.map(n=>n.data).join(''),direction:window.getComputedStyle(element).direction,characters:[]};
|
|
107
|
+
}
|
|
108
|
+
play(phase,mode,settings,seed,now){for(const run of this.runs.values()){
|
|
109
|
+
run.effect.cancel();if(mode==='none')continue;
|
|
110
|
+
run.effect.setMode(phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:mode);run.effect.configure(settings);
|
|
111
|
+
const b=run.view.bounds;run.effect.playRegion(run.view,{x:b.minX-.001,y:b.minY-.001,width:b.maxX-b.minX+.002,height:b.maxY-b.minY+.002,direction:run.engine.domFont?run.node.parentElement.ownerDocument.defaultView.getComputedStyle(run.node.parentElement).direction:'ltr'},200*run.engine.scale,now,{departing:phase==='exit',seed});
|
|
112
|
+
run.effect.prepareCharacterCenters(run.view,()=>run.characters);
|
|
113
|
+
}}
|
|
114
|
+
step(now,reduced){let active=false;for(const run of this.runs.values())active=run.effect.step(now,reduced)||active;return active;}
|
|
115
|
+
stats(){return {runs:this.runs.size,triangles:[...this.runs.values()].reduce((n,r)=>n+(r.view.triangleCount??0),0),active:[...this.runs.values()].some(r=>r.effect.active)};}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {FontRasterizer} from './font-rasterizer.js';
|
|
2
|
+
import {domRasterCache} from './dom-raster-cache.js';
|
|
3
|
+
|
|
4
|
+
const owners=new WeakMap();
|
|
5
|
+
export function readDOMFont(element){
|
|
6
|
+
const style=element.ownerDocument.defaultView.getComputedStyle(element);
|
|
7
|
+
const font={family:style.fontFamily,weight:Number(style.fontWeight)||400,style:style.fontStyle};
|
|
8
|
+
font.key=JSON.stringify([font.family,font.weight,font.style]);return font;
|
|
9
|
+
}
|
|
10
|
+
export function domFontAtSize(engine,size){
|
|
11
|
+
const font=engine.domFont;
|
|
12
|
+
return font?`${font.style} ${font.weight} ${size}px ${font.family}`:`${engine.weight} ${size}px "${engine.face.family}"`;
|
|
13
|
+
}
|
|
14
|
+
export function matchesDOMFont(engine,style){
|
|
15
|
+
return engine.domFont?engine.domFont.family===style.fontFamily&&engine.domFont.weight===(Number(style.fontWeight)||400)&&engine.domFont.style===style.fontStyle
|
|
16
|
+
:style.fontFamily.includes(engine.face.family)&&Number(style.fontWeight)===engine.weight;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Borrow the host's CSS font stack. Never register, replace or delete its FontFace.
|
|
20
|
+
export async function adoptDOMFont(engine,element){
|
|
21
|
+
let owner=owners.get(engine);
|
|
22
|
+
if(!owner){
|
|
23
|
+
owner={sequence:0,disposed:false};owners.set(engine,owner);
|
|
24
|
+
const dispose=engine.dispose.bind(engine);
|
|
25
|
+
engine.dispose=()=>{owner.disposed=true;owner.sequence++;if(engine.domFont)engine.face=null;dispose();};
|
|
26
|
+
}
|
|
27
|
+
const next=readDOMFont(element);
|
|
28
|
+
const document=element.ownerDocument,shared=domRasterCache(document);
|
|
29
|
+
if(owner.disposed||next.key===engine.domFont?.key&&owner.epoch===shared.epoch)return false;
|
|
30
|
+
const sequence=++owner.sequence,started=performance.now();
|
|
31
|
+
await shared.font(next.key,()=>document.fonts?.load(`${next.style} ${next.weight} 16px ${next.family}`,'ABCآبپچگژهمی'));
|
|
32
|
+
if(owner.disposed||sequence!==owner.sequence||readDOMFont(element).key!==next.key)return false;
|
|
33
|
+
const rasterizer=Object.create(FontRasterizer.prototype);
|
|
34
|
+
rasterizer.canvas=document.createElement('canvas');
|
|
35
|
+
rasterizer.context=rasterizer.canvas.getContext('2d',{willReadFrequently:true});
|
|
36
|
+
if(!rasterizer.context)throw Error('Canvas font rasterization unavailable');
|
|
37
|
+
rasterizer.font=`${next.style} ${next.weight} 200px ${next.family}`;rasterizer.cache=new Map();rasterizer.configure();
|
|
38
|
+
const metric=rasterizer.context.measureText('آبپچگژهمیABCgj');
|
|
39
|
+
rasterizer.ascent=Math.ceil(Math.max(metric.fontBoundingBoxAscent||0,metric.actualBoundingBoxAscent||0));
|
|
40
|
+
rasterizer.descent=Math.ceil(Math.max(metric.fontBoundingBoxDescent||0,metric.actualBoundingBoxDescent||0));
|
|
41
|
+
rasterizer.raster=function(text,direction='ltr'){
|
|
42
|
+
const key=JSON.stringify([this.font,this.ascent,this.descent,this.letterSpacing||0,this.wordSpacing||0,direction,text]);
|
|
43
|
+
return shared.raster(key,()=>{this.cache.clear();return FontRasterizer.prototype.raster.call(this,text,direction);});
|
|
44
|
+
};
|
|
45
|
+
owner.epoch=shared.epoch;
|
|
46
|
+
engine.domFont=next;engine.face={family:next.family};engine.weight=next.weight;engine.rasterizer=rasterizer;engine.clearMeshes();
|
|
47
|
+
const ascent=rasterizer.ascent+2,descent=rasterizer.descent+2;
|
|
48
|
+
engine.fontMetrics={ascent,descent,height:ascent+descent};engine.lineAdvance=engine.fontMetrics.height*Math.max(1,engine.lineHeight);
|
|
49
|
+
engine.fontLoadMs=performance.now()-started;return true;
|
|
50
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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'});}};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Validate native layout with whole-node line boxes before the expensive
|
|
2
|
+
// per-grapheme placement pass. Coordinates are relative to the slot, so page
|
|
3
|
+
// scrolling does not invalidate text geometry. Node identity matters: identical
|
|
4
|
+
// replacement markup still needs fresh run references.
|
|
5
|
+
const fields=['display','visibility','fontFamily','fontSize','fontWeight','fontStyle','fontStretch','fontFeatureSettings','fontVariationSettings','fontKerning','fontVariant','fontOpticalSizing','lineHeight','direction','unicodeBidi','whiteSpace','letterSpacing','wordSpacing','verticalAlign','textAlign','textIndent','textTransform','textDecorationLine','writingMode','textOrientation','wordBreak','overflowWrap','hyphens','tabSize','opacity','transform','overflowX','overflowY','color'];
|
|
6
|
+
export function createDOMTextFingerprint(element,canvas){
|
|
7
|
+
const document=element.ownerDocument,window=document.defaultView,ids=new WeakMap();let sequence=0;
|
|
8
|
+
const id=node=>{if(!ids.has(node))ids.set(node,++sequence);return ids.get(node);};
|
|
9
|
+
return ()=>{
|
|
10
|
+
const box=element.getBoundingClientRect(),left=box.left+element.clientLeft,top=box.top+element.clientTop;
|
|
11
|
+
const parts=[element.isConnected,element.clientWidth||box.width,element.clientHeight||box.height,element.clientLeft,element.clientTop];
|
|
12
|
+
function visit(node){
|
|
13
|
+
if(node===canvas)return;
|
|
14
|
+
if(node.nodeType===3){
|
|
15
|
+
const range=document.createRange();range.selectNodeContents(node);
|
|
16
|
+
parts.push(['text',id(node),node.data,[...range.getClientRects()].map(r=>[r.left-left,r.top-top,r.width,r.height])]);return;
|
|
17
|
+
}
|
|
18
|
+
if(node.nodeType!==1)return;
|
|
19
|
+
const style=window.getComputedStyle(node);
|
|
20
|
+
parts.push(['element',id(node),node.localName,node.isContentEditable,fields.map(name=>style[name])]);
|
|
21
|
+
for(const pseudo of ['::before','::after']){const css=window.getComputedStyle(node,pseudo);parts.push([css.display,css.content]);}
|
|
22
|
+
for(const child of node.childNodes)visit(child);
|
|
23
|
+
parts.push('end');
|
|
24
|
+
}
|
|
25
|
+
visit(element);return JSON.stringify(parts);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {JOINING_FORMS} from './font-mesh-engine.js';
|
|
2
|
+
// Color may differ: shape together, then paint the shared raster by native ranges.
|
|
3
|
+
// Keeping real Text nodes allows Range to remain the placement oracle.
|
|
4
|
+
export function groupDOMTextNodes(nodes,document){
|
|
5
|
+
const fields=['fontFamily','fontSize','fontWeight','fontStyle','fontStretch','fontFeatureSettings','fontVariationSettings','direction','unicodeBidi','whiteSpace','letterSpacing','wordSpacing','verticalAlign','opacity','transform','overflowX','overflowY','textDecorationLine'];
|
|
6
|
+
const groups=[];
|
|
7
|
+
for(const node of nodes){
|
|
8
|
+
const style=document.defaultView.getComputedStyle(node.parentElement),key=fields.map(k=>style[k]).join('|'),previous=groups.at(-1);
|
|
9
|
+
let adjacent=false;
|
|
10
|
+
if(previous&&previous.key===key){
|
|
11
|
+
const last=previous.nodes.at(-1),a=last.parentElement,b=node.parentElement;
|
|
12
|
+
const sameContainer=a===b||a.parentElement===b.parentElement&&style.display==='inline'&&document.defaultView.getComputedStyle(a).display==='inline';
|
|
13
|
+
if(sameContainer){const gap=document.createRange();gap.setStart(last,last.length);gap.setEnd(node,0);const fragment=gap.cloneContents();adjacent=!fragment.textContent&&!fragment.querySelector('br,hr,img,svg');}
|
|
14
|
+
}
|
|
15
|
+
if(adjacent){previous.nodes.push(node);previous.data+=node.data;}
|
|
16
|
+
else groups.push({first:node,parentElement:node.parentElement,nodes:[node],data:node.data,key});
|
|
17
|
+
}
|
|
18
|
+
return groups;
|
|
19
|
+
}
|
|
20
|
+
// Bounded Persian font and weight boundaries: preserve joining form without altering DOM.
|
|
21
|
+
// Ligatures spanning the boundary and unknown letters stay native.
|
|
22
|
+
export function prepareFontBoundaries(groups,document){
|
|
23
|
+
for(let i=1;i<groups.length;i++){
|
|
24
|
+
const a=groups[i-1],b=groups[i];
|
|
25
|
+
if(!/[\u0600-\u06ff]$/.test(a.data)||! /^[\u0600-\u06ff]/.test(b.data))continue;
|
|
26
|
+
const left=Array.from(a.data.replace(/\p{Mark}+$/u,'')).at(-1),right=Array.from(b.data)[0];
|
|
27
|
+
const x=document.defaultView.getComputedStyle(a.parentElement),y=document.defaultView.getComputedStyle(b.parentElement);
|
|
28
|
+
const ak=a.key.split('|'),bk=b.key.split('|');ak[0]=bk[0]=ak[2]=bk[2]='';
|
|
29
|
+
const range=document.createRange();range.setStart(a.nodes.at(-1),a.nodes.at(-1).length);range.setEnd(b.first,0);const gap=range.cloneContents();
|
|
30
|
+
if(ak.join('|')!==bk.join('|')||x.fontWeight===y.fontWeight&&x.fontFamily===y.fontFamily||x.display!=='inline'||y.display!=='inline'||a.parentElement.parentElement!==b.parentElement.parentElement||gap.textContent||gap.querySelector('br,hr,img,svg')||!JOINING_FORMS[left]||!JOINING_FORMS[right]||left==='ل'&&'اأإآ'.includes(right))return false;
|
|
31
|
+
if(JOINING_FORMS[left].length>2){a.joinEnd=true;b.joinStart=true;}
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
export function setRunRange(range,group,start,end){
|
|
36
|
+
let offset=0,startSet=false;
|
|
37
|
+
for(const node of group.nodes){const next=offset+node.length;
|
|
38
|
+
if(!startSet&&start<next){range.setStart(node,start-offset);startSet=true;}
|
|
39
|
+
if(startSet&&end<=next){range.setEnd(node,end-offset);return;}
|
|
40
|
+
offset=next;
|
|
41
|
+
}
|
|
42
|
+
throw RangeError('Text run range is outside its native nodes');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|