@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.
package/src/dom-once.js CHANGED
@@ -1,34 +1,39 @@
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
- }
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 imageEntry=element.tagName==='IMG'&&phase==='enter';
10
+ const nativeRestOpacity=imageEntry?Number(element.ownerDocument.defaultView.getComputedStyle(element).opacity):1;
11
+ const paintValue=element.style.getPropertyValue(paint),paintPriority=element.style.getPropertyPriority(paint);
12
+ if(phase==='enter')element.style.setProperty(paint,element.tagName==='IMG'?'0':'transparent');
13
+ if(previous&&element.style.visibility==='hidden'){element.style.setProperty('visibility',previous.value,previous.priority);hidden.delete(element);}
14
+ let surface,done=false,timer;let resolve,reject;
15
+ const finished=new Promise((a,b)=>{resolve=a;reject=b;});
16
+ function finish(status,error){if(done)return;done=true;clearTimeout(timer);running.delete(element);surface?.destroy();
17
+ if(phase==='enter'){if(paintValue)element.style.setProperty(paint,paintValue,paintPriority);else element.style.removeProperty(paint);}
18
+ if(status==='completed'&&phase==='exit'){hidden.set(element,{value:element.style.getPropertyValue('visibility'),priority:element.style.getPropertyPriority('visibility')});element.style.visibility='hidden';}
19
+ if(error)reject(error);else resolve({status,phase});
20
+ }
21
+ try{surface=attach(element.tagName==='IMG'?'attachImage':'attachText',element,{...options,presentation,resting:imageEntry?'native':'mesh',...(imageEntry?{nativeRestOpacity}:{}),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;}
22
+ running.set(element,surface);const view=element.ownerDocument.defaultView,started=view.performance.now();
23
+ // Completion belongs to the effect clock, not to the first visible frame.
24
+ // A hidden transient may finish without ever preparing a mesh or resolving
25
+ // visual readiness; visibility only changes how often we inspect its clock.
26
+ function poll(){
27
+ if(done)return;
28
+ const s=surface.stats();
29
+ if(s.disposed||!element.isConnected){finish('cancelled');return;}
30
+ if(!s.preparing&&s.mode==='native'&&s.reason&&!/initializing|not visible|not connected|native resting presentation|hidden after exit/i.test(s.reason)){finish('unsupported');return;}
31
+ if(s.completed===true){finish('completed');return;}
32
+ if(view.performance.now()-started>30000){finish('cancelled',Error('Animation preparation timed out'));return;}
33
+ const remaining=s.timeline?.finishAt-view.performance.now();
34
+ timer=setTimeout(poll,s.suspended?Math.min(1000,Math.max(100,remaining||1000)):16);
35
+ }
36
+ timer=setTimeout(poll,16);
37
+ surface.ready.then(()=>{if(!done){clearTimeout(timer);poll();}},error=>finish('cancelled',error));
38
+ return {finished,cancel:()=>finish('cancelled')};
39
+ }
package/src/dom-reveal.js CHANGED
@@ -1,75 +1,85 @@
1
- // Presentation-only masking: SVG snapshots retain their original paint opacity.
2
- export const revealOpacity=new WeakMap();
3
- let sequence=0;
4
- export function withRevealOnView(factory,element,THREE,options){
5
- // Transfer the boot mask in this same task, before paint and opacity capture.
6
- element.removeAttribute('data-thd-pending');
7
- const {revealOnView:config,...settings}=options;
8
- if(config===undefined||config===false)return factory(element,THREE,settings);
9
- if(!config||typeof config!=='object'||Array.isArray(config))throw TypeError('revealOnView requires an options object');
10
- const {threshold=0,once=true,root=null}=config,document=element.ownerDocument,window=document.defaultView;
11
- if(Object.keys(config).some(k=>!['threshold','once','root'].includes(k))||!Number.isFinite(threshold)||threshold<0||threshold>1||typeof once!=='boolean'||root!==null&&(root.ownerDocument!==document||root.nodeType!==1))throw TypeError('Invalid revealOnView options');
12
- if(!window.IntersectionObserver)throw Error('IntersectionObserver unavailable');
13
- const attribute='data-thd-reveal',previous=element.getAttribute(attribute),id=String(++sequence);
14
- const style=document.createElement('style');style.textContent=`[${attribute}="${id}"]{opacity:0!important}`;
15
- const originalOpacity=window.getComputedStyle(element).opacity;revealOpacity.set(element,originalOpacity);
16
- element.setAttribute(attribute,id);document.head.append(style);
17
- let held=true,disposed=false,ready=false,eligible=false,armed=true,count=0,fallback=false;let retry=null,attempts=0;
18
- const empty=new THREE.Scene(),renderers=new Set();let revealPending=false;
19
- const namespace={...THREE,WebGLRenderer:class extends THREE.WebGLRenderer{
20
- constructor(...args){super(...args);renderers.add(this);}
21
- render(scene,camera){this.revealCamera=camera;if(revealPending&&eligible){revealPending=false;count++;unmask();if(once)observer?.disconnect();}super.render(held?empty:scene,camera);
22
- // Readiness is not permanently determined by an empty/unsupported first
23
- // layout. A later successful render can complete the pending first entry.
24
- if((count===0||fallback)&&armed&&eligible&&(!once||count===0))queueMicrotask(()=>{if(disposed||!armed||!eligible||once&&count!==0)return;fallback=false;ready=true;if(!held)mask();trigger();});
25
- }
26
- clearReveal(){if(this.revealCamera)super.render(empty,this.revealCamera);}
27
- }};
28
- let surface,observer,sizeObserver;
29
- function mask(){clearTimeout(retry);retry=null;held=true;revealPending=false;revealOpacity.set(element,originalOpacity);if(!style.isConnected)document.head.append(style);for(const renderer of renderers)renderer.clearReveal();}
30
- function unmask(){clearTimeout(retry);retry=null;held=false;style.remove();revealOpacity.delete(element);}
31
- function isFallback(state){return state.mode!=='mesh'&&!state.preparing&&!!state.reason&&!/native resting presentation|not visible|not connected|hidden after exit/i.test(state.reason);}
32
- function showFallback(){fallback=true;revealPending=false;armed=true;unmask();}
33
- function cleanup(){disposed=true;observer?.disconnect();sizeObserver?.disconnect();unmask();if(previous===null)element.removeAttribute(attribute);else element.setAttribute(attribute,previous);}
34
- // Only supervise an eligible reveal until its first rendered frame.
35
- // A cancelled preparation/visibility frame must not consume the request.
36
- function ensureFrame(){
37
- clearTimeout(retry);retry=null;
38
- if(disposed||!eligible||!held)return;
39
- if(isFallback(surface.stats())){showFallback();return;}
40
- if(++attempts>100){surface.destroy();return;}
41
- if(!surface.stats().preparing){surface.refresh();if(revealPending)requestEntry();}
42
- retry=setTimeout(ensureFrame,100);
43
- }
44
- function requestEntry(){
45
- const state=surface.stats(),timeline=state.timeline;
46
- const running=timeline?timeline.phase==='enter'&&timeline.ends>window.performance.now():state.image?.state==='entering'&&state.image?.active;
47
- // Re-entering view resumes an unfinished entry; do not reset its clock.
48
- if(running)surface.refresh();else surface.play('enter');
49
- }
50
- function trigger(){
51
- if(disposed||!ready||!eligible||!armed)return;
52
- if(isFallback(surface.stats())){showFallback();return;}
53
- armed=false;revealPending=true;requestEntry();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);
54
- }
55
- try{
56
- surface=factory(element,namespace,settings);
57
- observer=new window.IntersectionObserver(entries=>{
58
- for(const entry of entries){
59
- const visible=entry.isIntersecting&&entry.intersectionRect.width>0&&entry.intersectionRect.height>0;
60
- if(!visible){eligible=false;clearTimeout(retry);retry=null;if(!once||count===0){armed=true;mask();}continue;}
61
- eligible=entry.intersectionRatio>=threshold;if(eligible&&armed){surface.refresh();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);}trigger();
62
- }
63
- },{root,threshold:[...new Set([0,threshold===0?0.000001:threshold])]});observer.observe(element);
64
- // A zero-area intersection may already have ratio 1. Growing to a real
65
- // box need not cross an IO threshold, so request a fresh observation once.
66
- let hadArea=element.getBoundingClientRect().width>0&&element.getBoundingClientRect().height>0;
67
- sizeObserver=new window.ResizeObserver(()=>{const box=element.getBoundingClientRect(),hasArea=box.width>0&&box.height>0;if(hasArea&&!hadArea&&(!once||count===0)){observer.unobserve(element);observer.observe(element);}hadArea=hasArea;});sizeObserver.observe(element);
68
- }catch(error){cleanup();surface?.destroy();throw error;}
69
- const destroy=surface.destroy,stats=surface.stats;
70
- surface.stats=()=>({...stats(),reveal:{waiting:held,count,threshold,once}});
71
- surface.destroy=()=>{if(disposed)return;cleanup();destroy();};
72
- surface.ready.then(()=>{if(disposed)return;ready=true;const state=surface.stats();if(isFallback(state)){showFallback();return;}if(eligible)surface.refresh();trigger();},()=>{if(!disposed){unmask();observer.disconnect();}});
73
- return surface;
1
+ // Presentation-only masking: SVG snapshots retain their original paint opacity.
2
+ export const revealOpacity=new WeakMap();
3
+ let sequence=0;
4
+ export function withRevealOnView(factory,element,THREE,options){
5
+ // Transfer the boot mask in this same task, before paint and opacity capture.
6
+ element.removeAttribute('data-thd-pending');
7
+ const {revealOnView:config,...settings}=options;
8
+ if(config===undefined||config===false)return factory(element,THREE,settings);
9
+ if(!config||typeof config!=='object'||Array.isArray(config))throw TypeError('revealOnView requires an options object');
10
+ const {threshold=0,once=true,root=null}=config,document=element.ownerDocument,window=document.defaultView;
11
+ if(Object.keys(config).some(k=>!['threshold','once','root'].includes(k))||!Number.isFinite(threshold)||threshold<0||threshold>1||typeof once!=='boolean'||root!==null&&(root.ownerDocument!==document||root.nodeType!==1))throw TypeError('Invalid revealOnView options');
12
+ if(!window.IntersectionObserver)throw Error('IntersectionObserver unavailable');
13
+ const attribute='data-thd-reveal',previous=element.getAttribute(attribute),id=String(++sequence);
14
+ const style=document.createElement('style');style.textContent=`[${attribute}="${id}"]{opacity:0!important}`;
15
+ const originalOpacity=window.getComputedStyle(element).opacity;revealOpacity.set(element,originalOpacity);
16
+ element.setAttribute(attribute,id);document.head.append(style);
17
+ let held=true,disposed=false,ready=false,eligible=false,armed=true,count=0,fallback=false,manual=false;let basePlay;let retry=null,attempts=0;
18
+ const empty=new THREE.Scene(),renderers=new Set();let revealPending=false;
19
+ const namespace={...THREE,WebGLRenderer:class extends THREE.WebGLRenderer{
20
+ constructor(...args){super(...args);renderers.add(this);}
21
+ render(scene,camera){this.revealCamera=camera;if(revealPending&&eligible){revealPending=false;count++;unmask();if(once)observer?.disconnect();}super.render(held?empty:scene,camera);
22
+ // Readiness is not permanently determined by an empty/unsupported first
23
+ // layout. A later successful render can complete the pending first entry.
24
+ if((count===0||fallback)&&armed&&eligible&&(!once||count===0))queueMicrotask(()=>{if(disposed||!armed||!eligible||once&&count!==0)return;fallback=false;ready=true;if(!held)mask();trigger();});
25
+ }
26
+ clearReveal(){if(this.revealCamera)super.render(empty,this.revealCamera);}
27
+ }};
28
+ let surface,observer,sizeObserver;
29
+ function mask(){clearTimeout(retry);retry=null;held=true;revealPending=false;revealOpacity.set(element,originalOpacity);if(!style.isConnected)document.head.append(style);for(const renderer of renderers)renderer.clearReveal();}
30
+ function unmask(){clearTimeout(retry);retry=null;held=false;style.remove();revealOpacity.delete(element);}
31
+ function isFallback(state){return state.mode!=='mesh'&&!state.preparing&&!!state.reason&&!/native resting presentation|not visible|not connected|hidden after exit/i.test(state.reason);}
32
+ function showFallback(){fallback=true;revealPending=false;armed=true;unmask();}
33
+ function cleanup(){disposed=true;observer?.disconnect();sizeObserver?.disconnect();unmask();if(previous===null)element.removeAttribute(attribute);else element.setAttribute(attribute,previous);}
34
+ // Only supervise an eligible reveal until its first rendered frame.
35
+ // A cancelled preparation/visibility frame must not consume the request.
36
+ function ensureFrame(){
37
+ clearTimeout(retry);retry=null;
38
+ if(disposed||!eligible||!held)return;
39
+ if(isFallback(surface.stats())){showFallback();return;}
40
+ if(++attempts>100){surface.destroy();return;}
41
+ if(!surface.stats().preparing){surface.refresh();if(revealPending)requestEntry();}
42
+ retry=setTimeout(ensureFrame,100);
43
+ }
44
+ function requestEntry(){
45
+ const state=surface.stats(),timeline=state.timeline;
46
+ const running=timeline?timeline.phase==='enter'&&timeline.ends>window.performance.now():state.image?.state==='entering'&&state.image?.active;
47
+ // Re-entering view resumes an unfinished entry; do not reset its clock.
48
+ if(manual||running)surface.refresh();else basePlay('enter');
49
+ }
50
+ function trigger(){
51
+ if(disposed||!ready||!eligible||!armed)return;
52
+ if(isFallback(surface.stats())){showFallback();return;}
53
+ armed=false;revealPending=true;requestEntry();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);
54
+ }
55
+ try{
56
+ surface=factory(element,namespace,settings);basePlay=surface.play.bind(surface);
57
+ observer=new window.IntersectionObserver(entries=>{
58
+ for(const entry of entries){
59
+ const visible=entry.isIntersecting&&entry.intersectionRect.width>0&&entry.intersectionRect.height>0;
60
+ if(!visible){eligible=false;clearTimeout(retry);retry=null;if(!manual&&(!once||count===0)){armed=true;mask();}continue;}
61
+ eligible=entry.intersectionRatio>=threshold;if(eligible&&armed){surface.refresh();attempts=0;clearTimeout(retry);retry=setTimeout(ensureFrame,100);}trigger();
62
+ }
63
+ },{root,threshold:[...new Set([0,threshold===0?0.000001:threshold])]});observer.observe(element);
64
+ // A zero-area intersection may already have ratio 1. Growing to a real
65
+ // box need not cross an IO threshold, so request a fresh observation once.
66
+ let hadArea=element.getBoundingClientRect().width>0&&element.getBoundingClientRect().height>0;
67
+ sizeObserver=new window.ResizeObserver(()=>{const box=element.getBoundingClientRect(),hasArea=box.width>0&&box.height>0;if(hasArea&&!hadArea&&(!once||count===0)){observer.unobserve(element);observer.observe(element);}hadArea=hasArea;});sizeObserver.observe(element);
68
+ }catch(error){cleanup();surface?.destroy();throw error;}
69
+ const destroy=surface.destroy,stats=surface.stats;
70
+ surface.stats=()=>({...stats(),reveal:{waiting:held,count,threshold,once,manual}});
71
+ // Explicit play owns its clock even before the first intersection. Retain the
72
+ // initial paint mask, but never let automatic entry restart that request.
73
+ surface.play=(phase='enter')=>{
74
+ if(disposed)throw Error('Surface disposed');
75
+ if(!['enter','exit'].includes(phase))throw TypeError('Expected enter or exit');
76
+ manual=true;armed=false;clearTimeout(retry);retry=null;
77
+ revealPending=held;basePlay(phase);
78
+ };
79
+ const cancel=surface.cancel?.bind(surface);
80
+ surface.cancel=()=>{if(disposed)return;manual=true;armed=false;revealPending=false;unmask();cancel?.();};
81
+ surface.destroy=()=>{if(disposed)return;cleanup();destroy();};
82
+ surface.ready.then(()=>{if(disposed)return;ready=true;const state=surface.stats();if(isFallback(state)){showFallback();return;}if(eligible)surface.refresh();trigger();},()=>{if(!disposed){unmask();observer.disconnect();}});
83
+ return surface;
74
84
  }
