@cosmictraveler002/anim-kit 1.2.0 → 1.3.0

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.
Files changed (39) hide show
  1. package/README.md +203 -31
  2. package/dist/anim-kit.standalone.js +5 -5
  3. package/dist/anim-kit.standalone.js.map +1 -1
  4. package/dist/core/gsap.d.ts +1 -1
  5. package/dist/core/gsap.d.ts.map +1 -1
  6. package/dist/core/gsap.js +1 -1
  7. package/dist/core/gsap.js.map +1 -1
  8. package/dist/core/smooth-scroll.d.ts +1 -1
  9. package/dist/core/smooth-scroll.js +1 -1
  10. package/dist/effects/drag-rail.d.ts +23 -0
  11. package/dist/effects/drag-rail.d.ts.map +1 -0
  12. package/dist/effects/drag-rail.js +196 -0
  13. package/dist/effects/drag-rail.js.map +1 -0
  14. package/dist/effects/preloader.js +1 -1
  15. package/dist/effects/reel-text.d.ts +19 -0
  16. package/dist/effects/reel-text.d.ts.map +1 -0
  17. package/dist/effects/reel-text.js +163 -0
  18. package/dist/effects/reel-text.js.map +1 -0
  19. package/dist/index.d.ts +7 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/three/index.d.ts +13 -0
  24. package/dist/three/index.d.ts.map +1 -0
  25. package/dist/three/index.js +12 -0
  26. package/dist/three/index.js.map +1 -0
  27. package/dist/three/webgl-media.d.ts +21 -0
  28. package/dist/three/webgl-media.d.ts.map +1 -0
  29. package/dist/three/webgl-media.js +268 -0
  30. package/dist/three/webgl-media.js.map +1 -0
  31. package/package.json +17 -1
  32. package/src/core/gsap.ts +1 -1
  33. package/src/core/smooth-scroll.ts +1 -1
  34. package/src/effects/drag-rail.ts +236 -0
  35. package/src/effects/preloader.ts +1 -1
  36. package/src/effects/reel-text.ts +212 -0
  37. package/src/index.ts +9 -1
  38. package/src/three/index.ts +12 -0
  39. package/src/three/webgl-media.ts +325 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Reel text — the per-character odometer roll.
