@ev-ry/fx 0.1.0-rc.2 → 0.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,318 +1,324 @@
1
- import {MAX_VIEWPORT_CLIPS,readViewportClip,applyViewportClips} from './viewport-clip.js';
2
- const ownerBackends=new WeakMap(),controlSessions=new WeakMap();
3
-
4
- // Opt-in DOM presentation: one visible GPU canvas; each surface keeps its local
5
- // scene/camera and a paint-free DOM anchor. No bitmap copies or hidden editors.
6
- export function createViewportRenderOwner(THREE,document,{escapeEffects=true,zIndex=100,root=null,local=false,documentCanvas=false}={}){
7
- if(!THREE?.WebGLRenderer||!document?.defaultView)throw TypeError('THREE and a window document required');
8
- if(typeof escapeEffects!=='boolean'||!Number.isFinite(zIndex))throw TypeError('Invalid viewport presentation options');
9
- if(root&&(root.ownerDocument!==document||root.tagName!=='DIALOG'))throw TypeError('Shared root must be a dialog in the owner document');
10
- const useDocumentCanvas=documentCanvas&&!local&&!root;
11
- let documentBand=null,bandMoves=0;
12
- const window=document.defaultView,leases=new Set(),handles=new Set(),listeners=[];
13
- let renderer=null,disposed=false,lost=false,frame=null,dirty=false,lastSignature='',frames=0,passes=0,renderMs=0,created=0;
14
- let observer=null,resizeObserver=null,layoutObserver=null,placements=[],tracking=false,bufferWidth=0,bufferHeight=0,bufferRatio=0,failure=null;
15
- const observedLayout=new Set(),externalAnimations=new Set();
16
- const spatialAnimations=new WeakMap();
17
- const materials=new WeakMap();
18
- let frameStyles=null,frameRects=null;
19
- function readStyle(node){if(!frameStyles)return window.getComputedStyle(node);let value=frameStyles.get(node);if(!value){value=window.getComputedStyle(node);frameStyles.set(node,value);}return value;}
20
- function readRect(node){if(!frameRects)return node.getBoundingClientRect();let value=frameRects.get(node);if(!value){value=node.getBoundingClientRect();frameRects.set(node,value);}return value;}
21
- let copies=0,copyMs=0,localLayoutDirty=false;
22
- const listen=(target,event,fn,options)=>{target.addEventListener(event,fn,options);listeners.push(()=>target.removeEventListener(event,fn,options));};
23
- function request(force=false){
24
- if(disposed||lost)return;dirty ||= force||local;
25
- if(local&&!force)localLayoutDirty=true;
26
- if(frame===null)frame=window.requestAnimationFrame(paint);
27
- }
28
- function stop(error){
29
- if(lost||disposed)return;lost=true;failure=String(error?.message||error);
30
- if(frame!==null)window.cancelAnimationFrame(frame);frame=null;placements=[];
31
- renderer.domElement.style.display='none';
32
- for(const lease of leases)lease.domElement.dispatchEvent(new window.Event('webglcontextlost',{cancelable:true}));
33
- }
34
- function onLoss(event){event.preventDefault();stop('WebGL context lost; recreate the DOM renderer to retry');}
35
- function geometryAnimation(animation){
36
- if(!spatialAnimations.has(animation))spatialAnimations.set(animation,animation.effect?.getKeyframes().some(f=>Object.keys(f).some(key=>/^(transform|translate|rotate|scale|perspective|opacity|width|height|top|left|right|bottom|margin.*|padding.*|fontSize|flex.*|grid.*)$/.test(key))));
37
- return animation.playState==='running'&&spatialAnimations.get(animation);
38
- }
39
- function observeLayout(){
40
- if(!resizeObserver)return;
41
- const next=new Set();
42
- for(const lease of leases)for(let node=lease.surface;node?.isConnected;node=node.parentElement)next.add(node);
43
- for(const node of observedLayout)if(!next.has(node)){resizeObserver.unobserve(node);observedLayout.delete(node);}
44
- for(const node of next)if(!observedLayout.has(node)){resizeObserver.observe(node);observedLayout.add(node);}
45
- }
46
- function ensure(){
47
- if(disposed||lost)throw Error('Shared viewport renderer unavailable');
48
- if(renderer)return;
49
- renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});created++;
50
- const canvas=renderer.domElement;canvas.setAttribute('data-thd-global-canvas','');canvas.setAttribute('aria-hidden','true');
51
- Object.assign(canvas.style,{position:'fixed',inset:'0',width:'100%',height:'100%',pointerEvents:'none',zIndex:String(zIndex),margin:'0',padding:'0',border:'0',display:'block'});
52
- if(useDocumentCanvas){canvas.style.position='absolute';canvas.style.inset='auto';canvas.style.left='0';canvas.setAttribute('data-thd-document-canvas','');}
53
- if(!local)(root||document.body||document.documentElement).append(canvas);renderer.autoClear=false;renderer.setClearColor(0,0);
54
- listen(canvas,'webglcontextlost',onLoss);
55
- for(const target of [window,window.visualViewport].filter(Boolean))for(const event of ['resize','scroll'])listen(target,event,()=>request(),{passive:true});
56
- listen(document,'scroll',()=>request(),{capture:true,passive:true});
57
- for(const event of ['transitionrun','transitionend','transitioncancel','animationstart','animationend','animationcancel'])listen(document,event,e=>{
58
- for(const animation of e.target.getAnimations?.()||[])if(geometryAnimation(animation))externalAnimations.add(animation);request();
59
- },true);
60
- for(const event of ['load','loadedmetadata','toggle','beforetoggle','close','fullscreenchange'])listen(document,event,()=>request(),true);
61
- listen(document,'visibilitychange',()=>request(true));
62
- if(window.ResizeObserver){resizeObserver=new window.ResizeObserver(()=>request());}
63
- if(window.PerformanceObserver?.supportedEntryTypes?.includes('layout-shift')){
64
- layoutObserver=new window.PerformanceObserver(()=>request());layoutObserver.observe({type:'layout-shift'});
65
- }
66
- observer=new window.MutationObserver(records=>{
67
- const relevant=records.filter(r=>r.target!==canvas&&!r.target.closest?.('[data-thd-viewport-anchor]'));
68
- if(relevant.length){
69
- const moved=relevant.some(r=>r.type==='childList'&&[...r.addedNodes,...r.removedNodes].some(node=>node.nodeType===1&&[...leases].some(lease=>lease.surface&&node.contains(lease.surface))));
70
- if(moved)observeLayout();request();
71
- }
72
- });
73
- observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['class','style','hidden','open','dir','data-theme']});
74
- }
75
- function surfaceIssue(lease){
76
- if(local)return lease.surface&&lease.escapeHost&&!lease.escapeHost.contains(lease.surface)?'Source moved outside attachment host; detach and attach in the new host':null;
77
- if(root&&(!root.isConnected||!root.open||!root.contains(lease.surface)))return 'Dialog closed or source outside registered root';
78
- if(lease.surface&&lease.escapeHost&&!lease.escapeHost.contains(lease.surface))return 'Source moved outside attachment host; detach and attach in the new host';
79
- if(document.fullscreenElement&&!document.fullscreenElement.contains(renderer.domElement))return 'Fullscreen content uses native text';
80
- let rounded=0;
81
- for(let node=lease.surface;node;node=node.parentElement){
82
- if(node!==root&&node.matches('dialog:modal'))return 'Top-layer dialog uses native text';
83
- if(node.hasAttribute('popover')&&node.matches(':popover-open'))return 'Top-layer popover uses native text';
84
- const style=readStyle(node);
85
- if(style.clipPath&&style.clipPath!=='none'||style.maskImage&&style.maskImage!=='none')return 'CSS masked layout uses native text';
86
- if(style.perspective!=='none'||style.rotate&&style.rotate!=='none'&&style.rotate!=='0deg')return 'Rotated or perspective DOM layout uses native text';
87
- if(style.scale&&style.scale!=='none'&&style.scale.split(/\s+/).some(value=>Number(value)<=0))return 'Reflected DOM layout uses native text';
88
- if(style.transform!=='none'){
89
- const matrix=new window.DOMMatrixReadOnly(style.transform);
90
- if(!matrix.is2D||Math.abs(matrix.b)>1e-7||Math.abs(matrix.c)>1e-7||matrix.a<=0||matrix.d<=0)return 'Rotated or skewed DOM layout uses native text';
91
- }
92
- if(node!==lease.surface&&(node===root||!(escapeEffects&&lease.escapeHost?.contains(node)))){
93
- if((style.overflowX==='clip'||style.overflowY==='clip')&&style.overflowClipMargin!=='0px')return 'Expanded CSS overflow clipping uses native text';
94
- try{if(readViewportClip(node,style,readRect(node))&&++rounded>MAX_VIEWPORT_CLIPS)return 'Too many nested rounded clips; native text retained';}
95
- catch{return 'Unresolved rounded clipping uses native text';}
96
- }
97
- if(node===root)break;
98
- }
99
- return null;
100
- }
101
- function measure(lease,viewport){
102
- const anchor=lease.domElement,source=lease.surface;
103
- if(lease.dead||!source?.isConnected||!anchor.isConnected||document.hidden)return null;
104
- // Inherited typography can change after an intact component is moved under
105
- // a new ancestor; the original surface observers do not know that ancestor.
106
- const appearance=readStyle(source),styleKey=[appearance.font,appearance.color,appearance.direction,appearance.textAlign,appearance.lineHeight,appearance.letterSpacing,appearance.wordSpacing,appearance.writingMode,appearance.paddingTop,appearance.paddingRight,appearance.paddingBottom,appearance.paddingLeft].join('|');
107
- if(lease.styleKey!==styleKey){const known=lease.styleKey!==undefined;lease.styleKey=styleKey;if(known)anchor.dispatchEvent(new window.Event('thdviewportchange'));}
108
- const anchorStyle=readStyle(anchor);
109
- const hidden=anchorStyle.display==='none'||anchorStyle.visibility!=='visible';
110
- // A native-rest/dormant surface needs only the appearance check above.
111
- // Continue checking established issues so corrected layouts can recover.
112
- if(hidden&&!lease.issue)return null;
113
- for(let node=source;node;node=node.parentElement)for(const animation of node.getAnimations?.()||[])if(geometryAnimation(animation))tracking=true;
114
- const issue=surfaceIssue(lease);
115
- if(issue!==lease.issue){lease.issue=issue;anchor.dispatchEvent(new window.Event('thdviewportchange'));}
116
- if(hidden||issue||!lease.scene)return null;
117
- const sourceRect=readRect(source);let rect=readRect(anchor);
118
- // Native editors may move inside a host without resizing. Their sibling
119
- // anchor can be stale until the surface next renders; use the live client box.
120
- if(/^(INPUT|TEXTAREA)$/.test(source.tagName)){
121
- const sx=source.offsetWidth?sourceRect.width/source.offsetWidth:1,sy=source.offsetHeight?sourceRect.height/source.offsetHeight:1;
122
- rect={left:sourceRect.left+source.clientLeft*sx,top:sourceRect.top+source.clientTop*sy,width:source.clientWidth*sx,height:source.clientHeight*sy};
123
- rect.right=rect.left+rect.width;rect.bottom=rect.top+rect.height;
124
- }
125
- if(!(rect.width>0&&rect.height>0&&sourceRect.width>0&&sourceRect.height>0))return null;
126
- const clip={left:viewport.left,top:viewport.top,right:viewport.right,bottom:viewport.bottom};
127
- let opacity=1;const roundedClips=[];
128
- for(let node=source;node;node=node.parentElement){
129
- const style=readStyle(node);
130
- if(style.display==='none'||style.visibility!=='visible'||node.hidden)return null;
131
- // The canvas inherits the dialog's CSS opacity itself; apply only descendants.
132
- if(node!==root)opacity*=Number(style.opacity);
133
- if(node===source||node!==root&&escapeEffects&&lease.escapeHost?.contains(node))continue;
134
- const box=readRect(node),sx=node.offsetWidth?box.width/node.offsetWidth:1,sy=node.offsetHeight?box.height/node.offsetHeight:1;
135
- if(/^(auto|scroll|hidden|clip)$/.test(style.overflowX)){clip.left=Math.max(clip.left,box.left+node.clientLeft*sx);clip.right=Math.min(clip.right,box.left+(node.clientLeft+node.clientWidth)*sx);}
136
- if(/^(auto|scroll|hidden|clip)$/.test(style.overflowY)){clip.top=Math.max(clip.top,box.top+node.clientTop*sy);clip.bottom=Math.min(clip.bottom,box.top+(node.clientTop+node.clientHeight)*sy);}
137
- const roundedClip=readViewportClip(node,style,box);if(roundedClip)roundedClips.push(roundedClip);
138
- if(node===root)break;
139
- }
140
- if(opacity<=0||clip.right<=clip.left||clip.bottom<=clip.top||sourceRect.right<=clip.left||sourceRect.left>=clip.right||sourceRect.bottom<=clip.top||sourceRect.top>=clip.bottom)return null;
141
- return {lease,rect,clip,opacity,roundedClips};
142
- }
143
- function setOpacity(scene,opacity){
144
- scene.traverse(object=>{
145
- for(const material of (Array.isArray(object.material)?object.material:[object.material])){
146
- if(!material?.isShaderMaterial)continue;
147
- if(materials.get(material)!==material.fragmentShader){
148
- if(!material.fragmentShader.includes('uniform float thdSurfaceOpacity;')){
149
- material.fragmentShader='uniform float thdSurfaceOpacity;\n'+material.fragmentShader.replace(/}\s*$/,'gl_FragColor.a *= thdSurfaceOpacity;\n}');
150
- material.needsUpdate=true;
151
- }
152
- material.uniforms.thdSurfaceOpacity??={value:opacity};materials.set(material,material.fragmentShader);
153
- }
154
- material.uniforms.thdSurfaceOpacity.value=opacity;
155
- }
156
- });
157
- }
158
- function paint(){frameStyles=new WeakMap();frameRects=new WeakMap();try{paintFrame();}catch(error){stop(error);}finally{frameStyles=null;frameRects=null;}}
159
- function escapeUniforms(scene,enabled){
160
- scene.traverse(object=>{for(const material of Array.isArray(object.material)?object.material:[object.material]){
161
- if(material?.uniforms?.hybridEscapeEnabled)material.uniforms.hybridEscapeEnabled.value=enabled?1:0;
162
- }});
163
- }
164
- function localCanvas(lease){
165
- if(!lease.localCanvas){
166
- const canvas=document.createElement('canvas'),context=canvas.getContext('2d');
167
- if(!context)throw Error('2D presentation unavailable');
168
- canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-local-canvas','');
169
- Object.assign(canvas.style,{position:'absolute',inset:'0',width:'100%',height:'100%',pointerEvents:'none'});
170
- lease.localCanvas=canvas;lease.localContext=context;lease.domElement.append(canvas);
171
- }
172
- return lease.localCanvas;
173
- }
174
- function paintLocal(){
175
- if(!dirty)return;dirty=false;
176
- const start=window.performance.now();
177
- for(const lease of leases){
178
- if(lease.dead||!lease.scene||!lease.width||!lease.height||!lease.needsPaint&&!localLayoutDirty)continue;
179
- lease.needsPaint=false;
180
- const canvas=localCanvas(lease),ratio=lease.ratio||1;
181
- renderer.setPixelRatio(ratio);renderer.setSize(lease.width,lease.height,false);
182
- renderer.setViewport(0,0,lease.width,lease.height);renderer.setScissorTest(false);renderer.clear(true,true,true);
183
- setOpacity(lease.scene,Number(window.getComputedStyle(lease.surface).opacity));
184
- applyViewportClips(lease.scene,[],{left:0,bottom:lease.height},ratio);escapeUniforms(lease.scene,false);
185
- renderer.render(lease.scene,lease.camera);passes++;
186
- const copyStart=window.performance.now();
187
- if(canvas.width!==renderer.domElement.width)canvas.width=renderer.domElement.width;
188
- if(canvas.height!==renderer.domElement.height)canvas.height=renderer.domElement.height;
189
- lease.localContext.clearRect(0,0,canvas.width,canvas.height);lease.localContext.drawImage(renderer.domElement,0,0);
190
- copies++;copyMs+=window.performance.now()-copyStart;
191
- }
192
- localLayoutDirty=false;renderMs+=window.performance.now()-start;frames++;
193
- }
194
- function paintFrame(){
195
- frame=null;if(disposed||lost||!renderer)return;
196
- if(local){paintLocal();return;}
197
- const canvas=renderer.domElement;
198
- if(useDocumentCanvas){
199
- const width=document.documentElement.clientWidth,height=window.innerHeight,y=Math.max(0,window.scrollY);
200
- const pageHeight=document.documentElement.scrollHeight,bandHeight=Math.min(pageHeight,height*2);
201
- const margin=Math.min(height*.2,Math.max(0,(bandHeight-height)/4));
202
- if(!documentBand||documentBand.width!==width||documentBand.height!==bandHeight||y<documentBand.top+margin||y+height>documentBand.top+bandHeight-margin){
203
- const top=Math.max(0,Math.min(Math.floor(y-height*.5),pageHeight-bandHeight));
204
- if(!documentBand||top!==documentBand.top||width!==documentBand.width||bandHeight!==documentBand.height){
205
- Object.assign(canvas.style,{top:top+'px',width:width+'px',height:bandHeight+'px'});
206
- documentBand={top,width,height:bandHeight};bandMoves++;dirty=true;
207
- }
208
- }
209
- }
210
- const viewport=canvas.getBoundingClientRect(),w=viewport.width,h=viewport.height;
211
- if(!(w>0&&h>0))return;
212
- tracking=false;
213
- for(const animation of externalAnimations)if(geometryAnimation(animation))tracking=true;else externalAnimations.delete(animation);
214
- const ratio=Math.min(window.devicePixelRatio||1,2),visible=[...leases].map(lease=>measure(lease,viewport)).filter(Boolean);
215
- if(tracking)request();
216
- // DOM order is the explicit paint order within this global layer.
217
- visible.sort((a,b)=>a.lease.surface.compareDocumentPosition(b.lease.surface)&4?-1:1);
218
- const signature=JSON.stringify([w,h,ratio,visible.map(({lease,rect,clip,opacity,roundedClips})=>[lease.id,rect.left,rect.top,rect.width,rect.height,clip,opacity,roundedClips])]);
219
- if(!dirty&&signature===lastSignature)return;
220
- dirty=false;lastSignature=signature;
221
- if(bufferRatio!==ratio){renderer.setPixelRatio(ratio);bufferRatio=ratio;bufferWidth=bufferHeight=0;}
222
- if(bufferWidth!==w||bufferHeight!==h){renderer.setSize(w,h,false);bufferWidth=w;bufferHeight=h;}
223
- renderer.setViewport(0,0,w,h);renderer.setScissorTest(false);renderer.clear(true,true,true);
224
- const start=window.performance.now();placements=[];
225
- for(const {lease,rect,clip,opacity,roundedClips} of visible){
226
- const camera=lease.camera,mapped=lease.mappedCamera||(lease.mappedCamera=camera.clone());mapped.copy(camera);
227
- const spanX=camera.right-camera.left,spanY=camera.top-camera.bottom;
228
- mapped.left=camera.left-(rect.left-viewport.left)*spanX/rect.width;
229
- mapped.right=mapped.left+w*spanX/rect.width;
230
- mapped.top=camera.top+(rect.top-viewport.top)*spanY/rect.height;
231
- mapped.bottom=mapped.top-h*spanY/rect.height;mapped.updateProjectionMatrix();
232
- renderer.setScissor(clip.left-viewport.left,viewport.bottom-clip.bottom,clip.right-clip.left,clip.bottom-clip.top);renderer.setScissorTest(true);renderer.clearDepth();
233
- setOpacity(lease.scene,opacity);applyViewportClips(lease.scene,roundedClips,viewport,ratio);escapeUniforms(lease.scene,escapeEffects);renderer.render(lease.scene,mapped);passes++;
234
- placements.push({id:lease.id,left:rect.left,top:rect.top,width:rect.width,height:rect.height,clip:{...clip},roundedClips:roundedClips.length});
235
- }
236
- renderer.setScissorTest(false);renderMs+=window.performance.now()-start;frames++;
237
- }
238
- let sequence=0;
239
- let createdLease=null;
240
- const backend={leases,handles,ensure,request,surfaceIssue,observeLayout,
241
- flush(){if(frame!==null)window.cancelAnimationFrame(frame);frame=null;dirty=true;paint();return !lost;},
242
- release(lease){leases.delete(lease);observeLayout();request(true);},
243
- available:()=>!disposed&&!lost,nextId:()=>++sequence,escapeEffects,local,
244
- prepare:lease=>{if(local){localCanvas(lease);lease.needsPaint=true;}},
245
- present(lease){lease.domElement.style.opacity=local?'1':'0';if(lease.localCanvas)lease.localCanvas.style.display=local?'block':'none';}};
246
- const Namespace={...THREE,WebGLRenderer:class{
247
- constructor(){
248
- ensure();this.id=++sequence;this.domElement=document.createElement('span');this.domElement.setAttribute('data-thd-viewport-anchor','');
249
- Object.assign(this.domElement.style,{display:'block',position:'absolute',pointerEvents:'none',opacity:local?'1':'0'});
250
- this.globalCanvas=true;this.escapeEffects=escapeEffects;this.dead=false;leases.add(this);
251
- this.backend=backend;createdLease=this;
252
- backend.prepare(this);
253
- }
254
- setSurface(element,{escapeHost=element}={}){
255
- this.surface=element;this.escapeHost=escapeHost;
256
- this.onCompositionStart=()=>{this.composing=true;};this.onCompositionEnd=()=>{this.composing=false;};
257
- element.addEventListener('compositionstart',this.onCompositionStart);element.addEventListener('compositionend',this.onCompositionEnd);
258
- this.backend.observeLayout();this.backend.request(true);
259
- }
260
- getSurfaceIssue(){return this.backend.surfaceIssue(this);}
261
- setPixelRatio(value){this.ratio=value;}
262
- setSize(width,height){this.width=width;this.height=height;}
263
- invalidate(){this.backend.request(true);}
264
- flushPresentation(){return this.backend.flush();}
265
- render(scene,camera){
266
- this.backend.ensure();if(this.dead)throw Error('Presentation disposed');if(!camera.isOrthographicCamera)throw TypeError('DOM viewport presentation requires an orthographic camera');
267
- this.scene=scene;this.camera=camera;this.needsPaint=true;this.backend.request(true);
268
- }
269
- dispose(){if(this.dead)return;this.dead=true;this.backend.release(this);this.surface?.removeEventListener('compositionstart',this.onCompositionStart);this.surface?.removeEventListener('compositionend',this.onCompositionEnd);this.scene=null;this.camera=null;this.domElement.remove();}
270
- forceContextLoss(){} // A surface cannot destroy its peers' context.
271
- }};
272
- const api={
273
- transfer(control,target,{recover=false}={}){
274
- const session=controlSessions.get(control),destination=ownerBackends.get(target);
275
- if(!session||session.backend!==backend||!backend.available()||!destination?.available())throw Error('Unavailable transfer owner');
276
- const lease=session.lease;
277
- if(!lease||control.stats().mode!=='mesh'&&!recover)throw Error('Only a visible mesh surface can transfer');
278
- if(lease.composing)throw Error('Transfer deferred during composition');
279
- if(!lease.scene||!lease.camera)throw Error('Surface must have rendered before transfer');
280
- if(destination===backend)return;
281
- if(destination.escapeEffects!==escapeEffects)throw Error('Incompatible escape policy');
282
- destination.ensure();const issue=destination.surfaceIssue(lease);if(issue)throw Error(issue);
283
- destination.prepare(lease);
284
- leases.delete(lease);handles.delete(control);
285
- destination.leases.add(lease);destination.handles.add(control);
286
- lease.backend=destination;session.backend=destination;lease.id=destination.nextId();lease.mappedCamera=null;lease.issue=null;
287
- destination.present(lease);
288
- observeLayout();destination.observeLayout();
289
- // Both buffers are updated in this task; the effect clock is not advanced.
290
- backend.flush();destination.flush();
291
- if(recover)lease.domElement.dispatchEvent(new window.Event('thdviewportchange'));
292
- },
293
- get domElement(){return renderer?.domElement??null;},
294
- refresh(){
295
- if(disposed)throw Error('Shared owner disposed');
296
- for(const animation of document.getAnimations?.()||[])if(geometryAnimation(animation))externalAnimations.add(animation);
297
- observeLayout();for(const control of handles)control.refresh?.();request(true);
298
- },
299
- create(factory,host,options){
300
- if(disposed)throw Error('Shared owner disposed');if(host?.ownerDocument!==document)throw TypeError('Host must belong to the owner document');
301
- if(root&&!root.contains(host))throw TypeError('Surface must belong to its registered dialog');
302
- createdLease=null;
303
- const control=factory(host,Namespace,options),destroy=control.destroy;handles.add(control);
304
- const session={lease:createdLease,backend};controlSessions.set(control,session);
305
- control.destroy=()=>{session.backend.handles.delete(control);controlSessions.delete(control);destroy();};return control;
306
- },
307
- stats:()=>({documentCanvas:useDocumentCanvas,documentBand,bandMoves,presentation:local?'local':'viewport',disposed,lost,failure,contexts:renderer&&!disposed?1:0,canvases:local?leases.size:renderer&&!disposed?1:0,created,controls:handles.size,leases:leases.size,copies,copyMs,frames,passes,renderMs,pending:frame!==null,escapeEffects,zIndex,placements,geometries:renderer?.info.memory.geometries||0}),
308
- destroy(){
309
- if(disposed)return;disposed=true;if(frame!==null)window.cancelAnimationFrame(frame);frame=null;observer?.disconnect();resizeObserver?.disconnect();resizeObserver=null;observedLayout.clear();layoutObserver?.disconnect();externalAnimations.clear();for(const off of listeners)off();
310
- const errors=[];for(const control of [...handles])try{control.destroy();}catch(error){errors.push(error);}
311
- if(renderer){try{renderer.dispose();renderer.forceContextLoss();}catch(error){errors.push(error);}renderer.domElement.remove();}
312
- if(errors.length)throw new AggregateError(errors,'Viewport owner cleanup failed');
313
- }
314
- };
315
- ownerBackends.set(api,backend);return api;
316
- }
1
+ import {MAX_VIEWPORT_CLIPS,readViewportClip,applyViewportClips} from './viewport-clip.js';
2
+ const ownerBackends=new WeakMap(),controlSessions=new WeakMap();
3
+
4
+ // Opt-in DOM presentation: one visible GPU canvas; each surface keeps its local
5
+ // scene/camera and a paint-free DOM anchor. No bitmap copies or hidden editors.
6
+ export function createViewportRenderOwner(THREE,document,{escapeEffects=true,zIndex=100,root=null,local=false,documentCanvas=false}={}){
7
+ if(!THREE?.WebGLRenderer||!document?.defaultView)throw TypeError('THREE and a window document required');
8
+ if(typeof escapeEffects!=='boolean'||!Number.isFinite(zIndex))throw TypeError('Invalid viewport presentation options');
9
+ if(root&&(root.ownerDocument!==document||root.tagName!=='DIALOG'))throw TypeError('Shared root must be a dialog in the owner document');
10
+ const useDocumentCanvas=documentCanvas&&!local&&!root;
11
+ let documentBand=null,bandMoves=0;
12
+ const window=document.defaultView,leases=new Set(),handles=new Set(),listeners=[];
13
+ let renderer=null,disposed=false,lost=false,frame=null,dirty=false,lastSignature='',frames=0,passes=0,renderMs=0,created=0;
14
+ let observer=null,resizeObserver=null,layoutObserver=null,placements=[],tracking=false,bufferWidth=0,bufferHeight=0,bufferRatio=0,failure=null;
15
+ const observedLayout=new Set(),externalAnimations=new Set();
16
+ const spatialAnimations=new WeakMap();
17
+ const materials=new WeakMap();
18
+ let frameStyles=null,frameRects=null;
19
+ function readStyle(node){if(!frameStyles)return window.getComputedStyle(node);let value=frameStyles.get(node);if(!value){value=window.getComputedStyle(node);frameStyles.set(node,value);}return value;}
20
+ function readRect(node){if(!frameRects)return node.getBoundingClientRect();let value=frameRects.get(node);if(!value){value=node.getBoundingClientRect();frameRects.set(node,value);}return value;}
21
+ let copies=0,copyMs=0,localLayoutDirty=false;
22
+ const listen=(target,event,fn,options)=>{target.addEventListener(event,fn,options);listeners.push(()=>target.removeEventListener(event,fn,options));};
23
+ function request(force=false){
24
+ if(disposed||lost)return;dirty ||= force||local;
25
+ if(local&&!force)localLayoutDirty=true;
26
+ if(frame===null)frame=window.requestAnimationFrame(paint);
27
+ }
28
+ function stop(error){
29
+ if(lost||disposed)return;lost=true;failure=String(error?.message||error);
30
+ if(frame!==null)window.cancelAnimationFrame(frame);frame=null;placements=[];
31
+ renderer.domElement.style.display='none';
32
+ for(const lease of leases)lease.domElement.dispatchEvent(new window.Event('webglcontextlost',{cancelable:true}));
33
+ }
34
+ function onLoss(event){event.preventDefault();stop('WebGL context lost; recreate the DOM renderer to retry');}
35
+ function geometryAnimation(animation){
36
+ if(!spatialAnimations.has(animation))spatialAnimations.set(animation,animation.effect?.getKeyframes().some(f=>Object.keys(f).some(key=>/^(transform|translate|rotate|scale|perspective|opacity|width|height|top|left|right|bottom|margin.*|padding.*|fontSize|flex.*|grid.*)$/.test(key))));
37
+ return animation.playState==='running'&&spatialAnimations.get(animation);
38
+ }
39
+ function observeLayout(){
40
+ if(!resizeObserver)return;
41
+ const next=new Set();
42
+ for(const lease of leases)for(let node=lease.surface;node?.isConnected;node=node.parentElement)next.add(node);
43
+ for(const node of observedLayout)if(!next.has(node)){resizeObserver.unobserve(node);observedLayout.delete(node);}
44
+ for(const node of next)if(!observedLayout.has(node)){resizeObserver.observe(node);observedLayout.add(node);}
45
+ }
46
+ function ensure(){
47
+ if(disposed||lost)throw Error('Shared viewport renderer unavailable');
48
+ if(renderer)return;
49
+ renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});created++;
50
+ const canvas=renderer.domElement;canvas.setAttribute('data-thd-global-canvas','');canvas.setAttribute('aria-hidden','true');
51
+ Object.assign(canvas.style,{position:'fixed',inset:'0',width:'100%',height:'100%',pointerEvents:'none',zIndex:String(zIndex),margin:'0',padding:'0',border:'0',display:'block'});
52
+ if(useDocumentCanvas){canvas.style.position='absolute';canvas.style.inset='auto';canvas.style.left='0';canvas.setAttribute('data-thd-document-canvas','');}
53
+ if(!local)(root||document.body||document.documentElement).append(canvas);renderer.autoClear=false;renderer.setClearColor(0,0);
54
+ listen(canvas,'webglcontextlost',onLoss);
55
+ for(const target of [window,window.visualViewport].filter(Boolean))for(const event of ['resize','scroll'])listen(target,event,()=>request(),{passive:true});
56
+ listen(document,'scroll',()=>request(),{capture:true,passive:true});
57
+ for(const event of ['transitionrun','transitionend','transitioncancel','animationstart','animationend','animationcancel'])listen(document,event,e=>{
58
+ for(const animation of e.target.getAnimations?.()||[])if(geometryAnimation(animation))externalAnimations.add(animation);request();
59
+ },true);
60
+ for(const event of ['load','loadedmetadata','toggle','beforetoggle','close','fullscreenchange'])listen(document,event,()=>request(),true);
61
+ listen(document,'visibilitychange',()=>request(true));
62
+ if(window.ResizeObserver){resizeObserver=new window.ResizeObserver(()=>request());}
63
+ if(window.PerformanceObserver?.supportedEntryTypes?.includes('layout-shift')){
64
+ layoutObserver=new window.PerformanceObserver(()=>request());layoutObserver.observe({type:'layout-shift'});
65
+ }
66
+ observer=new window.MutationObserver(records=>{
67
+ const relevant=records.filter(r=>r.target!==canvas&&!r.target.closest?.('[data-thd-viewport-anchor]'));
68
+ if(relevant.length){
69
+ const moved=relevant.some(r=>r.type==='childList'&&[...r.addedNodes,...r.removedNodes].some(node=>node.nodeType===1&&[...leases].some(lease=>lease.surface&&node.contains(lease.surface))));
70
+ if(moved)observeLayout();request();
71
+ }
72
+ });
73
+ observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['class','style','hidden','open','dir','data-theme']});
74
+ }
75
+ function surfaceIssue(lease){
76
+ if(local)return lease.surface&&lease.escapeHost&&!lease.escapeHost.contains(lease.surface)?'Source moved outside attachment host; detach and attach in the new host':null;
77
+ if(root&&(!root.isConnected||!root.open||!root.contains(lease.surface)))return 'Dialog closed or source outside registered root';
78
+ if(lease.surface&&lease.escapeHost&&!lease.escapeHost.contains(lease.surface))return 'Source moved outside attachment host; detach and attach in the new host';
79
+ if(document.fullscreenElement&&!document.fullscreenElement.contains(renderer.domElement))return 'Fullscreen content uses native text';
80
+ let rounded=0;
81
+ for(let node=lease.surface;node;node=node.parentElement){
82
+ if(node!==root&&node.matches('dialog:modal'))return 'Top-layer dialog uses native text';
83
+ if(node.hasAttribute('popover')&&node.matches(':popover-open'))return 'Top-layer popover uses native text';
84
+ const style=readStyle(node);
85
+ if(style.clipPath&&style.clipPath!=='none'||style.maskImage&&style.maskImage!=='none')return 'CSS masked layout uses native text';
86
+ if(style.perspective!=='none'||style.rotate&&style.rotate!=='none'&&style.rotate!=='0deg')return 'Rotated or perspective DOM layout uses native text';
87
+ if(style.scale&&style.scale!=='none'&&style.scale.split(/\s+/).some(value=>Number(value)<=0))return 'Reflected DOM layout uses native text';
88
+ if(style.transform!=='none'){
89
+ const matrix=new window.DOMMatrixReadOnly(style.transform);
90
+ if(!matrix.is2D||Math.abs(matrix.b)>1e-7||Math.abs(matrix.c)>1e-7||matrix.a<=0||matrix.d<=0)return 'Rotated or skewed DOM layout uses native text';
91
+ }
92
+ if(node!==lease.surface&&(node===root||!(escapeEffects&&lease.escapeHost?.contains(node)))){
93
+ if((style.overflowX==='clip'||style.overflowY==='clip')&&style.overflowClipMargin!=='0px')return 'Expanded CSS overflow clipping uses native text';
94
+ try{if(readViewportClip(node,style,readRect(node))&&++rounded>MAX_VIEWPORT_CLIPS)return 'Too many nested rounded clips; native text retained';}
95
+ catch{return 'Unresolved rounded clipping uses native text';}
96
+ }
97
+ if(node===root)break;
98
+ }
99
+ return null;
100
+ }
101
+ function measure(lease,viewport){
102
+ const anchor=lease.domElement,source=lease.surface;
103
+ if(lease.dead||!source?.isConnected||!anchor.isConnected||document.hidden)return null;
104
+ // Inherited typography can change after an intact component is moved under
105
+ // a new ancestor; the original surface observers do not know that ancestor.
106
+ const appearance=readStyle(source),styleKey=[appearance.font,appearance.color,appearance.direction,appearance.textAlign,appearance.lineHeight,appearance.letterSpacing,appearance.wordSpacing,appearance.writingMode,appearance.paddingTop,appearance.paddingRight,appearance.paddingBottom,appearance.paddingLeft].join('|');
107
+ if(lease.styleKey!==styleKey){const known=lease.styleKey!==undefined;lease.styleKey=styleKey;if(known)anchor.dispatchEvent(new window.Event('thdviewportchange'));}
108
+ const anchorStyle=readStyle(anchor);
109
+ const hidden=anchorStyle.display==='none'||anchorStyle.visibility!=='visible';
110
+ // A native-rest/dormant surface needs only the appearance check above.
111
+ // Continue checking established issues so corrected layouts can recover.
112
+ if(hidden&&!lease.issue)return null;
113
+ for(let node=source;node;node=node.parentElement)for(const animation of node.getAnimations?.()||[])if(geometryAnimation(animation))tracking=true;
114
+ const issue=surfaceIssue(lease);
115
+ if(issue!==lease.issue){lease.issue=issue;anchor.dispatchEvent(new window.Event('thdviewportchange'));}
116
+ if(hidden||issue||!lease.scene)return null;
117
+ const sourceRect=readRect(source);let rect=readRect(anchor);
118
+ // Native editors may move inside a host without resizing. Their sibling
119
+ // anchor can be stale until the surface next renders; use the live client box.
120
+ if(/^(INPUT|TEXTAREA)$/.test(source.tagName)){
121
+ const sx=source.offsetWidth?sourceRect.width/source.offsetWidth:1,sy=source.offsetHeight?sourceRect.height/source.offsetHeight:1;
122
+ rect={left:sourceRect.left+source.clientLeft*sx,top:sourceRect.top+source.clientTop*sy,width:source.clientWidth*sx,height:source.clientHeight*sy};
123
+ rect.right=rect.left+rect.width;rect.bottom=rect.top+rect.height;
124
+ }
125
+ if(!(rect.width>0&&rect.height>0&&sourceRect.width>0&&sourceRect.height>0))return null;
126
+ const clip={left:viewport.left,top:viewport.top,right:viewport.right,bottom:viewport.bottom};
127
+ let opacity=1;const roundedClips=[];
128
+ for(let node=source;node;node=node.parentElement){
129
+ const style=readStyle(node);
130
+ if(style.display==='none'||style.visibility!=='visible'||node.hidden)return null;
131
+ // The canvas inherits the dialog's CSS opacity itself; apply only descendants.
132
+ if(node!==root)opacity*=Number(style.opacity);
133
+ if(node===source||node!==root&&escapeEffects&&lease.escapeHost?.contains(node))continue;
134
+ const box=readRect(node),sx=node.offsetWidth?box.width/node.offsetWidth:1,sy=node.offsetHeight?box.height/node.offsetHeight:1;
135
+ if(/^(auto|scroll|hidden|clip)$/.test(style.overflowX)){clip.left=Math.max(clip.left,box.left+node.clientLeft*sx);clip.right=Math.min(clip.right,box.left+(node.clientLeft+node.clientWidth)*sx);}
136
+ if(/^(auto|scroll|hidden|clip)$/.test(style.overflowY)){clip.top=Math.max(clip.top,box.top+node.clientTop*sy);clip.bottom=Math.min(clip.bottom,box.top+(node.clientTop+node.clientHeight)*sy);}
137
+ const roundedClip=readViewportClip(node,style,box);if(roundedClip)roundedClips.push(roundedClip);
138
+ if(node===root)break;
139
+ }
140
+ if(opacity<=0||clip.right<=clip.left||clip.bottom<=clip.top||sourceRect.right<=clip.left||sourceRect.left>=clip.right||sourceRect.bottom<=clip.top||sourceRect.top>=clip.bottom)return null;
141
+ return {lease,rect,clip,opacity,roundedClips};
142
+ }
143
+ function setOpacity(scene,opacity){
144
+ scene.traverse(object=>{
145
+ for(const material of (Array.isArray(object.material)?object.material:[object.material])){
146
+ if(!material?.isShaderMaterial)continue;
147
+ if(materials.get(material)!==material.fragmentShader){
148
+ if(!material.fragmentShader.includes('uniform float thdSurfaceOpacity;')){
149
+ material.fragmentShader='uniform float thdSurfaceOpacity;\n'+material.fragmentShader.replace(/}\s*$/,'gl_FragColor.a *= thdSurfaceOpacity;\n}');
150
+ material.needsUpdate=true;
151
+ }
152
+ material.uniforms.thdSurfaceOpacity??={value:opacity};materials.set(material,material.fragmentShader);
153
+ }
154
+ material.uniforms.thdSurfaceOpacity.value=opacity;
155
+ }
156
+ });
157
+ }
158
+ function paint(){frameStyles=new WeakMap();frameRects=new WeakMap();try{paintFrame();}catch(error){stop(error);}finally{frameStyles=null;frameRects=null;}}
159
+ function escapeUniforms(scene,enabled){
160
+ scene.traverse(object=>{for(const material of Array.isArray(object.material)?object.material:[object.material]){
161
+ if(material?.uniforms?.hybridEscapeEnabled)material.uniforms.hybridEscapeEnabled.value=enabled?1:0;
162
+ }});
163
+ }
164
+ function localCanvas(lease){
165
+ if(!lease.localCanvas){
166
+ const canvas=document.createElement('canvas'),context=canvas.getContext('2d');
167
+ if(!context)throw Error('2D presentation unavailable');
168
+ canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-local-canvas','');
169
+ Object.assign(canvas.style,{position:'absolute',inset:'0',width:'100%',height:'100%',pointerEvents:'none'});
170
+ lease.localCanvas=canvas;lease.localContext=context;lease.domElement.append(canvas);
171
+ }
172
+ return lease.localCanvas;
173
+ }
174
+ function paintLocal(){
175
+ if(!dirty)return;dirty=false;
176
+ const start=window.performance.now();
177
+ for(const lease of leases){
178
+ if(lease.dead||!lease.scene||!lease.width||!lease.height||!lease.needsPaint&&!localLayoutDirty)continue;
179
+ lease.needsPaint=false;
180
+ const canvas=localCanvas(lease),ratio=lease.ratio||1;
181
+ // Local leases share a stable physical-pixel scratch buffer.
182
+ const pw=Math.floor(lease.width*ratio),ph=Math.floor(lease.height*ratio);
183
+ if(bufferRatio!==1){renderer.setPixelRatio(1);bufferRatio=1;}
184
+ if(pw>bufferWidth||ph>bufferHeight){bufferWidth=Math.max(bufferWidth,pw);bufferHeight=Math.max(bufferHeight,ph);renderer.setSize(bufferWidth,bufferHeight,false);}
185
+ // Clear the entire retained buffer, not only this lease's viewport:
186
+ // cropped canvas copies must never see pixels from a previous lease.
187
+ renderer.setScissorTest(false);renderer.clear(true,true,true);
188
+ renderer.setViewport(0,0,pw,ph);renderer.setScissor(0,0,pw,ph);renderer.setScissorTest(true);
189
+ setOpacity(lease.scene,Number(window.getComputedStyle(lease.surface).opacity));
190
+ applyViewportClips(lease.scene,[],{left:0,bottom:lease.height},ratio);escapeUniforms(lease.scene,false);
191
+ renderer.render(lease.scene,lease.camera);passes++;
192
+ const copyStart=window.performance.now();
193
+ if(canvas.width!==pw)canvas.width=pw;
194
+ if(canvas.height!==ph)canvas.height=ph;
195
+ lease.localContext.clearRect(0,0,canvas.width,canvas.height);lease.localContext.drawImage(renderer.domElement,0,bufferHeight-ph,pw,ph,0,0,pw,ph);
196
+ copies++;copyMs+=window.performance.now()-copyStart;
197
+ }
198
+ localLayoutDirty=false;renderMs+=window.performance.now()-start;frames++;
199
+ }
200
+ function paintFrame(){
201
+ frame=null;if(disposed||lost||!renderer)return;
202
+ if(local){paintLocal();return;}
203
+ const canvas=renderer.domElement;
204
+ if(useDocumentCanvas){
205
+ const width=document.documentElement.clientWidth,height=window.innerHeight,y=Math.max(0,window.scrollY);
206
+ const pageHeight=document.documentElement.scrollHeight,bandHeight=Math.min(pageHeight,height*2);
207
+ const margin=Math.min(height*.2,Math.max(0,(bandHeight-height)/4));
208
+ if(!documentBand||documentBand.width!==width||documentBand.height!==bandHeight||y<documentBand.top+margin||y+height>documentBand.top+bandHeight-margin){
209
+ const top=Math.max(0,Math.min(Math.floor(y-height*.5),pageHeight-bandHeight));
210
+ if(!documentBand||top!==documentBand.top||width!==documentBand.width||bandHeight!==documentBand.height){
211
+ Object.assign(canvas.style,{top:top+'px',width:width+'px',height:bandHeight+'px'});
212
+ documentBand={top,width,height:bandHeight};bandMoves++;dirty=true;
213
+ }
214
+ }
215
+ }
216
+ const viewport=canvas.getBoundingClientRect(),w=viewport.width,h=viewport.height;
217
+ if(!(w>0&&h>0))return;
218
+ tracking=false;
219
+ for(const animation of externalAnimations)if(geometryAnimation(animation))tracking=true;else externalAnimations.delete(animation);
220
+ const ratio=Math.min(window.devicePixelRatio||1,2),visible=[...leases].map(lease=>measure(lease,viewport)).filter(Boolean);
221
+ if(tracking)request();
222
+ // DOM order is the explicit paint order within this global layer.
223
+ visible.sort((a,b)=>a.lease.surface.compareDocumentPosition(b.lease.surface)&4?-1:1);
224
+ const signature=JSON.stringify([w,h,ratio,visible.map(({lease,rect,clip,opacity,roundedClips})=>[lease.id,rect.left,rect.top,rect.width,rect.height,clip,opacity,roundedClips])]);
225
+ if(!dirty&&signature===lastSignature)return;
226
+ dirty=false;lastSignature=signature;
227
+ if(bufferRatio!==ratio){renderer.setPixelRatio(ratio);bufferRatio=ratio;bufferWidth=bufferHeight=0;}
228
+ if(bufferWidth!==w||bufferHeight!==h){renderer.setSize(w,h,false);bufferWidth=w;bufferHeight=h;}
229
+ renderer.setViewport(0,0,w,h);renderer.setScissorTest(false);renderer.clear(true,true,true);
230
+ const start=window.performance.now();placements=[];
231
+ for(const {lease,rect,clip,opacity,roundedClips} of visible){
232
+ const camera=lease.camera,mapped=lease.mappedCamera||(lease.mappedCamera=camera.clone());mapped.copy(camera);
233
+ const spanX=camera.right-camera.left,spanY=camera.top-camera.bottom;
234
+ mapped.left=camera.left-(rect.left-viewport.left)*spanX/rect.width;
235
+ mapped.right=mapped.left+w*spanX/rect.width;
236
+ mapped.top=camera.top+(rect.top-viewport.top)*spanY/rect.height;
237
+ mapped.bottom=mapped.top-h*spanY/rect.height;mapped.updateProjectionMatrix();
238
+ renderer.setScissor(clip.left-viewport.left,viewport.bottom-clip.bottom,clip.right-clip.left,clip.bottom-clip.top);renderer.setScissorTest(true);renderer.clearDepth();
239
+ setOpacity(lease.scene,opacity);applyViewportClips(lease.scene,roundedClips,viewport,ratio);escapeUniforms(lease.scene,escapeEffects);renderer.render(lease.scene,mapped);passes++;
240
+ placements.push({id:lease.id,left:rect.left,top:rect.top,width:rect.width,height:rect.height,clip:{...clip},roundedClips:roundedClips.length});
241
+ }
242
+ renderer.setScissorTest(false);renderMs+=window.performance.now()-start;frames++;
243
+ }
244
+ let sequence=0;
245
+ let createdLease=null;
246
+ const backend={leases,handles,ensure,request,surfaceIssue,observeLayout,
247
+ flush(){if(frame!==null)window.cancelAnimationFrame(frame);frame=null;dirty=true;paint();return !lost;},
248
+ release(lease){leases.delete(lease);observeLayout();request(true);},
249
+ available:()=>!disposed&&!lost,nextId:()=>++sequence,escapeEffects,local,
250
+ prepare:lease=>{if(local){localCanvas(lease);lease.needsPaint=true;}},
251
+ present(lease){lease.domElement.style.opacity=local?'1':'0';if(lease.localCanvas)lease.localCanvas.style.display=local?'block':'none';}};
252
+ const Namespace={...THREE,WebGLRenderer:class{
253
+ constructor(){
254
+ ensure();this.id=++sequence;this.domElement=document.createElement('span');this.domElement.setAttribute('data-thd-viewport-anchor','');
255
+ Object.assign(this.domElement.style,{display:'block',position:'absolute',pointerEvents:'none',opacity:local?'1':'0'});
256
+ this.globalCanvas=true;this.escapeEffects=escapeEffects;this.dead=false;leases.add(this);
257
+ this.backend=backend;createdLease=this;
258
+ backend.prepare(this);
259
+ }
260
+ setSurface(element,{escapeHost=element}={}){
261
+ this.surface=element;this.escapeHost=escapeHost;
262
+ this.onCompositionStart=()=>{this.composing=true;};this.onCompositionEnd=()=>{this.composing=false;};
263
+ element.addEventListener('compositionstart',this.onCompositionStart);element.addEventListener('compositionend',this.onCompositionEnd);
264
+ this.backend.observeLayout();this.backend.request(true);
265
+ }
266
+ getSurfaceIssue(){return this.backend.surfaceIssue(this);}
267
+ setPixelRatio(value){this.ratio=value;}
268
+ setSize(width,height){this.width=width;this.height=height;}
269
+ invalidate(){this.backend.request(true);}
270
+ flushPresentation(){return this.backend.flush();}
271
+ render(scene,camera){
272
+ this.backend.ensure();if(this.dead)throw Error('Presentation disposed');if(!camera.isOrthographicCamera)throw TypeError('DOM viewport presentation requires an orthographic camera');
273
+ this.scene=scene;this.camera=camera;this.needsPaint=true;this.backend.request(true);
274
+ }
275
+ dispose(){if(this.dead)return;this.dead=true;this.backend.release(this);this.surface?.removeEventListener('compositionstart',this.onCompositionStart);this.surface?.removeEventListener('compositionend',this.onCompositionEnd);this.scene=null;this.camera=null;this.domElement.remove();}
276
+ forceContextLoss(){} // A surface cannot destroy its peers' context.
277
+ }};
278
+ const api={
279
+ transfer(control,target,{recover=false}={}){
280
+ const session=controlSessions.get(control),destination=ownerBackends.get(target);
281
+ if(!session||session.backend!==backend||!backend.available()||!destination?.available())throw Error('Unavailable transfer owner');
282
+ const lease=session.lease;
283
+ if(!lease||control.stats().mode!=='mesh'&&!recover)throw Error('Only a visible mesh surface can transfer');
284
+ if(lease.composing)throw Error('Transfer deferred during composition');
285
+ if(!lease.scene||!lease.camera)throw Error('Surface must have rendered before transfer');
286
+ if(destination===backend)return;
287
+ if(destination.escapeEffects!==escapeEffects)throw Error('Incompatible escape policy');
288
+ destination.ensure();const issue=destination.surfaceIssue(lease);if(issue)throw Error(issue);
289
+ destination.prepare(lease);
290
+ leases.delete(lease);handles.delete(control);
291
+ destination.leases.add(lease);destination.handles.add(control);
292
+ lease.backend=destination;session.backend=destination;lease.id=destination.nextId();lease.mappedCamera=null;lease.issue=null;
293
+ destination.present(lease);
294
+ observeLayout();destination.observeLayout();
295
+ // Both buffers are updated in this task; the effect clock is not advanced.
296
+ backend.flush();destination.flush();
297
+ if(recover)lease.domElement.dispatchEvent(new window.Event('thdviewportchange'));
298
+ },
299
+ get domElement(){return renderer?.domElement??null;},
300
+ refresh(){
301
+ if(disposed)throw Error('Shared owner disposed');
302
+ for(const animation of document.getAnimations?.()||[])if(geometryAnimation(animation))externalAnimations.add(animation);
303
+ observeLayout();for(const control of handles)control.refresh?.();request(true);
304
+ },
305
+ create(factory,host,options){
306
+ if(disposed)throw Error('Shared owner disposed');if(host?.ownerDocument!==document)throw TypeError('Host must belong to the owner document');
307
+ if(root&&!root.contains(host))throw TypeError('Surface must belong to its registered dialog');
308
+ createdLease=null;
309
+ const control=factory(host,Namespace,options),destroy=control.destroy;handles.add(control);
310
+ const session={lease:createdLease,backend};controlSessions.set(control,session);
311
+ control.destroy=()=>{session.backend.handles.delete(control);controlSessions.delete(control);destroy();};return control;
312
+ },
313
+ stats:()=>({documentCanvas:useDocumentCanvas,documentBand,bandMoves,presentation:local?'local':'viewport',disposed,lost,failure,contexts:renderer&&!disposed?1:0,canvases:local?leases.size:renderer&&!disposed?1:0,created,controls:handles.size,leases:leases.size,copies,copyMs,frames,passes,renderMs,pending:frame!==null,escapeEffects,zIndex,placements,geometries:renderer?.info.memory.geometries||0}),
314
+ destroy(){
315
+ if(disposed)return;disposed=true;if(frame!==null)window.cancelAnimationFrame(frame);frame=null;observer?.disconnect();resizeObserver?.disconnect();resizeObserver=null;observedLayout.clear();layoutObserver?.disconnect();externalAnimations.clear();for(const off of listeners)off();
316
+ const errors=[];for(const control of [...handles])try{control.destroy();}catch(error){errors.push(error);}
317
+ if(renderer){try{renderer.dispose();renderer.forceContextLoss();}catch(error){errors.push(error);}renderer.domElement.remove();}
318
+ if(errors.length)throw new AggregateError(errors,'Viewport owner cleanup failed');
319
+ }
320
+ };
321
+ ownerBackends.set(api,backend);return api;
322
+ }
317
323
 
318
324