@ev-ry/fx 0.1.0-rc.1 → 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.
@@ -1,251 +1,295 @@
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 {createLifecycle} from './runtime-lifecycle.js';
5
- import {documentDirection} from './text-direction.js';
6
- import {textEditMotions} from './text-edit-motions.js';
7
- import {normalizeTextEffectOptions} from './text-effect-options.js';
8
- import {adoptDOMFont,domFontAtSize,matchesDOMFont} from './dom-surface-font.js';
9
- import {DOMRichText} from './dom-rich-text.js';
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 {createLifecycle} from './runtime-lifecycle.js';
5
+ import {documentDirection} from './text-direction.js';
6
+ import {textEditMotions} from './text-edit-motions.js';
7
+ import {normalizeTextEffectOptions} from './text-effect-options.js';
8
+ import {adoptDOMFont,domFontAtSize,matchesDOMFont} from './dom-surface-font.js';
9
+ import {DOMRichText} from './dom-rich-text.js';
10
10
  import {createDOMTextFingerprint} from './dom-text-fingerprint.js';
11
-
12
- // Presentation attachment: the original text node, parent control and semantics
13
- // remain owned by the host. The deliberately narrow first contract is a plain,
14
- // single-line text slot. Unsupported layout always retains its native paint.
15
- export function attachTextSurface(element,THREE,options={}){
16
- if(!element?.ownerDocument?.defaultView)throw TypeError('A DOM text slot is required');
17
- const document=element.ownerDocument,window=document.defaultView,life=createLifecycle();
18
- let effectName=options.inputEffect??'dust-wind';
19
- let resting=options.resting??'mesh';
20
- const validResting=value=>{if(!['mesh','native'].includes(value))throw TypeError('Expected mesh or native resting mode');};validResting(resting);
21
- const validMode=mode=>mode==='none'||!!textEditMotions[mode];
22
- if(!validMode(effectName))throw TypeError('Invalid inputEffect');
23
- let settings=normalizeTextEffectOptions({formation:-1,...options.inputEffectOptions},effectName==='none'?'dust-wind':effectName);
24
- let enabled=options.enabled!==false,renderer,canvas,engine,view,effect,scene,content,camera;
25
- let frame=null,revision=0,preparing=false,dirty=true,layout=null,committed=null,reason=null,fatal=false,intersecting=true;
26
- let mode='native',draws=0,builds=0,submittedSize='',pendingPhase=options.initialPhase??null,phase='enter',lastSeed=null;
27
- let effectStarted=null,pendingStarted=null;
28
- function preserveMotion(){if(pendingPhase===null&&effectStarted!==null&&window.performance.now()-effectStarted<settings.duration){pendingPhase=phase;pendingStarted=effectStarted;}}
29
- let resolveReady;const ready=new Promise(resolve=>{resolveReady=resolve;});
30
- let rich=null;
31
- let fingerprint=null,preparedFingerprint=null,fontRevision=0;
32
- const needsCharacters=()=>settings.bounceLines||[effectName,settings.exitEffect==='same'?effectName:settings.exitEffect].some(name=>textEditMotions[name]?.characterCenters||textEditMotions[name]?.characterFrame);
33
- const reduced=window.matchMedia('(prefers-reduced-motion: reduce)'),forced=window.matchMedia('(forced-colors: active)');
34
- const originalStyle=new Map(),ownedStyle=new Map();
35
- function ownStyle(name,value){
36
- if(!originalStyle.has(name))originalStyle.set(name,[element.style.getPropertyValue(name),element.style.getPropertyPriority(name)]);
37
- if(element.style.getPropertyValue(name)!==value)element.style.setProperty(name,value);
38
- ownedStyle.set(name,value);
39
- }
40
- function restoreStyle(name){
41
- if(!ownedStyle.has(name))return;
42
- if(element.style.getPropertyValue(name)===ownedStyle.get(name)){
43
- const [value,priority]=originalStyle.get(name);if(value)element.style.setProperty(name,value,priority);else element.style.removeProperty(name);
44
- }
45
- ownedStyle.delete(name);originalStyle.delete(name);
46
- }
11
+ import {createTextPaintMask} from './dom-text-paint-mask.js';
12
+
13
+ // Presentation attachment: the original text node, parent control and semantics
14
+ // remain owned by the host. The deliberately narrow first contract is a plain,
15
+ // single-line text slot. Unsupported layout always retains its native paint.
16
+ export function attachTextSurface(element,THREE,options={}){
17
+ if(!element?.ownerDocument?.defaultView)throw TypeError('A DOM text slot is required');
18
+ const document=element.ownerDocument,window=document.defaultView,life=createLifecycle();
19
+ let effectName=options.inputEffect??'dust-wind';
20
+ let resting=options.resting??'mesh';
21
+ const validResting=value=>{if(!['mesh','native'].includes(value))throw TypeError('Expected mesh or native resting mode');};validResting(resting);
22
+ const validMode=mode=>mode==='none'||!!textEditMotions[mode];
23
+ if(!validMode(effectName))throw TypeError('Invalid inputEffect');
24
+ let settings=normalizeTextEffectOptions({formation:-1,...options.inputEffectOptions},effectName==='none'?'dust-wind':effectName);
25
+ let enabled=options.enabled!==false,renderer,canvas,engine,view,effect,scene,content,camera;
26
+ let frame=null,revision=0,preparing=false,dirty=true,layout=null,committed=null,reason=null,fatal=false,intersecting=true;
27
+ let mode='native',draws=0,builds=0,submittedSize='',pendingPhase=options.initialPhase??null,phase='enter',lastSeed=null;
28
+ let effectStarted=null,pendingStarted=null,settleStarted=null;
29
+ let nativeMask;
30
+ function preserveMotion(){if(pendingPhase===null&&effectStarted!==null&&window.performance.now()-effectStarted<settings.duration){pendingPhase=phase;pendingStarted=effectStarted;}}
31
+ let resolveReady;const ready=new Promise(resolve=>{resolveReady=resolve;});
32
+ let rich=null;
33
+ let fingerprint=null,preparedFingerprint=null,fontRevision=0;
34
+ const needsCharacters=()=>settings.bounceLines||[effectName,settings.exitEffect==='same'?effectName:settings.exitEffect].some(name=>textEditMotions[name]?.characterCenters||textEditMotions[name]?.characterFrame);
35
+ const reduced=window.matchMedia('(prefers-reduced-motion: reduce)'),forced=window.matchMedia('(forced-colors: active)');
36
+ const originalStyle=new Map(),ownedStyle=new Map();
37
+ function ownStyle(name,value){
38
+ if(!originalStyle.has(name))originalStyle.set(name,[element.style.getPropertyValue(name),element.style.getPropertyPriority(name)]);
39
+ if(element.style.getPropertyValue(name)!==value)element.style.setProperty(name,value);
40
+ ownedStyle.set(name,value);
41
+ }
42
+ function restoreStyle(name){
43
+ if(!ownedStyle.has(name))return;
44
+ if(element.style.getPropertyValue(name)===ownedStyle.get(name)){
45
+ const [value,priority]=originalStyle.get(name);if(value)element.style.setProperty(name,value,priority);else element.style.removeProperty(name);
46
+ }
47
+ ownedStyle.delete(name);originalStyle.delete(name);
48
+ }
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));}
47
50
  function native(why=null){
48
- rich?.restore();
51
+ nativeMask?.release();
52
+ rich?.restore();
49
53
  mode='native';reason=why;restoreStyle('-webkit-text-fill-color');restoreStyle('text-shadow');
50
- if(canvas)canvas.style.display='none';
51
- renderer?.invalidate?.();
52
- }
53
- function fail(error){
54
- if(life.disposed)return;fatal=true;effect?.cancel();rich?.cancel();native(String(error?.message||error));
55
- if(frame!==null){window.cancelAnimationFrame(frame);frame=null;}
56
- resolveReady({mode,reason});
54
+ if(exitOwnsPaint(why))nativeMask?.hold();
55
+ if(canvas)canvas.style.display='none';
56
+ renderer?.invalidate?.();
57
+ }
58
+ function fail(error){
59
+ if(life.disposed)return;fatal=true;effect?.cancel();rich?.cancel();native(String(error?.message||error));
60
+ if(frame!==null){window.cancelAnimationFrame(frame);frame=null;}
61
+ resolveReady({mode,reason});
62
+ }
63
+ function request(){if(!life.disposed&&!fatal&&!document.hidden&&intersecting&&frame===null)frame=window.requestAnimationFrame(render);}
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?.();
57
72
  }
