@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.
- package/README.md +203 -31
- package/dist/anim-kit.standalone.js +5 -5
- package/dist/anim-kit.standalone.js.map +1 -1
- package/dist/core/gsap.d.ts +1 -1
- package/dist/core/gsap.d.ts.map +1 -1
- package/dist/core/gsap.js +1 -1
- package/dist/core/gsap.js.map +1 -1
- package/dist/core/smooth-scroll.d.ts +1 -1
- package/dist/core/smooth-scroll.js +1 -1
- package/dist/effects/drag-rail.d.ts +23 -0
- package/dist/effects/drag-rail.d.ts.map +1 -0
- package/dist/effects/drag-rail.js +196 -0
- package/dist/effects/drag-rail.js.map +1 -0
- package/dist/effects/preloader.js +1 -1
- package/dist/effects/reel-text.d.ts +19 -0
- package/dist/effects/reel-text.d.ts.map +1 -0
- package/dist/effects/reel-text.js +163 -0
- package/dist/effects/reel-text.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/three/index.d.ts +13 -0
- package/dist/three/index.d.ts.map +1 -0
- package/dist/three/index.js +12 -0
- package/dist/three/index.js.map +1 -0
- package/dist/three/webgl-media.d.ts +21 -0
- package/dist/three/webgl-media.d.ts.map +1 -0
- package/dist/three/webgl-media.js +268 -0
- package/dist/three/webgl-media.js.map +1 -0
- package/package.json +17 -1
- package/src/core/gsap.ts +1 -1
- package/src/core/smooth-scroll.ts +1 -1
- package/src/effects/drag-rail.ts +236 -0
- package/src/effects/preloader.ts +1 -1
- package/src/effects/reel-text.ts +212 -0
- package/src/index.ts +9 -1
- package/src/three/index.ts +12 -0
- 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
|
|
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
|
+
}
|