@ev-ry/fx 0.1.0-rc.1
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/LICENSE +21 -0
- package/NOTICE.md +13 -0
- package/QUICKSTART.fa.md +94 -0
- package/README.md +116 -0
- package/assets/fonts/Estedad-OFL.txt +93 -0
- package/assets/vendor/bidi-LICENSE.txt +22 -0
- package/assets/vendor/bidi.min.js +1 -0
- package/assets/vendor/three-LICENSE.txt +21 -0
- package/assets/vendor/three.min.js +7 -0
- package/build-report.json +328 -0
- package/docs/GUIDE.md +125 -0
- package/docs/RELEASE-NOTES.md +40 -0
- package/examples/AnimatedTitle.jsx +21 -0
- package/examples/navigation-away.html +1 -0
- package/examples/navigation.html +38 -0
- package/examples/script.html +2 -0
- package/package.json +57 -0
- package/src/dom-attachment.d.ts +78 -0
- package/src/dom-attachment.js +187 -0
- package/src/dom-auto-reveal.d.ts +17 -0
- package/src/dom-auto-reveal.js +53 -0
- package/src/dom-auto-route.js +25 -0
- package/src/dom-free-bootstrap.js +42 -0
- package/src/dom-free-loader.d.ts +13 -0
- package/src/dom-free-loader.js +12 -0
- package/src/dom-free-script.js +12 -0
- package/src/dom-free.d.ts +16 -0
- package/src/dom-free.js +42 -0
- package/src/dom-image-raster.js +22 -0
- package/src/dom-image-surface.js +109 -0
- package/src/dom-image-swap.js +27 -0
- package/src/dom-once.js +34 -0
- package/src/dom-raster-cache.js +33 -0
- package/src/dom-reveal-boot.js +11 -0
- package/src/dom-reveal.js +75 -0
- package/src/dom-rich-text.js +119 -0
- package/src/dom-surface-font.js +50 -0
- package/src/dom-svg-surface.js +43 -0
- package/src/dom-text-fingerprint.js +27 -0
- package/src/dom-text-runs.js +45 -0
- package/src/dom-text-surface.js +254 -0
- package/src/font-mesh-engine.js +368 -0
- package/src/font-rasterizer.js +65 -0
- package/src/hybrid-text-flow.js +45 -0
- package/src/image-preparation-queue.js +16 -0
- package/src/image-source.js +61 -0
- package/src/image-surface.js +162 -0
- package/src/insertion-range.js +29 -0
- package/src/mesh-generator-core.js +149 -0
- package/src/motion-envelope.js +22 -0
- package/src/motion.js +52 -0
- package/src/native-run-shaping.js +60 -0
- package/src/particle-centers.js +50 -0
- package/src/raster-texture-material.js +79 -0
- package/src/raster-texture-mesh.js +35 -0
- package/src/render-owner.js +63 -0
- package/src/runtime-font-engine.js +242 -0
- package/src/runtime-lifecycle.js +26 -0
- package/src/text-direction.js +25 -0
- package/src/text-edit-effect.js +101 -0
- package/src/text-edit-motions.js +15 -0
- package/src/text-effect-options.js +29 -0
- package/src/text-effect-path.js +61 -0
- package/src/text-mesh-density.js +36 -0
- package/src/text-motion-character-centers.js +74 -0
- package/src/text-motion-contour.js +22 -0
- package/src/text-motion-primitives.js +91 -0
- package/src/text-motion-programs.js +1 -0
- package/src/text-motion-recipes.js +71 -0
- package/src/text-scene.js +272 -0
- package/src/triangle-coverage.js +25 -0
- package/src/triangle-effect.js +194 -0
- package/src/triangle-motion-frame.js +38 -0
- package/src/viewport-clip.js +119 -0
- package/src/viewport-render-owner.js +318 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface SurfaceOptions {
|
|
2
|
+
/** Text/image/SVG only. Re-arm after complete exit. */
|
|
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
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {withRevealOnView} from './dom-reveal.js';
|
|
2
|
+
import {attachSVGSurface} from './dom-svg-surface.js';
|
|
3
|
+
import {swapDOMImage} from './dom-image-swap.js';
|
|
4
|
+
import {animateDOMOnce} from './dom-once.js';
|
|
5
|
+
import {attachImageSurface} from './dom-image-surface.js';
|
|
6
|
+
import {createRenderOwner} from './render-owner.js';
|
|
7
|
+
import {createViewportRenderOwner} from './viewport-render-owner.js';
|
|
8
|
+
import {attachTextSurface} from './dom-text-surface.js';
|
|
9
|
+
import {chooseDOMPresentation} from './dom-auto-route.js';
|
|
10
|
+
|
|
11
|
+
// One optional visual owner. Application components retain their DOM, values,
|
|
12
|
+
// events, fonts and lifecycle; this module knows nothing about EV or its theme.
|
|
13
|
+
const attached = new WeakMap();
|
|
14
|
+
const rendererOwners=new WeakMap();
|
|
15
|
+
|
|
16
|
+
// Explicit mixed presentation. Existing owners retain rendering and surface state.
|
|
17
|
+
export function createDOMInstallation(THREE, document, {presentation='local', documentCanvas=true, ...rendererOptions} = {}) {
|
|
18
|
+
rendererOptions.documentCanvas=documentCanvas;
|
|
19
|
+
const modes = {local:'local', global:'viewport',auto:'auto'};
|
|
20
|
+
const validate = mode => { if (!Object.hasOwn(modes, mode)) throw TypeError('Expected local, global or auto presentation'); };
|
|
21
|
+
validate(presentation);
|
|
22
|
+
const owners = new Map(), surfaces = new Map(), layers = new Map();
|
|
23
|
+
let disposed = false;
|
|
24
|
+
const automatic=new Map(),window=document.defaultView;
|
|
25
|
+
const routeEvents=['toggle','close','fullscreenchange','compositionend','transitionrun','transitionend','transitioncancel','animationstart','animationend','animationcancel'];
|
|
26
|
+
let observer=null,routeFrame=null;
|
|
27
|
+
function schedule(){if(!disposed&&automatic.size&&routeFrame===null){routeFrame=true;queueMicrotask(reconcile);}}
|
|
28
|
+
function watch(){
|
|
29
|
+
if(observer)return;
|
|
30
|
+
observer=new window.MutationObserver(records=>{
|
|
31
|
+
const owned=node=>node.nodeType===1&&node.matches('[data-thd-viewport-anchor],[data-thd-global-canvas]');
|
|
32
|
+
if(records.some(r=>!r.target.closest?.('[data-thd-viewport-anchor],[data-thd-global-canvas]')&&!(r.type==='childList'&&[...r.addedNodes,...r.removedNodes].every(owned))))schedule();
|
|
33
|
+
});
|
|
34
|
+
observer.observe(document.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:['class','style','open','hidden','popover']});
|
|
35
|
+
for(const event of routeEvents)document.addEventListener(event,schedule,true);
|
|
36
|
+
window.addEventListener('resize',schedule);
|
|
37
|
+
}
|
|
38
|
+
function unwatch(){
|
|
39
|
+
if(automatic.size)return;
|
|
40
|
+
observer?.disconnect();observer=null;
|
|
41
|
+
routeFrame=null;
|
|
42
|
+
for(const event of routeEvents)document.removeEventListener(event,schedule,true);
|
|
43
|
+
window.removeEventListener('resize',schedule);
|
|
44
|
+
}
|
|
45
|
+
function reconcile(){
|
|
46
|
+
routeFrame=null;
|
|
47
|
+
if(disposed)return;
|
|
48
|
+
const styles=new WeakMap(),readStyle=node=>{let value=styles.get(node);if(!value){value=window.getComputedStyle(node);styles.set(node,value);}return value;};
|
|
49
|
+
for(const [surface,state] of automatic){
|
|
50
|
+
const target=chooseDOMPresentation(surface.element,layers,readStyle);
|
|
51
|
+
state.reason=target.reason;state.pending=false;state.error=null;
|
|
52
|
+
try{handoff(surface,target,true);}
|
|
53
|
+
catch(error){state.pending=true;state.error=error.message;}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function follow(surface){automatic.set(surface,{reason:'pending',pending:true,error:null});watch();schedule();surface.ready.then(schedule);}
|
|
57
|
+
function attach(method, element, {presentation:mode=presentation, layer=null, ...options} = {}) {
|
|
58
|
+
if (disposed) throw Error('DOM installation disposed');
|
|
59
|
+
validate(mode);
|
|
60
|
+
const auto=mode==='auto';
|
|
61
|
+
if(auto){if(layer!==null)throw TypeError('Auto selects its layer; use global for an explicit layer');const target=chooseDOMPresentation(element,layers);mode=target.presentation;layer=target.layer;}
|
|
62
|
+
if(layer!==null&&(mode!=='global'||!layers.has(layer)))throw TypeError('Unknown layer or non-global layer request');
|
|
63
|
+
const key=layer===null?mode:'layer:'+layer;
|
|
64
|
+
let owner = owners.get(key);
|
|
65
|
+
if (!owner) {
|
|
66
|
+
owner = wrapOwner(createViewportRenderOwner(THREE, document, {...rendererOptions, ...(layer===null?{}:layers.get(layer)), local:mode==='local'}));
|
|
67
|
+
owners.set(key, owner);
|
|
68
|
+
}
|
|
69
|
+
let surface;
|
|
70
|
+
try { surface = owner[method](element, options); }
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!owner.stats().controls) { owners.delete(key); owner.destroy(); }
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
surfaces.set(surface, key);
|
|
76
|
+
if(auto)follow(surface);
|
|
77
|
+
const destroy = surface.destroy;
|
|
78
|
+
surface.destroy = () => {
|
|
79
|
+
if (!surfaces.has(surface)) return;
|
|
80
|
+
const currentKey=surfaces.get(surface),currentOwner=owners.get(currentKey);
|
|
81
|
+
surfaces.delete(surface);
|
|
82
|
+
automatic.delete(surface);unwatch();
|
|
83
|
+
try { destroy(); }
|
|
84
|
+
finally {
|
|
85
|
+
if (!currentOwner.stats().controls && owners.get(currentKey) === currentOwner) {
|
|
86
|
+
owners.delete(currentKey); currentOwner.destroy();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return surface;
|
|
91
|
+
}
|
|
92
|
+
function handoff(surface,{presentation:mode='global',layer=null}={},recover=false){
|
|
93
|
+
if(disposed||!surfaces.has(surface))throw Error('Surface is not owned by this installation');
|
|
94
|
+
const from=surfaces.get(surface),to=layer===null?mode:'layer:'+layer;
|
|
95
|
+
if(layer!==null&&(mode!=='global'||!layers.has(layer)))throw TypeError('Unknown layer or non-global layer request');
|
|
96
|
+
if(from===to)return;
|
|
97
|
+
const previous=owners.get(from);
|
|
98
|
+
let next=owners.get(to);
|
|
99
|
+
if(!next){next=wrapOwner(createViewportRenderOwner(THREE,document,{...rendererOptions,...(layer===null?{}:layers.get(layer)),local:mode==='local'}));owners.set(to,next);}
|
|
100
|
+
try{rendererOwners.get(previous).transfer(surface,rendererOwners.get(next),{recover});}
|
|
101
|
+
catch(error){if(!next.stats().controls){owners.delete(to);next.destroy();}throw error;}
|
|
102
|
+
surfaces.set(surface,to);
|
|
103
|
+
if(!previous.stats().controls){owners.delete(from);previous.destroy();}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
transfer(surface,{presentation:mode='global',layer=null}={}){
|
|
107
|
+
if(disposed||!surfaces.has(surface))throw Error('Surface is not owned by this installation');
|
|
108
|
+
validate(mode);
|
|
109
|
+
if(mode==='auto'){if(layer!==null)throw TypeError('Auto selects its layer');follow(surface);return;}
|
|
110
|
+
handoff(surface,{presentation:mode,layer});automatic.delete(surface);unwatch();
|
|
111
|
+
},
|
|
112
|
+
routing(surface){
|
|
113
|
+
if(!surfaces.has(surface))throw Error('Unknown surface');
|
|
114
|
+
const key=surfaces.get(surface),state=automatic.get(surface);
|
|
115
|
+
return {requested:state?'auto':key==='local'?'local':'global',presentation:key==='local'?'local':'global',layer:key.startsWith('layer:')?key.slice(6):null,reason:state?.reason||'explicit',pending:state?.pending||false,error:state?.error||null};
|
|
116
|
+
},
|
|
117
|
+
registerLayer(name, {root, zIndex=100} = {}) {
|
|
118
|
+
if(disposed)throw Error('DOM installation disposed');
|
|
119
|
+
if(typeof name!=='string'||!name||layers.has(name))throw TypeError('Unique layer name required');
|
|
120
|
+
if(root?.ownerDocument!==document||root.tagName!=='DIALOG'||!Number.isFinite(zIndex))throw TypeError('Layer requires a dialog in the owner document');
|
|
121
|
+
layers.set(name,{root,zIndex});
|
|
122
|
+
schedule();
|
|
123
|
+
},
|
|
124
|
+
unregisterLayer(name) {
|
|
125
|
+
if([...surfaces.values()].includes('layer:'+name))throw Error('Detach layer surfaces before unregistering');
|
|
126
|
+
layers.delete(name);
|
|
127
|
+
schedule();
|
|
128
|
+
},
|
|
129
|
+
attachText:(element, options) => attach('attachText', element, options),
|
|
130
|
+
attachImage:(element, options) => attach('attachImage', element, options),
|
|
131
|
+
attachSVG:(element, options) => attach('attachSVG', element, options),
|
|
132
|
+
animateOnce:(element, options) => animateDOMOnce(attach,element,options),
|
|
133
|
+
swapImage:(previous,next,options)=>swapDOMImage((element,settings)=>animateDOMOnce(attach,element,settings),previous,next,options),
|
|
134
|
+
refresh() {
|
|
135
|
+
if (disposed) throw Error('DOM installation disposed');
|
|
136
|
+
for (const owner of owners.values()) owner.refresh();
|
|
137
|
+
schedule();
|
|
138
|
+
},
|
|
139
|
+
stats() {
|
|
140
|
+
const entries = [...owners].map(([mode, owner]) => [mode, owner.stats()]);
|
|
141
|
+
return {disposed, controls:surfaces.size, contexts:entries.reduce((n,[,s])=>n+s.contexts,0),
|
|
142
|
+
copies:entries.reduce((n,[,s])=>n+s.copies,0), owners:Object.fromEntries(entries)};
|
|
143
|
+
},
|
|
144
|
+
destroy() {
|
|
145
|
+
if (disposed) return;
|
|
146
|
+
disposed = true;
|
|
147
|
+
const errors = [];
|
|
148
|
+
for (const surface of [...surfaces.keys()]) try { surface.destroy(); } catch (error) { errors.push(error); }
|
|
149
|
+
for (const owner of owners.values()) try { owner.destroy(); } catch (error) { errors.push(error); }
|
|
150
|
+
owners.clear();layers.clear();
|
|
151
|
+
if (errors.length) throw new AggregateError(errors, 'DOM installation cleanup failed');
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createDOMRenderer(THREE, document, options = {}) {
|
|
157
|
+
const owner = createRenderOwner(THREE, document, options);
|
|
158
|
+
return wrapOwner(owner);
|
|
159
|
+
}
|
|
160
|
+
function wrapOwner(owner){
|
|
161
|
+
function attach(factory, element, options) {
|
|
162
|
+
if (attached.has(element)) throw new Error('Element already has a THD attachment');
|
|
163
|
+
const surface = owner.create((element,namespace,settings)=>withRevealOnView(factory,element,namespace,settings), element, options);
|
|
164
|
+
attached.set(element, surface);
|
|
165
|
+
const destroy = surface.destroy;
|
|
166
|
+
surface.destroy = () => {
|
|
167
|
+
try { destroy(); }
|
|
168
|
+
finally { if (attached.get(element) === surface) attached.delete(element); }
|
|
169
|
+
};
|
|
170
|
+
return surface;
|
|
171
|
+
}
|
|
172
|
+
const api={
|
|
173
|
+
get domElement() { return owner.domElement ?? null; },
|
|
174
|
+
attachText: (element, options = {}) => attach(attachTextSurface, element, options),
|
|
175
|
+
attachImage: (element, options = {}) => attach(attachImageSurface, element, options),
|
|
176
|
+
attachSVG: (element, options = {}) => attach(attachSVGSurface, element, options),
|
|
177
|
+
stats: owner.stats,
|
|
178
|
+
refresh: owner.refresh,
|
|
179
|
+
destroy: owner.destroy
|
|
180
|
+
};
|
|
181
|
+
rendererOwners.set(api,owner);return api;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type {DOMPresentation, DOMInstallation, createDOMRenderer} from './dom-attachment.js';
|
|
2
|
+
export interface AutoRevealOptions {
|
|
3
|
+
textSelector?: string;
|
|
4
|
+
imageSelector?: string;
|
|
5
|
+
presentation?: DOMPresentation;
|
|
6
|
+
threshold?: number;
|
|
7
|
+
once?: boolean;
|
|
8
|
+
intersectionRoot?: Element | null;
|
|
9
|
+
}
|
|
10
|
+
/** Explicit scan. Call refresh after route/content changes; destroy on unmount. */
|
|
11
|
+
export function createAutoReveal(THREE: Parameters<typeof createDOMRenderer>[0], root?: Document | Element, options?: AutoRevealOptions): {
|
|
12
|
+
|
|
13
|
+
refresh(): void;
|
|
14
|
+
stats(): {disposed: boolean; attached: number; failures: {element: Element; message: string}[]; renderer: ReturnType<DOMInstallation['stats']>};
|
|
15
|
+
destroy(): void;
|
|
16
|
+
};
|
|
17
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {createDOMInstallation} from './dom-attachment.js';
|
|
2
|
+
|
|
3
|
+
// Explicit initialization; importing this module never changes the page.
|
|
4
|
+
export function createAutoReveal(THREE, root = document, options = {}, sharedOwner = null) {
|
|
5
|
+
const doc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
6
|
+
const textSelector = options.textSelector ?? 'h1,h2,[data-thd-text]';
|
|
7
|
+
const imageSelector = options.imageSelector ?? 'img[data-thd-image]';
|
|
8
|
+
const exclude = '[data-thd-ignore],nav,dialog,[role="dialog"],button,a,[contenteditable]:not([contenteditable="false"])';
|
|
9
|
+
// Validate selectors before allocating render resources.
|
|
10
|
+
root.querySelectorAll(textSelector); root.querySelectorAll(imageSelector);
|
|
11
|
+
if (options.threshold !== undefined && (!Number.isFinite(options.threshold) || options.threshold < 0 || options.threshold > 1)) throw TypeError('Invalid threshold');
|
|
12
|
+
if (options.once !== undefined && typeof options.once !== 'boolean') throw TypeError('Invalid once');
|
|
13
|
+
if (options.intersectionRoot != null && (options.intersectionRoot.nodeType !== 1 || options.intersectionRoot.ownerDocument !== doc)) throw TypeError('Invalid intersection root');
|
|
14
|
+
const owner = sharedOwner ?? createDOMInstallation(THREE, doc, {presentation: options.presentation ?? 'auto'});
|
|
15
|
+
const entries = new Map();
|
|
16
|
+
let disposed = false, failures = [];
|
|
17
|
+
const settings = {
|
|
18
|
+
resting: 'native',
|
|
19
|
+
inputEffect: 'dust-wind',
|
|
20
|
+
inputEffectOptions: {duration: 2000, particleShape: 'triangle'},
|
|
21
|
+
revealOnView: {threshold: options.threshold ?? 0, once: options.once ?? true, root: options.intersectionRoot ?? null}
|
|
22
|
+
};
|
|
23
|
+
function refresh() {
|
|
24
|
+
if (disposed) throw Error('Auto reveal disposed');
|
|
25
|
+
failures = [];
|
|
26
|
+
const candidates = [...root.querySelectorAll(`${textSelector},${imageSelector}`)];
|
|
27
|
+
if (root.nodeType === 1 && root.matches(`${textSelector},${imageSelector}`)) candidates.unshift(root);
|
|
28
|
+
const hasForeignContent=el=>[...el.querySelectorAll('input,textarea,button,select,[contenteditable],img,svg,canvas')].some(node=>
|
|
29
|
+
!node.closest('[data-thd-viewport-anchor],[data-thd-text-surface]'));
|
|
30
|
+
const eligible = candidates.filter(el => !el.closest(exclude) && !hasForeignContent(el) && (el.matches(imageSelector) ? el.tagName === 'IMG' : !!el.textContent.trim()));
|
|
31
|
+
// Attach outer text once; never mask overlapping parent/child surfaces.
|
|
32
|
+
const targets = eligible.filter(el => !eligible.some(parent => parent !== el && parent.contains(el)));
|
|
33
|
+
for (const [el, surface] of entries) if (!targets.includes(el)) { surface.destroy(); entries.delete(el); }
|
|
34
|
+
for (const el of targets) {
|
|
35
|
+
if (entries.has(el)) continue;
|
|
36
|
+
try {
|
|
37
|
+
entries.set(el, el.tagName === 'IMG'
|
|
38
|
+
? owner.attachImage(el, {...settings, inputEffect: 'drifting-snow'})
|
|
39
|
+
: owner.attachText(el, settings));
|
|
40
|
+
} catch (error) { failures.push({element: el, message: String(error.message || error)}); }
|
|
41
|
+
}
|
|
42
|
+
// Offscreen surfaces prepare lazily; scanning must not wait for intersection.
|
|
43
|
+
}
|
|
44
|
+
const api = {
|
|
45
|
+
refresh,
|
|
46
|
+
stats: () => ({disposed, attached: entries.size, failures: [...failures], renderer: owner.stats()}),
|
|
47
|
+
destroy() { if (disposed) return; disposed = true; if(sharedOwner){for(const surface of entries.values())surface.destroy();}else owner.destroy(); entries.clear(); }
|
|
48
|
+
};
|
|
49
|
+
refresh();
|
|
50
|
+
return api;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Deliberately bounded policy: registered dialog roots, ordinary page, or local
|
|
2
|
+
// CSS composition. Never infer stacking layers from z-index or create new roots.
|
|
3
|
+
export function chooseDOMPresentation(element,layers,readStyle=node=>node.ownerDocument.defaultView.getComputedStyle(node)){
|
|
4
|
+
const document=element.ownerDocument,window=document.defaultView;
|
|
5
|
+
let layer=null,reason='ordinary page';
|
|
6
|
+
if(document.fullscreenElement)return {presentation:'local',layer:null,reason:'fullscreen'};
|
|
7
|
+
for(let node=element;node;node=node.parentElement){
|
|
8
|
+
const style=readStyle(node);
|
|
9
|
+
if(node!==document.body&&node!==document.documentElement){
|
|
10
|
+
if(['fixed','sticky'].includes(style.position))return {presentation:'local',layer:null,reason:'fixed or sticky container'};
|
|
11
|
+
if(!node.matches('dialog')&&style.position!=='static'&&style.zIndex!=='auto')return {presentation:'local',layer:null,reason:'explicit stacking context'};
|
|
12
|
+
if(node!==element&&/(auto|scroll)/.test(style.overflowX+' '+style.overflowY))return {presentation:'local',layer:null,reason:'nested scroll container'};
|
|
13
|
+
}
|
|
14
|
+
if(style.transform!=='none'||style.perspective!=='none'||style.rotate&&style.rotate!=='none'&&style.rotate!=='0deg'||style.scale&&style.scale!=='none'||style.clipPath&&style.clipPath!=='none'||style.maskImage&&style.maskImage!=='none')
|
|
15
|
+
return {presentation:'local',layer:null,reason:'CSS transform or mask'};
|
|
16
|
+
if(layer===null&&node.matches('dialog')){
|
|
17
|
+
const entry=[...layers].find(([,value])=>value.root===node);
|
|
18
|
+
if(!entry||!node.open)return {presentation:'local',layer:null,reason:entry?'closed dialog':'unregistered dialog'};
|
|
19
|
+
layer=entry[0];reason='registered dialog';
|
|
20
|
+
}
|
|
21
|
+
if(node.hasAttribute('popover')&&node.matches(':popover-open'))return {presentation:'local',layer:null,reason:'popover'};
|
|
22
|
+
}
|
|
23
|
+
return {presentation:'global',layer,reason};
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {createFree,FREE_EFFECTS} from './dom-free.js';
|
|
2
|
+
|
|
3
|
+
// Shared initialization for the classic loader and explicit module loader.
|
|
4
|
+
// Importing this module does not attach content or access a browser document.
|
|
5
|
+
const dependencies=new WeakMap();
|
|
6
|
+
export async function bootstrapFree({document=globalThis.document,THREE,auto=false}={}){
|
|
7
|
+
const window=document?.defaultView;
|
|
8
|
+
if(!window)throw TypeError('THD Free needs a browser document');
|
|
9
|
+
if(typeof auto!=='boolean')throw TypeError('THD Free auto must be a boolean');
|
|
10
|
+
let three=THREE??window.THREE;
|
|
11
|
+
if(!three){
|
|
12
|
+
let loading=dependencies.get(document);
|
|
13
|
+
if(!loading){
|
|
14
|
+
loading=new Promise((resolve,reject)=>{
|
|
15
|
+
const script=document.createElement('script');
|
|
16
|
+
script.src=new URL('../assets/vendor/three.min.js',import.meta.url).href;
|
|
17
|
+
script.onload=()=>{script.onload=script.onerror=null;resolve(window.THREE);};
|
|
18
|
+
script.onerror=()=>{script.remove();reject(Error('THD: Three.js failed to load'));};
|
|
19
|
+
document.head.append(script);
|
|
20
|
+
});
|
|
21
|
+
dependencies.set(document,loading);
|
|
22
|
+
loading.catch(()=>{if(dependencies.get(document)===loading)dependencies.delete(document);});
|
|
23
|
+
}
|
|
24
|
+
three=await loading;
|
|
25
|
+
}
|
|
26
|
+
const api={create:(root=document,options={})=>createFree(three,root,options),effects:FREE_EFFECTS};
|
|
27
|
+
if(auto){
|
|
28
|
+
if(document.readyState==='loading')await new Promise(resolve=>document.addEventListener('DOMContentLoaded',resolve,{once:true}));
|
|
29
|
+
const instance=api.instance=api.create();
|
|
30
|
+
const stop=()=>{window.removeEventListener('pagehide',leave);window.removeEventListener('pageshow',restore);};
|
|
31
|
+
const leave=event=>{if(!event.persisted){stop();instance.destroy();}};
|
|
32
|
+
const restore=event=>{
|
|
33
|
+
if(instance.stats().disposed){stop();return;}
|
|
34
|
+
if(event.persisted)instance.refresh();
|
|
35
|
+
};
|
|
36
|
+
// BFCache suspends the page. Keep the same owner/once state for return;
|
|
37
|
+
// dispose only when the page is actually discarded.
|
|
38
|
+
window.addEventListener('pagehide',leave);
|
|
39
|
+
window.addEventListener('pageshow',restore);
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze(api);
|
|
42
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {createFree,FREE_EFFECTS} from './dom-free.js';
|
|
2
|
+
export interface FreeLoaderOptions {
|
|
3
|
+
auto?: boolean;
|
|
4
|
+
document?: Document;
|
|
5
|
+
THREE?: Parameters<typeof createFree>[0];
|
|
6
|
+
}
|
|
7
|
+
export interface FreeLoaderAPI {
|
|
8
|
+
create(root?: Document|Element,options?: Parameters<typeof createFree>[2]):ReturnType<typeof createFree>;
|
|
9
|
+
effects: typeof FREE_EFFECTS;
|
|
10
|
+
readonly instance?: ReturnType<typeof createFree>;
|
|
11
|
+
}
|
|
12
|
+
/** Browser initialization; imports are inert. Repeated calls reuse the first initialization. */
|
|
13
|
+
export function loadFree(options?:FreeLoaderOptions):Promise<Readonly<FreeLoaderAPI>>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
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
|
+
window.THDFree=Object.freeze({ready});
|
|
10
|
+
ready.catch(error=>{console.error(error);window.dispatchEvent(new window.CustomEvent('thd:error',{detail:error}));});
|
|
11
|
+
return ready;
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
(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
|
+
window.THDFree=Object.freeze({ready});
|
|
11
|
+
ready.catch(error=>{console.error(error);window.dispatchEvent(new CustomEvent('thd:error',{detail:error}));});
|
|
12
|
+
})();
|
|
@@ -0,0 +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
|
+
};
|
|
15
|
+
|
|
16
|
+
|
package/src/dom-free.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
return Object.freeze({ready:surface.ready,play:(phase='enter')=>{
|
|
18
|
+
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
|
+
return owner.swapImage(previous,next,{presentation:config.presentation??'global',waitForExit:config.waitForExit??false,
|
|
32
|
+
exitEffect:config.exit===false?null:FREE_EFFECTS.imageExit,inputEffect:FREE_EFFECTS.imageEnter,
|
|
33
|
+
inputEffectOptions:{duration:2000,particleShape:'triangle'}});
|
|
34
|
+
},
|
|
35
|
+
refresh(){if(disposed)throw Error('Free installation disposed');automatic?.refresh();owner.refresh();},
|
|
36
|
+
stats:()=>({disposed,automatic:automatic?.stats()??null,renderer:owner.stats()}),
|
|
37
|
+
destroy(){if(disposed)return;disposed=true;try{automatic?.destroy();}finally{owner.destroy();}}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {imageRasterSource} from './image-source.js';
|
|
2
|
+
// Capture the supported native image box, including cover/contain and radii.
|
|
3
|
+
export async function rasterizeDOMImage(element,{maxSide=2048}={}){
|
|
4
|
+
const start=performance.now();
|
|
5
|
+
const doc=element.ownerDocument,css=doc.defaultView.getComputedStyle(element),box=element.getBoundingClientRect();
|
|
6
|
+
if(!box.width||!box.height)throw Error('Image not visible');
|
|
7
|
+
const scale=Math.min(2,maxSide/Math.max(box.width,box.height),Math.sqrt(2097152/(box.width*box.height))),canvas=doc.createElement('canvas');
|
|
8
|
+
canvas.width=Math.max(1,Math.round(box.width*scale));canvas.height=Math.max(1,Math.round(box.height*scale));
|
|
9
|
+
const context=canvas.getContext('2d',{willReadFrequently:true,colorSpace:'srgb'});context.scale(canvas.width/box.width,canvas.height/box.height);
|
|
10
|
+
try{
|
|
11
|
+
const radii=['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius'].map(key=>{
|
|
12
|
+
const parts=css[key].split(/\s+/),read=(s,n)=>parseFloat(s)*(s.endsWith('%')?n/100:1);
|
|
13
|
+
return {x:read(parts[0],box.width),y:read(parts[1]??parts[0],box.height)};
|
|
14
|
+
});
|
|
15
|
+
context.beginPath();context.roundRect(0,0,box.width,box.height,radii);context.clip();
|
|
16
|
+
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
|
+
const w=css.objectFit==='fill'?box.width:element.naturalWidth*factor,h=css.objectFit==='fill'?box.height:element.naturalHeight*factor;
|
|
18
|
+
context.drawImage(element,(box.width-w)/2,(box.height-h)/2,w,h);
|
|
19
|
+
const width=canvas.width,height=canvas.height,rgba=context.getImageData(0,0,width,height).data;
|
|
20
|
+
return {source:imageRasterSource({width,height,rgba,mask:false,type:'image/png',originalWidth:width,originalHeight:height,decodeMs:performance.now()-start}),width,height};
|
|
21
|
+
}finally{canvas.width=canvas.height=0;}
|
|
22
|
+
}
|