58
- function request(){if(!life.disposed&&!fatal&&!document.hidden&&intersecting&&frame===null)frame=window.requestAnimationFrame(render);}
59
- function suspend(){if(frame!==null)window.cancelAnimationFrame(frame);frame=null;if(renderer?.globalCanvas)native('not visible');renderer?.invalidate?.();}
60
73
  function refresh(){
61
74
  if(life.disposed)return;revision++;dirty=true;request();
62
75
  }
63
- function slot(){
64
- const children=[...element.childNodes].filter(node=>node!==canvas&&node.nodeType!==8);
65
- if(children.some(node=>node.nodeType!==3)||children.length>1)return null;
66
- return {node:children[0]??null,text:children[0]?.data??''};
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();
67
84
  }
68
- function measure(current,collectCharacters){
69
- const style=window.getComputedStyle(element),size=parseFloat(style.fontSize),box=element.getBoundingClientRect();
70
- const width=element.clientWidth||box.width,height=element.clientHeight||box.height;
71
- if(!element.isConnected||!(width>0&&height>0))return {reason:'Text slot is not visible'};
72
- if(!matchesDOMFont(engine,style)||!(size>0)||style.fontStyle!=='normal'||style.textTransform!=='none'||style.writingMode!=='horizontal-tb'
73
- ||!['normal','0px'].includes(style.letterSpacing)||!['normal','0px'].includes(style.wordSpacing)||style.textDecorationLine!=='none'
74
- ||/[\n\r\t]/.test(current.text))return {reason:'Unsupported text typography; native text retained'};
75
- for(const pseudo of ['::before','::after']){
76
- const p=window.getComputedStyle(element,pseudo);if(p.display!=='none'&&!['none','normal','""',"''"].includes(p.content))return {reason:'Generated text content is not supported'};
77
- }
78
- const factor=size/200,context=engine.rasterizer.context;context.font=domFontAtSize(engine,size);
79
- const metrics=context.measureText('Hgآی'),expected=context.measureText(current.text).width;engine.rasterizer.configure();
80
- if(!Number.isFinite(metrics.fontBoundingBoxAscent))return {reason:'Native font metrics are unavailable'};
81
- const direction=element.dir==='auto'?documentDirection(current.text,style.direction):style.direction;
82
- const rows=[],characters=[];
83
- if(current.node&&current.text){
84
- const range=document.createRange();range.selectNodeContents(current.node);
85
- const rects=[...range.getClientRects()].filter(rect=>rect.height>0),rect=range.getBoundingClientRect();
86
- if(!rects.length)return {reason:'Text has no measurable layout'};
87
- if(rects.some(r=>Math.abs(r.top-rects[0].top)>.75)||Math.abs(rect.width-expected)>Math.max(1,expected*.015))return {reason:'Wrapped or transformed text uses native rendering'};
88
- const top=rect.top-box.top-element.clientTop,left=rect.left-box.left-element.clientLeft;
89
- const baseline=top+(rect.height+metrics.fontBoundingBoxAscent-metrics.fontBoundingBoxDescent)/2;
90
- rows.push({start:0,end:current.text.length,text:current.text,top,left,right:left+rect.width,baseline,direction});
91
- if(collectCharacters)for(const part of new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(current.text)){
92
- range.setStart(current.node,part.index);range.setEnd(current.node,part.index+part.segment.length);
93
- const r=range.getBoundingClientRect();
94
- if(r.width>0)characters.push({x:(r.left-box.left-element.clientLeft)/factor*engine.scale,
95
- y:-(baseline+metrics.fontBoundingBoxDescent)/factor*engine.scale,
96
- width:r.width/factor*engine.scale,height:(metrics.fontBoundingBoxAscent+metrics.fontBoundingBoxDescent)/factor*engine.scale,direction});
97
- }
98
- }
99
- return {rows,characters,factor,width,height,size,direction,color:style.color,text:current.text,
100
- key:JSON.stringify([current.text,engine.domFont?.key??engine.face.family,size,width,height,rows.map(r=>[r.left,r.baseline,r.direction])])};
101
- }
102
- async function prepare(){
103
- if(preparing||life.disposed||fatal)return;
104
- let stamp,surfaceIssue;
105
- const collectCharacters=!!needsCharacters();
106
- try{
107
- surfaceIssue=renderer.getSurfaceIssue?.();
108
- stamp=JSON.stringify([fontRevision,collectCharacters,fingerprint()]);
109
- if(!surfaceIssue&&enabled&&!forced.matches&&layout&&stamp===preparedFingerprint){dirty=false;reason=null;request();return;}
110
- }catch(error){fail(error);return;}
111
- dirty=false;preparing=true;const version=revision;
112
- preparedFingerprint=null;
113
- try{
114
- if(surfaceIssue){effect.cancel();rich?.cancel();native(surfaceIssue);resolveReady({mode,reason});return;}
115
- const current=slot();
116
- const typography=window.getComputedStyle(element);
117
- let wrapped=false;
118
- if(current?.node){const range=document.createRange();range.selectNodeContents(current.node);const rects=[...range.getClientRects()].filter(r=>r.height>0);wrapped=rects.some(r=>Math.abs(r.top-rects[0].top)>.75);}
119
- const collapsedWhitespace=current&&['normal','nowrap'].includes(typography.whiteSpace)&&/(^[ \t\r\n\f]|[ \t\r\n\f]$|[ \t\r\n\f]{2,}|[\t\r\n\f])/.test(current.text);
120
- if(!current||rich||wrapped||collapsedWhitespace||!['normal','0px'].includes(typography.letterSpacing)||!['normal','0px'].includes(typography.wordSpacing)){
121
- if(!enabled||forced.matches){rich?.cancel();native(!enabled?'disabled':'forced colors');resolveReady({mode,reason});return;}
122
- if(!rich){effect.cancel();view.dispose();rich=new DOMRichText(element,THREE,content,canvas,options);life.own(()=>rich.dispose());}
123
- const next=await rich.prepare(()=>life.disposed||version!==revision,()=>{if(rich.stats().active)preserveMotion();});
124
- if(!next||life.disposed||version!==revision)return;
125
- if(next.reason){rich.cancel();native(next.reason);resolveReady({mode,reason});return;}
126
- if(committed!==null&&committed!==next.text&&pendingPhase===null)pendingPhase='enter';
127
- committed=next.text;layout=next;reason=null;preparedFingerprint=stamp;builds++;request();return;
128
- }
129
- if(!enabled||forced.matches){effect.cancel();native(!enabled?'disabled':'forced colors');resolveReady({mode,reason});return;}
130
- const changed=await adoptDOMFont(engine,element);
131
- if(life.disposed||version!==revision)return;
132
- const next=measure(current,collectCharacters);
133
- if(next.reason){effect.cancel();native(next.reason);resolveReady({mode,reason});return;}
134
- const densityChanged=engine.setDisplayFontSize(next.size);
135
- if(changed||densityChanged||!layout||next.key!==layout.key){
136
- if(effect.active)preserveMotion();
137
- if(pendingPhase===null)native();effect.cancel();
138
- const result=await engine.prepareRows(next,{cancelled:()=>life.disposed||version!==revision});
139
- if(!result||life.disposed||version!==revision)return;
140
- const textChanged=committed!==null&&committed!==next.text;
141
- view.setText(next.text);committed=next.text;builds++;
142
- if(textChanged&&pendingPhase===null)pendingPhase='enter';
143
- }
144
- layout=next;reason=null;preparedFingerprint=stamp;request();
145
- }catch(error){fail(error);}
146
- finally{preparing=false;if(!life.disposed&&dirty)request();}
147
- }
148
- function beginEffect(now){
149
- if(pendingPhase===null||!layout)return;
150
- const resuming=pendingStarted!==null;phase=pendingPhase;pendingPhase=null;now=pendingStarted??now;pendingStarted=null;effectStarted=now;content.visible=true;
151
- if(rich){if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();rich.play(phase,reduced.matches?'none':effectName,settings,lastSeed,now);if(effectName==='none'||reduced.matches)content.visible=phase==='enter';return;}
152
- if(effectName==='none'||reduced.matches||!committed){effect.cancel();content.visible=phase==='enter';return;}
153
- const effectMode=phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:effectName;
154
- effect.setMode(effectMode);effect.configure(settings);
155
- const b=view.bounds,rectangle={x:b.minX-.001,y:b.minY-.001,width:b.maxX-b.minX+.002,height:b.maxY-b.minY+.002,direction:layout.direction};
156
- if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();
157
- effect.playRegion(view,rectangle,200*engine.scale,now,{departing:phase==='exit',seed:lastSeed});
158
- effect.prepareCharacterCenters(view,()=>layout.characters);
159
- }
160
- function render(now){
161
- frame=null;if(life.disposed||fatal||document.hidden||!intersecting)return;
162
- if(dirty){prepare();return;}
163
- if(preparing||!layout||!enabled||forced.matches||reason)return;
164
- try{
165
- if(!canvas.isConnected)element.append(canvas);
166
- if(window.getComputedStyle(element).position==='static')ownStyle('position','relative');
167
- const {width,height,size,factor}=layout,margin=renderer.globalCanvas?0:Math.ceil(size*3),w=width+margin*2,h=height+margin*2;
168
- const dpr=Math.min(window.devicePixelRatio||1,2),sizeKey=[w,h,dpr].join(':');
169
- if(submittedSize!==sizeKey){
170
- renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);submittedSize=sizeKey;
171
- Object.assign(canvas.style,{left:-margin+'px',top:-margin+'px',width:w+'px',height:h+'px'});
172
- camera.left=-margin;camera.right=width+margin;camera.top=margin;camera.bottom=-height-margin;camera.updateProjectionMatrix();
173
- }
174
- content.scale.setScalar(factor/engine.scale);
175
- view.uniforms.tint.value.setStyle(window.getComputedStyle(element).color);
176
- beginEffect(now);const active=rich?rich.step(now,reduced.matches):effect.step(now,reduced.matches);
177
- if(!active&&phase==='exit')content.visible=false;
178
- if(!active&&phase!=='exit'&&resting==='native'){renderer.render(scene,camera);native('native resting presentation');resolveReady({mode,reason});return;}
85
+ function slot(){
86
+ const children=[...element.childNodes].filter(node=>node!==canvas&&node.nodeType!==8);
87
+ if(children.some(node=>node.nodeType!==3)||children.length>1)return null;
88
+ return {node:children[0]??null,text:children[0]?.data??''};
89
+ }
90
+ function measure(current,collectCharacters){
91
+ const style=window.getComputedStyle(element),size=parseFloat(style.fontSize),box=element.getBoundingClientRect();
92
+ const width=element.clientWidth||box.width,height=element.clientHeight||box.height;
93
+ if(!element.isConnected||!(width>0&&height>0))return {reason:'Text slot is not visible'};
94
+ if(!matchesDOMFont(engine,style)||!(size>0)||style.fontStyle!=='normal'||style.textTransform!=='none'||style.writingMode!=='horizontal-tb'
95
+ ||!['normal','0px'].includes(style.letterSpacing)||!['normal','0px'].includes(style.wordSpacing)||style.textDecorationLine!=='none'
96
+ ||/[\n\r\t]/.test(current.text))return {reason:'Unsupported text typography; native text retained'};
97
+ for(const pseudo of ['::before','::after']){
98
+ const p=window.getComputedStyle(element,pseudo);if(p.display!=='none'&&!['none','normal','""',"''"].includes(p.content))return {reason:'Generated text content is not supported'};
99
+ }
100
+ const factor=size/200,context=engine.rasterizer.context;context.font=domFontAtSize(engine,size);
101
+ const metrics=context.measureText('Hgآی'),expected=context.measureText(current.text).width;engine.rasterizer.configure();
102
+ if(!Number.isFinite(metrics.fontBoundingBoxAscent))return {reason:'Native font metrics are unavailable'};
103
+ const direction=element.dir==='auto'?documentDirection(current.text,style.direction):style.direction;
104
+ const rows=[],characters=[];
105
+ if(current.node&&current.text){
106
+ const range=document.createRange();range.selectNodeContents(current.node);
107
+ const rects=[...range.getClientRects()].filter(rect=>rect.height>0),rect=range.getBoundingClientRect();
108
+ if(!rects.length)return {reason:'Text has no measurable layout'};
109
+ if(rects.some(r=>Math.abs(r.top-rects[0].top)>.75)||Math.abs(rect.width-expected)>Math.max(1,expected*.015))return {reason:'Wrapped or transformed text uses native rendering'};
110
+ const top=rect.top-box.top-element.clientTop,left=rect.left-box.left-element.clientLeft;
111
+ const baseline=top+(rect.height+metrics.fontBoundingBoxAscent-metrics.fontBoundingBoxDescent)/2;
112
+ rows.push({start:0,end:current.text.length,text:current.text,top,left,right:left+rect.width,baseline,direction});
113
+ if(collectCharacters)for(const part of new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(current.text)){
114
+ range.setStart(current.node,part.index);range.setEnd(current.node,part.index+part.segment.length);
115
+ const r=range.getBoundingClientRect();
116
+ if(r.width>0)characters.push({x:(r.left-box.left-element.clientLeft)/factor*engine.scale,
117
+ y:-(baseline+metrics.fontBoundingBoxDescent)/factor*engine.scale,
118
+ width:r.width/factor*engine.scale,height:(metrics.fontBoundingBoxAscent+metrics.fontBoundingBoxDescent)/factor*engine.scale,direction});
119
+ }
120
+ }
121
+ return {rows,characters,factor,width,height,size,direction,color:style.color,text:current.text,
122
+ key:JSON.stringify([current.text,engine.domFont?.key??engine.face.family,size,width,height,rows.map(r=>[r.left,r.baseline,r.direction])])};
123
+ }
124
+ async function prepare(){
125
+ if(preparing||life.disposed||fatal)return;
126
+ let stamp,surfaceIssue;
127
+ const collectCharacters=!!needsCharacters();
128
+ try{
129
+ surfaceIssue=renderer.getSurfaceIssue?.();
130
+ stamp=JSON.stringify([fontRevision,collectCharacters,fingerprint()]);
131
+ if(!surfaceIssue&&enabled&&!forced.matches&&layout&&stamp===preparedFingerprint){dirty=false;reason=null;request();return;}
132
+ }catch(error){fail(error);return;}
133
+ dirty=false;preparing=true;const version=revision;
134
+ preparedFingerprint=null;
135
+ try{
136
+ if(surfaceIssue){effect.cancel();rich?.cancel();native(surfaceIssue);resolveReady({mode,reason});return;}
137
+ const current=slot();
138
+ const typography=window.getComputedStyle(element);
139
+ let wrapped=false;
140
+ if(current?.node){const range=document.createRange();range.selectNodeContents(current.node);const rects=[...range.getClientRects()].filter(r=>r.height>0);wrapped=rects.some(r=>Math.abs(r.top-rects[0].top)>.75);}
141
+ const collapsedWhitespace=current&&['normal','nowrap'].includes(typography.whiteSpace)&&/(^[ \t\r\n\f]|[ \t\r\n\f]$|[ \t\r\n\f]{2,}|[\t\r\n\f])/.test(current.text);
142
+ if(!current||rich||wrapped||collapsedWhitespace||!['normal','0px'].includes(typography.letterSpacing)||!['normal','0px'].includes(typography.wordSpacing)){
143
+ if(!enabled||forced.matches){rich?.cancel();native(!enabled?'disabled':'forced colors');resolveReady({mode,reason});return;}
144
+ if(!rich){effect.cancel();view.dispose();rich=new DOMRichText(element,THREE,content,canvas,options);life.own(()=>rich.dispose());}
145
+ const next=await rich.prepare(()=>life.disposed||version!==revision,()=>{if(rich.stats().active)preserveMotion();});
146
+ if(!next||life.disposed||version!==revision)return;
147
+ if(next.reason){rich.cancel();native(next.reason);resolveReady({mode,reason});return;}
148
+ if(committed!==null&&committed!==next.text&&pendingPhase===null)pendingPhase='enter';
149
+ committed=next.text;layout=next;reason=null;preparedFingerprint=stamp;builds++;request();return;
150
+ }
151
+ if(!enabled||forced.matches){effect.cancel();native(!enabled?'disabled':'forced colors');resolveReady({mode,reason});return;}
152
+ const changed=await adoptDOMFont(engine,element);
153
+ if(life.disposed||version!==revision)return;
154
+ const next=measure(current,collectCharacters);
155
+ if(next.reason){effect.cancel();native(next.reason);resolveReady({mode,reason});return;}
156
+ engine.rasterizer.displayFontSize=next.size;
157
+ const densityChanged=engine.setDisplayFontSize(next.size);
158
+ if(changed||densityChanged||!layout||next.key!==layout.key){
159
+ if(effect.active)preserveMotion();
160
+ if(pendingPhase===null)native();effect.cancel();
161
+ const result=await engine.prepareRows(next,{cancelled:()=>life.disposed||version!==revision});
162
+ if(!result||life.disposed||version!==revision)return;
163
+ const textChanged=committed!==null&&committed!==next.text;
164
+ view.setText(next.text);committed=next.text;builds++;
165
+ if(textChanged&&pendingPhase===null)pendingPhase='enter';
166
+ }
167
+ layout=next;reason=null;preparedFingerprint=stamp;request();
168
+ }catch(error){fail(error);}
169
+ finally{preparing=false;if(!life.disposed&&dirty)request();}
170
+ }
171
+ function beginEffect(now){
172
+ if(pendingPhase===null||!layout)return;
173
+ settleStarted=null;
174
+ const resuming=pendingStarted!==null;phase=pendingPhase;pendingPhase=null;now=pendingStarted??now;pendingStarted=null;effectStarted=now;content.visible=true;
175
+ if(rich){if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();rich.play(phase,reduced.matches?'none':effectName,settings,lastSeed,now);if(effectName==='none'||reduced.matches)content.visible=phase==='enter';return;}
176
+ if(effectName==='none'||reduced.matches||!committed){effect.cancel();content.visible=phase==='enter';return;}
177
+ const effectMode=phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:effectName;
178
+ effect.setMode(effectMode);effect.configure(settings);
179
+ const b=view.bounds,rectangle={x:b.minX-.001,y:b.minY-.001,width:b.maxX-b.minX+.002,height:b.maxY-b.minY+.002,direction:layout.direction};
180
+ if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();
181
+ effect.playRegion(view,rectangle,200*engine.scale,now,{departing:phase==='exit',seed:lastSeed});
182
+ effect.prepareCharacterCenters(view,()=>layout.characters);
183
+ }
184
+ function render(now){
185
+ frame=null;if(life.disposed||fatal||document.hidden||!intersecting)return;
186
+ if(dirty){prepare();return;}
187
+ if(preparing||!layout||!enabled||forced.matches||reason)return;
188
+ try{
189
+ if(!canvas.isConnected)element.append(canvas);
190
+ if(window.getComputedStyle(element).position==='static')ownStyle('position','relative');
191
+ const {width,height,size,factor}=layout,margin=renderer.globalCanvas?0:Math.ceil(size*3),w=width+margin*2,h=height+margin*2;
192
+ const dpr=Math.min(window.devicePixelRatio||1,2),sizeKey=[w,h,dpr].join(':');
193
+ if(submittedSize!==sizeKey){
194
+ renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);submittedSize=sizeKey;
195
+ Object.assign(canvas.style,{left:-margin+'px',top:-margin+'px',width:w+'px',height:h+'px'});
196
+ camera.left=-margin;camera.right=width+margin;camera.top=margin;camera.bottom=-height-margin;camera.updateProjectionMatrix();
197
+ }
198
+ content.scale.setScalar(factor/engine.scale);
199
+ view.uniforms.tint.value.setStyle(window.getComputedStyle(element).color);
200
+ beginEffect(now);const active=rich?rich.step(now,reduced.matches):effect.step(now,reduced.matches);
201
+ if(!active&&phase==='exit')content.visible=false;
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;
211
+ if(settleStarted===null)settleStarted=effectStarted===null?now:effectStarted+settings.duration;
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;}
215
+ }else settleStarted=null;
216
+ view.uniforms.presentationOpacity.value=1-meshFade;rich?.setPresentationOpacity(1-meshFade);
179
217
  const handingOffNative=mode==='native';
