@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/QUICKSTART.fa.md +99 -87
- package/README.md +120 -116
- package/build-report.json +48 -43
- package/docs/GUIDE.md +122 -112
- package/docs/RELEASE-NOTES.md +74 -39
- package/examples/evry-website.md +58 -0
- package/package.json +1 -1
- package/src/dom-attachment.d.ts +1 -1
- package/src/dom-free.d.ts +14 -14
- package/src/dom-free.js +61 -38
- package/src/dom-image-raster.js +14 -0
- package/src/dom-image-surface.js +150 -103
- package/src/dom-image-swap.js +5 -2
- package/src/dom-once.js +39 -34
- package/src/dom-reveal.js +83 -73
- package/src/dom-rich-text.js +133 -116
- package/src/dom-surface-font.js +4 -4
- package/src/dom-svg-surface.js +41 -39
- package/src/dom-text-fingerprint.js +27 -27
- package/src/dom-text-paint-mask.js +24 -0
- package/src/dom-text-surface.js +280 -236
- package/src/font-rasterizer.js +18 -7
- package/src/image-surface.js +161 -156
- package/src/render-owner.js +17 -12
- package/src/text-motion-primitives.js +7 -0
- package/src/text-scene.js +41 -39
- package/src/triangle-effect.js +2 -1
- package/src/viewport-render-owner.js +322 -316
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# EV-RY website: product showcase
|
|
2
|
+
|
|
3
|
+
The EV-RY marketing website is a real integration case: a four-product showcase, animated captions, one shared FX installation across client-side navigation, and native content at rest.
|
|
4
|
+
|
|
5
|
+
The website is currently a local integration at `http://127.0.0.1:8780/`, not a hosted demo included in this package. The public runnable examples are linked from the package README. The website backend, forms and artwork are not dependencies of FX.
|
|
6
|
+
|
|
7
|
+
## Initial reveal without a flash
|
|
8
|
+
|
|
9
|
+
Place `data-thd-pending` on the image and caption elements in the HTML, use the documented boot mask, then let `revealOnView` transfer the mask to the first rendered frame. Keep each returned handle until cleanup. Do not remove the mask manually on attachment readiness.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const imageSurface = engine.attachImage(image, {
|
|
13
|
+
presentation: 'global',
|
|
14
|
+
revealOnView: { threshold: 0, once: true }
|
|
15
|
+
});
|
|
16
|
+
const captionSurfaces = captionLines.map(line => {
|
|
17
|
+
const surface = engine.attachText(line, {
|
|
18
|
+
presentation: 'local',
|
|
19
|
+
revealOnView: { threshold: 0, once: true }
|
|
20
|
+
});
|
|
21
|
+
surface.play('enter');
|
|
22
|
+
return surface;
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The captions explicitly start with the slide, even when below the viewport. `play()` takes control from automatic intersection reveal while retaining its initial paint mask until a rendered frame. It does not wait for visibility or restart on reentry. Without explicit `play()`, `revealOnView` continues to wait for the configured intersection and follows its `once` setting.
|
|
27
|
+
|
|
28
|
+
## Alternate two image transitions
|
|
29
|
+
|
|
30
|
+
After decoding the next mounted image, alternate between incoming snow over the previous image and outgoing melt above the already visible next image:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const transition = engine.swapImage(previous, next, {
|
|
34
|
+
enter: !melt,
|
|
35
|
+
exit: melt,
|
|
36
|
+
topImage: melt ? 'previous' : 'next',
|
|
37
|
+
waitForExit: false,
|
|
38
|
+
presentation: 'global'
|
|
39
|
+
});
|
|
40
|
+
const result = await transition.finished;
|
|
41
|
+
if (result.status === 'completed') previous.remove();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use a busy flag to prevent overlapping swaps. Pause scheduling while the document is hidden. On navigation, cancel an outstanding transition, clear the scheduled timeout and destroy page handles; retain the shared engine and refresh its automatic attachments after inserting the new page. Install the boot mask before the new DOM can paint. Destroy the engine only when the application is disposed.
|
|
45
|
+
|
|
46
|
+
The five-second scheduling interval, caption layout, routing and product artwork belong to the website, not to the FX API. Local caption surfaces share rendering infrastructure; the retained scratch buffer avoids resizing the GPU buffer for each caption line.
|
|
47
|
+
|
|
48
|
+
Create incoming captions from fresh authored markup rather than cloning a currently attached element: a live attachment can carry temporary engine-owned visibility attributes and styles. Commit the incoming image and caption together only after `transition.finished` reports `completed`. If image decoding, attachment or the swap fails, destroy incoming caption handles, remove their layer, retain the previous caption and image, then retry. Track each acquired handle immediately so partial attachment failures can also be cleaned up.
|
|
49
|
+
|
|
50
|
+
Schedule one deadline five seconds from each transition start. After completion, wait only for the remaining time. If the transition itself overruns that deadline, start one next transition when available and establish a new deadline; do not discard interval ticks or queue catch-up transitions. Returning from a hidden tab and retrying a failed transition each establish a fresh five-second wait. This keeps start-to-start cadence stable without overlapping jobs.
|
|
51
|
+
|
|
52
|
+
## Completion
|
|
53
|
+
|
|
54
|
+
After `surface.play()`, await `surface.whenFinished()`. It returns a status of `completed`, `cancelled`, or `unsupported`. Completion includes the native handoff; avoid fixed cleanup timers or renderer statistics. Destroy page surfaces when navigating away.
|
|
55
|
+
|
|
56
|
+
Offscreen text and media retain their start time without running per-particle updates or drawing frames. Returning during the effect evaluates its current elapsed time; returning after it ends shows the final state without replay. `whenFinished()` also completes when content remains offscreen, so slider cleanup never depends on scrolling captions into view. It checks the suspended clock infrequently instead of maintaining a render loop. Concurrent waits share a pending promise; starting a new play cancels the previous wait. Cancel or destroy the handle to release an outstanding wait.
|
|
57
|
+
|
|
58
|
+
When an exit is requested offscreen, the engine immediately suppresses native paint while retaining layout and the DOM content. Native paint also remains hidden if an active departure leaves view. Reentry renders only the remaining departure particles; an expired exit stays hidden. Text, image and SVG attachments own this masking, so the site should not toggle native opacity or visibility to emulate it. Cancel/destroy restores the original presentation.
|
package/package.json
CHANGED
package/src/dom-attachment.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface SurfaceOptions {
|
|
2
|
-
/** Text/image/SVG only. Re-arm after complete exit. */
|
|
2
|
+
/** Text/image/SVG only. Re-arm after complete exit when once is false. Explicit play takes over automatic triggering. */
|
|
3
3
|
revealOnView?: false | {threshold?: number; once?: boolean; root?: Element | null};
|
|
4
4
|
resting?: 'mesh' | 'native';
|
|
5
5
|
inputEffect?: string;
|
package/src/dom-free.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type {AutoRevealOptions} from './dom-auto-reveal.js';
|
|
2
|
-
import type {DOMPresentation, SurfaceOptions, TextSurface, createDOMRenderer} from './dom-attachment.js';
|
|
3
|
-
export const FREE_EFFECTS: Readonly<{textEnter:'dust-wind';textExit:'smoke';imageEnter:'drifting-snow';imageExit:'melt'}>;
|
|
4
|
-
export type FreeSurface = Pick<TextSurface,'ready'|'play'|'refresh'|'stats'|'destroy'> & {cancel():void};
|
|
5
|
-
export interface FreeAttachOptions {revealOnView?: SurfaceOptions['revealOnView'];presentation?: DOMPresentation;}
|
|
6
|
-
export function createFree(THREE: Parameters<typeof createDOMRenderer>[0],root?: Document|Element,options?: AutoRevealOptions & {auto?:boolean;zIndex?:number;documentCanvas?:boolean;experimentalDocumentCanvas?:boolean}): {
|
|
7
|
-
attachText(element:HTMLElement,options?:FreeAttachOptions):FreeSurface;
|
|
8
|
-
attachImage(element:HTMLImageElement,options?:FreeAttachOptions):FreeSurface;
|
|
9
|
-
attachSVG(element:SVGSVGElement,options?:FreeAttachOptions):FreeSurface;
|
|
10
|
-
swapImage(previous:HTMLImageElement,next:HTMLImageElement,options?:{exit?:boolean;waitForExit?:boolean;presentation?:DOMPresentation}):{finished:Promise<{status:string}>;cancel():void};
|
|
11
|
-
refresh():void;
|
|
12
|
-
stats():{disposed:boolean;automatic:unknown;renderer:unknown};
|
|
13
|
-
destroy():void;
|
|
14
|
-
};
|
|
1
|
+
import type {AutoRevealOptions} from './dom-auto-reveal.js';
|
|
2
|
+
import type {DOMPresentation, SurfaceOptions, TextSurface, createDOMRenderer} from './dom-attachment.js';
|
|
3
|
+
export const FREE_EFFECTS: Readonly<{textEnter:'dust-wind';textExit:'smoke';imageEnter:'drifting-snow';imageExit:'melt'}>;
|
|
4
|
+
export type FreeSurface = Pick<TextSurface,'ready'|'play'|'refresh'|'stats'|'destroy'> & {cancel():void;whenFinished():Promise<{status:'completed'|'cancelled'|'unsupported'}>};
|
|
5
|
+
export interface FreeAttachOptions {revealOnView?: SurfaceOptions['revealOnView'];presentation?: DOMPresentation;}
|
|
6
|
+
export function createFree(THREE: Parameters<typeof createDOMRenderer>[0],root?: Document|Element,options?: AutoRevealOptions & {auto?:boolean;zIndex?:number;documentCanvas?:boolean;experimentalDocumentCanvas?:boolean}): {
|
|
7
|
+
attachText(element:HTMLElement,options?:FreeAttachOptions):FreeSurface;
|
|
8
|
+
attachImage(element:HTMLImageElement,options?:FreeAttachOptions):FreeSurface;
|
|
9
|
+
attachSVG(element:SVGSVGElement,options?:FreeAttachOptions):FreeSurface;
|
|
10
|
+
swapImage(previous:HTMLImageElement,next:HTMLImageElement,options?:{exit?:boolean;enter?:boolean;topImage?:'previous'|'next';waitForExit?:boolean;presentation?:DOMPresentation}):{finished:Promise<{status:string}>;cancel():void};
|
|
11
|
+
refresh():void;
|
|
12
|
+
stats():{disposed:boolean;automatic:unknown;renderer:unknown};
|
|
13
|
+
destroy():void;
|
|
14
|
+
};
|
|
15
15
|
|
|
16
16
|
|
package/src/dom-free.js
CHANGED
|
@@ -1,42 +1,65 @@
|
|
|
1
|
-
import {createDOMInstallation} from './dom-attachment.js';
|
|
2
|
-
import {createAutoReveal} from './dom-auto-reveal.js';
|
|
3
|
-
|
|
4
|
-
export const FREE_EFFECTS = Object.freeze({textEnter:'dust-wind', textExit:'smoke', imageEnter:'drifting-snow', imageExit:'melt'});
|
|
5
|
-
export function createFree(THREE, root = document, options = {}) {
|
|
6
|
-
const doc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
7
|
-
const owner = createDOMInstallation(THREE, doc, {presentation: options.presentation ?? 'auto',zIndex:options.zIndex??1,documentCanvas:options.documentCanvas??options.experimentalDocumentCanvas??true});
|
|
8
|
-
let automatic, disposed=false;
|
|
9
|
-
function attach(kind, element, config = {}) {
|
|
10
|
-
if(disposed)throw Error('Free installation disposed');
|
|
11
|
-
for(const key of Object.keys(config))if(!['revealOnView','presentation'].includes(key))throw TypeError(`Unsupported Free option: ${key}`);
|
|
12
|
-
const effectKind=kind==='svg'?'image':kind;
|
|
13
|
-
const surface=owner[kind==='text'?'attachText':kind==='svg'?'attachSVG':'attachImage'](element,{
|
|
14
|
-
...config,resting:'native',inputEffect:FREE_EFFECTS[effectKind+'Enter'],
|
|
15
|
-
inputEffectOptions:{duration:2000,particleShape:'triangle',exitEffect:FREE_EFFECTS[effectKind+'Exit']}
|
|
16
|
-
});
|
|
17
|
-
|
|
1
|
+
import {createDOMInstallation} from './dom-attachment.js';
|
|
2
|
+
import {createAutoReveal} from './dom-auto-reveal.js';
|
|
3
|
+
|
|
4
|
+
export const FREE_EFFECTS = Object.freeze({textEnter:'dust-wind', textExit:'smoke', imageEnter:'drifting-snow', imageExit:'melt'});
|
|
5
|
+
export function createFree(THREE, root = document, options = {}) {
|
|
6
|
+
const doc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
7
|
+
const owner = createDOMInstallation(THREE, doc, {presentation: options.presentation ?? 'auto',zIndex:options.zIndex??1,documentCanvas:options.documentCanvas??options.experimentalDocumentCanvas??true});
|
|
8
|
+
let automatic, disposed=false;const installationWaits=new Set();
|
|
9
|
+
function attach(kind, element, config = {}) {
|
|
10
|
+
if(disposed)throw Error('Free installation disposed');
|
|
11
|
+
for(const key of Object.keys(config))if(!['revealOnView','presentation'].includes(key))throw TypeError(`Unsupported Free option: ${key}`);
|
|
12
|
+
const effectKind=kind==='svg'?'image':kind;
|
|
13
|
+
const surface=owner[kind==='text'?'attachText':kind==='svg'?'attachSVG':'attachImage'](element,{
|
|
14
|
+
...config,resting:'native',inputEffect:FREE_EFFECTS[effectKind+'Enter'],
|
|
15
|
+
inputEffectOptions:{duration:2000,particleShape:'triangle',exitEffect:FREE_EFFECTS[effectKind+'Exit']}
|
|
16
|
+
});
|
|
17
|
+
let currentWait=null,requestedPlay=false;
|
|
18
|
+
const whenFinished=()=>{
|
|
19
|
+
if(currentWait)return currentWait.promise;
|
|
20
|
+
const wait={};currentWait=wait;
|
|
21
|
+
wait.promise=new Promise(resolve=>{
|
|
22
|
+
let timeoutStarted=null,timer,done=false;
|
|
23
|
+
const finish=status=>{if(done)return;done=true;clearTimeout(timer);installationWaits.delete(wait.cancel);if(currentWait===wait)currentWait=null;resolve({status});};
|
|
24
|
+
wait.cancel=()=>finish('cancelled');installationWaits.add(wait.cancel);
|
|
25
|
+
const poll=()=>{const s=surface.stats();
|
|
26
|
+
if(s.disposed||disposed||!element.isConnected){finish('cancelled');return;}
|
|
27
|
+
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;}
|
|
28
|
+
const completed=typeof s.completed==='boolean'?s.completed:s.mode==='native'&&['native resting presentation','hidden after exit'].includes(s.reason);
|
|
29
|
+
if((!s.reveal||s.reveal.count>0||s.reveal.manual)&&completed){finish('completed');return;}
|
|
30
|
+
// An untouched intersection reveal may legitimately wait minutes.
|
|
31
|
+
// Bound actual preparation/play, not the time before first visibility.
|
|
32
|
+
if(requestedPlay||s.timeline?.started!=null||s.preparing&&!s.suspended)timeoutStarted??=Date.now();
|
|
33
|
+
if(timeoutStarted!==null&&Date.now()-timeoutStarted>30000){finish('cancelled');return;}
|
|
34
|
+
const remaining=s.timeline?.finishAt-doc.defaultView.performance.now();
|
|
35
|
+
timer=setTimeout(poll,s.suspended?Math.min(1000,Math.max(100,remaining||1000)):16);
|
|
36
|
+
};timer=setTimeout(poll,16);
|
|
37
|
+
});return wait.promise;
|
|
38
|
+
};
|
|
39
|
+
return Object.freeze({whenFinished,ready:surface.ready,play:(phase='enter')=>{
|
|
18
40
|
if(!['enter','exit'].includes(phase))throw TypeError('Invalid phase');
|
|
19
|
-
surface.play(phase);
|
|
20
|
-
},cancel:()=>surface.cancel(),refresh:()=>surface.refresh(),stats:()=>surface.stats(),destroy:()=>surface.destroy()});
|
|
21
|
-
}
|
|
22
|
-
// Automatic and manual surfaces lease the same installation.
|
|
23
|
-
const autoOwner={attachText:(el,c)=>attach('text',el,{revealOnView:c.revealOnView}),attachImage:(el,c)=>attach('image',el,{revealOnView:c.revealOnView}),stats:()=>owner.stats()};
|
|
24
|
-
try { if(options.auto!==false)automatic=createAutoReveal(THREE,root,options,autoOwner); }
|
|
25
|
-
catch(error){owner.destroy();throw error;}
|
|
26
|
-
return Object.freeze({attachText:(el,c)=>attach('text',el,c),attachImage:(el,c)=>attach('image',el,c),attachSVG:(el,c)=>attach('svg',el,c),
|
|
27
|
-
swapImage(previous,next,config={}){
|
|
28
|
-
if(disposed)throw Error('Free installation disposed');
|
|
29
|
-
for(const key of Object.keys(config))if(!['exit','waitForExit','presentation'].includes(key))throw TypeError(`Unsupported Free swap option: ${key}`);
|
|
30
|
-
for(const key of ['exit','waitForExit'])if(config[key]!==undefined&&typeof config[key]!=='boolean')throw TypeError(`Invalid ${key}`);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
41
|
+
surface.play(phase);requestedPlay=true;currentWait?.cancel();
|
|
42
|
+
},cancel:()=>{surface.cancel();requestedPlay=false;currentWait?.cancel();},refresh:()=>surface.refresh(),stats:()=>surface.stats(),destroy:()=>{currentWait?.cancel();surface.destroy();}});
|
|
43
|
+
}
|
|
44
|
+
// Automatic and manual surfaces lease the same installation.
|
|
45
|
+
const autoOwner={attachText:(el,c)=>attach('text',el,{revealOnView:c.revealOnView}),attachImage:(el,c)=>attach('image',el,{revealOnView:c.revealOnView}),stats:()=>owner.stats()};
|
|
46
|
+
try { if(options.auto!==false)automatic=createAutoReveal(THREE,root,options,autoOwner); }
|
|
47
|
+
catch(error){owner.destroy();throw error;}
|
|
48
|
+
return Object.freeze({attachText:(el,c)=>attach('text',el,c),attachImage:(el,c)=>attach('image',el,c),attachSVG:(el,c)=>attach('svg',el,c),
|
|
49
|
+
swapImage(previous,next,config={}){
|
|
50
|
+
if(disposed)throw Error('Free installation disposed');
|
|
51
|
+
for(const key of Object.keys(config))if(!['exit','enter','topImage','waitForExit','presentation'].includes(key))throw TypeError(`Unsupported Free swap option: ${key}`);
|
|
52
|
+
for(const key of ['exit','enter','waitForExit'])if(config[key]!==undefined&&typeof config[key]!=='boolean')throw TypeError(`Invalid ${key}`);
|
|
53
|
+
if(config.topImage!==undefined&&!['previous','next'].includes(config.topImage))throw TypeError('Invalid topImage');
|
|
54
|
+
return owner.swapImage(previous,next,{enterEffect:config.enter!==false,topImage:config.topImage??'next',presentation:config.presentation??'global',waitForExit:config.waitForExit??false,
|
|
55
|
+
exitEffect:config.exit===false?null:FREE_EFFECTS.imageExit,inputEffect:FREE_EFFECTS.imageEnter,
|
|
56
|
+
inputEffectOptions:{duration:2000,particleShape:'triangle'}});
|
|
57
|
+
},
|
|
58
|
+
refresh(){if(disposed)throw Error('Free installation disposed');automatic?.refresh();owner.refresh();},
|
|
59
|
+
stats:()=>({disposed,automatic:automatic?.stats()??null,renderer:owner.stats()}),
|
|
60
|
+
destroy(){if(disposed)return;disposed=true;for(const cancel of [...installationWaits])cancel();try{automatic?.destroy();}finally{owner.destroy();}}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
40
63
|
|
|
41
64
|
|
|
42
65
|
|
package/src/dom-image-raster.js
CHANGED
|
@@ -13,6 +13,20 @@ export async function rasterizeDOMImage(element,{maxSide=2048}={}){
|
|
|
13
13
|
return {x:read(parts[0],box.width),y:read(parts[1]??parts[0],box.height)};
|
|
14
14
|
});
|
|
15
15
|
context.beginPath();context.roundRect(0,0,box.width,box.height,radii);context.clip();
|
|
16
|
+
// Bake ancestor overflow masks into the texture; moving particles keep the
|
|
17
|
+
// final image silhouette rather than snapping to a rounded frame at rest.
|
|
18
|
+
for(let parent=element.parentElement;parent;parent=parent.parentElement){
|
|
19
|
+
const style=doc.defaultView.getComputedStyle(parent);
|
|
20
|
+
if(!['hidden','clip','auto','scroll'].includes(style.overflowX)||!['hidden','clip','auto','scroll'].includes(style.overflowY))continue;
|
|
21
|
+
const rect=parent.getBoundingClientRect();
|
|
22
|
+
if(style.transform!=='none')continue;
|
|
23
|
+
const left=parseFloat(style.borderLeftWidth)||0,top=parseFloat(style.borderTopWidth)||0;
|
|
24
|
+
const right=parseFloat(style.borderRightWidth)||0,bottom=parseFloat(style.borderBottomWidth)||0;
|
|
25
|
+
const corners=['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius'];
|
|
26
|
+
const borders=[[left,top],[right,top],[right,bottom],[left,bottom]];
|
|
27
|
+
const clipRadii=corners.map((key,i)=>{const parts=style[key].split(/\s+/);const read=(s,n)=>parseFloat(s)*(s.endsWith('%')?n/100:1);return {x:Math.max(0,read(parts[0],rect.width)-borders[i][0]),y:Math.max(0,read(parts[1]??parts[0],rect.height)-borders[i][1])};});
|
|
28
|
+
context.beginPath();context.roundRect(rect.left-box.left+left,rect.top-box.top+top,Math.max(0,rect.width-left-right),Math.max(0,rect.height-top-bottom),clipRadii);context.clip();
|
|
29
|
+
}
|
|
16
30
|
const factor=css.objectFit==='cover'?Math.max(box.width/element.naturalWidth,box.height/element.naturalHeight):Math.min(box.width/element.naturalWidth,box.height/element.naturalHeight);
|
|
17
31
|
const w=css.objectFit==='fill'?box.width:element.naturalWidth*factor,h=css.objectFit==='fill'?box.height:element.naturalHeight*factor;
|
|
18
32
|
context.drawImage(element,(box.width-w)/2,(box.height-h)/2,w,h);
|
package/src/dom-image-surface.js
CHANGED
|
@@ -1,106 +1,153 @@
|
|
|
1
|
-
import {createImageSurface,IMAGE_EFFECT_MODES} from './image-surface.js';
|
|
2
|
-
import {normalizeTextEffectOptions} from './text-effect-options.js';
|
|
3
|
-
import {rasterizeDOMImage} from './dom-image-raster.js';
|
|
4
|
-
import {queueImagePreparation} from './image-preparation-queue.js';
|
|
5
|
-
|
|
6
|
-
// Native IMG retains source, alternative text and events. This adapter supplies
|
|
7
|
-
// only a presentation scene to the existing DOM owner and image effect engine.
|
|
8
|
-
export function attachImageSurface(element,THREE,options={}){
|
|
9
|
-
if(element?.tagName!=='IMG'||!element.parentElement)throw TypeError('A mounted IMG is required');
|
|
10
|
-
function validate(value){if(!['mesh','native'].includes(value.resting??'mesh'))throw TypeError('Invalid resting presentation');if(!IMAGE_EFFECT_MODES.includes(value.inputEffect??'dust-wind'))throw TypeError('Invalid image effect');normalizeTextEffectOptions(value.inputEffectOptions??{},value.inputEffect??'dust-wind');}validate(options);
|
|
11
|
-
const document=element.ownerDocument,window=document.defaultView,parent=element.parentElement;
|
|
12
|
-
const renderer=new THREE.WebGLRenderer({alpha:true,antialias:true}),canvas=renderer.domElement;
|
|
13
|
-
const scene=new THREE.Scene(),camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=10;
|
|
14
|
-
const originalOpacity=element.style.opacity,originalPosition=parent.style.position;
|
|
15
|
-
const ownsPosition=window.getComputedStyle(parent).position==='static';if(ownsPosition)parent.style.position='relative';
|
|
16
|
-
Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none'});canvas.setAttribute('aria-hidden','true');parent.append(canvas);
|
|
17
|
-
renderer.setSurface?.(parent);
|
|
18
|
-
let pendingSurface=null,baseWidth=1,baseHeight=1;
|
|
19
|
-
let requested=null,preparing=false,inFlight=false,prepareTimer=null,queueJob=null,committedURL='',resizeStarted=0,failed=false;
|
|
20
|
-
let disposed=false,frame=null,surface=null,revision=0,source='',mode='native',reason=null,phase='enter',resolve;
|
|
21
|
-
let
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
1
|
+
import {createImageSurface,IMAGE_EFFECT_MODES} from './image-surface.js';
|
|
2
|
+
import {normalizeTextEffectOptions} from './text-effect-options.js';
|
|
3
|
+
import {rasterizeDOMImage} from './dom-image-raster.js';
|
|
4
|
+
import {queueImagePreparation} from './image-preparation-queue.js';
|
|
5
|
+
|
|
6
|
+
// Native IMG retains source, alternative text and events. This adapter supplies
|
|
7
|
+
// only a presentation scene to the existing DOM owner and image effect engine.
|
|
8
|
+
export function attachImageSurface(element,THREE,options={}){
|
|
9
|
+
if(element?.tagName!=='IMG'||!element.parentElement)throw TypeError('A mounted IMG is required');
|
|
10
|
+
function validate(value){if(!['mesh','native'].includes(value.resting??'mesh'))throw TypeError('Invalid resting presentation');if(!IMAGE_EFFECT_MODES.includes(value.inputEffect??'dust-wind'))throw TypeError('Invalid image effect');normalizeTextEffectOptions(value.inputEffectOptions??{},value.inputEffect??'dust-wind');}validate(options);
|
|
11
|
+
const document=element.ownerDocument,window=document.defaultView,parent=element.parentElement;
|
|
12
|
+
const renderer=new THREE.WebGLRenderer({alpha:true,antialias:true}),canvas=renderer.domElement;
|
|
13
|
+
const scene=new THREE.Scene(),camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=10;
|
|
14
|
+
const originalOpacity=element.style.opacity,nativeRestOpacity=options.nativeRestOpacity??(originalOpacity===''?1:Number(originalOpacity)),originalPosition=parent.style.position;
|
|
15
|
+
const ownsPosition=window.getComputedStyle(parent).position==='static';if(ownsPosition)parent.style.position='relative';
|
|
16
|
+
Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none'});canvas.setAttribute('aria-hidden','true');parent.append(canvas);
|
|
17
|
+
renderer.setSurface?.(parent);
|
|
18
|
+
let pendingSurface=null,baseWidth=1,baseHeight=1;
|
|
19
|
+
let requested=null,preparing=false,inFlight=false,prepareTimer=null,queueJob=null,committedURL='',resizeStarted=0,failed=false;
|
|
20
|
+
let disposed=false,frame=null,surface=null,revision=0,source='',mode='native',reason=null,phase='enter',resolve;
|
|
21
|
+
let effectStarted=null,queuedStarted=null,inViewport=true,exitHeld=false,nativeHidden=false;
|
|
22
|
+
let queuedPhase=options.initialPhase??null;
|
|
23
|
+
const ready=new Promise(r=>resolve=r),reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
24
|
+
function native(error,restore=false){
|
|
25
|
+
if(restore)exitHeld=false;
|
|
26
|
+
const hidden=!disposed&&!restore&&(exitHeld||surface?.stats().state==='hidden');
|
|
27
|
+
nativeHidden=hidden;mode='native';reason=error||null;element.style.opacity=hidden?'0':(options.nativeRestOpacity!==undefined?String(nativeRestOpacity):originalOpacity);canvas.style.display='none';options.onPresentation?.({mode,reason,hidden});renderer.invalidate?.();
|
|
28
|
+
}
|
|
29
|
+
function holdExit(){exitHeld=true;nativeHidden=true;element.style.opacity='0';options.onPresentation?.({mode,reason,hidden:true});}
|
|
30
|
+
function suspend(){
|
|
31
|
+
if(queuedPhase!==null&&queuedStarted===null)queuedStarted=window.performance.now();
|
|
32
|
+
if(queuedPhase==='exit'||queuedPhase===null&&phase==='exit'&&effectStarted!==null)holdExit();
|
|
33
|
+
if(frame!==null)window.cancelAnimationFrame(frame);frame=null;canvas.style.display='none';renderer.invalidate?.();
|
|
34
|
+
}
|
|
35
|
+
function request(){if(!disposed&&inViewport&&!document.hidden&&frame===null)frame=window.requestAnimationFrame(draw);}
|
|
36
|
+
const runtime={THREE,document,renderer,camera,requestRender:request,register(control){scene.add(control.object);return ()=>scene.remove(control.object);}};
|
|
37
|
+
function draw(now){frame=null;if(disposed||!surface||failed||queuedPhase!==null&&preparing)return;if(!inViewport||document.hidden){suspend();return;}try{
|
|
38
|
+
const box=element.getBoundingClientRect(),p=parent.getBoundingClientRect(),css=window.getComputedStyle(element);
|
|
39
|
+
if(!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});return;}
|
|
40
|
+
if(css.transform!=='none'||css.objectPosition!=='50% 50%'||!['fill','contain','cover'].includes(css.objectFit)||parseFloat(css.paddingTop)||parseFloat(css.paddingLeft)||parseFloat(css.borderTopWidth)){native('Unsupported image layout',true);resolve({mode,reason});return;}
|
|
41
|
+
const s=surface.stats();if(!s.source)return;
|
|
42
|
+
const w=box.width,h=box.height;
|
|
43
|
+
surface.object.scale.set(w/baseWidth,h/baseHeight,1);surface.setDisplaySize(w,h);
|
|
44
|
+
camera.left=-box.width/2;camera.right=box.width/2;camera.top=box.height/2;camera.bottom=-box.height/2;camera.updateProjectionMatrix();
|
|
45
|
+
Object.assign(canvas.style,{left:(box.left-p.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-p.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',display:'block'});
|
|
46
|
+
canvas.style.zIndex=css.zIndex;renderer.setSize(box.width,box.height,false);if(queuedPhase!==null&&!preparing){phase=queuedPhase;effectStarted=queuedStarted??now;queuedStarted=null;if(phase==='exit'&&surface.stats().state==='entering')surface.frame(effectStarted,reduced.matches);surface.setPresentationOpacity(1);surface[queuedPhase](effectStarted);queuedPhase=null;}surface.frame(now,reduced.matches);
|
|
47
|
+
const state=surface.stats();
|
|
48
|
+
const handoff=options.resting==='native'&&phase==='enter'&&effectStarted!==null&&!reduced.matches;
|
|
49
|
+
const end=effectStarted===null?now:effectStarted+state.duration;
|
|
50
|
+
// Reveal complete native pixels only once assembly finishes.
|
|
51
|
+
const nativeAlpha=handoff&&now>=end?1:0;
|
|
52
|
+
const meshFade=handoff?Math.max(0,Math.min(1,(now-end)/250)):0;
|
|
53
|
+
surface.setPresentationOpacity(1-meshFade);
|
|
54
|
+
if(!state.active&&(options.resting==='native'||reduced.matches)&&(!handoff||meshFade>=1)){
|
|
55
|
+
// Submit before handing off: reveal gates acknowledge even reduced motion.
|
|
56
|
+
renderer.render(scene,camera);
|
|
57
|
+
native(state.state==='hidden'?'hidden after exit':'native resting presentation');
|
|
58
|
+
if(state.state==='hidden')element.style.opacity='0';
|
|
59
|
+
resolve({mode,reason});return;
|
|
60
|
+
}
|
|
61
|
+
const handingOffNative=mode==='native';
|
|
62
|
+
element.style.opacity=String(nativeAlpha*nativeRestOpacity);nativeHidden=nativeAlpha===0;mode='mesh';reason=null;renderer.render(scene,camera);
|
|
63
|
+
options.onPresentation?.({mode,reason,hidden:nativeHidden});
|
|
64
|
+
if(handingOffNative)renderer.flushPresentation?.();
|
|
65
|
+
resolve({mode,reason});if(handoff&&meshFade<1)request();
|
|
66
|
+
}catch(error){native(error.message,true);resolve({mode,reason});}}
|
|
67
|
+
function nearViewport(box){const margin=window.innerHeight*.5;return box.bottom>=-margin&&box.top<=window.innerHeight+margin&&box.right>=0&&box.left<=window.innerWidth;}
|
|
68
|
+
function measure(){const url=element.currentSrc||element.src,css=window.getComputedStyle(element),box=element.getBoundingClientRect();return {url,css,box,key:JSON.stringify([url,box.width,box.height,css.objectFit,css.objectPosition,css.borderTopLeftRadius,css.borderTopRightRadius,css.borderBottomLeftRadius,css.borderBottomRightRadius])};}
|
|
69
|
+
function schedule(delay=0){clearTimeout(prepareTimer);if(disposed||inFlight||!requested)return;preparing=true;prepareTimer=setTimeout(prepare,delay);}
|
|
70
|
+
async function prepare(){
|
|
71
|
+
prepareTimer=null;if(disposed||inFlight||!requested)return;
|
|
72
|
+
inFlight=true;preparing=true;const token=revision,current=requested;
|
|
73
|
+
try{
|
|
74
|
+
if(!element.complete||!element.naturalWidth)await element.decode();
|
|
75
|
+
if(disposed||token!==revision)return;
|
|
76
|
+
queueJob=queueImagePreparation(window,async()=>{
|
|
77
|
+
if(disposed||token!==revision)return false;
|
|
78
|
+
// Resize/decode may have changed the measured box without an observer
|
|
79
|
+
// callback yet. Do not allocate pixels for the stale dimensions.
|
|
80
|
+
const measured=measure();if(measured.key!==current.key){refresh();return false;}
|
|
81
|
+
const raster=await rasterizeDOMImage(element,{maxSide:Math.min(2048,renderer.capabilities?.maxTextureSize||2048)});
|
|
82
|
+
if(disposed||token!==revision)return false;
|
|
83
|
+
const next=surface??createImageSurface(runtime,{width:raster.width,height:raster.height,effect:options.inputEffect??'dust-wind',settings:options.inputEffectOptions??{}});
|
|
84
|
+
if(!surface)pendingSurface=next;
|
|
85
|
+
next.setDisplaySize(current.box.width,current.box.height);
|
|
86
|
+
// DOM source/paint changes replace pixels, not the caller's animation
|
|
87
|
+
// intent. Keep a departure hidden and preserve an active clock/seed
|
|
88
|
+
// across src, srcset and SVG snapshot replacements just as on resize.
|
|
89
|
+
await next.setSource(raster.source,{preserveMotion:!!surface,bounds:{width:raster.width,height:raster.height}});
|
|
90
|
+
if(disposed||token!==revision){if(next!==surface)next.destroy();return false;}
|
|
91
|
+
surface=next;pendingSurface=null;baseWidth=raster.width;baseHeight=raster.height;source=current.key;committedURL=current.url;requested=null;resizeStarted=0;return true;
|
|
92
|
+
});
|
|
93
|
+
await queueJob.promise;
|
|
94
|
+
}catch(error){pendingSurface?.destroy();pendingSurface=null;if(!disposed&&token===revision){requested=null;source='';resizeStarted=0;failed=true;native(error.message,true);resolve({mode,reason});}}
|
|
95
|
+
finally{queueJob=null;inFlight=false;preparing=false;if(!disposed){if(requested)schedule(surface?50:0);request();}}
|
|
96
|
+
}
|
|
97
|
+
function refresh(){
|
|
98
|
+
if(disposed)return;const current=measure(),{box,css}=current;
|
|
99
|
+
if(!element.isConnected||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'||!nearViewport(box)){
|
|
100
|
+
if(requested){revision++;requested=null;queueJob?.cancel();}clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;
|
|
101
|
+
if(!surface||!box.width||!box.height||css.display==='none'||css.visibility!=='visible'){native('Image not visible');resolve({mode,reason});}return;
|
|
102
|
+
}
|
|
103
|
+
if(current.key===source){if(requested){revision++;requested=null;queueJob?.cancel();clearTimeout(prepareTimer);prepareTimer=null;preparing=inFlight;}if(surface)request();return;}
|
|
104
|
+
if(current.key===requested?.key)return;
|
|
105
|
+
requested=current;revision++;failed=false;queueJob?.cancel();
|
|
106
|
+
// Keep the current texture/motion during a resize burst. Rebuild the latest
|
|
107
|
+
// size after 50ms of quiet, with a 150ms bound for continuous transitions.
|
|
108
|
+
const resizing=surface&&committedURL===current.url;
|
|
109
|
+
if(resizing&&!resizeStarted)resizeStarted=performance.now();
|
|
110
|
+
schedule(resizing?Math.max(0,Math.min(50,150-(performance.now()-resizeStarted))):0);
|
|
111
|
+
}
|
|
112
|
+
const resize=new window.ResizeObserver(refresh);resize.observe(element);
|
|
113
|
+
const styleKey=()=>element.style.cssText.replace(/(?:^|;)\s*opacity\s*:[^;]*/g,'');let lastStyle=styleKey();
|
|
114
|
+
const observer=new window.MutationObserver(records=>{const next=styleKey();if(records.some(r=>r.attributeName!=='style')||next!==lastStyle){lastStyle=next;refresh();}});observer.observe(element,{attributes:true,attributeFilter:['src','srcset','sizes','class','style']});
|
|
115
|
+
const proximity=window.IntersectionObserver?new window.IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting))refresh();},{rootMargin:`${window.innerHeight*.5}px 0px`}):null;proximity?.observe(element);
|
|
116
|
+
const visibility=new window.IntersectionObserver(entries=>{
|
|
117
|
+
inViewport=entries.at(-1)?.isIntersecting!==false;
|
|
118
|
+
if(inViewport){refresh();request();}
|
|
119
|
+
else suspend();
|
|
120
|
+
});visibility.observe(element);
|
|
121
|
+
const onVisibility=()=>{if(document.hidden)suspend();else{refresh();request();}};
|
|
122
|
+
document.addEventListener('visibilitychange',onVisibility);
|
|
123
|
+
const onScroll=()=>{if(mode==='mesh')request();if(!proximity&&!surface)refresh();};
|
|
124
|
+
reduced.addEventListener('change',request);element.addEventListener('load',refresh);window.addEventListener('resize',refresh);window.addEventListener('scroll',onScroll,true);
|
|
125
|
+
if(queuedPhase!==null){const box=element.getBoundingClientRect();if(document.hidden||box.bottom<=0||box.top>=window.innerHeight||box.right<=0||box.left>=window.innerWidth){inViewport=false;suspend();}}
|
|
126
|
+
refresh();
|
|
127
|
+
return {element,ready,refresh,cancel(){if(disposed)return;phase='enter';effectStarted=null;queuedPhase=null;queuedStarted=null;exitHeld=false;surface?.setPresentationOpacity(1);surface?.show();native();request();},play(value='enter'){
|
|
128
|
+
if(disposed)throw Error('Surface disposed');
|
|
129
|
+
if(!['enter','exit'].includes(value))throw TypeError('Expected enter or exit');
|
|
130
|
+
phase=value;queuedPhase=value;exitHeld=false;
|
|
131
|
+
const box=element.getBoundingClientRect(),outside=box.bottom<=0||box.top>=window.innerHeight||box.right<=0||box.left>=window.innerWidth;
|
|
132
|
+
queuedStarted=document.hidden||!inViewport||outside?window.performance.now():null;
|
|
133
|
+
if(outside)inViewport=false;
|
|
134
|
+
if(document.hidden||!inViewport)suspend();
|
|
135
|
+
request();
|
|
136
|
+
},
|
|
137
|
+
update(next={}){const candidate={...options,...next};validate(candidate);options=candidate;surface?.setEffect(options.inputEffect??'dust-wind',options.inputEffectOptions??{});request();},
|
|
138
|
+
stats:()=>{
|
|
139
|
+
const image=surface?.stats(),started=queuedPhase!==null?queuedStarted:effectStarted,currentPhase=queuedPhase??phase;
|
|
140
|
+
const duration=image?.duration??options.inputEffectOptions?.duration??2000;
|
|
141
|
+
const finishAt=started===null?null:started+(reduced.matches?0:duration+(currentPhase==='enter'&&options.resting==='native'?250:0));
|
|
142
|
+
const suspended=document.hidden||!inViewport;
|
|
143
|
+
const unsupported=failed||reason==='Unsupported image layout';
|
|
144
|
+
const completed=!unsupported&&started!==null&&(suspended?window.performance.now()>=finishAt:
|
|
145
|
+
queuedPhase===null&&(currentPhase==='exit'?image?.state==='hidden':options.resting==='native'?mode==='native'&&reason==='native resting presentation':image?.state==='visible'&&!image.active));
|
|
146
|
+
return {disposed,mode,reason,phase:currentPhase,preparing,suspended,completed,hidden:exitHeld||nativeHidden,timeline:{phase:currentPhase,started,ends:started===null?null:started+duration,finishAt},handoff:mode==='mesh'&&options.resting==='native'&&phase==='enter'&&effectStarted!==null,image};
|
|
147
|
+
},
|
|
148
|
+
destroy(){if(disposed)return;disposed=true;revision++;requested=null;clearTimeout(prepareTimer);queueJob?.cancel();if(frame!==null)window.cancelAnimationFrame(frame);resize.disconnect();observer.disconnect();proximity?.disconnect();visibility.disconnect();document.removeEventListener('visibilitychange',onVisibility);reduced.removeEventListener('change',request);element.removeEventListener('load',refresh);window.removeEventListener('resize',refresh);window.removeEventListener('scroll',onScroll,true);surface?.destroy();pendingSurface?.destroy();native();renderer.dispose();renderer.forceContextLoss();canvas.remove();if(ownsPosition&&parent.style.position==='relative')parent.style.position=originalPosition;resolve({mode:'native',reason:'destroyed'});}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
104
151
|
|
|
105
152
|
|
|
106
153
|
|
package/src/dom-image-swap.js
CHANGED
|
@@ -8,7 +8,10 @@ export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=
|
|
|
8
8
|
parent.append(next);Object.assign(next.style,{position:'absolute',left:(box.left-bounds.left-parent.clientLeft+parent.scrollLeft)+'px',top:(box.top-bounds.top-parent.clientTop+parent.scrollTop)+'px',width:box.width+'px',height:box.height+'px',margin:'0',visibility:'hidden'});
|
|
9
9
|
if(view.getComputedStyle(previous).position==='static')previous.style.position='relative';previous.style.zIndex=topImage==='previous'?'1':'0';next.style.zIndex=topImage==='next'?'1':'0';
|
|
10
10
|
// Local layers preserve the native next image above a departing canvas too.
|
|
11
|
-
|
|
11
|
+
// A departing previous image can use the shared canvas above the native
|
|
12
|
+
// next image. Forcing local here clips particles to the rounded host.
|
|
13
|
+
const departingOverNative=!enterEffect&&topImage==='previous';
|
|
14
|
+
const layerOptions=!departingOverNative&&(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
|
|
12
15
|
const beginEntry=()=>{next.style.visibility='visible';if(enterEffect)jobs.push(animate(next,{...layerOptions,phase:'enter'}));};
|
|
13
16
|
const earlyEntry=!enterEffect||(!waitForExit&&topImage==='previous');if(earlyEntry)beginEntry();
|
|
14
17
|
if(exitEffect){
|
|
@@ -20,7 +23,7 @@ export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=
|
|
|
20
23
|
if(success){previous.style.visibility='hidden';return {status:'completed'};}
|
|
21
24
|
rollback();return {status:cancelled?'cancelled':'unsupported'};
|
|
22
25
|
}catch(error){jobs.forEach(j=>j.cancel());rollback();throw error;}})();
|
|
23
|
-
function rollback(){previous.style.position=previousPosition;previous.style.zIndex=previousZ;previous.style.visibility=previousVisibility;next.style.cssText=css;if(origin)origin.insertBefore(next,sibling?.parentNode===origin?sibling:null);parent.style.position=parentPosition;}
|
|
26
|
+
function rollback(){previous.style.position=previousPosition;previous.style.zIndex=previousZ;previous.style.visibility=previousVisibility;next.style.cssText=css;if(origin)origin.insertBefore(next,sibling?.parentNode===origin?sibling:null);else next.remove();parent.style.position=parentPosition;}
|
|
24
27
|
return {finished,cancel(){cancelled=true;jobs.forEach(j=>j.cancel());}};
|
|
25
28
|
}
|
|
26
29
|
|