75
85
 
@@ -1,119 +1,136 @@
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
- }
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(alpha=0){for(const run of this.runs.values())for(const text of run.nodes)for(const [name,value] of [['-webkit-text-fill-color',alpha>0?`color-mix(in srgb, ${text.parentElement.ownerDocument.defaultView.getComputedStyle(text.parentElement).color} ${alpha*100}%, transparent)`:'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
+ values.get(name)[2]=value;
16
+ if(node.style.getPropertyValue(name)!==value)node.style.setProperty(name,value);
17
+ }}
18
+ setPresentationOpacity(value){for(const run of this.runs.values())run.view.uniforms.presentationOpacity.value=value;}
19
+ cancel(){for(const r of this.runs.values())r.effect.cancel();}
20
+ drop(run){run.effect.dispose();run.view.dispose();run.engine.dispose();run.group.removeFromParent();}
21
+ dispose(){this.disposed=true;this.restore();for(const r of this.runs.values())this.drop(r);this.runs.clear();}
22
+ async prepare(cancelled,onRebuild=()=>{}){
23
+ const {element,canvas,THREE}=this,document=element.ownerDocument,window=document.defaultView;
24
+ if(element.isContentEditable||element.matches('input,textarea,select,img,svg'))return {reason:'Rich attachment is for display text only'};
25
+ const raw=[],walker=document.createTreeWalker(element,window.NodeFilter.SHOW_TEXT);
26
+ while(walker.nextNode()){const node=walker.currentNode;if(!canvas.contains(node)&&node.data.trim())raw.push(node);}
27
+ const nodes=groupDOMTextNodes(raw,document);
28
+ 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'};
29
+ // Shaping across styled-node boundaries needs a shared shaping context.
30
+ if(!prepareFontBoundaries(nodes,document))return {reason:'Unsupported joining boundary uses native shaping'};
31
+ this.shapingMismatch=null;
32
+ const box=element.getBoundingClientRect(),width=element.clientWidth||box.width,height=element.clientHeight||box.height;
33
+ if(!(width>0&&height>0))return {reason:'Text is not visible'};
34
+ 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);}
35
+ const segmenter=new Intl.Segmenter(undefined,{granularity:'grapheme'});
36
+ for(const node of nodes){
37
+ if(cancelled()||this.disposed)return null;
38
+ const parent=node.parentElement,style=window.getComputedStyle(parent),size=parseFloat(style.fontSize);
39
+ const collapseWhitespace=['normal','nowrap'].includes(style.whiteSpace);
40
+ 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'};
41
+ for(let ancestor=parent;ancestor;ancestor=ancestor.parentElement){
42
+ const css=window.getComputedStyle(ancestor);
43
+ 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'};}
44
+ 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'};
45
+ if(ancestor===element)break;
46
+ }
47
+ let run=this.runs.get(node.first);
48
+ if(!run){const group=new THREE.Group();this.content.add(group);const engine=new HybridTextFlowEngine({scale:.036,divisions:this.options.divisions??'auto',textRendering:'texture'});
49
+ 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);}
50
+ run.nodes=node.nodes;
51
+ const fontChanged=await adoptDOMFont(run.engine,parent);
52
+ // A newly loaded font can change glyph pixels without changing CSS names,
53
+ // advances or line boxes. Rebind the run to the refreshed raster source.
54
+ if(fontChanged)run.key=null;
55
+ if(cancelled()||this.disposed)return null;
56
+ const factor=size/200,context=run.engine.rasterizer.context;context.font=domFontAtSize(run.engine,size);context.direction=style.direction;
57
+ const spacing=[style.letterSpacing,style.wordSpacing].map(value=>value==='normal'?0:parseFloat(value));
58
+ 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'};
59
+ const spacingKey=JSON.stringify(spacing.map(value=>value/factor));
60
+ if(run.spacingKey!==spacingKey){run.engine.clearMeshes();run.engine.rasterizer.cache.clear();run.key=null;run.spacingKey=spacingKey;}
61
+ run.engine.rasterizer.letterSpacing=spacing[0]/factor;run.engine.rasterizer.wordSpacing=spacing[1]/factor;
62
+ if('letterSpacing' in context)context.letterSpacing=`${spacing[0]}px`;
63
+ if('wordSpacing' in context)context.wordSpacing=`${spacing[1]}px`;
64
+ const metrics=context.measureText('Hgآی'),rows=[],characters=[];let row=null;
65
+ if(!Number.isFinite(metrics.fontBoundingBoxAscent))return {reason:'Native font metrics unavailable'};
66
+ for(const part of segmenter.segment(node.data)){
67
+ const range=document.createRange();setRunRange(range,node,part.index,part.index+part.segment.length);
68
+ const rect=range.getBoundingClientRect();if(!rect.width||!rect.height)continue;
69
+ const top=rect.top-box.top-element.clientTop;
70
+ 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);}
71
+ 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);
72
+ const unit=run.engine.scale/factor;
73
+ 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});
74
+ }
75
+ for(const r of rows){
76
+ // Preserve original offsets for DOM Range/color mapping; normalize only
77
+ // the raster string when CSS collapses ASCII whitespace. NBSP and
78
+ // preformatted spacing are deliberately untouched.
79
+ // Collapsible whitespace at a visual line end has no native advance.
80
+ // Range may still report a rectangle for it (notably with negative
81
+ // letter-spacing on Android), so exclude it before canvas measurement.
82
+ if(collapseWhitespace&&(r.end<node.data.length||node===nodes.at(-1))){
83
+ while(r.end>r.start&&/[ \t\r\n\f]/.test(node.data[r.end-1]))r.end--;
84
+ if(r.end>r.start){
85
+ const range=document.createRange();setRunRange(range,node,r.start,r.end);
86
+ const rects=[...range.getClientRects()].filter(rect=>rect.height>0);
87
+ if(rects.length){r.left=Math.min(...rects.map(rect=>rect.left))-box.left-element.clientLeft;r.right=Math.max(...rects.map(rect=>rect.right))-box.left-element.clientLeft;}
88
+ }
89
+ }
90
+ let text=node.data.slice(r.start,r.end);
91
+ if(collapseWhitespace)text=text.replace(/[ \t\r\n\f]+/g,' ');
92
+ r.text=(r.start===0&&node.joinStart?'\u200d':'')+text+(r.end===node.data.length&&node.joinEnd?'\u200d':'');
93
+ const expected=context.measureText(r.text).width;if(Math.abs(expected-(r.right-r.left))>Math.max(1,expected*.015)){
94
+ this.shapingMismatch={text:r.text,canvasWidth:expected,domWidth:r.right-r.left,font:context.font,letterSpacing:context.letterSpacing,wordSpacing:context.wordSpacing,kerning:context.fontKerning,textRendering:context.textRendering};
95
+ return {reason:'Rich run shaping differs from DOM; native text retained'};
96
+ }
97
+ }
98
+ run.engine.rasterizer.displayFontSize=size;run.engine.rasterizer.configure();const layout={rows,factor};
99
+ const paints=node.nodes.map(text=>({text,color:window.getComputedStyle(text.parentElement).color}));
100
+ const multicolor=new Set(paints.map(p=>p.color)).size>1;
101
+ const key=JSON.stringify([node.data,size,run.engine.domFont.key,rows,paints.map(p=>p.color)]);
102
+ if(key!==run.key){onRebuild();run.effect.cancel();run.engine.setDisplayFontSize(size);await run.engine.prepareRows(layout,{cancelled});if(cancelled()||this.disposed)return null;
103
+ if(multicolor)for(let i=0;i<rows.length;i++){
104
+ const row=rows[i],entry=run.engine.flowLayout.entries[i],item=entry.metrics.items[0],glyph=item.glyph;
105
+ const spans=[];let offset=0;
106
+ 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;
107
+ const range=document.createRange();setRunRange(range,node,start,end);
108
+ const color=new THREE.Color().setStyle(paint.color).convertLinearToSRGB();
109
+ 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});
110
+ }
111
+ const source=glyph.rasterSurface,rgba=new Uint8ClampedArray(source.rgba);
112
+ for(let x=0;x<source.width;x++){
113
+ const px=row.left+(x+.5-glyph.drawOffsetX)*factor;
114
+ 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;}}
115
+ 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);}
116
+ }
117
+ // A presentation-owned copy preserves the engine's reusable alpha raster.
118
+ item.glyph={...glyph,rasterSurface:{...source,rgba,mask:false}};
119
+ }
120
+ run.view.setText(node.data);run.key=key;}
121
+ run.group.scale.setScalar(factor/run.engine.scale);run.view.uniforms.tint.value.setStyle(multicolor?'white':style.color);run.size=size;run.characters=characters;
122
+ }
123
+ return {width,height,size:parseFloat(window.getComputedStyle(element).fontSize),factor:.036,text:nodes.map(n=>n.data).join(''),direction:window.getComputedStyle(element).direction,characters:[]};
124
+ }
125
+ play(phase,mode,settings,seed,now){for(const run of this.runs.values()){
126
+ run.effect.cancel();if(mode==='none')continue;
127
+ run.effect.setMode(phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:mode);run.effect.configure(settings);
128
+ 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});
129
+ run.effect.prepareCharacterCenters(run.view,()=>run.characters);
130
+ }}
131
+ step(now,reduced){let active=false;for(const run of this.runs.values())active=run.effect.step(now,reduced)||active;return active;}
132
+ stats(){return {shapingMismatch:this.shapingMismatch??null,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)};}
133
+ }
117
134
 
