@ev-ry/fx 0.1.0-rc.1 → 0.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QUICKSTART.fa.md +2 -2
- package/README.md +116 -116
- package/build-report.json +19 -19
- package/docs/GUIDE.md +120 -116
- package/docs/RELEASE-NOTES.md +52 -40
- package/package.json +1 -1
- package/src/dom-image-raster.js +14 -0
- package/src/dom-rich-text.js +133 -116
- package/src/dom-surface-font.js +4 -4
- package/src/dom-text-fingerprint.js +27 -27
- package/src/dom-text-surface.js +261 -248
- package/src/font-rasterizer.js +18 -7
- package/src/image-surface.js +3 -2
- package/src/text-scene.js +41 -39
package/src/dom-text-surface.js
CHANGED
|
@@ -1,251 +1,264 @@
|
|
|
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
|
-
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
|
-
}
|
|
47
|
-
function native(why=null){
|
|
48
|
-
rich?.restore();
|
|
49
|
-
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});
|
|
57
|
-
}
|
|
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
|
-
function refresh(){
|
|
61
|
-
if(life.disposed)return;revision++;dirty=true;request();
|
|
62
|
-
}
|
|
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??''};
|
|
67
|
-
}
|
|
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&¤t.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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
effect.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
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
|
+
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,settleStarted=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
|
+
}
|
|
47
|
+
function native(why=null){
|
|
48
|
+
rich?.restore();
|
|
49
|
+
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});
|
|
57
|
+
}
|
|
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
|
+
function refresh(){
|
|
61
|
+
if(life.disposed)return;revision++;dirty=true;request();
|
|
62
|
+
}
|
|
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??''};
|
|
67
|
+
}
|
|
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&¤t.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
|
+
engine.rasterizer.displayFontSize=next.size;
|
|
135
|
+
const densityChanged=engine.setDisplayFontSize(next.size);
|
|
136
|
+
if(changed||densityChanged||!layout||next.key!==layout.key){
|
|
137
|
+
if(effect.active)preserveMotion();
|
|
138
|
+
if(pendingPhase===null)native();effect.cancel();
|
|
139
|
+
const result=await engine.prepareRows(next,{cancelled:()=>life.disposed||version!==revision});
|
|
140
|
+
if(!result||life.disposed||version!==revision)return;
|
|
141
|
+
const textChanged=committed!==null&&committed!==next.text;
|
|
142
|
+
view.setText(next.text);committed=next.text;builds++;
|
|
143
|
+
if(textChanged&&pendingPhase===null)pendingPhase='enter';
|
|
144
|
+
}
|
|
145
|
+
layout=next;reason=null;preparedFingerprint=stamp;request();
|
|
146
|
+
}catch(error){fail(error);}
|
|
147
|
+
finally{preparing=false;if(!life.disposed&&dirty)request();}
|
|
148
|
+
}
|
|
149
|
+
function beginEffect(now){
|
|
150
|
+
if(pendingPhase===null||!layout)return;
|
|
151
|
+
settleStarted=null;
|
|
152
|
+
const resuming=pendingStarted!==null;phase=pendingPhase;pendingPhase=null;now=pendingStarted??now;pendingStarted=null;effectStarted=now;content.visible=true;
|
|
153
|
+
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;}
|
|
154
|
+
if(effectName==='none'||reduced.matches||!committed){effect.cancel();content.visible=phase==='enter';return;}
|
|
155
|
+
const effectMode=phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:effectName;
|
|
156
|
+
effect.setMode(effectMode);effect.configure(settings);
|
|
157
|
+
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};
|
|
158
|
+
if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();
|
|
159
|
+
effect.playRegion(view,rectangle,200*engine.scale,now,{departing:phase==='exit',seed:lastSeed});
|
|
160
|
+
effect.prepareCharacterCenters(view,()=>layout.characters);
|
|
161
|
+
}
|
|
162
|
+
function render(now){
|
|
163
|
+
frame=null;if(life.disposed||fatal||document.hidden||!intersecting)return;
|
|
164
|
+
if(dirty){prepare();return;}
|
|
165
|
+
if(preparing||!layout||!enabled||forced.matches||reason)return;
|
|
166
|
+
try{
|
|
167
|
+
if(!canvas.isConnected)element.append(canvas);
|
|
168
|
+
if(window.getComputedStyle(element).position==='static')ownStyle('position','relative');
|
|
169
|
+
const {width,height,size,factor}=layout,margin=renderer.globalCanvas?0:Math.ceil(size*3),w=width+margin*2,h=height+margin*2;
|
|
170
|
+
const dpr=Math.min(window.devicePixelRatio||1,2),sizeKey=[w,h,dpr].join(':');
|
|
171
|
+
if(submittedSize!==sizeKey){
|
|
172
|
+
renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);submittedSize=sizeKey;
|
|
173
|
+
Object.assign(canvas.style,{left:-margin+'px',top:-margin+'px',width:w+'px',height:h+'px'});
|
|
174
|
+
camera.left=-margin;camera.right=width+margin;camera.top=margin;camera.bottom=-height-margin;camera.updateProjectionMatrix();
|
|
175
|
+
}
|
|
176
|
+
content.scale.setScalar(factor/engine.scale);
|
|
177
|
+
view.uniforms.tint.value.setStyle(window.getComputedStyle(element).color);
|
|
178
|
+
beginEffect(now);const active=rich?rich.step(now,reduced.matches):effect.step(now,reduced.matches);
|
|
179
|
+
if(!active&&phase==='exit')content.visible=false;
|
|
180
|
+
let nativeAlpha=0,meshFade=0;
|
|
181
|
+
if(active&&phase!=='exit'&&resting==='native'&&effectStarted!==null)
|
|
182
|
+
nativeAlpha=Math.max(0,Math.min(1,(now-(effectStarted+settings.duration-175))/350));
|
|
183
|
+
if(!active&&phase!=='exit'&&resting==='native'){
|
|
184
|
+
// Use the effect clock, not the first idle frame. Slow frames must not
|
|
185
|
+
// restart the handoff or jump native alpha back to its halfway point.
|
|
186
|
+
if(settleStarted===null)settleStarted=effectStarted===null?now:effectStarted+settings.duration;
|
|
187
|
+
meshFade=reduced.matches?1:Math.min(1,(now-settleStarted)/350);
|
|
188
|
+
nativeAlpha=reduced.matches?1:Math.min(1,(now-settleStarted+175)/350);
|
|
189
|
+
if(meshFade>=1){renderer.render(scene,camera);native('native resting presentation');resolveReady({mode,reason});return;}
|
|
190
|
+
}else settleStarted=null;
|
|
191
|
+
view.uniforms.presentationOpacity.value=1-meshFade;rich?.setPresentationOpacity(1-meshFade);
|
|
192
|
+
const handingOffNative=mode==='native';
|
|
193
|
+
renderer.render(scene,camera);draws++;
|
|
194
|
+
canvas.style.display='block';if(!rich)ownStyle('-webkit-text-fill-color',nativeAlpha>0?`color-mix(in srgb, ${window.getComputedStyle(element).color} ${nativeAlpha*100}%, transparent)`:'transparent');ownStyle('text-shadow','none');mode='mesh';reason=null;
|
|
195
|
+
rich?.hide(nativeAlpha);
|
|
196
|
+
// Commit the shared canvas before the browser paints hidden native text.
|
|
197
|
+
if(handingOffNative)renderer.flushPresentation?.();
|
|
198
|
+
renderer.invalidate?.();
|
|
199
|
+
resolveReady({mode,reason});if(active||meshFade<1&&settleStarted!==null)request();
|
|
200
|
+
}catch(error){fail(error);}
|
|
201
|
+
}
|
|
202
|
+
life.own(()=>{if(frame!==null)window.cancelAnimationFrame(frame);frame=null;});
|
|
203
|
+
life.own(()=>{for(const name of [...ownedStyle.keys()])restoreStyle(name);});
|
|
204
|
+
try{
|
|
205
|
+
renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});life.own(()=>{renderer.dispose();renderer.forceContextLoss();});
|
|
206
|
+
renderer.setSurface?.(element,{escapeHost:element.closest('button,a,[role="button"]')||element});
|
|
207
|
+
canvas=renderer.domElement;canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-text-surface','');
|
|
208
|
+
fingerprint=createDOMTextFingerprint(element,canvas);
|
|
209
|
+
Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none',margin:'0',padding:'0',border:'0',maxWidth:'none',maxHeight:'none'});
|
|
210
|
+
element.append(canvas);life.own(()=>canvas.remove());
|
|
211
|
+
life.listen(canvas,'webglcontextlost',event=>{event.preventDefault();fail('WebGL context lost; native text retained');});
|
|
212
|
+
life.listen(canvas,'thdviewportchange',refresh);
|
|
213
|
+
scene=new THREE.Scene();content=new THREE.Group();scene.add(content);
|
|
214
|
+
camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=50;
|
|
215
|
+
engine=new HybridTextFlowEngine({scale:.036,divisions:options.divisions??'auto',textRendering:'texture'});life.own(()=>engine.dispose());
|
|
216
|
+
view=new SharedTextScene(THREE,engine,content);life.own(()=>view.dispose());
|
|
217
|
+
effect=new TextEditEffect(THREE,{mode:effectName==='none'?'dust-wind':effectName,settings});life.own(()=>effect.dispose());
|
|
218
|
+
const mutations=new window.MutationObserver(records=>{
|
|
219
|
+
if(records.some(record=>record.target!==canvas&&!canvas.contains(record.target)))refresh();
|
|
220
|
+
});
|
|
221
|
+
mutations.observe(element,{childList:true,characterData:true,attributes:true,attributeFilter:['class','style','dir'],subtree:true});life.own(()=>mutations.disconnect());
|
|
222
|
+
// Theme and control state are inherited from the existing DOM hierarchy.
|
|
223
|
+
// Observe only attributes on this slot's ancestors, never a document subtree.
|
|
224
|
+
for(let ancestor=element.parentElement;ancestor;ancestor=ancestor.parentElement){
|
|
225
|
+
mutations.observe(ancestor,{attributes:true,attributeFilter:['class','style','dir','disabled','aria-disabled']});
|
|
226
|
+
}
|
|
227
|
+
const resize=new window.ResizeObserver(refresh);resize.observe(element);life.own(()=>resize.disconnect());
|
|
228
|
+
const control=element.closest('button,a,[role="button"]')||element.parentElement||element;
|
|
229
|
+
for(const event of ['mouseenter','mouseleave','focusin','focusout','pointerdown','pointerup','pointercancel'])life.listen(control,event,refresh);
|
|
230
|
+
if(window.IntersectionObserver){
|
|
231
|
+
const visibility=new window.IntersectionObserver(entries=>{
|
|
232
|
+
const next=entries.at(-1)?.isIntersecting!==false;if(next===intersecting)return;
|
|
233
|
+
intersecting=next;if(next)refresh();else suspend();
|
|
234
|
+
});visibility.observe(element);life.own(()=>visibility.disconnect());
|
|
235
|
+
}
|
|
236
|
+
life.listen(document,'visibilitychange',()=>{if(document.hidden)suspend();else refresh();});
|
|
237
|
+
life.listen(window,'resize',refresh);
|
|
238
|
+
for(const event of ['loadingdone','loadingerror'])life.listen(document.fonts,event,()=>{fontRevision++;refresh();});
|
|
239
|
+
life.listen(forced,'change',()=>{native();refresh();});life.listen(reduced,'change',refresh);
|
|
240
|
+
request();
|
|
241
|
+
}catch(error){fail(error);}
|
|
242
|
+
return {element,ready,
|
|
243
|
+
refresh,
|
|
244
|
+
update(next={}){
|
|
245
|
+
if(life.disposed)throw Error('Text surface disposed');
|
|
246
|
+
const candidate=next.inputEffect??effectName;if(!validMode(candidate))throw TypeError('Invalid inputEffect');
|
|
247
|
+
validResting(next.resting??resting);resting=next.resting??resting;
|
|
248
|
+
const normalized=normalizeTextEffectOptions(next.inputEffectOptions?{formation:-1,...next.inputEffectOptions}:settings,candidate==='none'?'dust-wind':candidate);
|
|
249
|
+
effectName=candidate;settings=normalized;if('enabled' in next)enabled=next.enabled!==false;
|
|
250
|
+
effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();
|
|
251
|
+
},
|
|
252
|
+
cancel(){if(life.disposed)return;effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();},
|
|
253
|
+
play(next='enter'){
|
|
254
|
+
if(life.disposed)throw Error('Text surface disposed');if(!['enter','exit'].includes(next))throw TypeError('Expected enter or exit');
|
|
255
|
+
pendingPhase=next;pendingStarted=null;refresh();
|
|
256
|
+
},
|
|
257
|
+
stats:()=>({timeline:{phase,started:effectStarted,ends:effectStarted===null?null:effectStarted+settings.duration},disposed:life.disposed,mode,reason,committed,preparing,draws,builds,pending:frame!==null,suspended:document.hidden||!intersecting,triangles:rich?rich.stats().triangles:view?.triangleCount??0,
|
|
258
|
+
width:layout?.width??0,height:layout?.height??0,fontFamily:engine?.face?.family,displayFontSize:layout?.size,divisions:engine?.divisions,effect:effect?.stats(),rich:rich?.stats()??null}),
|
|
259
|
+
destroy(){if(life.disposed)return;native('destroyed');resolveReady({mode,reason});life.destroy();}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
249
262
|
|
|
250
263
|
|
|
251
264
|
|
package/src/font-rasterizer.js
CHANGED
|
@@ -15,8 +15,8 @@ export class FontRasterizer {
|
|
|
15
15
|
this.context.font = this.font;
|
|
16
16
|
// Build the cached source for legibility, independently of the GPU's cheaper
|
|
17
17
|
// moving-particle sampler. Reapply after canvas resize resets drawing state.
|
|
18
|
-
if ('textRendering' in this.context) this.context.textRendering = 'optimizeLegibility';
|
|
19
|
-
if ('fontKerning' in this.context) this.context.fontKerning = 'normal';
|
|
18
|
+
if ('textRendering' in this.context) this.context.textRendering = this.textRendering ?? 'optimizeLegibility';
|
|
19
|
+
if ('fontKerning' in this.context) this.context.fontKerning = this.fontKerning ?? 'normal';
|
|
20
20
|
if ('letterSpacing' in this.context) this.context.letterSpacing = `${this.letterSpacing || 0}px`;
|
|
21
21
|
if ('wordSpacing' in this.context) this.context.wordSpacing = `${this.wordSpacing || 0}px`;
|
|
22
22
|
this.context.textAlign = 'left'; this.context.textBaseline = 'alphabetic';
|
|
@@ -29,16 +29,27 @@ export class FontRasterizer {
|
|
|
29
29
|
const key = direction + ':' + text;
|
|
30
30
|
if (this.cache.has(key)) return this.cache.get(key);
|
|
31
31
|
this.configure(direction);
|
|
32
|
-
const metric = this.context.measureText(text);
|
|
32
|
+
const metric = this.context.measureText(text);
|
|
33
|
+
let horizontalScale = 1;
|
|
34
|
+
if (this.displayFontSize > 0 && metric.width > 0) {
|
|
35
|
+
const factor = this.displayFontSize / 200;
|
|
36
|
+
this.context.font = this.font.replace('200px', `${this.displayFontSize}px`);
|
|
37
|
+
if ('letterSpacing' in this.context) this.context.letterSpacing = `${(this.letterSpacing || 0) * factor}px`;
|
|
38
|
+
if ('wordSpacing' in this.context) this.context.wordSpacing = `${(this.wordSpacing || 0) * factor}px`;
|
|
39
|
+
const advance = this.context.measureText(text).width / factor;
|
|
40
|
+
if (Number.isFinite(advance) && advance > 0) horizontalScale = advance / metric.width;
|
|
41
|
+
this.configure(direction);
|
|
42
|
+
}
|
|
33
43
|
const padding = 2;
|
|
34
|
-
const drawOffsetX = Math.ceil(Math.max(0, metric.actualBoundingBoxLeft || 0)) + padding;
|
|
44
|
+
const drawOffsetX = Math.ceil(Math.max(0, (metric.actualBoundingBoxLeft || 0)*horizontalScale)) + padding;
|
|
35
45
|
const baseline = Math.ceil(Math.max(this.ascent, metric.actualBoundingBoxAscent || 0)) + padding;
|
|
36
|
-
const width = Math.max(2, Math.ceil(Math.max(metric.width, metric.actualBoundingBoxRight || 0) + drawOffsetX + padding));
|
|
46
|
+
const width = Math.max(2, Math.ceil(Math.max(metric.width, metric.actualBoundingBoxRight || 0)*horizontalScale + drawOffsetX + padding));
|
|
37
47
|
const height = Math.max(2, Math.ceil(baseline + Math.max(this.descent, metric.actualBoundingBoxDescent || 0) + padding));
|
|
38
48
|
this.canvas.width = width; this.canvas.height = height; this.configure(direction);
|
|
39
|
-
this.context.
|
|
49
|
+
this.context.save();this.context.translate(drawOffsetX,baseline);this.context.scale(horizontalScale,1);
|
|
50
|
+
this.context.fillText(text,0,0);this.context.restore();
|
|
40
51
|
const rgba = this.context.getImageData(0, 0, width, height).data;
|
|
41
|
-
const result = { width, height, baseline, drawOffsetX, advance: metric.width, rgba };
|
|
52
|
+
const result = { width, height, baseline, drawOffsetX, advance: metric.width*horizontalScale, rgba };
|
|
42
53
|
this.cache.set(key, result);
|
|
43
54
|
return result;
|
|
44
55
|
}
|
package/src/image-surface.js
CHANGED
|
@@ -8,9 +8,10 @@ import {normalizeTextEffectOptions} from './text-effect-options.js';
|
|
|
8
8
|
import {textEditMotions} from './text-edit-motions.js';
|
|
9
9
|
|
|
10
10
|
export const IMAGE_EFFECT_MODES=Object.freeze(Object.keys(textEditMotions));
|
|
11
|
-
// Images
|
|
11
|
+
// Images share motion recipes with text, but preserve source color by default.
|
|
12
12
|
export function normalizeImageEffectOptions(settings={},mode='dust-wind'){
|
|
13
|
-
|
|
13
|
+
const normalized=normalizeTextEffectOptions({formation:-1,...settings},mode);
|
|
14
|
+
return Object.freeze({...normalized,recipe:Object.freeze({...normalized.recipe,glow:settings.recipe?.glow??0})});
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// Images use twice the text-derived rows: half-size cells, with a bounded grid.
|