@ev-ry/fx 0.1.0-rc.3 → 0.1.0-rc.4
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 +18 -18
- package/README.md +9 -8
- package/build-report.json +44 -44
- package/docs/RELEASE-NOTES.md +21 -11
- package/examples/evry-website.md +23 -23
- package/package.json +57 -57
- package/src/dom-attachment.d.ts +72 -72
- package/src/dom-free-loader.js +17 -11
- package/src/dom-free-script.js +17 -11
- package/src/dom-free.js +27 -26
- package/src/dom-image-surface.js +25 -12
- package/src/dom-image-swap.js +30 -27
- package/src/dom-raster-cache.js +33 -33
- package/src/dom-text-surface.js +53 -53
- package/src/font-rasterizer.js +20 -20
- package/src/insertion-range.js +29 -29
- package/src/raster-texture-material.js +79 -79
- package/src/raster-texture-mesh.js +35 -35
- package/src/runtime-font-engine.js +43 -43
- package/src/text-edit-effect.js +101 -101
- package/src/text-edit-motions.js +15 -15
- package/src/text-effect-options.js +28 -28
- package/src/text-effect-path.js +61 -61
- package/src/text-motion-primitives.js +98 -98
- package/src/text-motion-recipes.js +71 -71
- package/src/triangle-effect.js +195 -195
package/src/dom-attachment.d.ts
CHANGED
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
export interface SurfaceOptions {
|
|
2
|
-
/** Text/image/SVG only. Re-arm after complete exit when once is false. Explicit play takes over automatic triggering. */
|
|
3
|
-
revealOnView?: false | {threshold?: number; once?: boolean; root?: Element | null};
|
|
4
|
-
resting?: 'mesh' | 'native';
|
|
5
|
-
inputEffect?: string;
|
|
6
|
-
inputEffectOptions?: {
|
|
7
|
-
particleShape?: 'triangle' | 'square';
|
|
8
|
-
duration?: number;
|
|
9
|
-
exitEffect?: string;
|
|
10
|
-
formation?: number;
|
|
11
|
-
chaos?: number;
|
|
12
|
-
motion?: number;
|
|
13
|
-
horizontal?: number;
|
|
14
|
-
vertical?: number;
|
|
15
|
-
flipX?: boolean;
|
|
16
|
-
flipY?: boolean;
|
|
17
|
-
exitFlipX?: boolean;
|
|
18
|
-
exitFlipY?: boolean;
|
|
19
|
-
bounceLines?: boolean;
|
|
20
|
-
seed?: number;
|
|
21
|
-
recipe?: Record<string, number | boolean | string>;
|
|
22
|
-
};
|
|
23
|
-
divisions?: 'auto' | number;
|
|
24
|
-
}
|
|
25
|
-
export interface SurfaceState {
|
|
26
|
-
disposed: boolean;
|
|
27
|
-
mode: string;
|
|
28
|
-
reason?: string | null;
|
|
29
|
-
committed?: string | null;
|
|
30
|
-
[key: string]: unknown;
|
|
31
|
-
}
|
|
32
|
-
export interface Surface {
|
|
33
|
-
element: HTMLElement;
|
|
34
|
-
ready: Promise<{mode: string; reason?: string | null}>;
|
|
35
|
-
refresh(): void;
|
|
36
|
-
update(options: Pick<SurfaceOptions, 'inputEffect' | 'inputEffectOptions' | 'resting'>): void;
|
|
37
|
-
stats(): SurfaceState;
|
|
38
|
-
destroy(): void;
|
|
39
|
-
}
|
|
40
|
-
export interface TextSurface extends Surface { cancel?(): void; play(phase?: 'enter' | 'exit'): void; }
|
|
41
|
-
export type DOMPresentation = 'local' | 'global' | 'auto';
|
|
42
|
-
export interface DOMInstallation {
|
|
43
|
-
swapImage(previous: HTMLImageElement, next: HTMLImageElement, options?: SurfaceOptions & {exitEffect?: string | null; waitForExit?: boolean; enterEffect?: boolean; topImage?: 'previous' | 'next'; presentation?: DOMPresentation}): {finished: Promise<{status: string}>; cancel(): void};
|
|
44
|
-
attachSVG(element: SVGSVGElement, options?: SurfaceOptions & {presentation?: DOMPresentation}): Omit<TextSurface, 'element'> & {element: SVGSVGElement};
|
|
45
|
-
animateOnce(element: HTMLElement, options?: SurfaceOptions & {phase?: 'enter' | 'exit'; presentation?: DOMPresentation; layer?: string}): {finished: Promise<{status: string; phase: string}>; cancel(): void};
|
|
46
|
-
attachImage(element: HTMLImageElement, options?: SurfaceOptions & {presentation?: DOMPresentation; layer?: string}): TextSurface;
|
|
47
|
-
attachInput(element: HTMLInputElement | HTMLTextAreaElement, options?: SurfaceOptions & {host?: HTMLElement; presentation?: DOMPresentation; layer?: string}): Surface;
|
|
48
|
-
attachText(element: HTMLElement, options?: SurfaceOptions & {presentation?: DOMPresentation; layer?: string}): TextSurface;
|
|
49
|
-
registerLayer(name: string, options: {root: HTMLDialogElement; zIndex?: number}): void;
|
|
50
|
-
unregisterLayer(name: string): void;
|
|
51
|
-
/** Manual transfer preserving the native editor, scene and effect timeline. */
|
|
52
|
-
transfer(surface: Surface, destination?: {presentation?: DOMPresentation; layer?: string | null}): void;
|
|
53
|
-
routing(surface: Surface): {requested: DOMPresentation; presentation: 'local' | 'global'; layer: string | null; reason: string; pending: boolean; error: string | null};
|
|
54
|
-
refresh(): void;
|
|
55
|
-
stats(): {disposed: boolean; controls: number; contexts: number; copies: number; owners: Partial<Record<string, ReturnType<DOMRenderer['stats']>>>};
|
|
56
|
-
destroy(): void;
|
|
57
|
-
}
|
|
58
|
-
/** Explicit or bounded automatic routing; existing native editors remain owned by the host. */
|
|
59
|
-
export function createDOMInstallation(THREE: Parameters<typeof createDOMRenderer>[0], document: Document, options?: {presentation?: DOMPresentation; documentCanvas?: boolean; escapeEffects?: boolean; zIndex?: number}): DOMInstallation;
|
|
60
|
-
export interface DOMRenderer {
|
|
61
|
-
attachSVG(element: SVGSVGElement, options?: SurfaceOptions): Omit<TextSurface, 'element'> & {element: SVGSVGElement};
|
|
62
|
-
attachImage(element: HTMLImageElement, options?: SurfaceOptions): TextSurface;
|
|
63
|
-
readonly domElement: HTMLCanvasElement | null;
|
|
64
|
-
/** Refresh all attached surfaces after external layout/value changes. */
|
|
65
|
-
refresh(): void;
|
|
66
|
-
attachText(element: HTMLElement, options?: SurfaceOptions): TextSurface;
|
|
67
|
-
attachInput(element: HTMLInputElement | HTMLTextAreaElement, options?: SurfaceOptions & {host?: HTMLElement}): Surface;
|
|
68
|
-
stats(): {disposed: boolean; lost: boolean; contexts: number; controls: number; leases: number; [key: string]: unknown};
|
|
69
|
-
destroy(): void;
|
|
70
|
-
}
|
|
71
|
-
/** THREE is injected: the host retains its existing Three.js dependency. */
|
|
72
|
-
export function createDOMRenderer(THREE: {WebGLRenderer: new (...args: any[]) => any; [key: string]: any}, document: Document, options?: {presentation?: 'local' | 'viewport'; escapeEffects?: boolean; zIndex?: number; root?: HTMLDialogElement}): DOMRenderer;
|
|
1
|
+
export interface SurfaceOptions {
|
|
2
|
+
/** Text/image/SVG only. Re-arm after complete exit when once is false. Explicit play takes over automatic triggering. */
|
|
3
|
+
revealOnView?: false | {threshold?: number; once?: boolean; root?: Element | null};
|
|
4
|
+
resting?: 'mesh' | 'native';
|
|
5
|
+
inputEffect?: string;
|
|
6
|
+
inputEffectOptions?: {
|
|
7
|
+
particleShape?: 'triangle' | 'square';
|
|
8
|
+
duration?: number;
|
|
9
|
+
exitEffect?: string;
|
|
10
|
+
formation?: number;
|
|
11
|
+
chaos?: number;
|
|
12
|
+
motion?: number;
|
|
13
|
+
horizontal?: number;
|
|
14
|
+
vertical?: number;
|
|
15
|
+
flipX?: boolean;
|
|
16
|
+
flipY?: boolean;
|
|
17
|
+
exitFlipX?: boolean;
|
|
18
|
+
exitFlipY?: boolean;
|
|
19
|
+
bounceLines?: boolean;
|
|
20
|
+
seed?: number;
|
|
21
|
+
recipe?: Record<string, number | boolean | string>;
|
|
22
|
+
};
|
|
23
|
+
divisions?: 'auto' | number;
|
|
24
|
+
}
|
|
25
|
+
export interface SurfaceState {
|
|
26
|
+
disposed: boolean;
|
|
27
|
+
mode: string;
|
|
28
|
+
reason?: string | null;
|
|
29
|
+
committed?: string | null;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface Surface {
|
|
33
|
+
element: HTMLElement;
|
|
34
|
+
ready: Promise<{mode: string; reason?: string | null}>;
|
|
35
|
+
refresh(): void;
|
|
36
|
+
update(options: Pick<SurfaceOptions, 'inputEffect' | 'inputEffectOptions' | 'resting'>): void;
|
|
37
|
+
stats(): SurfaceState;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
export interface TextSurface extends Surface { cancel?(): void; play(phase?: 'enter' | 'exit'): void; }
|
|
41
|
+
export type DOMPresentation = 'local' | 'global' | 'auto';
|
|
42
|
+
export interface DOMInstallation {
|
|
43
|
+
swapImage(previous: HTMLImageElement, next: HTMLImageElement, options?: SurfaceOptions & {exitEffect?: string | null; waitForExit?: boolean; enterEffect?: boolean; topImage?: 'previous' | 'next'; presentation?: DOMPresentation}): {finished: Promise<{status: string}>; cancel(): void};
|
|
44
|
+
attachSVG(element: SVGSVGElement, options?: SurfaceOptions & {presentation?: DOMPresentation}): Omit<TextSurface, 'element'> & {element: SVGSVGElement};
|
|
45
|
+
animateOnce(element: HTMLElement, options?: SurfaceOptions & {phase?: 'enter' | 'exit'; presentation?: DOMPresentation; layer?: string}): {finished: Promise<{status: string; phase: string}>; cancel(): void};
|
|
46
|
+
attachImage(element: HTMLImageElement, options?: SurfaceOptions & {presentation?: DOMPresentation; layer?: string}): TextSurface;
|
|
47
|
+
attachInput(element: HTMLInputElement | HTMLTextAreaElement, options?: SurfaceOptions & {host?: HTMLElement; presentation?: DOMPresentation; layer?: string}): Surface;
|
|
48
|
+
attachText(element: HTMLElement, options?: SurfaceOptions & {presentation?: DOMPresentation; layer?: string}): TextSurface;
|
|
49
|
+
registerLayer(name: string, options: {root: HTMLDialogElement; zIndex?: number}): void;
|
|
50
|
+
unregisterLayer(name: string): void;
|
|
51
|
+
/** Manual transfer preserving the native editor, scene and effect timeline. */
|
|
52
|
+
transfer(surface: Surface, destination?: {presentation?: DOMPresentation; layer?: string | null}): void;
|
|
53
|
+
routing(surface: Surface): {requested: DOMPresentation; presentation: 'local' | 'global'; layer: string | null; reason: string; pending: boolean; error: string | null};
|
|
54
|
+
refresh(): void;
|
|
55
|
+
stats(): {disposed: boolean; controls: number; contexts: number; copies: number; owners: Partial<Record<string, ReturnType<DOMRenderer['stats']>>>};
|
|
56
|
+
destroy(): void;
|
|
57
|
+
}
|
|
58
|
+
/** Explicit or bounded automatic routing; existing native editors remain owned by the host. */
|
|
59
|
+
export function createDOMInstallation(THREE: Parameters<typeof createDOMRenderer>[0], document: Document, options?: {presentation?: DOMPresentation; documentCanvas?: boolean; escapeEffects?: boolean; zIndex?: number}): DOMInstallation;
|
|
60
|
+
export interface DOMRenderer {
|
|
61
|
+
attachSVG(element: SVGSVGElement, options?: SurfaceOptions): Omit<TextSurface, 'element'> & {element: SVGSVGElement};
|
|
62
|
+
attachImage(element: HTMLImageElement, options?: SurfaceOptions): TextSurface;
|
|
63
|
+
readonly domElement: HTMLCanvasElement | null;
|
|
64
|
+
/** Refresh all attached surfaces after external layout/value changes. */
|
|
65
|
+
refresh(): void;
|
|
66
|
+
attachText(element: HTMLElement, options?: SurfaceOptions): TextSurface;
|
|
67
|
+
attachInput(element: HTMLInputElement | HTMLTextAreaElement, options?: SurfaceOptions & {host?: HTMLElement}): Surface;
|
|
68
|
+
stats(): {disposed: boolean; lost: boolean; contexts: number; controls: number; leases: number; [key: string]: unknown};
|
|
69
|
+
destroy(): void;
|
|
70
|
+
}
|
|
71
|
+
/** THREE is injected: the host retains its existing Three.js dependency. */
|
|
72
|
+
export function createDOMRenderer(THREE: {WebGLRenderer: new (...args: any[]) => any; [key: string]: any}, document: Document, options?: {presentation?: 'local' | 'viewport'; escapeEffects?: boolean; zIndex?: number; root?: HTMLDialogElement}): DOMRenderer;
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
|
package/src/dom-free-loader.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
/** EV-RY FX Free loader (MIT) — https://ev-ry.com/fx/ */
|
|
1
2
|
import {bootstrapFree} from './dom-free-bootstrap.js';
|
|
2
|
-
|
|
3
|
-
/** Explicit npm/module entry. The first call selects auto mode; later calls reuse readiness. */
|
|
4
|
-
export function loadFree(options={}){
|
|
5
|
-
const window=(options.document??globalThis.document)?.defaultView;
|
|
6
|
-
if(!window)throw TypeError('THD Free needs a browser document');
|
|
7
|
-
if(window.THDFree)return window.THDFree.ready;
|
|
8
|
-
const ready=bootstrapFree(options);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
|
|
4
|
+
/** Explicit npm/module entry. The first call selects auto mode; later calls reuse readiness. */
|
|
5
|
+
export function loadFree(options={}){
|
|
6
|
+
const window=(options.document??globalThis.document)?.defaultView;
|
|
7
|
+
if(!window)throw TypeError('THD Free needs a browser document');
|
|
8
|
+
if(window.THDFree)return window.THDFree.ready;
|
|
9
|
+
const ready=bootstrapFree(options);
|
|
10
|
+
const namespace=Object.freeze({ready});
|
|
11
|
+
window.THDFree=namespace;
|
|
12
|
+
ready.catch(error=>{
|
|
13
|
+
if(window.THDFree===namespace)delete window.THDFree;
|
|
14
|
+
console.error(error);
|
|
15
|
+
window.dispatchEvent(new window.CustomEvent('thd:error',{detail:error}));
|
|
16
|
+
});
|
|
17
|
+
return ready;
|
|
18
|
+
}
|
package/src/dom-free-script.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
/** EV-RY FX Free classic loader (MIT) — https://ev-ry.com/fx/ */
|
|
1
2
|
(function(){
|
|
2
|
-
const script=document.currentScript;
|
|
3
|
-
if(!script?.src)throw Error('THD: use a classic script tag, or import loadFree from @thd/free/script');
|
|
4
|
-
const base=new URL('.',script.src);
|
|
5
|
-
if(window.THDFree)return;
|
|
6
|
-
const ready=(async()=>{
|
|
7
|
-
const {bootstrapFree}=await import(new URL('./dom-free-bootstrap.js',base).href);
|
|
8
|
-
return bootstrapFree({document,auto:script.hasAttribute('data-thd-auto')});
|
|
9
|
-
})();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
const script=document.currentScript;
|
|
4
|
+
if(!script?.src)throw Error('THD: use a classic script tag, or import loadFree from @thd/free/script');
|
|
5
|
+
const base=new URL('.',script.src);
|
|
6
|
+
if(window.THDFree)return;
|
|
7
|
+
const ready=(async()=>{
|
|
8
|
+
const {bootstrapFree}=await import(new URL('./dom-free-bootstrap.js',base).href);
|
|
9
|
+
return bootstrapFree({document,auto:script.hasAttribute('data-thd-auto')});
|
|
10
|
+
})();
|
|
11
|
+
const namespace=Object.freeze({ready});
|
|
12
|
+
window.THDFree=namespace;
|
|
13
|
+
ready.catch(error=>{
|
|
14
|
+
if(window.THDFree===namespace)delete window.THDFree;
|
|
15
|
+
console.error(error);
|
|
16
|
+
window.dispatchEvent(new CustomEvent('thd:error',{detail:error}));
|
|
17
|
+
});
|
|
18
|
+
})();
|
package/src/dom-free.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
/** EV-RY FX Free (MIT) — https://ev-ry.com/fx/ */
|
|
2
|
+
import {createDOMInstallation} from './dom-attachment.js';
|
|
2
3
|
import {createAutoReveal} from './dom-auto-reveal.js';
|
|
3
4
|
|
|
4
5
|
export const FREE_EFFECTS = Object.freeze({textEnter:'dust-wind', textExit:'smoke', imageEnter:'drifting-snow', imageExit:'melt'});
|
|
5
6
|
export function createFree(THREE, root = document, options = {}) {
|
|
6
7
|
const doc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
7
8
|
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
|
+
let automatic, disposed=false;const installationWaits=new Set();
|
|
9
10
|
function attach(kind, element, config = {}) {
|
|
10
11
|
if(disposed)throw Error('Free installation disposed');
|
|
11
12
|
for(const key of Object.keys(config))if(!['revealOnView','presentation'].includes(key))throw TypeError(`Unsupported Free option: ${key}`);
|
|
@@ -14,32 +15,32 @@ export function createFree(THREE, root = document, options = {}) {
|
|
|
14
15
|
...config,resting:'native',inputEffect:FREE_EFFECTS[effectKind+'Enter'],
|
|
15
16
|
inputEffectOptions:{duration:2000,particleShape:'triangle',exitEffect:FREE_EFFECTS[effectKind+'Exit']}
|
|
16
17
|
});
|
|
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;}
|
|
18
|
+
let currentWait=null,requestedPlay=false;
|
|
19
|
+
const whenFinished=()=>{
|
|
20
|
+
if(currentWait)return currentWait.promise;
|
|
21
|
+
const wait={};currentWait=wait;
|
|
22
|
+
wait.promise=new Promise(resolve=>{
|
|
23
|
+
let timeoutStarted=null,timer,done=false;
|
|
24
|
+
const finish=status=>{if(done)return;done=true;clearTimeout(timer);installationWaits.delete(wait.cancel);if(currentWait===wait)currentWait=null;resolve({status});};
|
|
25
|
+
wait.cancel=()=>finish('cancelled');installationWaits.add(wait.cancel);
|
|
26
|
+
const poll=()=>{const s=surface.stats();
|
|
27
|
+
if(s.disposed||disposed||!element.isConnected){finish('cancelled');return;}
|
|
28
|
+
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;}
|
|
29
|
+
const completed=typeof s.completed==='boolean'?s.completed:s.mode==='native'&&['native resting presentation','hidden after exit'].includes(s.reason);
|
|
30
|
+
if((!s.reveal||s.reveal.count>0||s.reveal.manual)&&completed){finish('completed');return;}
|
|
31
|
+
// An untouched intersection reveal may legitimately wait minutes.
|
|
32
|
+
// Bound actual preparation/play, not the time before first visibility.
|
|
33
|
+
if(requestedPlay||s.timeline?.started!=null||s.preparing&&!s.suspended)timeoutStarted??=Date.now();
|
|
34
|
+
if(timeoutStarted!==null&&Date.now()-timeoutStarted>30000){finish('cancelled');return;}
|
|
34
35
|
const remaining=s.timeline?.finishAt-doc.defaultView.performance.now();
|
|
35
36
|
timer=setTimeout(poll,s.suspended?Math.min(1000,Math.max(100,remaining||1000)):16);
|
|
36
|
-
};timer=setTimeout(poll,16);
|
|
37
|
-
});return wait.promise;
|
|
38
|
-
};
|
|
37
|
+
};timer=setTimeout(poll,16);
|
|
38
|
+
});return wait.promise;
|
|
39
|
+
};
|
|
39
40
|
return Object.freeze({whenFinished,ready:surface.ready,play:(phase='enter')=>{
|
|
40
|
-
if(!['enter','exit'].includes(phase))throw TypeError('Invalid phase');
|
|
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();}});
|
|
41
|
+
if(!['enter','exit'].includes(phase))throw TypeError('Invalid phase');
|
|
42
|
+
surface.play(phase);requestedPlay=true;currentWait?.cancel();
|
|
43
|
+
},cancel:()=>{surface.cancel();requestedPlay=false;currentWait?.cancel();},refresh:()=>surface.refresh(),stats:()=>surface.stats(),destroy:()=>{currentWait?.cancel();surface.destroy();}});
|
|
43
44
|
}
|
|
44
45
|
// Automatic and manual surfaces lease the same installation.
|
|
45
46
|
const autoOwner={attachText:(el,c)=>attach('text',el,{revealOnView:c.revealOnView}),attachImage:(el,c)=>attach('image',el,{revealOnView:c.revealOnView}),stats:()=>owner.stats()};
|
|
@@ -57,7 +58,7 @@ export function createFree(THREE, root = document, options = {}) {
|
|
|
57
58
|
},
|
|
58
59
|
refresh(){if(disposed)throw Error('Free installation disposed');automatic?.refresh();owner.refresh();},
|
|
59
60
|
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
|
+
destroy(){if(disposed)return;disposed=true;for(const cancel of [...installationWaits])cancel();try{automatic?.destroy();}finally{owner.destroy();}}
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
|
package/src/dom-image-surface.js
CHANGED
|
@@ -11,7 +11,17 @@ export function attachImageSurface(element,THREE,options={}){
|
|
|
11
11
|
const document=element.ownerDocument,window=document.defaultView,parent=element.parentElement;
|
|
12
12
|
const renderer=new THREE.WebGLRenderer({alpha:true,antialias:true}),canvas=renderer.domElement;
|
|
13
13
|
const scene=new THREE.Scene(),camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=10;
|
|
14
|
-
|
|
14
|
+
let originalOpacity=element.style.opacity,ownedOpacity=null;
|
|
15
|
+
const nativeRestOpacity=options.nativeRestOpacity??(originalOpacity===''?1:Number(originalOpacity)),originalPosition=parent.style.position;
|
|
16
|
+
function captureHostOpacity(){
|
|
17
|
+
if(ownedOpacity===null||element.style.opacity!==ownedOpacity)originalOpacity=element.style.opacity;
|
|
18
|
+
}
|
|
19
|
+
function writeOpacity(value){captureHostOpacity();element.style.opacity=value;ownedOpacity=element.style.opacity;}
|
|
20
|
+
function restoreOpacity(){
|
|
21
|
+
captureHostOpacity();
|
|
22
|
+
if(ownedOpacity!==null&&element.style.opacity===ownedOpacity)element.style.opacity=originalOpacity;
|
|
23
|
+
ownedOpacity=null;
|
|
24
|
+
}
|
|
15
25
|
const ownsPosition=window.getComputedStyle(parent).position==='static';if(ownsPosition)parent.style.position='relative';
|
|
16
26
|
Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none'});canvas.setAttribute('aria-hidden','true');parent.append(canvas);
|
|
17
27
|
renderer.setSurface?.(parent);
|
|
@@ -24,9 +34,12 @@ export function attachImageSurface(element,THREE,options={}){
|
|
|
24
34
|
function native(error,restore=false){
|
|
25
35
|
if(restore)exitHeld=false;
|
|
26
36
|
const hidden=!disposed&&!restore&&(exitHeld||surface?.stats().state==='hidden');
|
|
27
|
-
nativeHidden=hidden;mode='native';reason=error||null;
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
nativeHidden=hidden;mode='native';reason=error||null;
|
|
38
|
+
if(disposed)restoreOpacity();
|
|
39
|
+
else{captureHostOpacity();writeOpacity(hidden?'0':(options.nativeRestOpacity!==undefined?String(nativeRestOpacity):originalOpacity));}
|
|
40
|
+
canvas.style.display='none';options.onPresentation?.({mode,reason,hidden});renderer.invalidate?.();
|
|
41
|
+
}
|
|
42
|
+
function holdExit(){exitHeld=true;nativeHidden=true;writeOpacity('0');options.onPresentation?.({mode,reason,hidden:true});}
|
|
30
43
|
function suspend(){
|
|
31
44
|
if(queuedPhase!==null&&queuedStarted===null)queuedStarted=window.performance.now();
|
|
32
45
|
if(queuedPhase==='exit'||queuedPhase===null&&phase==='exit'&&effectStarted!==null)holdExit();
|
|
@@ -55,12 +68,12 @@ export function attachImageSurface(element,THREE,options={}){
|
|
|
55
68
|
// Submit before handing off: reveal gates acknowledge even reduced motion.
|
|
56
69
|
renderer.render(scene,camera);
|
|
57
70
|
native(state.state==='hidden'?'hidden after exit':'native resting presentation');
|
|
58
|
-
if(state.state==='hidden')
|
|
71
|
+
if(state.state==='hidden')writeOpacity('0');
|
|
59
72
|
resolve({mode,reason});return;
|
|
60
73
|
}
|
|
61
74
|
const handingOffNative=mode==='native';
|
|
62
|
-
|
|
63
|
-
options.onPresentation?.({mode,reason,hidden:nativeHidden});
|
|
75
|
+
writeOpacity(String(nativeAlpha*nativeRestOpacity));nativeHidden=nativeAlpha===0;mode='mesh';reason=null;renderer.render(scene,camera);
|
|
76
|
+
options.onPresentation?.({mode,reason,hidden:nativeHidden});
|
|
64
77
|
if(handingOffNative)renderer.flushPresentation?.();
|
|
65
78
|
resolve({mode,reason});if(handoff&&meshFade<1)request();
|
|
66
79
|
}catch(error){native(error.message,true);resolve({mode,reason});}}
|
|
@@ -83,10 +96,10 @@ export function attachImageSurface(element,THREE,options={}){
|
|
|
83
96
|
const next=surface??createImageSurface(runtime,{width:raster.width,height:raster.height,effect:options.inputEffect??'dust-wind',settings:options.inputEffectOptions??{}});
|
|
84
97
|
if(!surface)pendingSurface=next;
|
|
85
98
|
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}});
|
|
99
|
+
// DOM source/paint changes replace pixels, not the caller's animation
|
|
100
|
+
// intent. Keep a departure hidden and preserve an active clock/seed
|
|
101
|
+
// across src, srcset and SVG snapshot replacements just as on resize.
|
|
102
|
+
await next.setSource(raster.source,{preserveMotion:!!surface,bounds:{width:raster.width,height:raster.height}});
|
|
90
103
|
if(disposed||token!==revision){if(next!==surface)next.destroy();return false;}
|
|
91
104
|
surface=next;pendingSurface=null;baseWidth=raster.width;baseHeight=raster.height;source=current.key;committedURL=current.url;requested=null;resizeStarted=0;return true;
|
|
92
105
|
});
|
|
@@ -143,7 +156,7 @@ export function attachImageSurface(element,THREE,options={}){
|
|
|
143
156
|
const unsupported=failed||reason==='Unsupported image layout';
|
|
144
157
|
const completed=!unsupported&&started!==null&&(suspended?window.performance.now()>=finishAt:
|
|
145
158
|
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};
|
|
159
|
+
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
160
|
},
|
|
148
161
|
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
162
|
};
|
package/src/dom-image-swap.js
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
// Place the next native image over the previous image; animate transiently.
|
|
2
|
-
export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=false,enterEffect=true,topImage='next',...options}={}){
|
|
3
|
-
if(!['previous','next'].includes(topImage))throw TypeError('Expected previous or next topImage');
|
|
4
|
-
if(previous===next||previous?.tagName!=='IMG'||next?.tagName!=='IMG'||!previous.parentElement)throw TypeError('Two different IMG elements are required');
|
|
5
|
-
const previousVisibility=previous.style.visibility,previousPosition=previous.style.position,previousZ=previous.style.zIndex;let cancelled=false,jobs=[];const parent=previous.parentElement,origin=next.parentNode,sibling=next.nextSibling,css=next.style.cssText,parentPosition=parent.style.position;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if(view.getComputedStyle(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
if(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if(
|
|
24
|
-
|
|
1
|
+
// Place the next native image over the previous image; animate transiently.
|
|
2
|
+
export function swapDOMImage(animate,previous,next,{exitEffect=null,waitForExit=false,enterEffect=true,topImage='next',...options}={}){
|
|
3
|
+
if(!['previous','next'].includes(topImage))throw TypeError('Expected previous or next topImage');
|
|
4
|
+
if(previous===next||previous?.tagName!=='IMG'||next?.tagName!=='IMG'||!previous.parentElement)throw TypeError('Two different IMG elements are required');
|
|
5
|
+
const previousVisibility=previous.style.visibility,previousPosition=previous.style.position,previousZ=previous.style.zIndex;let cancelled=false,settled=false,rolledBack=false,jobs=[];const parent=previous.parentElement,origin=next.parentNode,sibling=next.nextSibling,css=next.style.cssText,parentPosition=parent.style.position;
|
|
6
|
+
let resolveCancellation;
|
|
7
|
+
const cancellation=new Promise(resolve=>{resolveCancellation=resolve;});
|
|
8
|
+
const operation=(async()=>{try{await Promise.all([previous.decode(),next.decode()]);if(cancelled)return {status:'cancelled'};
|
|
9
|
+
const view=previous.ownerDocument.defaultView,box=previous.getBoundingClientRect();if(view.getComputedStyle(parent).position==='static')parent.style.position='relative';const bounds=parent.getBoundingClientRect();
|
|
10
|
+
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'});
|
|
11
|
+
if(view.getComputedStyle(previous).position==='static')previous.style.position='relative';previous.style.zIndex=topImage==='previous'?'1':'0';next.style.zIndex=topImage==='next'?'1':'0';
|
|
12
|
+
// Local layers preserve the native next image above a departing canvas too.
|
|
13
|
+
// A departing previous image can use the shared canvas above the native
|
|
14
|
+
// next image. Forcing local here clips particles to the rounded host.
|
|
15
|
+
const departingOverNative=!enterEffect&&topImage==='previous';
|
|
16
|
+
const layerOptions=!departingOverNative&&(!enterEffect||topImage==='previous')?{...options,presentation:'local'}:options;
|
|
17
|
+
const beginEntry=()=>{next.style.visibility='visible';if(enterEffect)jobs.push(animate(next,{...layerOptions,phase:'enter'}));};
|
|
18
|
+
const earlyEntry=!enterEffect||(!waitForExit&&topImage==='previous');if(earlyEntry)beginEntry();
|
|
19
|
+
if(exitEffect){
|
|
20
|
+
const exit=animate(previous,{...layerOptions,inputEffect:exitEffect,phase:'exit'});jobs.push(exit);
|
|
21
|
+
if(waitForExit){const result=await exit.finished;if(cancelled||result.status!=='completed'){rollback();return {status:cancelled?'cancelled':result.status};}}
|
|
22
|
+
}
|
|
23
|
+
if(!earlyEntry)beginEntry();
|
|
24
|
+
const results=await Promise.all(jobs.map(job=>job.finished));const success=!cancelled&&results.every(r=>r.status==='completed');
|
|
25
|
+
if(success){previous.style.visibility='hidden';return {status:'completed'};}
|
|
26
|
+
rollback();return {status:cancelled?'cancelled':'unsupported'};
|
|
25
27
|
}catch(error){jobs.forEach(j=>j.cancel());rollback();throw error;}})();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
28
|
+
const finished=Promise.race([operation,cancellation]).then(result=>{settled=true;return result;},error=>{settled=true;throw error;});
|
|
29
|
+
function rollback(){if(rolledBack)return;rolledBack=true;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;}
|
|
30
|
+
return {finished,cancel(){if(settled||cancelled)return;cancelled=true;jobs.forEach(j=>j.cancel());rollback();resolveCancellation({status:'cancelled'});}};
|
|
31
|
+
}
|
|
29
32
|
|
|
30
33
|
|
package/src/dom-raster-cache.js
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
// Document-scoped, bounded source pixels only. Geometry, tint and motion remain
|
|
2
|
-
// attachment-owned. Live glyph records can outlive an LRU entry safely.
|
|
3
|
-
const documents=new WeakMap();
|
|
4
|
-
const MAX_BYTES=16*1024*1024,MAX_ENTRIES=128,MAX_FONTS=64;
|
|
5
|
-
export function domRasterCache(document){
|
|
6
|
-
let cache=documents.get(document);if(cache)return cache;
|
|
7
|
-
const rasters=new Map(),fonts=new Map();let bytes=0,hits=0,misses=0,epoch=0;
|
|
8
|
-
function invalidate(){epoch++;rasters.clear();fonts.clear();bytes=0;}
|
|
9
|
-
cache={
|
|
10
|
-
get epoch(){return epoch;},
|
|
11
|
-
font(key,load){
|
|
12
|
-
let promise=fonts.get(key);
|
|
13
|
-
if(!promise){promise=Promise.resolve().then(load);fonts.set(key,promise);if(fonts.size>MAX_FONTS)fonts.delete(fonts.keys().next().value);promise.catch(()=>{if(fonts.get(key)===promise)fonts.delete(key);});}
|
|
14
|
-
return promise;
|
|
15
|
-
},
|
|
16
|
-
raster(key,build){
|
|
17
|
-
const old=rasters.get(key);
|
|
18
|
-
if(old){rasters.delete(key);rasters.set(key,old);hits++;return old;}
|
|
19
|
-
misses++;const result=build(),size=result.rgba.byteLength;
|
|
20
|
-
if(size<=MAX_BYTES){
|
|
21
|
-
while(rasters.size&&(bytes+size>MAX_BYTES||rasters.size>=MAX_ENTRIES)){const first=rasters.keys().next().value;bytes-=rasters.get(first).rgba.byteLength;rasters.delete(first);}
|
|
22
|
-
rasters.set(key,result);bytes+=size;
|
|
23
|
-
}
|
|
24
|
-
return result;
|
|
25
|
-
},
|
|
26
|
-
stats:()=>({bytes,entries:rasters.size,hits,misses,epoch,maxBytes:MAX_BYTES}),
|
|
27
|
-
invalidate
|
|
28
|
-
};
|
|
29
|
-
// Font files may finish after an engine first used the CSS fallback font.
|
|
30
|
-
document.fonts?.addEventListener('loadingdone',invalidate);
|
|
31
|
-
document.fonts?.addEventListener('loadingerror',invalidate);
|
|
32
|
-
documents.set(document,cache);return cache;
|
|
33
|
-
}
|
|
1
|
+
// Document-scoped, bounded source pixels only. Geometry, tint and motion remain
|
|
2
|
+
// attachment-owned. Live glyph records can outlive an LRU entry safely.
|
|
3
|
+
const documents=new WeakMap();
|
|
4
|
+
const MAX_BYTES=16*1024*1024,MAX_ENTRIES=128,MAX_FONTS=64;
|
|
5
|
+
export function domRasterCache(document){
|
|
6
|
+
let cache=documents.get(document);if(cache)return cache;
|
|
7
|
+
const rasters=new Map(),fonts=new Map();let bytes=0,hits=0,misses=0,epoch=0;
|
|
8
|
+
function invalidate(){epoch++;rasters.clear();fonts.clear();bytes=0;}
|
|
9
|
+
cache={
|
|
10
|
+
get epoch(){return epoch;},
|
|
11
|
+
font(key,load){
|
|
12
|
+
let promise=fonts.get(key);
|
|
13
|
+
if(!promise){promise=Promise.resolve().then(load);fonts.set(key,promise);if(fonts.size>MAX_FONTS)fonts.delete(fonts.keys().next().value);promise.catch(()=>{if(fonts.get(key)===promise)fonts.delete(key);});}
|
|
14
|
+
return promise;
|
|
15
|
+
},
|
|
16
|
+
raster(key,build){
|
|
17
|
+
const old=rasters.get(key);
|
|
18
|
+
if(old){rasters.delete(key);rasters.set(key,old);hits++;return old;}
|
|
19
|
+
misses++;const result=build(),size=result.rgba.byteLength;
|
|
20
|
+
if(size<=MAX_BYTES){
|
|
21
|
+
while(rasters.size&&(bytes+size>MAX_BYTES||rasters.size>=MAX_ENTRIES)){const first=rasters.keys().next().value;bytes-=rasters.get(first).rgba.byteLength;rasters.delete(first);}
|
|
22
|
+
rasters.set(key,result);bytes+=size;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
},
|
|
26
|
+
stats:()=>({bytes,entries:rasters.size,hits,misses,epoch,maxBytes:MAX_BYTES}),
|
|
27
|
+
invalidate
|
|
28
|
+
};
|
|
29
|
+
// Font files may finish after an engine first used the CSS fallback font.
|
|
30
|
+
document.fonts?.addEventListener('loadingdone',invalidate);
|
|
31
|
+
document.fonts?.addEventListener('loadingerror',invalidate);
|
|
32
|
+
documents.set(document,cache);return cache;
|
|
33
|
+
}
|