118
135
 
119
136
 
@@ -4,8 +4,8 @@ import {domRasterCache} from './dom-raster-cache.js';
4
4
  const owners=new WeakMap();
5
5
  export function readDOMFont(element){
6
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;
7
+ const font={family:style.fontFamily,weight:Number(style.fontWeight)||400,style:style.fontStyle,kerning:style.fontKerning,textRendering:style.textRendering};
8
+ font.key=JSON.stringify([font.family,font.weight,font.style,font.kerning,font.textRendering]);return font;
9
9
  }
10
10
  export function domFontAtSize(engine,size){
11
11
  const font=engine.domFont;
@@ -34,12 +34,12 @@ export async function adoptDOMFont(engine,element){
34
34
  rasterizer.canvas=document.createElement('canvas');
35
35
  rasterizer.context=rasterizer.canvas.getContext('2d',{willReadFrequently:true});
36
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();
37
+ rasterizer.font=`${next.style} ${next.weight} 200px ${next.family}`;rasterizer.cache=new Map();rasterizer.fontKerning=next.kerning;rasterizer.textRendering=next.textRendering;rasterizer.configure();
38
38
  const metric=rasterizer.context.measureText('آبپچگژهمیABCgj');
39
39
  rasterizer.ascent=Math.ceil(Math.max(metric.fontBoundingBoxAscent||0,metric.actualBoundingBoxAscent||0));
40
40
  rasterizer.descent=Math.ceil(Math.max(metric.fontBoundingBoxDescent||0,metric.actualBoundingBoxDescent||0));
41
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]);
42
+ const key=JSON.stringify([this.font,this.displayFontSize,this.fontKerning,this.textRendering,this.ascent,this.descent,this.letterSpacing||0,this.wordSpacing||0,direction,text]);
43
43
  return shared.raster(key,()=>{this.cache.clear();return FontRasterizer.prototype.raster.call(this,text,direction);});
44
44
  };
45
45
  owner.epoch=shared.epoch;