3
+ *
4
+ * Every character gets its own overflow-hidden mask and a vertical strip of
5
+ * ghost glyphs stacked under the final one; the strips roll upward (staggered
6
+ * left→right) and land on the real text. This is the DOM twin of a shader
7
+ * slot-reel: character cells spun per glyph (`u_roll`/`u_spin` style), ported
8
+ * to plain GSAP so it needs no canvas. Why it is shaped this way:
9
+ *
10
+ * - **Built eagerly, rolled on trigger.** The masks exist as soon as the
11
+ * effect mounts (so layout is stable and tests can see the structure); the
12
+ * roll plays immediately or on scroll-enter. On completion the original
13
+ * `innerHTML` is restored — screen readers and copy/paste see clean text —
14
+ * and a `replay` re-entry re-splits from scratch. The ScrollTrigger is
15
+ * registered **once per element**, outside the rebuild path, so replay
16
+ * never stacks triggers.
17
+ * - **Width probed, never guessed.** Each wrapper's width is measured from
18
+ * the *final* glyph (hidden probe in the body) so ghost frames of a
19
+ * different width can't jitter the line while it spins. jsdom reports 0 —
20
+ * the wrapper then shrink-wraps, which is why the unit smoke still passes.
21
+ * - **Same-case ghosts.** Uppercase rolls through the uppercase alphabet,
22
+ * lowercase through lowercase, digits through digits — the line never
23
+ * changes colour/shape mid-roll the way mixed-case ghosts would. Spaces and
24
+ * punctuation stay as plain text (they don't spin).
25
+ * - **Fidelity.** `destroy()` kills the timeline + trigger, clears the stamp
26
+ * and puts the original `innerHTML` back byte for byte.
27
+ *
28
+ * reelText("[data-reel]", { mode: "scroll", frames: 4 })
29
+ *
30
+ * CSS: nothing required — masks, strip and frame heights are inline. Give the
31
+ * target the `line-height` you want the cells to inherit.
32
+ */
33
+ import { gsap, ScrollTrigger, initGSAP } from "../core/gsap.js";
34
+ import { guard } from "../core/guard.js";
35
+ import { toArray } from "../core/util.js";
36
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
37
+
38
+ export interface ReelTextOptions extends CommonOptions {
39
+ /** `'immediate'` rolls on mount; `'scroll'` rolls on viewport enter. @default `"immediate"` */
40
+ mode?: "immediate" | "scroll";
41
+ /** ScrollTrigger start (only with `mode: "scroll"`). @default `"top 85%"` */
42
+ start?: string;
43
+ /** Ghost frames per character before the final glyph. @default 4 */
44
+ frames?: number;
45
+ /** Roll duration per character, seconds. @default 0.8 */
46
+ duration?: number;
47
+ /** Stagger between characters, seconds. @default 0.05 */
48
+ stagger?: number;
49
+ /** GSAP ease for the roll. @default `"power4.out"` */
50
+ ease?: string;
51
+ /** Re-arm on every scroll re-entry instead of playing once. @default false */
52
+ replay?: boolean;
53
+ }
54
+
55
+ interface Cell {
56
+ el: HTMLElement;
57
+ original: string;
58
+ }
59
+
60
+ const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
61
+ const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
62
+ const DIGITS = "0123456789";
63
+
64
+ /** Only alphanumerics get a reel; spaces/punctuation pass through untouched. */
65
+ const spins = (ch: string) => /[A-Za-z0-9]/.test(ch);
66
+
67
+ const ghostsFor = (ch: string, count: number): string => {
68
+ const pool = /[A-Z]/.test(ch) ? UPPERCASE : /[a-z]/.test(ch) ? LOWERCASE : DIGITS;
69
+ let out = "";
70
+ for (let i = 0; i < count; i++) out += pool[Math.floor(Math.random() * pool.length)];
71
+ return out;
72
+ };
73
+
74
+ /** Width of a glyph — 0 in jsdom, where wrappers may shrink-wrap instead. */
75
+ const measureGlyph = (ch: string, sample: HTMLElement): number => {
76
+ const doc = sample.ownerDocument;
77
+ if (!doc.body) return 0;
78
+ const probe = doc.createElement("span");
79
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
80
+ probe.textContent = ch;
81
+ doc.body.appendChild(probe);
82
+ const w = probe.getBoundingClientRect().width;
83
+ probe.remove();
84
+ return Math.round(w);
85
+ };
86
+
87
+ export function reelText(target: TargetLike, options: ReelTextOptions = {}): Destroy {
88
+ initGSAP();
89
+
90
+ const els = toArray<HTMLElement>(target);
91
+ if (!els.length) return () => {};
92
+
93
+ const {
94
+ mode = "immediate",
95
+ start = "top 85%",
96
+ frames = 4,
97
+ duration = 0.8,
98
+ stagger = 0.05,
99
+ ease = "power4.out",
100
+ replay = false,
101
+ } = options;
102
+
103
+ return guard(options, () => {
104
+ const cells: Cell[] = [];
105
+ const timelines = new Map<HTMLElement, gsap.core.Timeline>();
106
+ const triggers: ScrollTrigger[] = [];
107
+
108
+ /** Unwind to the original markup (on completion and on destroy). */
109
+ const restore = (el: HTMLElement) => {
110
+ const i = cells.findIndex((c) => c.el === el);
111
+ if (i < 0) return;
112
+ el.innerHTML = cells[i].original;
113
+ delete el.dataset.akReel;
114
+ cells.splice(i, 1);
115
+ };
116
+
117
+ /**
118
+ * Split one element into masks + a paused roll timeline.
119
+ * Returns the timeline; the caller decides when (and whether) it plays.
120
+ */
121
+ const build = (el: HTMLElement): gsap.core.Timeline | null => {
122
+ const text = el.textContent ?? "";
123
+ if (!text.trim()) return null;
124
+
125
+ timelines.get(el)?.kill(); // a finished roll from a previous entry
126
+ // Keep the FIRST original: a second build must not snapshot masked HTML.
127
+ if (!cells.some((c) => c.el === el)) cells.push({ el, original: el.innerHTML });
128
+
129
+ const lineHeight =
130
+ parseFloat(getComputedStyle(el).lineHeight) || el.getBoundingClientRect().height || 16;
131
+
132
+ // Original markup → per-char segments (the element must be plain text).
133
+ el.textContent = "";
134
+ const strips: HTMLElement[] = [];
135
+ const ends: number[] = [];
136
+
137
+ for (const ch of [...text]) {
138
+ if (!spins(ch)) {
139
+ el.appendChild(el.ownerDocument.createTextNode(ch));
140
+ continue;
141
+ }
142
+ const wrapper = el.ownerDocument.createElement("span");
143
+ wrapper.className = "ak-reel__ch";
144
+ wrapper.style.cssText = `display:inline-block;overflow:hidden;vertical-align:top;height:${lineHeight}px;`;
145
+
146
+ const width = measureGlyph(ch, el);
147
+ if (width > 0) wrapper.style.width = `${width}px`;
148
+
149
+ const strip = el.ownerDocument.createElement("span");
150
+ strip.className = "ak-reel__strip";
151
+ strip.style.cssText = "display:flex;flex-direction:column;will-change:transform;";
152
+
153
+ for (const frame of ghostsFor(ch, frames) + ch) {
154
+ const frameEl = el.ownerDocument.createElement("span");
155
+ frameEl.style.cssText = `display:flex;align-items:center;justify-content:center;height:${lineHeight}px;flex:none;`;
156
+ frameEl.textContent = frame;
157
+ strip.appendChild(frameEl);
158
+ }
159
+
160
+ wrapper.appendChild(strip);
161
+ el.appendChild(wrapper);
162
+ strips.push(strip);
163
+ ends.push(-frames * lineHeight);
164
+ }
165
+
166
+ el.dataset.akReel = "true";
167
+
168
+ const tl = gsap.timeline({ paused: true, onComplete: () => restore(el) });
169
+ strips.forEach((strip, i) => {
170
+ tl.to(strip, { y: ends[i], duration, ease }, i * stagger);
171
+ });
172
+ timelines.set(el, tl);
173
+ return tl;
174
+ };
175
+
176
+ els.forEach((el) => {
177
+ const tl = build(el);
178
+ if (!tl) return;
179
+
180
+ if (mode === "immediate") {
181
+ tl.play(0);
182
+ return;
183
+ }
184
+
185
+ // Registered once per element — rebuilds on replay reuse this trigger.
186
+ triggers.push(
187
+ ScrollTrigger.create({
188
+ trigger: el,
189
+ start,
190
+ once: !replay,
191
+ onEnter: () => {
192
+ // Restored after the last roll (or never started) → rebuild first.
193
+ const active = el.dataset.akReel === "true" ? timelines.get(el) : build(el);
194
+ active?.play(0);
195
+ },
196
+ }),
197
+ );
198
+ });
199
+
200
+ return () => {
201
+ triggers.forEach((st) => st.kill());
202
+ triggers.length = 0;
203
+ timelines.forEach((tl) => tl.kill());
204
+ timelines.clear();
205
+ cells.slice().forEach((cell) => {
206
+ cell.el.innerHTML = cell.original;
207
+ delete cell.el.dataset.akReel;
208
+ });
209
+ cells.length = 0;
210
+ };
211
+ });
212
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * anim-kit — modular animation library extracted from dzinrstudio.com.
2
+ * anim-kit — modular animation library by kalakritico.in.
3
3
  *
4
4
  * Core:
5
5
  * smoothScroll() Lenis + ScrollTrigger bridge (lerp 0.08)
@@ -24,10 +24,12 @@
24
24
  * Infinite / loops:
25
25
  * marquee() dual-row constant-speed marquee
26
26
  * dragStrip() infinite draggable carousel with rotation
27
+ * dragRail() bounded drag rail — inertia, tanh rubber-band, wheel
27
28
  * rollText() rolling word rotator (seamless row loop)
28
29
  *
29
30
  * Text extras:
30
31
  * scrambleText() decode / cipher reveal per character
32
+ * reelText() per-character odometer roll (masked reel strips)
31
33
  * flipWords() words that glide between two layouts (FLIP)
32
34
  *
33
35
  * Micro-interactions:
@@ -96,9 +98,15 @@ export type { MarqueeOptions } from "./effects/marquee.js";
96
98
  export { rollText } from "./effects/roll-text.js";
97
99
  export type { RollTextOptions } from "./effects/roll-text.js";
98
100
 
101
+ export { reelText } from "./effects/reel-text.js";
102
+ export type { ReelTextOptions } from "./effects/reel-text.js";
103
+
99
104
  export { dragStrip } from "./effects/drag-strip.js";
100
105
  export type { DragStripOptions } from "./effects/drag-strip.js";
101
106
 
107
+ export { dragRail } from "./effects/drag-rail.js";
108
+ export type { DragRailOptions } from "./effects/drag-rail.js";
109
+
102
110
  /* ---- text extras ---- */
103
111
  export { scrambleText } from "./effects/scramble-text.js";
104
112
  export type { ScrambleTextOptions } from "./effects/scramble-text.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@cosmictraveler002/anim-kit/three` — the WebGL entry.
3
+ *
4
+ * Everything here renders media through **three.js**, which is an *optional
5
+ * peer dependency*: install `three` alongside anim-kit to use this subpath.
6
+ * The core barrel (`@cosmictraveler002/anim-kit`) never imports three, so
7
+ * plain DOM/scroll users keep a gsap+lenis-only dependency tree.
8
+ *
9
+ * import { webglMedia } from "@cosmictraveler002/anim-kit/three";
10
+ */
11
+ export { webglMedia } from "./webgl-media.js";
12
+ export type { WebglMediaOptions } from "./webgl-media.js";
@@ -0,0 +1,325 @@
1
+ /**
2
+ * WebGL media — the shader card: a DOM `<img>` re-rendered as a rounded,
3
+ * hover-reactive GL plane.
4
+ *
5
+ * The image keeps owning layout and alt text; the effect paints it onto a
6
+ * canvas that covers the wrapper, so the card can do what CSS can't: press a
7
+ * *dent* into the picture on hover (sample squeeze + fake dome lighting),
8
+ * split its channels (chroma), and wipe in on reveal — all inside one
9
+ * fragment shader. This is the media-card pattern from three.js portfolios,
10
+ * kept dependency-honest:
11
+ *
12
+ * - **Optional peer.** Lives behind the `@cosmictraveler002/anim-kit/three`
13
+ * subpath; the core barrel stays gsap+lenis only. `three` is an optional
14
+ * peer — install it to use this entry, the core never imports it.
15
+ * - **Silent no-op ladder.** Missing target → no-op. No
16
+ * `WebGLRenderingContext` (jsdom, SSR, WebGL-disabled browsers) → no-op
17
+ * *before* a context is ever requested (a probe call would itself log).
18
+ * Renderer construction throws → no-op. Texture 404s → the plain `<img>`
19
+ * stays visible. Under `prefers-reduced-motion` the canvas never mounts.
20
+ * - **Cover crop in-shader.** The texture is sampled with `object-fit: cover`
21
+ * maths (`scale = uRes / uTexSize`, windowed around the centre), so any
22
+ * source aspect fills the card without letterboxing.
23
+ * - **Rounded corners are shader-true.** A rounded-box SDF with a 1px smooth
24
+ * edge — not `border-radius` on the canvas — so the dent/chroma never bleed
25
+ * into the corner.
26
+ * - **Fidelity.** `destroy()` kills ticker + tweens + observers/listeners,
27
+ * disposes geometry/material/texture/renderer, removes the canvas and
28
+ * restores the `<img>` opacity and wrapper position it changed.
29
+ *
30
+ * webglMedia("[data-gl]", { corner: 18, dent: 70, chroma: 2 })
31
+ *
32
+ * Markup: a wrapper element containing `<img>` (or pass `src` explicitly):
33
+ *
34
+ * <figure class="gl-card" data-gl><img src="photo.jpg" alt="" /></figure>
35
+ */
36
+ import * as THREE from "three";
37
+ import { gsap, initGSAP } from "../core/gsap.js";
38
+ import { guard } from "../core/guard.js";
39
+ import { one } from "../core/util.js";
40
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
41
+
42
+ export interface WebglMediaOptions extends CommonOptions {
43
+ /** Image source — defaults to the wrapper's first `<img>`. */
44
+ src?: string;
45
+ /** Corner radius, px. @default 16 */
46
+ corner?: number;
47
+ /** Dent depth pressed into the card on hover, px. @default 70 */
48
+ dent?: number;
49
+ /** Chromatic split at full hover, px. @default 2 */
50
+ chroma?: number;
51
+ /** Wipe the card in when its texture loads. @default true */
52
+ reveal?: boolean;
53
+ /** Reveal duration, seconds. @default 1.1 */
54
+ revealDuration?: number;
55
+ /** Hover response duration, seconds. @default 0.6 */
56
+ hoverDuration?: number;
57
+ /** Device-pixel-ratio cap. @default 2 */
58
+ dpr?: number;
59
+ }
60
+
61
+ const VERT = /* glsl */ `
62
+ varying vec2 vUv;
63
+ uniform float uHover;
64
+ uniform float uDent;
65
+ uniform float uTime;
66
+
67
+ void main() {
68
+ vUv = uv;
69
+ vec3 p = position;
70
+ float dome = max(cos(uv.x * 3.14159265) * cos(uv.y * 3.14159265), 0.0);
71
+ dome *= 0.94 + 0.06 * sin(uTime * 1.6);
72
+ // Pressed-in curvature while hovered — the fragment does the visible work.
73
+ p.z -= uHover * uDent * dome;
74
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
75
+ }
76
+ `;
77
+
78
+ const FRAG = /* glsl */ `
79
+ precision highp float;
80
+ varying vec2 vUv;
81
+ uniform sampler2D uTex;
82
+ uniform vec2 uRes;
83
+ uniform vec2 uTexSize;
84
+ uniform float uCorner;
85
+ uniform float uHover;
86
+ uniform float uDent;
87
+ uniform float uChroma;
88
+ uniform float uProgress;
89
+ uniform float uTime;
90
+
91
+ float sdRoundBox(vec2 p, vec2 b, float r) {
92
+ vec2 q = abs(p) - b + r;
93
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
94
+ }
95
+
96
+ void main() {
97
+ vec2 px = (vUv - 0.5) * uRes;
98
+ float d = sdRoundBox(px, uRes * 0.5, uCorner);
99
+ float alpha = 1.0 - smoothstep(-1.0, 1.0, d); // 1px-ish AA on the corner
100
+
101
+ // Entrance wipe: grows from the bottom with a soft leading edge.
102
+ if (uProgress < 1.0) {
103
+ alpha *= 1.0 - smoothstep(uProgress - 0.05, uProgress, vUv.y);
104
+ }
105
+ if (alpha < 0.003) discard;
106
+
107
+ // object-fit: cover — window the texture around its centre.
108
+ vec2 scale = uRes / max(uTexSize, vec2(1.0));
109
+ vec2 cover = (vUv - 0.5) * (scale / max(scale.x, scale.y)) + 0.5;
110
+
111
+ // Hover dent: squeeze the sample toward the centre (breathing slightly).
112
+ vec2 c = cover - 0.5;
113
+ float dome = max(cos(vUv.x * 3.14159265) * cos(vUv.y * 3.14159265), 0.0);
114
+ dome *= 0.94 + 0.06 * sin(uTime * 1.6);
115
+ vec2 suv = 0.5 + c * (1.0 - uHover * uDent * 0.0016 * dome);
116
+
117
+ float chroma = uChroma * uHover * dome / max(uRes.x, 1.0);
118
+ vec3 col;
119
+ col.r = texture2D(uTex, suv + vec2(chroma, 0.0)).r;
120
+ col.g = texture2D(uTex, suv).g;
121
+ col.b = texture2D(uTex, suv - vec2(chroma, 0.0)).b;
122
+
123
+ // Fake lighting off the dome slope: sheen at the crest, shade at the rim.
124
+ float light = dome * uHover;
125
+ col += light * 0.12;
126
+ col *= 1.0 - 0.12 * uHover * (1.0 - dome);
127
+
128
+ gl_FragColor = vec4(col, alpha);
129
+ }
130
+ `;
131
+
132
+ export function webglMedia(target: TargetLike, options: WebglMediaOptions = {}): Destroy {
133
+ initGSAP();
134
+
135
+ const host = one<HTMLElement>(target);
136
+ if (!host) return () => {};
137
+
138
+ const {
139
+ src,
140
+ corner = 16,
141
+ dent = 70,
142
+ chroma = 2,
143
+ reveal = true,
144
+ revealDuration = 1.1,
145
+ hoverDuration = 0.6,
146
+ dpr = 2,
147
+ } = options;
148
+
149
+ return guard(options, () => {
150
+ /* ---------------- availability ladder (silent, no probes) ---------------- */
151
+ if (typeof window === "undefined" || !("WebGLRenderingContext" in window)) return () => {};
152
+
153
+ let renderer: THREE.WebGLRenderer;
154
+ try {
155
+ renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
156
+ } catch {
157
+ return () => {}; // context refused (disabled/blacklisted) — plain img stays
158
+ }
159
+
160
+ const img =
161
+ host.tagName === "IMG" ? null : (host.querySelector("img") as HTMLImageElement | null);
162
+ const textureSrc = src || img?.currentSrc || img?.src;
163
+ if (!textureSrc) {
164
+ renderer.dispose();
165
+ return () => {};
166
+ }
167
+
168
+ /* ---------------- layout + styles we must restore ---------------- */
169
+
170
+ const prevPosition = host.style.position;
171
+ if (!prevPosition && getComputedStyle(host).position === "static") {
172
+ host.style.position = "relative";
173
+ }
174
+ const prevImgOpacity = img?.style.opacity ?? "";
175
+ host.dataset.akGl = "true";
176
+
177
+ const w = Math.max(1, host.clientWidth);
178
+ const h = Math.max(1, host.clientHeight);
179
+
180
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dpr));
181
+ renderer.setSize(w, h, false);
182
+ const canvas = renderer.domElement;
183
+ canvas.style.cssText =
184
+ "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
185
+
186
+ const scene = new THREE.Scene();
187
+ const camera = new THREE.OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, -100, 100);
188
+ camera.position.z = 10;
189
+
190
+ const geometry = new THREE.PlaneGeometry(1, 1, 1, 1);
191
+ const uniforms = {
192
+ uTex: { value: null as THREE.Texture | null },
193
+ uRes: { value: new THREE.Vector2(w, h) },
194
+ uTexSize: { value: new THREE.Vector2(1, 1) },
195
+ uCorner: { value: corner },
196
+ uHover: { value: 0 },
197
+ uDent: { value: dent },
198
+ uChroma: { value: chroma },
199
+ uProgress: { value: reveal ? 0 : 1 },
200
+ uTime: { value: 0 },
201
+ };
202
+ const material = new THREE.ShaderMaterial({
203
+ vertexShader: VERT,
204
+ fragmentShader: FRAG,
205
+ uniforms,
206
+ transparent: true,
207
+ });
208
+ const mesh = new THREE.Mesh(geometry, material);
209
+ mesh.scale.set(w, h, 1);
210
+ mesh.visible = false;
211
+ scene.add(mesh);
212
+ host.appendChild(canvas);
213
+
214
+ /* ---------------- texture ---------------- */
215
+
216
+ let disposed = false;
217
+ const loader = new THREE.TextureLoader();
218
+ let revealTween: gsap.core.Tween | null = null;
219
+
220
+ loader.load(
221
+ textureSrc,
222
+ (texture) => {
223
+ if (disposed) {
224
+ texture.dispose();
225
+ return;
226
+ }
227
+ texture.minFilter = THREE.LinearFilter;
228
+ texture.magFilter = THREE.LinearFilter;
229
+ // No sRGB decode: the raw shader passes sampled values through, so the
230
+ // texture must stay an ordinary RGBA8 upload (built-in colorSpace
231
+ // conversion only happens inside three's own material chunks).
232
+ uniforms.uTex.value = texture;
233
+ uniforms.uTexSize.value.set(texture.image.width || 1, texture.image.height || 1);
234
+ mesh.visible = true;
235
+ if (img) img.style.opacity = "0"; // canvas owns the pixels from here
236
+ if (reveal) {
237
+ revealTween = gsap.fromTo(
238
+ uniforms.uProgress,
239
+ { value: 0 },
240
+ { value: 1, duration: revealDuration, ease: "power2.inOut" },
241
+ );
242
+ }
243
+ },
244
+ undefined,
245
+ () => {
246
+ /* 404 / decode failure — the plain <img> was never hidden. */
247
+ },
248
+ );
249
+
250
+ /* ---------------- hover ---------------- */
251
+
252
+ let hoverTween: gsap.core.Tween | null = null;
253
+ const hoverTo = (value: number) => {
254
+ hoverTween = gsap.to(uniforms.uHover, {
255
+ value,
256
+ duration: hoverDuration,
257
+ ease: "power3.out",
258
+ overwrite: "auto",
259
+ });
260
+ };
261
+ const onEnter = () => hoverTo(1);
262
+ const onLeave = () => hoverTo(0);
263
+ host.addEventListener("pointerenter", onEnter);
264
+ host.addEventListener("pointerleave", onLeave);
265
+
266
+ /* ---------------- loop / observers ---------------- */
267
+
268
+ let visible = true;
269
+ let io: IntersectionObserver | null = null;
270
+ if (typeof IntersectionObserver === "function") {
271
+ io = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), {
272
+ rootMargin: "120px",
273
+ });
274
+ io.observe(host);
275
+ }
276
+
277
+ let ro: ResizeObserver | null = null;
278
+ const resize = () => {
279
+ const rw = Math.max(1, host.clientWidth);
280
+ const rh = Math.max(1, host.clientHeight);
281
+ renderer.setSize(rw, rh, false);
282
+ camera.left = -rw / 2;
283
+ camera.right = rw / 2;
284
+ camera.top = rh / 2;
285
+ camera.bottom = -rh / 2;
286
+ camera.updateProjectionMatrix();
287
+ mesh.scale.set(rw, rh, 1);
288
+ uniforms.uRes.value.set(rw, rh);
289
+ };
290
+ if (typeof ResizeObserver === "function") {
291
+ ro = new ResizeObserver(resize);
292
+ ro.observe(host);
293
+ }
294
+
295
+ const render = () => {
296
+ if (!visible || !mesh.visible) return;
297
+ uniforms.uTime.value = gsap.ticker.time;
298
+ renderer.render(scene, camera);
299
+ };
300
+ gsap.ticker.add(render);
301
+
302
+ /* ---------------- teardown ---------------- */
303
+
304
+ return () => {
305
+ disposed = true;
306
+ gsap.ticker.remove(render);
307
+ hoverTween?.kill();
308
+ revealTween?.kill();
309
+ gsap.killTweensOf(uniforms.uHover);
310
+ gsap.killTweensOf(uniforms.uProgress);
311
+ host.removeEventListener("pointerenter", onEnter);
312
+ host.removeEventListener("pointerleave", onLeave);
313
+ io?.disconnect();
314
+ ro?.disconnect();
315
+ uniforms.uTex.value?.dispose();
316
+ geometry.dispose();
317
+ material.dispose();
318
+ renderer.dispose();
319
+ canvas.remove();
320
+ delete host.dataset.akGl;
321
+ host.style.position = prevPosition;
322
+ if (img) img.style.opacity = prevImgOpacity;
323
+ };
324
+ });
325
+ }