180
218
  renderer.render(scene,camera);draws++;
181
- canvas.style.display='block';ownStyle('-webkit-text-fill-color','transparent');ownStyle('text-shadow','none');mode='mesh';reason=null;
182
- rich?.hide();
183
- // Commit the shared canvas before the browser paints hidden native text.
184
- if(handingOffNative)renderer.flushPresentation?.();
185
- renderer.invalidate?.();
186
- resolveReady({mode,reason});if(active)request();
187
- }catch(error){fail(error);}
188
- }
189
- life.own(()=>{if(frame!==null)window.cancelAnimationFrame(frame);frame=null;});
190
- life.own(()=>{for(const name of [...ownedStyle.keys()])restoreStyle(name);});
191
- try{
192
- renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});life.own(()=>{renderer.dispose();renderer.forceContextLoss();});
193
- renderer.setSurface?.(element,{escapeHost:element.closest('button,a,[role="button"]')||element});
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();
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;
223
+ rich?.hide(nativeAlpha);
224
+ // Commit the shared canvas before the browser paints hidden native text.
225
+ if(handingOffNative)renderer.flushPresentation?.();
226
+ renderer.invalidate?.();
227
+ resolveReady({mode,reason});if(active||meshFade<1&&settleStarted!==null)request();
228
+ }catch(error){fail(error);}
229
+ }
230
+ life.own(()=>{if(frame!==null)window.cancelAnimationFrame(frame);frame=null;});
231
+ life.own(()=>{for(const name of [...ownedStyle.keys()])restoreStyle(name);});
232
+ try{
233
+ renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});life.own(()=>{renderer.dispose();renderer.forceContextLoss();});
234
+ renderer.setSurface?.(element,{escapeHost:element.closest('button,a,[role="button"]')||element});
194
235
  canvas=renderer.domElement;canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-text-surface','');
