use-scroll-animate 5.5.0 → 5.7.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/CHANGELOG.md +24 -0
- package/dist/components/effects.cjs +736 -0
- package/dist/components/effects.cjs.map +1 -1
- package/dist/components/effects.d.cts +165 -2
- package/dist/components/effects.d.ts +165 -2
- package/dist/components/effects.js +723 -3
- package/dist/components/effects.js.map +1 -1
- package/dist/components.umd.js +2 -2
- package/dist/components.umd.js.map +1 -1
- package/docs/ROADMAP.md +2 -2
- package/docs/components.md +37 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { registerBuiltinEffects, registerEffects } from './fx.js';
|
|
2
|
-
import { x as defineElement } from '../chunks/base-C3Sw9sAO.js';
|
|
1
|
+
import { playEffect, registerBuiltinEffects, registerEffects } from './fx.js';
|
|
2
|
+
import { x as defineElement, p as prefersReducedMotion } from '../chunks/base-C3Sw9sAO.js';
|
|
3
3
|
import '../chunks/core-DN3hHbHh.js';
|
|
4
4
|
import './tokens.js';
|
|
5
5
|
import '../chunks/fx-ChjxrMBo.js';
|
|
@@ -1113,6 +1113,714 @@ const GENERATIVE_FX = [
|
|
|
1113
1113
|
}),
|
|
1114
1114
|
];
|
|
1115
1115
|
|
|
1116
|
+
let actx = null;
|
|
1117
|
+
let current = null;
|
|
1118
|
+
const mediaSources = new WeakMap();
|
|
1119
|
+
/** The running analyser, if `enableAudio()` was called. */
|
|
1120
|
+
const getAudio = () => current;
|
|
1121
|
+
function audioContext() {
|
|
1122
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
1123
|
+
if (!AC)
|
|
1124
|
+
throw new Error('[use-scroll-animate] Web Audio is not available');
|
|
1125
|
+
if (!actx || actx.state === 'closed')
|
|
1126
|
+
actx = new AC();
|
|
1127
|
+
return actx;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Start analysing `input` and make it the source of every sound-reactive
|
|
1131
|
+
* effect. Call from a user gesture. Replaces a previous source.
|
|
1132
|
+
*/
|
|
1133
|
+
async function enableAudio(input = 'mic', opts = {}) {
|
|
1134
|
+
current?.stop();
|
|
1135
|
+
const ac = audioContext();
|
|
1136
|
+
if (ac.state === 'suspended')
|
|
1137
|
+
await ac.resume().catch(() => undefined);
|
|
1138
|
+
const analyser = ac.createAnalyser();
|
|
1139
|
+
analyser.fftSize = opts.fftSize || 512;
|
|
1140
|
+
analyser.smoothingTimeConstant = opts.smoothing ?? 0.8;
|
|
1141
|
+
let src;
|
|
1142
|
+
let release = () => { };
|
|
1143
|
+
const media = typeof input === 'string' && input !== 'mic' ? document.querySelector(input) : input;
|
|
1144
|
+
if (media === 'mic') {
|
|
1145
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
1146
|
+
src = ac.createMediaStreamSource(stream);
|
|
1147
|
+
src.connect(analyser); // never to the speakers: no feedback
|
|
1148
|
+
release = () => stream.getTracks().forEach((t) => t.stop());
|
|
1149
|
+
}
|
|
1150
|
+
else if (media && typeof media.play === 'function') {
|
|
1151
|
+
const m = media;
|
|
1152
|
+
let node = mediaSources.get(m);
|
|
1153
|
+
if (!node)
|
|
1154
|
+
mediaSources.set(m, (node = ac.createMediaElementSource(m)));
|
|
1155
|
+
try {
|
|
1156
|
+
node.disconnect();
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
/* not connected */
|
|
1160
|
+
}
|
|
1161
|
+
src = node;
|
|
1162
|
+
src.connect(analyser);
|
|
1163
|
+
analyser.connect(ac.destination);
|
|
1164
|
+
release = () => {
|
|
1165
|
+
try {
|
|
1166
|
+
node.disconnect();
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
/* already */
|
|
1170
|
+
}
|
|
1171
|
+
node.connect(ac.destination); // keep the media audible
|
|
1172
|
+
};
|
|
1173
|
+
if (m.paused)
|
|
1174
|
+
await m.play()?.catch?.(() => undefined);
|
|
1175
|
+
}
|
|
1176
|
+
else if (media && typeof media.getTracks === 'function') {
|
|
1177
|
+
src = ac.createMediaStreamSource(media);
|
|
1178
|
+
src.connect(analyser);
|
|
1179
|
+
}
|
|
1180
|
+
else
|
|
1181
|
+
throw new Error(`[use-scroll-animate] enableAudio: no audio source for ${String(input)}`);
|
|
1182
|
+
const freq = new Uint8Array(analyser.frequencyBinCount);
|
|
1183
|
+
const wave = new Uint8Array(analyser.fftSize);
|
|
1184
|
+
const self = {
|
|
1185
|
+
context: ac,
|
|
1186
|
+
analyser,
|
|
1187
|
+
sample: () => {
|
|
1188
|
+
analyser.getByteFrequencyData(freq);
|
|
1189
|
+
analyser.getByteTimeDomainData(wave);
|
|
1190
|
+
let sq = 0;
|
|
1191
|
+
for (let i = 0; i < wave.length; i++)
|
|
1192
|
+
sq += ((wave[i] - 128) / 128) ** 2;
|
|
1193
|
+
const n = Math.max(1, Math.round(freq.length * 0.08));
|
|
1194
|
+
let b = 0;
|
|
1195
|
+
for (let i = 0; i < n; i++)
|
|
1196
|
+
b += freq[i];
|
|
1197
|
+
return { level: Math.min(1, Math.sqrt(sq / wave.length) * 2), bass: b / n / 255, freq, wave };
|
|
1198
|
+
},
|
|
1199
|
+
stop: () => {
|
|
1200
|
+
if (current === self) {
|
|
1201
|
+
current = null;
|
|
1202
|
+
if (raf && typeof cancelAnimationFrame === 'function')
|
|
1203
|
+
cancelAnimationFrame(raf);
|
|
1204
|
+
raf = 0;
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
src.disconnect();
|
|
1208
|
+
analyser.disconnect();
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
/* already */
|
|
1212
|
+
}
|
|
1213
|
+
release();
|
|
1214
|
+
setVars(0, 0);
|
|
1215
|
+
},
|
|
1216
|
+
};
|
|
1217
|
+
current = self;
|
|
1218
|
+
kick();
|
|
1219
|
+
return self;
|
|
1220
|
+
}
|
|
1221
|
+
/** Stop the current audio source (if any). */
|
|
1222
|
+
function disableAudio() {
|
|
1223
|
+
current?.stop();
|
|
1224
|
+
}
|
|
1225
|
+
/** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
|
|
1226
|
+
function createBeatDetector(o = {}) {
|
|
1227
|
+
const { threshold = 1.35, cooldown = 250, history = 43, floor = 0.08 } = o;
|
|
1228
|
+
const hist = [];
|
|
1229
|
+
let last = -Infinity;
|
|
1230
|
+
return (energy, now) => {
|
|
1231
|
+
const avg = hist.length ? hist.reduce((a, b) => a + b, 0) / hist.length : energy;
|
|
1232
|
+
const ready = hist.length >= 8;
|
|
1233
|
+
hist.push(energy);
|
|
1234
|
+
if (hist.length > history)
|
|
1235
|
+
hist.shift();
|
|
1236
|
+
if (ready && energy > floor && energy > avg * threshold && now - last >= cooldown) {
|
|
1237
|
+
last = now;
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
return false;
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
const listeners = new Set();
|
|
1244
|
+
let raf = 0;
|
|
1245
|
+
function setVars(level, bass) {
|
|
1246
|
+
if (typeof document === 'undefined')
|
|
1247
|
+
return;
|
|
1248
|
+
const s = document.documentElement.style;
|
|
1249
|
+
s.setProperty('--usa-audio-level', level.toFixed(3));
|
|
1250
|
+
s.setProperty('--usa-audio-bass', bass.toFixed(3));
|
|
1251
|
+
}
|
|
1252
|
+
function loop(now) {
|
|
1253
|
+
raf = 0;
|
|
1254
|
+
if (!current)
|
|
1255
|
+
return;
|
|
1256
|
+
const { level, bass } = current.sample();
|
|
1257
|
+
const reduced = prefersReducedMotion();
|
|
1258
|
+
setVars(reduced ? 0 : level, reduced ? 0 : bass);
|
|
1259
|
+
for (const l of Array.from(listeners))
|
|
1260
|
+
if (l.detect(bass, now))
|
|
1261
|
+
l.cb({ energy: bass, time: now });
|
|
1262
|
+
kick();
|
|
1263
|
+
}
|
|
1264
|
+
function kick() {
|
|
1265
|
+
if (!raf && current && typeof requestAnimationFrame === 'function')
|
|
1266
|
+
raf = requestAnimationFrame(loop);
|
|
1267
|
+
}
|
|
1268
|
+
/** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
|
|
1269
|
+
function onBeat(cb, o = {}) {
|
|
1270
|
+
const l = { detect: createBeatDetector(o), cb };
|
|
1271
|
+
listeners.add(l);
|
|
1272
|
+
kick();
|
|
1273
|
+
return () => void listeners.delete(l);
|
|
1274
|
+
}
|
|
1275
|
+
/** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
|
|
1276
|
+
function bindBeat(el, name, options = {}) {
|
|
1277
|
+
const { threshold, cooldown, history, floor, ...fx } = options;
|
|
1278
|
+
return onBeat(() => {
|
|
1279
|
+
if (!prefersReducedMotion())
|
|
1280
|
+
playEffect(el, name, fx).catch(() => undefined);
|
|
1281
|
+
}, { threshold, cooldown, history, floor });
|
|
1282
|
+
}
|
|
1283
|
+
// --- visual effects ----------------------------------------------------------
|
|
1284
|
+
/** The current sample, or a gentle synthetic one while no audio is enabled. */
|
|
1285
|
+
function read(t, bins) {
|
|
1286
|
+
if (current)
|
|
1287
|
+
return { ...current.sample(), idle: false };
|
|
1288
|
+
const freq = new Uint8Array(bins);
|
|
1289
|
+
const wave = new Uint8Array(bins * 2);
|
|
1290
|
+
for (let i = 0; i < bins; i++)
|
|
1291
|
+
freq[i] = 70 + 50 * Math.sin(t * 2 + i * 0.35) * Math.cos(t * 0.7 + i * 0.11) * (1 - i / bins);
|
|
1292
|
+
for (let i = 0; i < wave.length; i++)
|
|
1293
|
+
wave[i] = 128 + 22 * Math.sin(t * 3 + i * 0.12);
|
|
1294
|
+
return { level: 0.25 + 0.1 * Math.sin(t * 2), bass: 0.35 + 0.2 * Math.sin(t * 2.4), freq, wave, idle: true };
|
|
1295
|
+
}
|
|
1296
|
+
const audioFx = (name, description, defaults, spec) => ({
|
|
1297
|
+
name,
|
|
1298
|
+
kind: 'background',
|
|
1299
|
+
description,
|
|
1300
|
+
reduced: 'skip',
|
|
1301
|
+
defaults: { colors: PALETTE, background: '#0b0d12', speed: 1, quality: 1, ...defaults },
|
|
1302
|
+
run: (el, o, ctx) => canvasBackground(el, ctx, spec, o),
|
|
1303
|
+
});
|
|
1304
|
+
const AUDIO_FX = [
|
|
1305
|
+
audioFx('spectrum-bars', 'Frequency bars dance to the audio (mirror them with `mirror: true`).', { bars: 48, gap: 2, mirror: false }, {
|
|
1306
|
+
draw: ({ ctx, w, h, t, o }) => {
|
|
1307
|
+
ctx.fillStyle = o.background;
|
|
1308
|
+
ctx.fillRect(0, 0, w, h);
|
|
1309
|
+
const s = read(t, o.bars);
|
|
1310
|
+
const per = Math.max(1, Math.floor((s.freq.length * 0.7) / o.bars));
|
|
1311
|
+
const bw = w / o.bars;
|
|
1312
|
+
for (let i = 0; i < o.bars; i++) {
|
|
1313
|
+
let v = 0;
|
|
1314
|
+
for (let k = 0; k < per; k++)
|
|
1315
|
+
v += s.freq[Math.min(s.freq.length - 1, i * per + k)];
|
|
1316
|
+
const bh = (v / per / 255) * h * (o.mirror ? 0.5 : 0.9);
|
|
1317
|
+
ctx.fillStyle = o.colors[i % o.colors.length];
|
|
1318
|
+
ctx.fillRect(i * bw + o.gap / 2, o.mirror ? h / 2 - bh : h - bh, Math.max(1, bw - o.gap), o.mirror ? bh * 2 : bh);
|
|
1319
|
+
}
|
|
1320
|
+
},
|
|
1321
|
+
}),
|
|
1322
|
+
audioFx('pulse-ring', 'Glowing rings pulse with the bass.', { rings: 3, color: '#7c5cff' }, {
|
|
1323
|
+
draw: ({ ctx, w, h, t, o }) => {
|
|
1324
|
+
ctx.fillStyle = o.background;
|
|
1325
|
+
ctx.fillRect(0, 0, w, h);
|
|
1326
|
+
const s = read(t, 32);
|
|
1327
|
+
const [r, g, b] = hexRgb(o.color);
|
|
1328
|
+
const base = Math.min(w, h) * 0.18;
|
|
1329
|
+
for (let i = 0; i < o.rings; i++) {
|
|
1330
|
+
const rad = base * (1 + i * 0.55) * (1 + s.bass * 0.6);
|
|
1331
|
+
ctx.strokeStyle = `rgba(${r},${g},${b},${(0.9 - i * 0.25) * (0.4 + s.level)})`;
|
|
1332
|
+
ctx.lineWidth = 3 + s.bass * 10 - i;
|
|
1333
|
+
ctx.beginPath();
|
|
1334
|
+
ctx.arc(w / 2, h / 2, rad, 0, Math.PI * 2);
|
|
1335
|
+
ctx.stroke();
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
}),
|
|
1339
|
+
audioFx('wave-ring', 'The live waveform wrapped into a circle.', { color: '#22d3ee', amplitude: 0.35 }, {
|
|
1340
|
+
draw: ({ ctx, w, h, t, o }) => {
|
|
1341
|
+
ctx.fillStyle = o.background;
|
|
1342
|
+
ctx.fillRect(0, 0, w, h);
|
|
1343
|
+
const s = read(t, 64);
|
|
1344
|
+
const n = s.wave.length;
|
|
1345
|
+
const base = Math.min(w, h) * 0.28;
|
|
1346
|
+
ctx.strokeStyle = o.color;
|
|
1347
|
+
ctx.lineWidth = 2;
|
|
1348
|
+
ctx.beginPath();
|
|
1349
|
+
for (let i = 0; i <= n; i++) {
|
|
1350
|
+
const a = (i / n) * Math.PI * 2;
|
|
1351
|
+
const rad = base * (1 + ((s.wave[i % n] - 128) / 128) * o.amplitude * 2);
|
|
1352
|
+
const x = w / 2 + Math.cos(a) * rad;
|
|
1353
|
+
const y = h / 2 + Math.sin(a) * rad;
|
|
1354
|
+
if (i)
|
|
1355
|
+
ctx.lineTo(x, y);
|
|
1356
|
+
else
|
|
1357
|
+
ctx.moveTo(x, y);
|
|
1358
|
+
}
|
|
1359
|
+
ctx.closePath();
|
|
1360
|
+
ctx.stroke();
|
|
1361
|
+
},
|
|
1362
|
+
}),
|
|
1363
|
+
];
|
|
1364
|
+
/**
|
|
1365
|
+
* `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
|
|
1366
|
+
* `[data-audio-toggle]`, or one it renders) that enables the audio source on
|
|
1367
|
+
* click; children with `data-usa-beat="effect"` play that effect on every
|
|
1368
|
+
* beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
|
|
1369
|
+
* Emits `usa-beat` and `usa-audio-error`.
|
|
1370
|
+
*/
|
|
1371
|
+
function defineAudio(tag = 'usa-audio') {
|
|
1372
|
+
return defineElement(tag, (Base) => class UsaAudio extends Base {
|
|
1373
|
+
constructor() {
|
|
1374
|
+
super(...arguments);
|
|
1375
|
+
this.active = false;
|
|
1376
|
+
this.audio = null;
|
|
1377
|
+
this.off = null;
|
|
1378
|
+
}
|
|
1379
|
+
static get observedAttributes() {
|
|
1380
|
+
return ['source'];
|
|
1381
|
+
}
|
|
1382
|
+
button() {
|
|
1383
|
+
let b = this.querySelector('[data-audio-toggle]');
|
|
1384
|
+
if (!b) {
|
|
1385
|
+
b = document.createElement('button');
|
|
1386
|
+
b.type = 'button';
|
|
1387
|
+
b.setAttribute('data-audio-toggle', '');
|
|
1388
|
+
b.textContent = this.str('label', '🔊 Enable sound-reactive effects');
|
|
1389
|
+
this.prepend(b);
|
|
1390
|
+
this.onCleanup(() => b.remove());
|
|
1391
|
+
}
|
|
1392
|
+
return b;
|
|
1393
|
+
}
|
|
1394
|
+
async toggle() {
|
|
1395
|
+
const b = this.button();
|
|
1396
|
+
if (this.active)
|
|
1397
|
+
return this.stop();
|
|
1398
|
+
try {
|
|
1399
|
+
this.audio = await enableAudio(this.str('source', 'mic'));
|
|
1400
|
+
this.active = true;
|
|
1401
|
+
this.removeAttribute('data-audio-error');
|
|
1402
|
+
b.setAttribute('aria-pressed', 'true');
|
|
1403
|
+
this.off = onBeat((d) => {
|
|
1404
|
+
this.dispatchEvent(new CustomEvent('usa-beat', { detail: d, bubbles: true }));
|
|
1405
|
+
if (prefersReducedMotion())
|
|
1406
|
+
return;
|
|
1407
|
+
this.querySelectorAll('[data-usa-beat]').forEach((el) => {
|
|
1408
|
+
let opts = {};
|
|
1409
|
+
try {
|
|
1410
|
+
opts = JSON.parse(el.dataset.usaBeatOptions || '{}') || {};
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
/* ignore bad JSON */
|
|
1414
|
+
}
|
|
1415
|
+
playEffect(el, el.dataset.usaBeat || 'pulse', opts).catch(() => undefined);
|
|
1416
|
+
});
|
|
1417
|
+
}, { threshold: this.num('threshold', 1.35), cooldown: this.num('cooldown', 250) });
|
|
1418
|
+
}
|
|
1419
|
+
catch (err) {
|
|
1420
|
+
this.setAttribute('data-audio-error', '');
|
|
1421
|
+
this.dispatchEvent(new CustomEvent('usa-audio-error', { detail: { error: err }, bubbles: true }));
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
stop() {
|
|
1425
|
+
this.off?.();
|
|
1426
|
+
this.off = null;
|
|
1427
|
+
if (this.audio && current === this.audio)
|
|
1428
|
+
this.audio.stop();
|
|
1429
|
+
this.audio = null;
|
|
1430
|
+
this.active = false;
|
|
1431
|
+
this.querySelector('[data-audio-toggle]')?.setAttribute('aria-pressed', 'false');
|
|
1432
|
+
}
|
|
1433
|
+
mount() {
|
|
1434
|
+
const b = this.button();
|
|
1435
|
+
b.setAttribute('aria-pressed', 'false');
|
|
1436
|
+
this.listen(b, 'click', () => void this.toggle());
|
|
1437
|
+
this.onCleanup(() => this.stop());
|
|
1438
|
+
}
|
|
1439
|
+
}, { id: 'usa-audio', text: 'usa-audio{display:block}usa-audio[data-audio-error] [data-audio-toggle]{outline:2px solid #ff5c8a}' });
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const touchOk = (e, o) => o.touch || e.pointerType !== 'touch';
|
|
1443
|
+
/** A fixed, viewport-sized canvas in the fx layer, redrawn while `draw` returns true. */
|
|
1444
|
+
function overlayCanvas(draw) {
|
|
1445
|
+
const c = document.createElement('canvas');
|
|
1446
|
+
c.style.cssText = 'position:absolute;inset:0;width:100%;height:100%';
|
|
1447
|
+
fxLayer().appendChild(c);
|
|
1448
|
+
const g = c.getContext('2d');
|
|
1449
|
+
let raf = 0;
|
|
1450
|
+
const frame = (now) => {
|
|
1451
|
+
raf = 0;
|
|
1452
|
+
if (!g)
|
|
1453
|
+
return;
|
|
1454
|
+
const w = innerWidth;
|
|
1455
|
+
const h = innerHeight;
|
|
1456
|
+
const dpr = Math.min(2, devicePixelRatio || 1);
|
|
1457
|
+
if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
|
|
1458
|
+
c.width = Math.round(w * dpr);
|
|
1459
|
+
c.height = Math.round(h * dpr);
|
|
1460
|
+
}
|
|
1461
|
+
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1462
|
+
g.clearRect(0, 0, w, h);
|
|
1463
|
+
if (draw(g, w, h, now))
|
|
1464
|
+
raf = requestAnimationFrame(frame);
|
|
1465
|
+
};
|
|
1466
|
+
return {
|
|
1467
|
+
kick: () => {
|
|
1468
|
+
if (!raf && g)
|
|
1469
|
+
raf = requestAnimationFrame(frame);
|
|
1470
|
+
},
|
|
1471
|
+
stop: () => {
|
|
1472
|
+
cancelAnimationFrame(raf);
|
|
1473
|
+
raf = 0;
|
|
1474
|
+
c.remove();
|
|
1475
|
+
},
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
/** Pointer trail on `el`: keeps the last `life` ms of points and redraws them. */
|
|
1479
|
+
function trail(el, o, paint) {
|
|
1480
|
+
const pts = [];
|
|
1481
|
+
const oc = overlayCanvas((g, _w, _h, now) => {
|
|
1482
|
+
while (pts.length && now - pts[0].t > o.life)
|
|
1483
|
+
pts.shift();
|
|
1484
|
+
if (pts.length > 1)
|
|
1485
|
+
paint(g, pts, now);
|
|
1486
|
+
return pts.length > 0;
|
|
1487
|
+
});
|
|
1488
|
+
const move = (e) => {
|
|
1489
|
+
if (!touchOk(e, o))
|
|
1490
|
+
return;
|
|
1491
|
+
pts.push({ x: e.clientX, y: e.clientY, t: performance.now() });
|
|
1492
|
+
if (pts.length > 64)
|
|
1493
|
+
pts.shift();
|
|
1494
|
+
oc.kick();
|
|
1495
|
+
};
|
|
1496
|
+
el.addEventListener('pointermove', move, { passive: true });
|
|
1497
|
+
return () => {
|
|
1498
|
+
el.removeEventListener('pointermove', move);
|
|
1499
|
+
oc.stop();
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
const CURSOR_FX = [
|
|
1503
|
+
{
|
|
1504
|
+
name: 'comet-trail',
|
|
1505
|
+
kind: 'cursor',
|
|
1506
|
+
description: 'A glowing comet tail follows the pointer over the element (persistent; `color`, `width`, `life`).',
|
|
1507
|
+
defaults: { color: '#7c5cff', width: 10, life: 320, touch: false },
|
|
1508
|
+
run: (el, o) => trail(el, o, (g, pts, now) => {
|
|
1509
|
+
const [r, gg, b] = hexRgb(o.color);
|
|
1510
|
+
g.lineCap = 'round';
|
|
1511
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1512
|
+
const k = 1 - (now - pts[i].t) / o.life;
|
|
1513
|
+
g.strokeStyle = `rgba(${r},${gg},${b},${(k * 0.9).toFixed(3)})`;
|
|
1514
|
+
g.lineWidth = Math.max(0.5, o.width * k);
|
|
1515
|
+
g.beginPath();
|
|
1516
|
+
g.moveTo(pts[i - 1].x, pts[i - 1].y);
|
|
1517
|
+
g.lineTo(pts[i].x, pts[i].y);
|
|
1518
|
+
g.stroke();
|
|
1519
|
+
}
|
|
1520
|
+
}),
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
name: 'ribbon-trail',
|
|
1524
|
+
kind: 'cursor',
|
|
1525
|
+
description: 'A smooth rainbow ribbon flows behind the pointer (persistent; `width`, `life`).',
|
|
1526
|
+
defaults: { width: 18, life: 600, touch: false },
|
|
1527
|
+
run: (el, o) => trail(el, o, (g, pts, now) => {
|
|
1528
|
+
for (let i = 2; i < pts.length; i++) {
|
|
1529
|
+
const k = 1 - (now - pts[i].t) / o.life;
|
|
1530
|
+
const a = pts[i - 2];
|
|
1531
|
+
const m = pts[i - 1];
|
|
1532
|
+
const b = pts[i];
|
|
1533
|
+
g.strokeStyle = `hsla(${(pts[i].t / 6) % 360},90%,62%,${(k * 0.85).toFixed(3)})`;
|
|
1534
|
+
g.lineWidth = Math.max(0.5, o.width * k * Math.sin(Math.PI * Math.min(1, (i / pts.length) * 1.1)));
|
|
1535
|
+
g.lineCap = 'round';
|
|
1536
|
+
g.beginPath();
|
|
1537
|
+
g.moveTo((a.x + m.x) / 2, (a.y + m.y) / 2);
|
|
1538
|
+
g.quadraticCurveTo(m.x, m.y, (m.x + b.x) / 2, (m.y + b.y) / 2);
|
|
1539
|
+
g.stroke();
|
|
1540
|
+
}
|
|
1541
|
+
}),
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
name: 'sparkle-trail',
|
|
1545
|
+
kind: 'cursor',
|
|
1546
|
+
description: 'Little stars twinkle off the pointer as it moves (persistent; `colors`, `spacing` px between stars).',
|
|
1547
|
+
defaults: { colors: PALETTE, spacing: 14, size: 14, touch: false },
|
|
1548
|
+
run: (el, o, ctx) => {
|
|
1549
|
+
let lx = -1e4;
|
|
1550
|
+
let ly = -1e4;
|
|
1551
|
+
const move = (e) => {
|
|
1552
|
+
if (!touchOk(e, o) || Math.hypot(e.clientX - lx, e.clientY - ly) < o.spacing)
|
|
1553
|
+
return;
|
|
1554
|
+
lx = e.clientX;
|
|
1555
|
+
ly = e.clientY;
|
|
1556
|
+
const s = o.size * rand(0.6, 1.2);
|
|
1557
|
+
spawn(e.clientX - s / 2, e.clientY - s / 2, `font-size:${s}px;line-height:1;color:${o.colors[Math.floor(rand(0, o.colors.length))]}`, ctx, [{ transform: 'translate(0,0) scale(0) rotate(0deg)', opacity: 1 }, { transform: `translate(${rand(-14, 14)}px,${rand(4, 26)}px) scale(1) rotate(${rand(-90, 90)}deg)`, opacity: 0 }], { duration: rand(500, 800), easing: 'ease-out' }, '✦');
|
|
1558
|
+
};
|
|
1559
|
+
el.addEventListener('pointermove', move, { passive: true });
|
|
1560
|
+
return () => el.removeEventListener('pointermove', move);
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
name: 'magnetic-dots',
|
|
1565
|
+
kind: 'cursor',
|
|
1566
|
+
description: 'A grid of dots behind the content leans toward the pointer like iron filings to a magnet (persistent; `gap`, `radius`, `color`).',
|
|
1567
|
+
defaults: { gap: 22, radius: 120, color: '#7c5cff', background: 'transparent', touch: false },
|
|
1568
|
+
reduced: 'skip',
|
|
1569
|
+
run: (el, o, ctx) => {
|
|
1570
|
+
const p = { x: -1e4, y: -1e4 };
|
|
1571
|
+
const move = (e) => {
|
|
1572
|
+
if (!touchOk(e, o))
|
|
1573
|
+
return;
|
|
1574
|
+
const r = el.getBoundingClientRect();
|
|
1575
|
+
p.x = e.clientX - r.left;
|
|
1576
|
+
p.y = e.clientY - r.top;
|
|
1577
|
+
};
|
|
1578
|
+
const leave = () => ((p.x = -1e4), (p.y = -1e4));
|
|
1579
|
+
el.addEventListener('pointermove', move, { passive: true });
|
|
1580
|
+
el.addEventListener('pointerleave', leave);
|
|
1581
|
+
const off = canvasBackground(el, ctx, {
|
|
1582
|
+
draw: ({ ctx: g, w, h }) => {
|
|
1583
|
+
g.clearRect(0, 0, w, h);
|
|
1584
|
+
if (o.background !== 'transparent') {
|
|
1585
|
+
g.fillStyle = o.background;
|
|
1586
|
+
g.fillRect(0, 0, w, h);
|
|
1587
|
+
}
|
|
1588
|
+
g.fillStyle = o.color;
|
|
1589
|
+
for (let y = o.gap / 2; y < h; y += o.gap)
|
|
1590
|
+
for (let x = o.gap / 2; x < w; x += o.gap) {
|
|
1591
|
+
const dx = p.x - x;
|
|
1592
|
+
const dy = p.y - y;
|
|
1593
|
+
const d = Math.hypot(dx, dy);
|
|
1594
|
+
const k = d < o.radius ? (1 - d / o.radius) ** 2 : 0;
|
|
1595
|
+
const s = 1.5 + k * 3;
|
|
1596
|
+
g.globalAlpha = 0.35 + k * 0.65;
|
|
1597
|
+
g.fillRect(x + dx * k * 0.35 - s / 2, y + dy * k * 0.35 - s / 2, s, s);
|
|
1598
|
+
}
|
|
1599
|
+
g.globalAlpha = 1;
|
|
1600
|
+
},
|
|
1601
|
+
}, o);
|
|
1602
|
+
return () => {
|
|
1603
|
+
el.removeEventListener('pointermove', move);
|
|
1604
|
+
el.removeEventListener('pointerleave', leave);
|
|
1605
|
+
off();
|
|
1606
|
+
};
|
|
1607
|
+
},
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
name: 'spotlight-cursor',
|
|
1611
|
+
kind: 'cursor',
|
|
1612
|
+
description: 'A soft light follows the pointer across the element, easing behind it (persistent; `color`, `size`).',
|
|
1613
|
+
defaults: { color: '#ffffff', size: 260, ease: 0.18, touch: false },
|
|
1614
|
+
run: (el, o) => {
|
|
1615
|
+
const [r, g, b] = hexRgb(o.color);
|
|
1616
|
+
const s = document.createElement('span');
|
|
1617
|
+
s.setAttribute('aria-hidden', 'true');
|
|
1618
|
+
s.style.cssText = `position:absolute;inset:0;pointer-events:none;border-radius:inherit;opacity:0;transition:opacity .25s;mix-blend-mode:soft-light;background:radial-gradient(circle ${o.size / 2}px at var(--sx,50%) var(--sy,50%),rgba(${r},${g},${b},.75),transparent)`;
|
|
1619
|
+
const restore = el.style.position;
|
|
1620
|
+
if (getComputedStyle(el).position === 'static')
|
|
1621
|
+
el.style.position = 'relative';
|
|
1622
|
+
el.appendChild(s);
|
|
1623
|
+
let tx = 0;
|
|
1624
|
+
let ty = 0;
|
|
1625
|
+
let x = 0;
|
|
1626
|
+
let y = 0;
|
|
1627
|
+
let raf = 0;
|
|
1628
|
+
const tick = () => {
|
|
1629
|
+
x += (tx - x) * o.ease;
|
|
1630
|
+
y += (ty - y) * o.ease;
|
|
1631
|
+
s.style.setProperty('--sx', `${x.toFixed(1)}px`);
|
|
1632
|
+
s.style.setProperty('--sy', `${y.toFixed(1)}px`);
|
|
1633
|
+
raf = Math.abs(tx - x) + Math.abs(ty - y) > 0.3 ? requestAnimationFrame(tick) : 0;
|
|
1634
|
+
};
|
|
1635
|
+
const move = (e) => {
|
|
1636
|
+
if (!touchOk(e, o))
|
|
1637
|
+
return;
|
|
1638
|
+
const rc = el.getBoundingClientRect();
|
|
1639
|
+
tx = e.clientX - rc.left;
|
|
1640
|
+
ty = e.clientY - rc.top;
|
|
1641
|
+
if (s.style.opacity !== '1')
|
|
1642
|
+
((x = tx), (y = ty), (s.style.opacity = '1'));
|
|
1643
|
+
if (!raf)
|
|
1644
|
+
raf = requestAnimationFrame(tick);
|
|
1645
|
+
};
|
|
1646
|
+
const leave = () => (s.style.opacity = '0');
|
|
1647
|
+
el.addEventListener('pointermove', move, { passive: true });
|
|
1648
|
+
el.addEventListener('pointerleave', leave);
|
|
1649
|
+
return () => {
|
|
1650
|
+
cancelAnimationFrame(raf);
|
|
1651
|
+
el.removeEventListener('pointermove', move);
|
|
1652
|
+
el.removeEventListener('pointerleave', leave);
|
|
1653
|
+
s.remove();
|
|
1654
|
+
el.style.position = restore;
|
|
1655
|
+
};
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
];
|
|
1659
|
+
// --- gestures → effects ------------------------------------------------------
|
|
1660
|
+
const GESTURES = ['fling', 'twist', 'long-press'];
|
|
1661
|
+
/** Release velocity (px/ms) from recent pointer samples: uses the last `window` ms (pure). */
|
|
1662
|
+
function flingVelocity(pts, window = 90) {
|
|
1663
|
+
if (pts.length < 2)
|
|
1664
|
+
return { vx: 0, vy: 0, speed: 0 };
|
|
1665
|
+
const last = pts[pts.length - 1];
|
|
1666
|
+
let first = pts[0];
|
|
1667
|
+
for (let i = pts.length - 2; i >= 0; i--) {
|
|
1668
|
+
first = pts[i];
|
|
1669
|
+
if (last.t - pts[i].t >= window)
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
const dt = Math.max(1, last.t - first.t);
|
|
1673
|
+
const vx = (last.x - first.x) / dt;
|
|
1674
|
+
const vy = (last.y - first.y) / dt;
|
|
1675
|
+
return { vx, vy, speed: Math.hypot(vx, vy) };
|
|
1676
|
+
}
|
|
1677
|
+
/** Signed smallest difference between two angles in degrees, in (-180, 180] (pure). */
|
|
1678
|
+
function angleDelta(a, b) {
|
|
1679
|
+
let d = (b - a) % 360;
|
|
1680
|
+
if (d > 180)
|
|
1681
|
+
d -= 360;
|
|
1682
|
+
if (d <= -180)
|
|
1683
|
+
d += 360;
|
|
1684
|
+
return d;
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Fire `effect` (a registered effect name, or a callback) when `gesture`
|
|
1688
|
+
* happens on `el`. Returns an unbind.
|
|
1689
|
+
*/
|
|
1690
|
+
function bindGesture(el, gesture, effect, o = {}) {
|
|
1691
|
+
const { velocity = 0.8, angle = 30, duration = 650, tolerance = 10, effectOptions = {} } = o;
|
|
1692
|
+
const pts = new Map();
|
|
1693
|
+
let path = [];
|
|
1694
|
+
let base = null;
|
|
1695
|
+
let press = null;
|
|
1696
|
+
const fire = (d, e) => {
|
|
1697
|
+
el.dispatchEvent(new CustomEvent('usa-gesture', { detail: d, bubbles: true }));
|
|
1698
|
+
if (typeof effect === 'function')
|
|
1699
|
+
effect(d, e);
|
|
1700
|
+
else
|
|
1701
|
+
playEffect(el, effect, effectOptions, e).catch(() => undefined);
|
|
1702
|
+
};
|
|
1703
|
+
const twistAngle = () => {
|
|
1704
|
+
const [a, b] = [...pts.values()];
|
|
1705
|
+
return (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
|
1706
|
+
};
|
|
1707
|
+
const charge = (k) => {
|
|
1708
|
+
el.style.setProperty('--usa-charge', k.toFixed(3));
|
|
1709
|
+
el.toggleAttribute('data-charging', k > 0 && k < 1);
|
|
1710
|
+
};
|
|
1711
|
+
const endPress = () => {
|
|
1712
|
+
if (!press)
|
|
1713
|
+
return;
|
|
1714
|
+
cancelAnimationFrame(press.raf);
|
|
1715
|
+
press = null;
|
|
1716
|
+
charge(0);
|
|
1717
|
+
};
|
|
1718
|
+
const down = (e) => {
|
|
1719
|
+
pts.set(e.pointerId ?? 1, { x: e.clientX, y: e.clientY });
|
|
1720
|
+
if (gesture === 'fling')
|
|
1721
|
+
path = [{ x: e.clientX, y: e.clientY, t: e.timeStamp || performance.now() }];
|
|
1722
|
+
if (gesture === 'twist' && pts.size === 2)
|
|
1723
|
+
base = twistAngle();
|
|
1724
|
+
if (gesture === 'long-press' && pts.size === 1) {
|
|
1725
|
+
press = { x: e.clientX, y: e.clientY, t0: performance.now(), raf: 0 };
|
|
1726
|
+
const step = (now) => {
|
|
1727
|
+
if (!press)
|
|
1728
|
+
return;
|
|
1729
|
+
const k = Math.min(1, (now - press.t0) / duration);
|
|
1730
|
+
charge(k);
|
|
1731
|
+
if (k >= 1) {
|
|
1732
|
+
endPress();
|
|
1733
|
+
el.style.setProperty('--usa-charge', '1');
|
|
1734
|
+
fire({ gesture, charge: 1 }, e);
|
|
1735
|
+
}
|
|
1736
|
+
else
|
|
1737
|
+
press.raf = requestAnimationFrame(step);
|
|
1738
|
+
};
|
|
1739
|
+
press.raf = requestAnimationFrame(step);
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
const move = (e) => {
|
|
1743
|
+
if (!pts.has(e.pointerId ?? 1))
|
|
1744
|
+
return;
|
|
1745
|
+
pts.set(e.pointerId ?? 1, { x: e.clientX, y: e.clientY });
|
|
1746
|
+
if (gesture === 'fling') {
|
|
1747
|
+
path.push({ x: e.clientX, y: e.clientY, t: e.timeStamp || performance.now() });
|
|
1748
|
+
if (path.length > 20)
|
|
1749
|
+
path.shift();
|
|
1750
|
+
}
|
|
1751
|
+
else if (gesture === 'twist' && pts.size >= 2 && base !== null) {
|
|
1752
|
+
const now = twistAngle();
|
|
1753
|
+
const d = angleDelta(base, now);
|
|
1754
|
+
if (Math.abs(d) >= angle) {
|
|
1755
|
+
base = now;
|
|
1756
|
+
fire({ gesture, angle: d, direction: d > 0 ? 'cw' : 'ccw' }, e);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
else if (gesture === 'long-press' && press && Math.hypot(e.clientX - press.x, e.clientY - press.y) > tolerance)
|
|
1760
|
+
endPress();
|
|
1761
|
+
};
|
|
1762
|
+
const up = (e) => {
|
|
1763
|
+
pts.delete(e.pointerId ?? 1);
|
|
1764
|
+
if (gesture === 'fling' && path.length) {
|
|
1765
|
+
path.push({ x: e.clientX, y: e.clientY, t: e.timeStamp || performance.now() });
|
|
1766
|
+
const v = flingVelocity(path);
|
|
1767
|
+
path = [];
|
|
1768
|
+
if (v.speed >= velocity) {
|
|
1769
|
+
const direction = Math.abs(v.vx) > Math.abs(v.vy) ? (v.vx > 0 ? 'right' : 'left') : v.vy > 0 ? 'down' : 'up';
|
|
1770
|
+
fire({ gesture, ...v, direction }, e);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (pts.size < 2)
|
|
1774
|
+
base = null;
|
|
1775
|
+
endPress();
|
|
1776
|
+
};
|
|
1777
|
+
el.addEventListener('pointerdown', down);
|
|
1778
|
+
el.addEventListener('pointermove', move);
|
|
1779
|
+
el.addEventListener('pointerup', up);
|
|
1780
|
+
el.addEventListener('pointercancel', up);
|
|
1781
|
+
if (gesture === 'twist' && !el.style.touchAction)
|
|
1782
|
+
el.style.touchAction = 'none';
|
|
1783
|
+
return () => {
|
|
1784
|
+
endPress();
|
|
1785
|
+
el.removeEventListener('pointerdown', down);
|
|
1786
|
+
el.removeEventListener('pointermove', move);
|
|
1787
|
+
el.removeEventListener('pointerup', up);
|
|
1788
|
+
el.removeEventListener('pointercancel', up);
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* `<usa-gesture-fx gesture="fling | twist | long-press" effect="tada"
|
|
1793
|
+
* options='{"…"}' velocity angle duration>` — plays `effect` on its first
|
|
1794
|
+
* child (or itself with `self`) when the gesture happens.
|
|
1795
|
+
*/
|
|
1796
|
+
function defineGestureFx(tag = 'usa-gesture-fx') {
|
|
1797
|
+
return defineElement(tag, (Base) => class UsaGestureFx extends Base {
|
|
1798
|
+
static get observedAttributes() {
|
|
1799
|
+
return ['gesture', 'effect', 'options'];
|
|
1800
|
+
}
|
|
1801
|
+
get gesture() {
|
|
1802
|
+
const g = this.str('gesture', 'fling');
|
|
1803
|
+
return GESTURES.includes(g) ? g : 'fling';
|
|
1804
|
+
}
|
|
1805
|
+
mount() {
|
|
1806
|
+
let effectOptions = {};
|
|
1807
|
+
try {
|
|
1808
|
+
effectOptions = JSON.parse(this.str('options', '{}')) || {};
|
|
1809
|
+
}
|
|
1810
|
+
catch {
|
|
1811
|
+
/* ignore bad JSON */
|
|
1812
|
+
}
|
|
1813
|
+
const target = this.flag('self') ? this : this.firstElementChild || this;
|
|
1814
|
+
const effect = this.str('effect', 'pulse');
|
|
1815
|
+
this.onCleanup(bindGesture(this, this.gesture, (_d, e) => void playEffect(target, effect, effectOptions, e).catch(() => undefined), {
|
|
1816
|
+
velocity: this.num('velocity', 0.8),
|
|
1817
|
+
angle: this.num('angle', 30),
|
|
1818
|
+
duration: this.num('duration', 650),
|
|
1819
|
+
}));
|
|
1820
|
+
}
|
|
1821
|
+
}, { id: 'usa-gesture-fx', text: 'usa-gesture-fx{display:inline-block;touch-action:none;user-select:none}' });
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1116
1824
|
/**
|
|
1117
1825
|
* use-scroll-animate/components/effects — the 5.x effect packs, all
|
|
1118
1826
|
* registered through `registerEffect()` (5.0) and playable with
|
|
@@ -1131,6 +1839,8 @@ const EFFECT_PACKS = {
|
|
|
1131
1839
|
physics: PHYSICS_FX,
|
|
1132
1840
|
page: PAGE_FX,
|
|
1133
1841
|
generative: GENERATIVE_FX,
|
|
1842
|
+
audio: AUDIO_FX,
|
|
1843
|
+
cursor: CURSOR_FX,
|
|
1134
1844
|
};
|
|
1135
1845
|
/** 5.1: card & click effects 2.0. */
|
|
1136
1846
|
function registerCardClickEffects() {
|
|
@@ -1148,9 +1858,19 @@ function registerPageEffects() {
|
|
|
1148
1858
|
function registerGenerativeEffects() {
|
|
1149
1859
|
registerEffects(EFFECT_PACKS.generative);
|
|
1150
1860
|
}
|
|
1861
|
+
/** 5.6: sound-reactive (Web Audio) backgrounds. */
|
|
1862
|
+
function registerAudioEffects() {
|
|
1863
|
+
registerEffects(EFFECT_PACKS.audio);
|
|
1864
|
+
}
|
|
1865
|
+
/** 5.7: cursor trails, magnetic dots, spotlight cursor. */
|
|
1866
|
+
function registerCursorEffects() {
|
|
1867
|
+
registerEffects(EFFECT_PACKS.cursor);
|
|
1868
|
+
}
|
|
1151
1869
|
/** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
|
|
1152
1870
|
function defineEffectElements() {
|
|
1153
1871
|
defineStory();
|
|
1872
|
+
defineAudio();
|
|
1873
|
+
defineGestureFx();
|
|
1154
1874
|
}
|
|
1155
1875
|
/** Register the built-ins and every pack (idempotent). */
|
|
1156
1876
|
function registerAllEffects() {
|
|
@@ -1159,5 +1879,5 @@ function registerAllEffects() {
|
|
|
1159
1879
|
registerEffects(defs);
|
|
1160
1880
|
}
|
|
1161
1881
|
|
|
1162
|
-
export { CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bounceKeyframes, canvasBackground, defineEffectElements, defineStory, formatCount, fxLayer, hexRgb, noise2, registerAllEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
1882
|
+
export { AUDIO_FX, CARD_FX, CLICK_FX, CURSOR_FX, EFFECT_PACKS, GENERATIVE_FX, GESTURES, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, angleDelta, bindBeat, bindGesture, bounceKeyframes, canvasBackground, createBeatDetector, defineAudio, defineEffectElements, defineGestureFx, defineStory, disableAudio, enableAudio, flingVelocity, formatCount, fxLayer, getAudio, hexRgb, noise2, onBeat, registerAllEffects, registerAudioEffects, registerCardClickEffects, registerCursorEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
1163
1883
|
//# sourceMappingURL=effects.js.map
|