195
- fingerprint=createDOMTextFingerprint(element,canvas);
196
- Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none',margin:'0',padding:'0',border:'0',maxWidth:'none',maxHeight:'none'});
197
- element.append(canvas);life.own(()=>canvas.remove());
198
- life.listen(canvas,'webglcontextlost',event=>{event.preventDefault();fail('WebGL context lost; native text retained');});
199
- life.listen(canvas,'thdviewportchange',refresh);
200
- scene=new THREE.Scene();content=new THREE.Group();scene.add(content);
201
- camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=50;
202
- engine=new HybridTextFlowEngine({scale:.036,divisions:options.divisions??'auto',textRendering:'texture'});life.own(()=>engine.dispose());
203
- view=new SharedTextScene(THREE,engine,content);life.own(()=>view.dispose());
204
- effect=new TextEditEffect(THREE,{mode:effectName==='none'?'dust-wind':effectName,settings});life.own(()=>effect.dispose());
205
- const mutations=new window.MutationObserver(records=>{
206
- if(records.some(record=>record.target!==canvas&&!canvas.contains(record.target)))refresh();
207
- });
208
- mutations.observe(element,{childList:true,characterData:true,attributes:true,attributeFilter:['class','style','dir'],subtree:true});life.own(()=>mutations.disconnect());
209
- // Theme and control state are inherited from the existing DOM hierarchy.
210
- // Observe only attributes on this slot's ancestors, never a document subtree.
211
- for(let ancestor=element.parentElement;ancestor;ancestor=ancestor.parentElement){
212
- mutations.observe(ancestor,{attributes:true,attributeFilter:['class','style','dir','disabled','aria-disabled']});
213
- }
214
- const resize=new window.ResizeObserver(refresh);resize.observe(element);life.own(()=>resize.disconnect());
215
- const control=element.closest('button,a,[role="button"]')||element.parentElement||element;
216
- for(const event of ['mouseenter','mouseleave','focusin','focusout','pointerdown','pointerup','pointercancel'])life.listen(control,event,refresh);
217
- if(window.IntersectionObserver){
218
- const visibility=new window.IntersectionObserver(entries=>{
219
- const next=entries.at(-1)?.isIntersecting!==false;if(next===intersecting)return;
220
- intersecting=next;if(next)refresh();else suspend();
221
- });visibility.observe(element);life.own(()=>visibility.disconnect());
222
- }
223
- life.listen(document,'visibilitychange',()=>{if(document.hidden)suspend();else refresh();});
224
- life.listen(window,'resize',refresh);
225
- for(const event of ['loadingdone','loadingerror'])life.listen(document.fonts,event,()=>{fontRevision++;refresh();});
226
- life.listen(forced,'change',()=>{native();refresh();});life.listen(reduced,'change',refresh);
227
- request();
236
+ nativeMask=createTextPaintMask(element,canvas);life.own(()=>nativeMask.release());
237
+ fingerprint=createDOMTextFingerprint(element,canvas);
238
+ Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none',margin:'0',padding:'0',border:'0',maxWidth:'none',maxHeight:'none'});
239
+ element.append(canvas);life.own(()=>canvas.remove());
240
+ life.listen(canvas,'webglcontextlost',event=>{event.preventDefault();fail('WebGL context lost; native text retained');});
241
+ life.listen(canvas,'thdviewportchange',refresh);
242
+ scene=new THREE.Scene();content=new THREE.Group();scene.add(content);
243
+ camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=50;
244
+ engine=new HybridTextFlowEngine({scale:.036,divisions:options.divisions??'auto',textRendering:'texture'});life.own(()=>engine.dispose());
245
+ view=new SharedTextScene(THREE,engine,content);life.own(()=>view.dispose());
246
+ effect=new TextEditEffect(THREE,{mode:effectName==='none'?'dust-wind':effectName,settings});life.own(()=>effect.dispose());
247
+ const mutations=new window.MutationObserver(records=>{
248
+ if(records.some(record=>record.target!==canvas&&!canvas.contains(record.target)))refresh();
249
+ });
250
+ mutations.observe(element,{childList:true,characterData:true,attributes:true,attributeFilter:['class','style','dir'],subtree:true});life.own(()=>mutations.disconnect());
251
+ // Theme and control state are inherited from the existing DOM hierarchy.
252
+ // Observe only attributes on this slot's ancestors, never a document subtree.
253
+ for(let ancestor=element.parentElement;ancestor;ancestor=ancestor.parentElement){
254
+ mutations.observe(ancestor,{attributes:true,attributeFilter:['class','style','dir','disabled','aria-disabled']});
255
+ }
256
+ const resize=new window.ResizeObserver(refresh);resize.observe(element);life.own(()=>resize.disconnect());
257
+ const control=element.closest('button,a,[role="button"]')||element.parentElement||element;
258
+ for(const event of ['mouseenter','mouseleave','focusin','focusout','pointerdown','pointerup','pointercancel'])life.listen(control,event,refresh);
259
+ if(window.IntersectionObserver){
260
+ const visibility=new window.IntersectionObserver(entries=>{
261
+ const next=entries.at(-1)?.isIntersecting!==false;if(next===intersecting)return;
262
+ intersecting=next;if(next)refresh();else suspend();
263
+ });visibility.observe(element);life.own(()=>visibility.disconnect());
264
+ }
265
+ life.listen(document,'visibilitychange',()=>{if(document.hidden)suspend();else refresh();});
266
+ life.listen(window,'resize',refresh);
267
+ for(const event of ['loadingdone','loadingerror'])life.listen(document.fonts,event,()=>{fontRevision++;refresh();});
268
+ life.listen(forced,'change',()=>{native();refresh();});life.listen(reduced,'change',refresh);
269
+ if(pendingPhase!==null)play(pendingPhase);else request();
228
270
  }catch(error){fail(error);}
229
- return {element,ready,
230
- refresh,
231
- update(next={}){
232
- if(life.disposed)throw Error('Text surface disposed');
233
- const candidate=next.inputEffect??effectName;if(!validMode(candidate))throw TypeError('Invalid inputEffect');
234
- validResting(next.resting??resting);resting=next.resting??resting;
235
- const normalized=normalizeTextEffectOptions(next.inputEffectOptions?{formation:-1,...next.inputEffectOptions}:settings,candidate==='none'?'dust-wind':candidate);
236
- effectName=candidate;settings=normalized;if('enabled' in next)enabled=next.enabled!==false;
237
- effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();
238
- },
239
- cancel(){if(life.disposed)return;effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();},
240
- play(next='enter'){
241
- if(life.disposed)throw Error('Text surface disposed');if(!['enter','exit'].includes(next))throw TypeError('Expected enter or exit');
242
- pendingPhase=next;pendingStarted=null;refresh();
243
- },
244
- stats:()=>({timeline:{phase,started:effectStarted,ends:effectStarted===null?null:effectStarted+settings.duration},disposed:life.disposed,mode,reason,committed,preparing,draws,builds,pending:frame!==null,suspended:document.hidden||!intersecting,triangles:rich?rich.stats().triangles:view?.triangleCount??0,
245
- 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}),
246
- destroy(){if(life.disposed)return;native('destroyed');resolveReady({mode,reason});life.destroy();}
247
- };
248
- }
271
+ return {element,ready,
272
+ refresh,
273
+ update(next={}){
274
+ if(life.disposed)throw Error('Text surface disposed');
275
+ const candidate=next.inputEffect??effectName;if(!validMode(candidate))throw TypeError('Invalid inputEffect');
276
+ validResting(next.resting??resting);resting=next.resting??resting;
277
+ const normalized=normalizeTextEffectOptions(next.inputEffectOptions?{formation:-1,...next.inputEffectOptions}:settings,candidate==='none'?'dust-wind':candidate);
278
+ effectName=candidate;settings=normalized;if('enabled' in next)enabled=next.enabled!==false;
279
+ effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;native();refresh();
280
+ },
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};},
290
+ destroy(){if(life.disposed)return;native('destroyed');resolveReady({mode,reason});life.destroy();}
291
+ };
292
+ }
249
293
 
250
294
 
251
295